From b315d514157ec1f879a9ca0402aefce611a97ede Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 11 Mar 2026 15:28:56 -0700 Subject: [PATCH 1/2] feat(cli): add web onboarding and replayable auth --- API.md | 160 +- README.md | 89 +- .../dist/commands/analyze.d.ts | 16 + .../dist/commands/analyze.d.ts.map | 1 + .../interfacectl-cli/dist/commands/analyze.js | 106 + .../interfacectl-cli/dist/commands/auth.d.ts | 2 + .../dist/commands/auth.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/auth.js | 185 +- .../interfacectl-cli/dist/commands/init.d.ts | 8 +- .../dist/commands/init.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/init.js | 520 +++-- .../dist/commands/validate-extracted.d.ts | 1 + .../dist/commands/validate-extracted.d.ts.map | 2 +- .../dist/commands/validate-extracted.js | 33 +- .../dist/commands/validate.d.ts | 2 + .../dist/commands/validate.d.ts.map | 2 +- .../dist/commands/validate.js | 112 +- packages/interfacectl-cli/dist/index.js | 74 +- .../dist/utils/auth-profiles.d.ts | 39 +- .../dist/utils/auth-profiles.d.ts.map | 2 +- .../dist/utils/auth-profiles.js | 594 ++++-- .../dist/utils/browser-session.d.ts | 21 + .../dist/utils/browser-session.d.ts.map | 1 + .../dist/utils/browser-session.js | 115 ++ .../dist/utils/first-run-analysis.d.ts | 208 ++ .../dist/utils/first-run-analysis.d.ts.map | 1 + .../dist/utils/first-run-analysis.js | 1325 ++++++++++++ .../dist/utils/onboarding.d.ts | 13 + .../dist/utils/onboarding.d.ts.map | 2 +- .../interfacectl-cli/dist/utils/onboarding.js | 19 +- packages/interfacectl-cli/package.json | 3 +- .../interfacectl-cli/src/commands/analyze.ts | 141 ++ .../interfacectl-cli/src/commands/auth.ts | 238 ++- .../interfacectl-cli/src/commands/init.ts | 739 ++++--- .../src/commands/validate-extracted.ts | 127 +- .../interfacectl-cli/src/commands/validate.ts | 132 +- packages/interfacectl-cli/src/index.ts | 76 +- .../src/utils/auth-profiles.ts | 689 ++++--- .../src/utils/browser-session.ts | 154 ++ .../src/utils/first-run-analysis.ts | 1789 +++++++++++++++++ .../interfacectl-cli/src/utils/onboarding.ts | 35 +- .../test/first-run-experience.test.mjs | 320 +++ .../interfacectl-cli/test/init-auth.test.mjs | 321 ++- pnpm-lock.yaml | 29 + 44 files changed, 7274 insertions(+), 1176 deletions(-) create mode 100644 packages/interfacectl-cli/dist/commands/analyze.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/analyze.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/analyze.js create mode 100644 packages/interfacectl-cli/dist/utils/browser-session.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/browser-session.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/browser-session.js create mode 100644 packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/first-run-analysis.js create mode 100644 packages/interfacectl-cli/src/commands/analyze.ts create mode 100644 packages/interfacectl-cli/src/utils/browser-session.ts create mode 100644 packages/interfacectl-cli/src/utils/first-run-analysis.ts create mode 100644 packages/interfacectl-cli/test/first-run-experience.test.mjs diff --git a/API.md b/API.md index 4aa2607..b845dec 100644 --- a/API.md +++ b/API.md @@ -10,6 +10,164 @@ ## Commands +### `init` + +First-run onboarding for web surfaces. + +**Synopsis:** +```bash +interfacectl init [options] +``` + +**Description:** +`init` is the primary user-facing entry point for Surfaces onboarding on web surfaces. It: +- analyzes either a local app root (`--app-root`) or a live URL (`--url`) +- classifies the surface as `marketing`, `application`, or `unknown` using platform-owned heuristics +- extracts UI-system attributes across typography, color, layout, motion, icons, shell/auth primitives, sections, and copy-role signals +- decides whether to adopt an existing design system or synthesize a first draft from repeated norms +- writes four artifacts under `contracts/generated/` +- runs `validate-extracted` plus contract validation and prints a short summary + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--url ` | Surface URL for remote onboarding | none | +| `--app-root ` | Local app root for source-backed onboarding | none | +| `--extract-mode ` | Source mode override | inferred from inputs | +| `--surface ` | Surface identifier override | inferred from URL/path | +| `--surface-name ` | Surface display name override | derived from surface id | +| `--surface-kind ` | Confirm low-confidence classification in non-interactive flows | inferred | +| `--auth-profile ` | Replay a saved browser-session auth profile for protected remote onboarding | none | +| `--non-interactive` | Disable prompts | `false` | +| `--out-dir ` | Output directory for generated artifacts | `contracts/generated` | +| `--analysis-out ` | Explicit output path for `.analysis.json` | derived from `--out-dir` | +| `--draft-out ` | Explicit output path for `.design-system.draft.json` | derived from `--out-dir` | +| `--contract-out ` | Explicit output path for `.contract.json` | derived from `--out-dir` | +| `--report-out ` | Explicit output path for `.extraction.json` | derived from `--out-dir` | + +**Artifacts:** + +- `contracts/generated/.analysis.json` +- `contracts/generated/.design-system.draft.json` +- `contracts/generated/.contract.json` +- `contracts/generated/.extraction.json` + +**Notes:** +- First-run output is warn-first. Findings are surfaced in the summary rather than blocking the onboarding command unless artifact generation or validation infrastructure fails. +- If surface-kind inference is low confidence, interactive mode asks for confirmation. Non-interactive mode must pass `--surface-kind`. +- For protected remote URLs, `--auth-profile` must point at a replay-ready profile. Interactive `init` can capture one; non-interactive mode fails fast if the profile is missing, expired, legacy, or not replayable. +- First-party or dogfood surfaces are not used as baselines for inference or starter recommendations. + +--- + +### `analyze` + +Machine-readable first-run analysis for web surfaces. + +**Synopsis:** +```bash +interfacectl analyze [options] +``` + +**Description:** +`analyze` uses the same first-run analysis engine as `init` but writes only the analysis artifact. It is intended for power users and CI preparation when you want explainable classification and extraction evidence without generating the draft contract/draft-system artifacts. + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--url ` | Surface URL for remote analysis | none | +| `--app-root ` | Local app root for source-backed analysis | none | +| `--extract-mode ` | Source mode override | inferred from inputs | +| `--surface ` | Surface identifier override | inferred from URL/path | +| `--surface-name ` | Surface display name override | derived from surface id | +| `--surface-kind ` | Optional classification confirmation override | inferred | +| `--auth-profile ` | Replay a saved browser-session auth profile for protected remote analysis | none | +| `--out ` | Output file path for the analysis artifact | `contracts/generated/.analysis.json` | +| `--out-dir ` | Output directory when `--out` is not supplied | `contracts/generated` | + +**Output:** +- `contracts/generated/.analysis.json` + +--- + +### `auth` + +Manage replayable browser-session auth profiles for protected remote onboarding. + +**Synopsis:** +```bash +interfacectl auth [options] +``` + +**Subcommands:** + +#### `auth capture` + +Launches Chromium, opens a clean browser context, waits for manual sign-in, then stores the resulting Playwright `storageState` as a replayable auth profile. + +```bash +interfacectl auth capture --profile --url [--format text|json] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `--profile ` | Profile name | required | +| `--url ` | URL on the exact host to capture | required | +| `--format ` | Output format | `text` | + +Notes: +- Profiles are exact-host scoped in v1. If capture ends on a different hostname, the command fails and requires a capture on that host instead. +- Replay state is stored separately from metadata. `interfacectl` uses keychain-first storage with encrypted local-file fallback. +- Generated onboarding artifacts and CLI JSON outputs never include cookies, storage entries, or tokens. + +#### `auth list` + +Lists locally stored auth profiles and their replay readiness. + +```bash +interfacectl auth list [--format text|json] +``` + +The output includes `status`, `replayReady`, `capturedAt`, `expiresAt`, and storage mode metadata. + +#### `auth test` + +Validates a local auth profile. Without `--url`, it checks replay readiness only. With `--url`, it performs a real replayed navigation using the same browser observer as remote onboarding. + +```bash +interfacectl auth test --profile [--domain ] [--url ] [--format text|json] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `--profile ` | Profile name | required | +| `--domain ` | Optional exact-host scope override | inferred from `--url` when present | +| `--url ` | Protected URL to validate authenticated replay against | none | +| `--format ` | Output format | `text` | + +Notes: +- If `--auth-profile` is explicitly supplied to `init` or `analyze`, interfacectl does not silently fall back to anonymous access. +- Legacy v1 profiles remain listable but are not replayable; they must be re-captured. + +#### `auth revoke` / `auth clear` + +Deletes local auth profile metadata and any stored replay state. + +```bash +interfacectl auth revoke --profile --domain +interfacectl auth clear --all +``` + +If Chromium is not installed locally, install it with: + +```bash +pnpm --filter @surfaces/interfacectl-cli exec playwright install chromium +``` + +--- + ### `validate` Validates configured surfaces against a shared interface contract. @@ -457,7 +615,7 @@ Fails if the contract’s Phase 0 expectations (`surfaces[].phase0`) conflict wi **Synopsis:** ```bash -interfacectl validate-extracted --contract --extracted [--surface ] [--format text|json] [--exit-codes v1|v2] +interfacectl validate-extracted --contract --extracted [--surface ] [--format text|json] [--out ] [--exit-codes v1|v2] ``` **Contract shape (policy only; no x_* in policy):** diff --git a/README.md b/README.md index 5098eb0..6bec58a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This repository contains three packages: - **`@surfaces/interfacectl-cli`** — Command-line interface that consumes the validator to run contract checks from any repository. Most users only need this package. -- **`@surfaces/interfacectl-extractor`** — Library that extracts a contract from a Next.js app (Phase 0). Used by the CLI `generate-contract` command. Exports `extractContractFromNextApp({ appRoot, surfaceId })`. +- **`@surfaces/interfacectl-extractor`** — Library that extracts a contract from a Next.js app (Phase 0). Used by the CLI `init`, `analyze`, and `generate-contract` flows. Exports `extractContractFromNextApp({ appRoot, surfaceId })`. ## Requirements @@ -36,17 +36,67 @@ pnpm add -D @surfaces/interfacectl-cli ## Quick Start -After installation, validate your surfaces against a contract: +For a first-time web surface experience, start with `init`: ```bash -interfacectl validate --root . --contract ./contracts/ui.contract.json +interfacectl init --app-root apps/my-app --surface my-app +``` + +This inspects your surface, classifies it as `marketing`, `application`, or `unknown`, drafts a schema-valid contract, and emits a first design-system draft from repeated UI norms. For detailed command documentation, see [API.md](API.md). + +For protected remote apps, capture a replayable browser session first: + +```bash +interfacectl auth capture --profile staging-admin --url https://app.example.com/login +interfacectl init --url https://app.example.com --surface customer-app --auth-profile staging-admin ``` -For detailed command documentation, see [API.md](API.md). +`interfacectl` replays the saved browser session in Chromium, analyzes the rendered authenticated page, and keeps auth state out of generated artifacts. ## Commands Overview -The CLI provides four main commands: +The CLI provides two first-run commands, browser-session auth helpers, and the validation and enforcement commands: + +### `init` + +First-run onboarding for web surfaces. `init` analyzes either a local app root or a URL, drafts a first contract, writes a draft design-system artifact, runs validation, and prints a short onboarding summary grouped as adopted, normalized, flagged, and next steps. + +```bash +interfacectl init [options] +``` + +Outputs: + +- `contracts/generated/.analysis.json` +- `contracts/generated/.design-system.draft.json` +- `contracts/generated/.contract.json` +- `contracts/generated/.extraction.json` + +Default behavior is warn-first. If surface-kind inference is low confidence, interactive mode asks for confirmation and non-interactive mode requires `--surface-kind marketing|application|unknown`. + +### `analyze` + +Machine-readable first-run analysis for power users. `analyze` uses the same platform-owned heuristics as `init` but only writes the analysis artifact; it does not mutate contracts outside `contracts/generated/.analysis.json`. + +```bash +interfacectl analyze [options] +``` + +### `auth` + +Browser-session profile management for protected remote onboarding. Use `auth capture` to create or refresh a replayable profile, `auth list` to inspect readiness, `auth test --url ...` to verify real authenticated replay, and `auth revoke` / `auth clear` to remove stored state. + +```bash +interfacectl auth capture --profile --url +interfacectl auth test --profile --url +interfacectl auth list +``` + +Notes: + +- Profiles are exact-host scoped in v1. +- Remote analysis never silently falls back to anonymous access when `--auth-profile` is explicitly supplied. +- If Chromium is missing locally, install it with `pnpm --filter @surfaces/interfacectl-cli exec playwright install chromium`. ### `validate` @@ -82,9 +132,9 @@ This command does **not** perform enforcement or runtime gating. It produces a s interfacectl compile --contract --out ``` -### `generate-contract` (Phase 0) +### `generate-contract` (Phase 0 expert command) -Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no enforcement, no network calls. +Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no first-run classification, no design-system draft, and no onboarding summary. Prefer `interfacectl init` for the user-facing entry point. **Phase 0 scope:** Routes (app router), layout shell presence (`app/layout.tsx` or `app/(shell)/layout.tsx`), design system usage (`@surfaces/ui` component imports), and auth posture (`/auth` routes). The command also seeds `color.allowedValues` and web-surface `icons.allowedSources` from observed descriptors (default `icons.policy: "warn"`). Values that cannot be extracted safely are omitted and reported as warnings in the extraction report. @@ -101,9 +151,9 @@ interfacectl generate-contract --app-root --surface [--out

--extracted [--surface ] [--format text|json] [--exit-codes v2] @@ -176,6 +226,27 @@ Runtime (not implemented here) ## Usage Examples +### First run + +Analyze and draft a first contract from a local web app: + +```bash +interfacectl init --app-root apps/my-app --surface my-app +``` + +Inspect a public URL without writing a contract: + +```bash +interfacectl analyze --url https://example.com --surface example-site +``` + +Capture a protected app session and analyze the authenticated surface: + +```bash +interfacectl auth capture --profile customer-admin --url https://app.example.com/login +interfacectl analyze --url https://app.example.com/dashboard --surface customer-app --auth-profile customer-admin +``` + ### Validation Validate all surfaces against a contract: diff --git a/packages/interfacectl-cli/dist/commands/analyze.d.ts b/packages/interfacectl-cli/dist/commands/analyze.d.ts new file mode 100644 index 0000000..563842e --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/analyze.d.ts @@ -0,0 +1,16 @@ +import { type AnalysisSourceMode, type WebSurfaceKind } from "../utils/first-run-analysis.js"; +type ExtractMode = AnalysisSourceMode; +export interface AnalyzeCommandOptions { + url?: string; + appRoot?: string; + extractMode?: ExtractMode; + surface?: string; + surfaceName?: string; + surfaceKind?: WebSurfaceKind; + authProfile?: string; + out?: string; + outDir?: string; +} +export declare function runAnalyzeCommand(options: AnalyzeCommandOptions): Promise; +export {}; +//# sourceMappingURL=analyze.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/analyze.d.ts.map b/packages/interfacectl-cli/dist/commands/analyze.d.ts.map new file mode 100644 index 0000000..2388de1 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/analyze.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/commands/analyze.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACpB,MAAM,gCAAgC,CAAC;AAIxC,KAAK,WAAW,GAAG,kBAAkB,CAAC;AAEtC,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAoCD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,CA+EvF"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/analyze.js b/packages/interfacectl-cli/dist/commands/analyze.js new file mode 100644 index 0000000..65481b2 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/analyze.js @@ -0,0 +1,106 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { inspectAuthProfile } from "../utils/auth-profiles.js"; +import { analyzeSurface, stringifyStableArtifact, } from "../utils/first-run-analysis.js"; +import { suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName } from "../utils/onboarding.js"; +import { redactSensitiveText } from "../utils/redaction.js"; +const DEFAULT_OUT_DIR = "contracts/generated"; +function normalizeSurfaceId(raw) { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} +function inferSourceMode(options) { + if (options.extractMode === "local-root" || options.extractMode === "remote-url") { + return options.extractMode; + } + if (options.appRoot && !options.url) { + return "local-root"; + } + if (options.appRoot) { + return "local-root"; + } + return "remote-url"; +} +function resolveOutputPath(rootDir, surfaceId, options) { + if (options.out) { + return path.resolve(rootDir, options.out); + } + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, DEFAULT_OUT_DIR); + return path.join(outDir, `${surfaceId}.analysis.json`); +} +export async function runAnalyzeCommand(options) { + const rootDir = process.cwd(); + try { + const sourceMode = inferSourceMode(options); + if (sourceMode === "remote-url" && !options.url) { + throw new Error("Missing required --url for remote-url analysis."); + } + if (sourceMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for local-root analysis."); + } + if (sourceMode === "local-root") { + const appRoot = path.resolve(rootDir, options.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + throw new Error(`Local app root is missing app/: ${appRoot}`); + } + } + const surfaceSuggestion = options.surface ?? + (sourceMode === "remote-url" && options.url + ? suggestSurfaceIdFromUrl(options.url) + : suggestSurfaceIdFromPath(options.appRoot ?? "surface")); + const surfaceId = normalizeSurfaceId(surfaceSuggestion); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + let authMode = "none"; + let authProfileName; + let authStorageState; + if (sourceMode === "remote-url" && options.authProfile && options.url) { + const url = new URL(options.url); + const inspection = await inspectAuthProfile(options.authProfile, url.hostname); + if (inspection.status !== "ready" || !inspection.profile || !inspection.storageState) { + const reason = inspection.status === "missing" + ? "was not found" + : inspection.status === "expired" + ? "is expired" + : inspection.status === "legacy" + ? "is legacy and must be re-captured" + : "is not replay-ready and must be re-captured"; + throw new Error(`Auth profile "${options.authProfile}" for ${url.hostname} ${reason}.`); + } + authMode = "browser-session"; + authProfileName = inspection.profile.name; + authStorageState = inspection.storageState; + } + const result = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId, + surfaceName, + sourceMode, + appRoot: options.appRoot, + url: options.url, + surfaceKindOverride: options.surfaceKind, + authMode, + authProfileName, + authStorageState, + }); + const outputPath = resolveOutputPath(rootDir, surfaceId, options); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, stringifyStableArtifact(result.analysis), "utf-8"); + console.log(`Wrote analysis: ${outputPath}`); + console.log(`Inferred surface kind: ${result.analysis.classification.inferredKind} (${result.analysis.classification.confidence.toFixed(2)})`); + if (result.analysis.classification.requiresConfirmation && !options.surfaceKind) { + console.log("Note: classification is low confidence; pass --surface-kind to confirm seeding intent."); + } + return 0; + } + catch (error) { + console.error(redactSensitiveText(error.message)); + return 1; + } +} diff --git a/packages/interfacectl-cli/dist/commands/auth.d.ts b/packages/interfacectl-cli/dist/commands/auth.d.ts index a6fa80b..da8909c 100644 --- a/packages/interfacectl-cli/dist/commands/auth.d.ts +++ b/packages/interfacectl-cli/dist/commands/auth.d.ts @@ -1,11 +1,13 @@ export interface AuthCommandOptions { profile?: string; domain?: string; + url?: string; all?: boolean; format?: "text" | "json"; } export declare function runAuthListCommand(): Promise; export declare function runAuthListCommandWithOptions(options: AuthCommandOptions): Promise; +export declare function runAuthCaptureCommand(options: AuthCommandOptions): Promise; export declare function runAuthTestCommand(options: AuthCommandOptions): Promise; export declare function runAuthClearCommand(options: AuthCommandOptions): Promise; //# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/auth.d.ts.map b/packages/interfacectl-cli/dist/commands/auth.d.ts.map index 2125b83..ed41f3f 100644 --- a/packages/interfacectl-cli/dist/commands/auth.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/auth.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAED,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAE1D;AAED,wBAAsB,6BAA6B,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0BhG;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqDrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgCtF"} \ No newline at end of file +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAkBD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAE1D;AAED,wBAAsB,6BAA6B,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0BhG;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA+DxF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0JrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgCtF"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/auth.js b/packages/interfacectl-cli/dist/commands/auth.js index a828582..cc96b08 100644 --- a/packages/interfacectl-cli/dist/commands/auth.js +++ b/packages/interfacectl-cli/dist/commands/auth.js @@ -1,4 +1,20 @@ -import { clearAuthProfiles, findAuthProfile, getAuthStorageMode, isProfileExpired, listAuthProfiles, } from "../utils/auth-profiles.js"; +import { captureBrowserStorageState, observeRemotePage } from "../utils/browser-session.js"; +import { clearAuthProfiles, getAuthStorageMode, inspectAuthProfile, isLegacyAuthProfile, isProfileExpired, isProfileReplayReady, listAuthProfiles, saveReplayAuthProfile, } from "../utils/auth-profiles.js"; +function buildProfileStatus(profile) { + if (!profile) { + return "missing"; + } + if (isProfileExpired(profile)) { + return "expired"; + } + if (isLegacyAuthProfile(profile)) { + return "legacy"; + } + if (isProfileReplayReady(profile)) { + return "ready"; + } + return "not-ready"; +} export async function runAuthListCommand() { return runAuthListCommandWithOptions({}); } @@ -11,7 +27,8 @@ export async function runAuthListCommandWithOptions(options) { storageMode, profiles: profiles.map((profile) => ({ ...profile, - status: isProfileExpired(profile) ? "expired" : "active", + replayReady: isProfileReplayReady(profile), + status: buildProfileStatus(profile), })), }; console.log(JSON.stringify(payload, null, 2)); @@ -22,11 +39,63 @@ export async function runAuthListCommandWithOptions(options) { return 0; } for (const profile of profiles) { - const status = isProfileExpired(profile) ? "expired" : "active"; - console.log(`${profile.name} (${profile.domain}) mode=${profile.mode} status=${status} expires=${profile.expiresAt}`); + console.log(`${profile.name} (${profile.domain}) status=${buildProfileStatus(profile)} replayReady=${isProfileReplayReady(profile)} capturedAt=${profile.capturedAt ?? "n/a"} expires=${profile.expiresAt} storage=${storageMode}`); } return 0; } +export async function runAuthCaptureCommand(options) { + if (!options.profile || !options.url) { + const error = "Missing required --profile and/or --url for auth capture."; + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error }, null, 2)); + return 1; + } + console.error(error); + return 1; + } + try { + const requestedUrl = new URL(options.url); + const captured = await captureBrowserStorageState({ + url: requestedUrl.toString(), + }); + const finalUrl = new URL(captured.finalUrl); + if (finalUrl.hostname !== requestedUrl.hostname) { + throw new Error(`Capture finished on ${finalUrl.hostname}, but the requested host was ${requestedUrl.hostname}. Capture a profile for the final host instead.`); + } + const profile = await saveReplayAuthProfile({ + name: options.profile, + domain: finalUrl.hostname, + storageState: captured.storageState, + captureBrowser: "chromium", + }); + if (options.format === "json") { + console.log(JSON.stringify({ + ok: true, + storageMode: getAuthStorageMode(), + profile: { + ...profile, + replayReady: true, + status: buildProfileStatus(profile), + }, + finalUrl: finalUrl.toString(), + }, null, 2)); + return 0; + } + console.log(`Captured auth profile: ${profile.name} (${profile.domain})`); + console.log(`Final URL: ${finalUrl.toString()}`); + console.log(`Storage: ${getAuthStorageMode()}`); + return 0; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + return 1; + } + console.error(message); + return 1; + } +} export async function runAuthTestCommand(options) { if (!options.profile) { if (options.format === "json") { @@ -36,38 +105,114 @@ export async function runAuthTestCommand(options) { console.error("Missing --profile for auth test."); return 1; } - const profile = await findAuthProfile(options.profile, options.domain); - if (!profile) { + const domain = options.url ? new URL(options.url).hostname : options.domain; + if (!domain) { if (options.format === "json") { - console.log(JSON.stringify({ ok: false, error: `Auth profile not found: ${options.profile}` }, null, 2)); + console.log(JSON.stringify({ ok: false, error: "Provide --domain or --url for auth test." }, null, 2)); return 1; } - console.error(`Auth profile not found: ${options.profile}`); + console.error("Provide --domain or --url for auth test."); return 1; } - if (isProfileExpired(profile)) { + const inspection = await inspectAuthProfile(options.profile, domain); + if (inspection.status !== "ready" || !inspection.profile || !inspection.storageState) { + const error = inspection.status === "missing" + ? `Auth profile not found: ${options.profile} (${domain})` + : inspection.status === "expired" + ? `Auth profile expired: ${options.profile} (${domain})` + : inspection.status === "legacy" + ? `Auth profile is legacy and must be re-captured: ${options.profile} (${domain})` + : `Auth profile is not replay-ready and must be re-captured: ${options.profile} (${domain})`; if (options.format === "json") { console.log(JSON.stringify({ ok: false, - error: `Auth profile expired: ${profile.name} (${profile.domain})`, + error, storageMode: getAuthStorageMode(), - profile: { ...profile, status: "expired" }, + profile: inspection.profile + ? { + ...inspection.profile, + replayReady: isProfileReplayReady(inspection.profile), + status: buildProfileStatus(inspection.profile), + } + : undefined, }, null, 2)); return 1; } - console.error(`Auth profile expired: ${profile.name} (${profile.domain})`); + console.error(error); return 1; } - if (options.format === "json") { - console.log(JSON.stringify({ - ok: true, - storageMode: getAuthStorageMode(), - profile: { ...profile, status: "active" }, - }, null, 2)); + if (!options.url) { + if (options.format === "json") { + console.log(JSON.stringify({ + ok: true, + storageMode: getAuthStorageMode(), + profile: { + ...inspection.profile, + replayReady: true, + status: buildProfileStatus(inspection.profile), + }, + }, null, 2)); + return 0; + } + console.log(`Auth profile replay-ready: ${inspection.profile.name} (${inspection.profile.domain})`); return 0; } - console.log(`Auth profile OK: ${profile.name} (${profile.domain})`); - return 0; + try { + const observation = await observeRemotePage({ + url: options.url, + storageState: inspection.storageState, + }); + const ok = new URL(observation.finalUrl).hostname === inspection.profile.domain && + !observation.loginDetected && + !observation.accessDeniedDetected; + if (options.format === "json") { + console.log(JSON.stringify({ + ok, + storageMode: getAuthStorageMode(), + profile: { + ...inspection.profile, + replayReady: true, + status: buildProfileStatus(inspection.profile), + }, + finalUrl: observation.finalUrl, + loginDetected: observation.loginDetected, + accessDeniedDetected: observation.accessDeniedDetected, + }, null, 2)); + return ok ? 0 : 1; + } + if (!ok) { + console.error(`Auth replay failed for ${inspection.profile.name} (${inspection.profile.domain}).`); + console.error(`Final URL: ${observation.finalUrl}`); + if (observation.loginDetected) { + console.error("The replayed session still resolved to a login page."); + } + if (observation.accessDeniedDetected) { + console.error("The replayed session resolved to an access-denied page."); + } + return 1; + } + console.log(`Auth replay OK: ${inspection.profile.name} (${inspection.profile.domain})`); + console.log(`Final URL: ${observation.finalUrl}`); + return 0; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.format === "json") { + console.log(JSON.stringify({ + ok: false, + error: message, + storageMode: getAuthStorageMode(), + profile: { + ...inspection.profile, + replayReady: true, + status: buildProfileStatus(inspection.profile), + }, + }, null, 2)); + return 1; + } + console.error(message); + return 1; + } } export async function runAuthClearCommand(options) { if (!options.all && !options.profile && !options.domain) { diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts b/packages/interfacectl-cli/dist/commands/init.d.ts index 287969a..420d7b3 100644 --- a/packages/interfacectl-cli/dist/commands/init.d.ts +++ b/packages/interfacectl-cli/dist/commands/init.d.ts @@ -1,13 +1,19 @@ -type ExtractMode = "remote-url" | "local-root"; +import { type AnalysisSourceMode, type WebSurfaceKind } from "../utils/first-run-analysis.js"; +type ExtractMode = AnalysisSourceMode; export interface InitOptions { url?: string; surface?: string; surfaceName?: string; + surfaceKind?: WebSurfaceKind; authProfile?: string; extractMode?: ExtractMode; appRoot?: string; nonInteractive?: boolean; outDir?: string; + analysisOut?: string; + draftOut?: string; + contractOut?: string; + reportOut?: string; } export declare function runInitCommand(options: InitOptions): Promise; export {}; diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts.map b/packages/interfacectl-cli/dist/commands/init.d.ts.map index 05cc638..177919f 100644 --- a/packages/interfacectl-cli/dist/commands/init.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/init.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AA6BA,KAAK,WAAW,GAAG,YAAY,GAAG,YAAY,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAmKD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAyK1E"} \ No newline at end of file +{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAmBA,OAAO,EAGL,KAAK,kBAAkB,EAEvB,KAAK,cAAc,EACpB,MAAM,gCAAgC,CAAC;AAIxC,KAAK,WAAW,GAAG,kBAAkB,CAAC;AA+BtC,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAkVD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CA8M1E"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/init.js b/packages/interfacectl-cli/dist/commands/init.js index 2b1fe1c..804cc76 100644 --- a/packages/interfacectl-cli/dist/commands/init.js +++ b/packages/interfacectl-cli/dist/commands/init.js @@ -1,15 +1,19 @@ -import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { extractContractFromNextApp, stableStringify } from "@surfaces/interfacectl-extractor"; import { getBundledContractSchema, validateContractStructure, } from "@surfaces/interfacectl-validator"; -import { findAuthProfile, getAuthStorageMode, isProfileExpired, saveBrowserSessionProfile, } from "../utils/auth-profiles.js"; -import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js"; -import { redactSensitiveText, redactSensitiveUrl } from "../utils/redaction.js"; -import { buildBootstrapContract, emitBootstrapRunArtifact, suggestSurfaceIdFromUrl, suggestSurfaceName, writeBootstrapArtifacts, } from "../utils/onboarding.js"; +import { runValidateCommand } from "./validate.js"; +import { runValidateExtractedCommand } from "./validate-extracted.js"; +import { getAuthStorageMode, inspectAuthProfile, saveReplayAuthProfile, } from "../utils/auth-profiles.js"; +import { captureBrowserStorageState } from "../utils/browser-session.js"; +import { analyzeSurface, stringifyStableArtifact, } from "../utils/first-run-analysis.js"; +import { emitOnboardingRunArtifact, suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName } from "../utils/onboarding.js"; +import { redactSensitiveText } from "../utils/redaction.js"; +const DEFAULT_OUT_DIR = "contracts/generated"; +const VALID_SURFACE_KINDS = new Set(["marketing", "application", "unknown"]); function normalizeSurfaceId(raw) { return raw .trim() @@ -18,14 +22,17 @@ function normalizeSurfaceId(raw) { .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } -function browserOpenCommand(url) { - if (process.platform === "darwin") { - return { cmd: "open", args: [url] }; +function inferSourceMode(options) { + if (options.extractMode === "local-root" || options.extractMode === "remote-url") { + return options.extractMode; } - if (process.platform === "win32") { - return { cmd: "cmd", args: ["/c", "start", "", url] }; + if (options.appRoot && !options.url) { + return "local-root"; } - return { cmd: "xdg-open", args: [url] }; + if (options.appRoot) { + return "local-root"; + } + return "remote-url"; } async function maybeCaptureAuthProfile(inputValue) { if (!inputValue.requiresAuth) { @@ -33,59 +40,78 @@ async function maybeCaptureAuthProfile(inputValue) { } const parsed = new URL(inputValue.url); const profileName = inputValue.profileName ?? `${parsed.hostname}-default`; - const existing = await findAuthProfile(profileName, parsed.hostname); - if (existing && !isProfileExpired(existing)) { - return { authMode: "browser-session", profileName: existing.name }; + const inspection = await inspectAuthProfile(profileName, parsed.hostname); + if (inspection.status === "ready" && inspection.profile && inspection.storageState) { + return { + authMode: "browser-session", + profileName: inspection.profile.name, + storageState: inspection.storageState, + }; } - const openTarget = browserOpenCommand(inputValue.url); - const child = spawn(openTarget.cmd, openTarget.args, { - stdio: "ignore", - detached: true, - }); - child.unref(); - const rl = readline.createInterface({ input, output }); - try { - await rl.question(`Opened browser for ${parsed.hostname}. Complete login, then press Enter to continue.`); + if (inputValue.nonInteractive) { + const reason = inspection.status === "missing" + ? "was not found" + : inspection.status === "expired" + ? "is expired" + : inspection.status === "legacy" + ? "is legacy and must be re-captured" + : "is not replay-ready and must be re-captured"; + throw new Error(`Auth profile "${profileName}" for ${parsed.hostname} ${reason}. Capture it interactively first or omit --auth-profile.`); } - finally { - rl.close(); + const captured = await captureBrowserStorageState({ + url: parsed.toString(), + }); + const finalUrl = new URL(captured.finalUrl); + if (finalUrl.hostname !== parsed.hostname) { + throw new Error(`Capture finished on ${finalUrl.hostname}, but the requested host was ${parsed.hostname}. Capture a profile for the final host instead.`); } - const profile = await saveBrowserSessionProfile({ + const profile = await saveReplayAuthProfile({ name: profileName, domain: parsed.hostname, + storageState: captured.storageState, + captureBrowser: "chromium", }); - return { authMode: "browser-session", profileName: profile.name }; + return { + authMode: "browser-session", + profileName: profile.name, + storageState: captured.storageState, + }; } async function promptInteractive(options) { const rl = readline.createInterface({ input, output }); try { - const url = options.url ?? (await rl.question("What is the first surface URL? ")).trim(); - const parsed = new URL(url); - const requiresAuthAnswer = (await rl.question("Is this surface behind login? (y/N) ")).trim().toLowerCase(); - const requiresAuth = requiresAuthAnswer === "y" || requiresAuthAnswer === "yes"; - const extractModeInput = (options.extractMode ?? - ((await rl.question("Extraction mode (remote-url/local-root) [remote-url]: ")).trim() || - "remote-url")).toLowerCase(); - const extractMode = extractModeInput === "local-root" ? "local-root" : "remote-url"; - const suggestedSurface = options.surface ?? suggestSurfaceIdFromUrl(parsed.toString()); - const providedSurface = (await rl.question(`Surface id [${suggestedSurface}]: `)).trim(); - const surfaceId = normalizeSurfaceId(providedSurface || suggestedSurface); - const suggestedName = options.surfaceName ?? suggestSurfaceName(surfaceId); - const providedName = (await rl.question(`Surface name [${suggestedName}]: `)).trim(); - const surfaceName = providedName || suggestedName; + const inferredMode = inferSourceMode(options); + const rawMode = ((options.extractMode ?? + (await rl.question(`Source mode (local-root/remote-url) [${inferredMode}]: `)).trim()) || + inferredMode).toLowerCase(); + const sourceMode = rawMode === "remote-url" ? "remote-url" : "local-root"; + const url = sourceMode === "remote-url" + ? new URL(options.url ?? (await rl.question("Surface URL: ")).trim()).toString() + : options.url?.trim() || undefined; + const appRoot = sourceMode === "local-root" + ? (options.appRoot ?? (await rl.question("Local app root: "))).trim() + : undefined; + const suggestedSurfaceId = options.surface ?? (sourceMode === "remote-url" && url + ? suggestSurfaceIdFromUrl(url) + : suggestSurfaceIdFromPath(appRoot ?? "surface")); + const rawSurfaceId = (await rl.question(`Surface id [${suggestedSurfaceId}]: `)).trim(); + const surfaceId = normalizeSurfaceId(rawSurfaceId || suggestedSurfaceId); + const suggestedSurfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const rawSurfaceName = (await rl.question(`Surface name [${suggestedSurfaceName}]: `)).trim(); + const surfaceName = rawSurfaceName || suggestedSurfaceName; + const requiresAuth = sourceMode === "remote-url" + ? ["y", "yes"].includes((await rl.question(`Does ${new URL(url ?? "https://example.com").hostname} require login? (y/N) `)).trim().toLowerCase()) + : false; const authProfileName = requiresAuth - ? (await rl.question(`Auth profile name [${options.authProfile ?? `${parsed.hostname}-default`}]: `)).trim() || options.authProfile || `${parsed.hostname}-default` + ? (await rl.question(`Auth profile name [${options.authProfile ?? `${new URL(url).hostname}-default`}]: `)).trim() || options.authProfile || `${new URL(url).hostname}-default` : null; - const appRoot = extractMode === "local-root" - ? (options.appRoot ?? - (await rl.question("Local app root (directory containing app/): "))).trim() - : undefined; return { - url: parsed.toString(), - extractMode, + sourceMode, + url, appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, surfaceId, surfaceName, + surfaceKind: options.surfaceKind, requiresAuth, authProfileName, }; @@ -98,190 +124,288 @@ async function resolveInputs(options) { if (!options.nonInteractive) { return promptInteractive(options); } - if (!options.url) { - throw new Error("Missing required --url in --non-interactive mode."); + const sourceMode = inferSourceMode(options); + if (sourceMode === "remote-url" && !options.url) { + throw new Error("Missing required --url for remote-url onboarding."); } - const parsed = new URL(options.url); - const extractMode = options.extractMode ?? "remote-url"; - const surfaceId = normalizeSurfaceId(options.surface ?? suggestSurfaceIdFromUrl(parsed.toString())); - const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); - const requiresAuth = Boolean(options.authProfile); - if (extractMode === "local-root" && !options.appRoot) { - throw new Error("Missing required --app-root for --extract-mode local-root."); + if (sourceMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for local-root onboarding."); } + const surfaceSuggestion = options.surface ?? + (sourceMode === "remote-url" && options.url + ? suggestSurfaceIdFromUrl(options.url) + : suggestSurfaceIdFromPath(options.appRoot ?? "surface")); + const surfaceId = normalizeSurfaceId(surfaceSuggestion); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); return { - url: parsed.toString(), - extractMode, + sourceMode, + url: options.url ? new URL(options.url).toString() : undefined, appRoot: options.appRoot, surfaceId, surfaceName, - requiresAuth, - authProfileName: options.authProfile ?? null, + surfaceKind: options.surfaceKind, + requiresAuth: sourceMode === "remote-url" && Boolean(options.authProfile), + authProfileName: sourceMode === "remote-url" ? options.authProfile ?? null : null, + }; +} +async function promptSurfaceKind(analysis) { + const rl = readline.createInterface({ input, output }); + try { + console.log(`Surface kind needs confirmation. interfacectl inferred "${analysis.classification.inferredKind}" (${analysis.classification.confidence.toFixed(2)} confidence).`); + for (const evidence of analysis.classification.supporting.slice(0, 3)) { + console.log(` support: ${evidence.message}`); + } + for (const evidence of analysis.classification.opposing.slice(0, 2)) { + console.log(` counter: ${evidence.message}`); + } + while (true) { + const answer = (await rl.question(`Confirm surface kind [${analysis.classification.inferredKind}]: `)).trim().toLowerCase(); + const value = (answer || analysis.classification.inferredKind); + if (VALID_SURFACE_KINDS.has(value)) { + return value; + } + console.log("Expected one of: marketing, application, unknown."); + } + } + finally { + rl.close(); + } +} +function resolveArtifactPaths(rootDir, surfaceId, options) { + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, DEFAULT_OUT_DIR); + const resolvePath = (explicit, fileName) => explicit ? path.resolve(rootDir, explicit) : path.join(outDir, fileName); + return { + outDir, + analysisPath: resolvePath(options.analysisOut, `${surfaceId}.analysis.json`), + draftPath: resolvePath(options.draftOut, `${surfaceId}.design-system.draft.json`), + contractPath: resolvePath(options.contractOut, `${surfaceId}.contract.json`), + reportPath: resolvePath(options.reportOut, `${surfaceId}.extraction.json`), }; } -async function writeJson(pathname, value) { - await mkdir(path.dirname(pathname), { recursive: true }); - await writeFile(pathname, `${stableStringify(value)}\n`, "utf-8"); +async function writeArtifact(filePath, payload) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, stringifyStableArtifact(payload), "utf-8"); +} +async function readJsonFile(filePath) { + return JSON.parse(await readFile(filePath, "utf-8")); +} +function relativeDisplay(rootDir, filePath) { + return path.relative(rootDir, filePath) || "."; +} +function collectFlagMessages(analysis, validateResult, validateExtractedResult) { + const flagged = [ + ...analysis.warnings.map((warning) => warning.message), + ...analysis.inconsistencies.findings.map((finding) => finding.message), + ...(validateResult.findings ?? []).map((finding) => finding.message), + ...validateExtractedResult.findings.map((finding) => finding.message), + ]; + return [...new Set(flagged)].slice(0, 8); +} +function collectFindingCodes(analysis, validateResult, validateExtractedResult) { + return [ + ...analysis.warnings.map((warning) => `analysis.${warning.code}`), + ...analysis.inconsistencies.findings.map((finding) => `analysis.${finding.code}`), + ...(validateResult.findings ?? []).map((finding) => `validate.${finding.code}`), + ...validateExtractedResult.findings.map((finding) => `validate-extracted.${finding.code}`), + ].sort((a, b) => a.localeCompare(b)); +} +function summarizeAdopted(analysis) { + const reasons = analysis.existingSystem.reasons.slice(0, 3); + if (analysis.existingSystem.mode === "adopt") { + return reasons.length > 0 + ? reasons + : ["Observed enough repeated system structure to formalize an existing design system."]; + } + return [ + `No stable existing system was detected; interfacectl drafted a first system from repeated norms (${analysis.existingSystem.score.toFixed(2)} score).`, + ]; +} +function summarizeNormalized(analysis) { + const seedCounts = analysis.proposedContract.seedCounts; + const items = [ + `${seedCounts.typographyTokens} typography token seed(s)`, + `${seedCounts.layoutTokens} layout token seed(s)`, + `${seedCounts.motionTokens} motion token seed(s)`, + `${seedCounts.colors} color value(s)`, + `${seedCounts.sections} section marker(s)`, + ]; + if (analysis.proposedContract.suggestedMarketingProfile) { + items.push("starter marketing profile suggestions"); + } + return items; +} +function logStage(step, total, message) { + console.log(`[${step}/${total}] ${message}`); +} +function hasBlockingValidationError(validateResult, validateExtractedResult) { + return ((validateResult.findings ?? []).some((finding) => finding.category === "E0") || + validateExtractedResult.findings.some((finding) => finding.category === "E0")); } export async function runInitCommand(options) { + const rootDir = process.cwd(); + const storageMode = getAuthStorageMode(); try { - const rootDir = process.cwd(); const resolved = await resolveInputs(options); - const authCapture = await maybeCaptureAuthProfile({ - requiresAuth: resolved.requiresAuth, - profileName: resolved.authProfileName, - url: resolved.url, - }); - const startTime = new Date().toISOString(); - if (resolved.extractMode === "local-root") { + if (resolved.sourceMode === "local-root") { const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); if (!existsSync(path.join(appRoot, "app"))) { console.error(`Local app root is missing app/: ${appRoot}`); return 1; } - const outDir = options.outDir - ? path.resolve(rootDir, options.outDir) - : path.resolve(rootDir, "contracts", "generated"); - const contractPath = path.join(outDir, `${resolved.surfaceId}.contract.json`); - const reportPath = path.join(outDir, `${resolved.surfaceId}.extraction.json`); - const { contract: extractedContract, report } = await extractContractFromNextApp({ - appRoot, - surfaceId: resolved.surfaceId, - }); - const seeded = await seedColorPolicyFromObservedDescriptors({ - workspaceRoot: rootDir, - appRoot, - surfaceId: resolved.surfaceId, - contract: extractedContract, - }); - const contract = seeded.contract; - const reportWithSeedWarnings = { - ...report, - warnings: [...report.warnings, ...seeded.warnings], - }; - const structure = validateContractStructure(contract, getBundledContractSchema()); - if (!structure.ok) { - console.error("Generated contract failed schema validation:"); - for (const issue of structure.errors) { - console.error(` ${issue}`); - } + } + logStage(1, 5, "Discovering source"); + const authCapture = resolved.sourceMode === "remote-url" && resolved.url + ? await maybeCaptureAuthProfile({ + requiresAuth: resolved.requiresAuth, + profileName: resolved.authProfileName, + url: resolved.url, + nonInteractive: Boolean(options.nonInteractive), + }) + : { authMode: "none", storageState: undefined }; + logStage(2, 5, "Analyzing surface kind and UI system"); + let analysisResult = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: resolved.surfaceKind, + authMode: authCapture.authMode, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + }); + if (!resolved.surfaceKind && analysisResult.analysis.classification.requiresConfirmation) { + if (options.nonInteractive) { + console.error(`Surface kind inference was low confidence (${analysisResult.analysis.classification.inferredKind}, ${analysisResult.analysis.classification.confidence.toFixed(2)}). Re-run with --surface-kind marketing|application|unknown.`); return 1; } - const reportWithOnboarding = { - ...reportWithSeedWarnings, - onboarding: { - sourceUrl: redactSensitiveUrl(resolved.url), + const confirmedKind = await promptSurfaceKind(analysisResult.analysis); + if (confirmedKind !== analysisResult.analysis.classification.confirmedKind) { + analysisResult = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: confirmedKind, authMode: authCapture.authMode, - extractMode: resolved.extractMode, - profileName: authCapture.profileName, - profileDomain: new URL(resolved.url).hostname, - startedAt: startTime, - completedAt: new Date().toISOString(), - detection: { - adapter: "next-app-static-extractor", - framework: "nextjs", - profile: "codebase", - }, - }, - }; - await writeJson(contractPath, contract); - await writeJson(reportPath, reportWithOnboarding); - const status = reportWithOnboarding.warnings.length > 0 ? "warn" : "pass"; - const findingCodes = reportWithOnboarding.warnings.map((warning) => `extract.${warning.code}`); - const run = await emitBootstrapRunArtifact({ + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + }); + } + } + logStage(3, 5, "Seeding contract and draft design system"); + const structure = validateContractStructure(analysisResult.contract, getBundledContractSchema()); + if (!structure.ok) { + console.error("Generated contract failed schema validation:"); + for (const issue of structure.errors) { + console.error(` ${issue}`); + } + return 1; + } + const artifacts = resolveArtifactPaths(rootDir, resolved.surfaceId, options); + await writeArtifact(artifacts.analysisPath, analysisResult.analysis); + await writeArtifact(artifacts.draftPath, analysisResult.draft); + await writeArtifact(artifacts.contractPath, analysisResult.contract); + await writeArtifact(artifacts.reportPath, analysisResult.extractionReport); + logStage(4, 5, "Validating generated outputs"); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-validate-")); + try { + const validatePath = path.join(tempDir, "validate.json"); + const validateExtractedPath = path.join(tempDir, "validate-extracted.json"); + const validateExitCode = await runValidateCommand({ + contractPath: artifacts.contractPath, + workspaceRoot: rootDir, + surfaceFilters: [resolved.surfaceId], + descriptorOverrides: [analysisResult.descriptor], + outputFormat: "json", + outputPath: validatePath, + exitCodes: "v2", + }); + const validateExtractedExitCode = await runValidateExtractedCommand({ + contractPath: artifacts.contractPath, + extractedPath: artifacts.reportPath, + surfaceId: resolved.surfaceId, + format: "json", + outputPath: validateExtractedPath, + exitCodes: "v2", + }); + const validateResult = await readJsonFile(validatePath); + const validateExtractedResult = await readJsonFile(validateExtractedPath); + logStage(5, 5, "Writing onboarding lineage"); + const findingCodes = collectFindingCodes(analysisResult.analysis, validateResult, validateExtractedResult); + const blockingValidationError = hasBlockingValidationError(validateResult, validateExtractedResult); + const status = blockingValidationError + ? "fail" + : findingCodes.length > 0 + ? "warn" + : "pass"; + const run = await emitOnboardingRunArtifact({ rootDir, surfaceId: resolved.surfaceId, + source: "generation", status, findingCodes, - extractionPath: contractPath, - reportPath, + extractionPath: artifacts.contractPath, + reportPath: artifacts.reportPath, }); + const adopted = summarizeAdopted(analysisResult.analysis); + const normalized = summarizeNormalized(analysisResult.analysis); + const flagged = collectFlagMessages(analysisResult.analysis, validateResult, validateExtractedResult); console.log(`Onboarding completed for ${resolved.surfaceId}.`); - console.log(`Wrote contract: ${contractPath}`); - console.log(`Wrote report: ${reportPath}`); + console.log(`Wrote analysis: ${artifacts.analysisPath}`); + console.log(`Wrote draft: ${artifacts.draftPath}`); + console.log(`Wrote contract: ${artifacts.contractPath}`); + console.log(`Wrote report: ${artifacts.reportPath}`); console.log(`Run id: ${run.runId}`); console.log(`Auth storage: ${storageMode}`); if (storageMode === "file") { console.log("Warning: keychain unavailable; using local file storage for opaque session references."); } - console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); - return 0; - } - const url = new URL(resolved.url); - const authAware = authCapture.authMode === "browser-session"; - const bootstrapContract = buildBootstrapContract({ - surfaceId: resolved.surfaceId, - surfaceName: resolved.surfaceName, - sourceUrl: redactSensitiveUrl(url.toString()), - authAware, - }); - const warnings = [ - { - code: "remote-url.bootstrap-only", - message: "Remote URL mode creates bootstrap extraction metadata only. Use local-root mode for full static extraction.", - }, - { - code: "color-seed.remote-unavailable", - message: "Color allowlist was not seeded from code in remote-url mode; generated contract uses an empty allowlist with warn policy.", - }, - ]; - const report = { - surfaceId: resolved.surfaceId, - appRoot: url.origin, - warnings, - extracted: { - routes: [url.pathname || "/"], - hasShell: false, - designSystemComponents: [], - authAware, - }, - onboarding: { - sourceUrl: redactSensitiveUrl(url.toString()), - authMode: authCapture.authMode, - extractMode: "remote-url", - profileName: authCapture.profileName, - profileDomain: url.hostname, - startedAt: startTime, - completedAt: new Date().toISOString(), - detection: { - adapter: "remote-url-bootstrap", - framework: "unknown", - profile: "bootstrap", - }, - }, - }; - const written = await writeBootstrapArtifacts({ - rootDir, - outDir: options.outDir, - surfaceId: resolved.surfaceId, - contract: bootstrapContract, - report, - }); - const run = await emitBootstrapRunArtifact({ - rootDir, - surfaceId: resolved.surfaceId, - status: "warn", - findingCodes: ["extract.remote-url.bootstrap-only"], - extractionPath: written.contractPath, - reportPath: written.reportPath, - }); - console.log(`Onboarding completed for ${resolved.surfaceId}.`); - console.log(`Wrote contract: ${written.contractPath}`); - console.log(`Wrote report: ${written.reportPath}`); - if (authCapture.profileName) { - console.log(`Auth profile: ${authCapture.profileName}`); + if (authCapture.profileName) { + console.log(`Auth profile: ${authCapture.profileName}`); + } + console.log(""); + console.log("adopted"); + for (const line of adopted) { + console.log(` - ${line}`); + } + console.log("normalized"); + for (const line of normalized) { + console.log(` - ${line}`); + } + console.log("flagged"); + if (flagged.length === 0) { + console.log(" - No onboarding findings."); + } + else { + for (const line of flagged) { + console.log(` - ${line}`); + } + } + console.log("next steps"); + console.log(` - interfacectl validate-extracted --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --extracted ${relativeDisplay(rootDir, artifacts.reportPath)} --surface ${resolved.surfaceId}`); + if (resolved.sourceMode === "local-root") { + console.log(` - Add surfaceRoots.${resolved.surfaceId} = "${relativeDisplay(rootDir, path.resolve(rootDir, resolved.appRoot ?? "."))}" in interfacectl.config.json for repeatable source-backed validation.`); + console.log(` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`); + } + else { + console.log(` - Re-run with --app-root to enable source-backed validate once the local web app checkout is available.`); + } + return blockingValidationError || validateExitCode === 10 || validateExtractedExitCode === 10 + ? 1 + : 0; } - console.log(`Run id: ${run.runId}`); - console.log(`Auth storage: ${storageMode}`); - if (storageMode === "file") { - console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + finally { + await rm(tempDir, { recursive: true, force: true }); } - console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); - return 0; } catch (error) { console.error(redactSensitiveText(error.message)); return 1; } } -const storageMode = getAuthStorageMode(); diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts index b403fe0..2bca442 100644 --- a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts @@ -17,6 +17,7 @@ export interface ValidateExtractedOptions { extractedPath: string; surfaceId?: string; format?: "text" | "json"; + outputPath?: string; exitCodes?: "v1" | "v2"; } export interface Finding { diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map index b2c021c..3059d5e 100644 --- a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate-extracted.d.ts","sourceRoot":"","sources":["../../src/commands/validate-extracted.ts"],"names":[],"mappings":"AAMA,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAC;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AA4JD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,MAAM,CAAC,CAwIjB"} \ No newline at end of file +{"version":3,"file":"validate-extracted.d.ts","sourceRoot":"","sources":["../../src/commands/validate-extracted.ts"],"names":[],"mappings":"AAMA,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAC;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AA4JD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,MAAM,CAAC,CAoJjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.js b/packages/interfacectl-cli/dist/commands/validate-extracted.js index f55cfb6..4cb792e 100644 --- a/packages/interfacectl-cli/dist/commands/validate-extracted.js +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.js @@ -1,5 +1,5 @@ import path from "node:path"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { getExitCodeVersion } from "../utils/exit-codes.js"; const AUTH_ROUTES = ["/auth/login", "/auth/callback", "/auth/session", "/auth/logout"]; function normalizeExtracted(raw) { @@ -144,6 +144,21 @@ export async function runValidateExtractedCommand(options) { const cwd = process.cwd(); const exitCodeVersion = getExitCodeVersion({ exitCodes: options.exitCodes }); const format = (options.format ?? "text").toLowerCase() === "json" ? "json" : "text"; + const outputPath = options.outputPath + ? path.resolve(cwd, options.outputPath) + : undefined; + const emit = async (contents, stream = "stdout") => { + if (outputPath) { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, contents, "utf-8"); + return; + } + if (stream === "stderr") { + process.stderr.write(contents); + return; + } + process.stdout.write(contents); + }; let contract; try { const contractResolved = path.resolve(cwd, options.contractPath); @@ -153,7 +168,7 @@ export async function runValidateExtractedCommand(options) { catch (err) { const message = err instanceof Error ? err.message : String(err); if (format === "json") { - console.log(JSON.stringify({ + await emit(`${JSON.stringify({ ok: false, findings: [ { @@ -163,7 +178,7 @@ export async function runValidateExtractedCommand(options) { message: `Failed to load contract: ${message}`, }, ], - }, null, 2)); + }, null, 2)}\n`); } else { console.error(`Failed to load contract: ${message}`); @@ -177,7 +192,7 @@ export async function runValidateExtractedCommand(options) { catch (err) { const message = err instanceof Error ? err.message : String(err); if (format === "json") { - console.log(JSON.stringify({ + await emit(`${JSON.stringify({ ok: false, findings: [ { @@ -187,7 +202,7 @@ export async function runValidateExtractedCommand(options) { message: `Failed to load extracted file: ${message}`, }, ], - }, null, 2)); + }, null, 2)}\n`); } else { console.error("Extracted file must be an extraction report (surfaceId + extracted) or a generated contract with x_extracted. Use --surface when surfaceId cannot be inferred."); @@ -197,7 +212,7 @@ export async function runValidateExtractedCommand(options) { } if (!extractedData) { if (format === "json") { - console.log(JSON.stringify({ + await emit(`${JSON.stringify({ ok: false, findings: [ { @@ -207,7 +222,7 @@ export async function runValidateExtractedCommand(options) { message: "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", }, ], - }, null, 2)); + }, null, 2)}\n`); } else { console.error("Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id."); @@ -217,7 +232,7 @@ export async function runValidateExtractedCommand(options) { const phase0 = getPhase0ForSurface(contract, extractedData.surfaceId); if (!phase0) { if (format === "json") { - console.log(JSON.stringify({ ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, null, 2)); + await emit(`${JSON.stringify({ ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, null, 2)}\n`); } else { console.log(`No phase0 block for surface ${extractedData.surfaceId}; nothing to compare.`); @@ -229,7 +244,7 @@ export async function runValidateExtractedCommand(options) { const ok = sorted.length === 0; const exitCode = ok ? 0 : exitCodeVersion === "v2" ? 30 : 1; if (format === "json") { - console.log(JSON.stringify({ ok, findings: sorted }, null, 2)); + await emit(`${JSON.stringify({ ok, findings: sorted }, null, 2)}\n`); return exitCode; } if (sorted.length === 0) { diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts b/packages/interfacectl-cli/dist/commands/validate.d.ts index e28325e..267d6f0 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts @@ -1,3 +1,4 @@ +import { type SurfaceDescriptor } from "@surfaces/interfacectl-validator"; import { type ExitCodeVersion } from "../utils/exit-codes.js"; type OutputFormat = "text" | "json"; export interface ValidateCommandOptions { @@ -5,6 +6,7 @@ export interface ValidateCommandOptions { schemaPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; + descriptorOverrides?: SurfaceDescriptor[]; outputFormat?: OutputFormat; outputPath?: string; configPath?: string; diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map index 2c1603a..ab2227b 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAyTjB"} \ No newline at end of file +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,iBAAiB,EAIvB,MAAM,kCAAkC,CAAC;AAK1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC1C,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAiUjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index c38019e..566fa64 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -152,67 +152,73 @@ export async function runValidateCommand(options) { } const contract = structureResult.contract; const surfaceFilters = new Set((options.surfaceFilters ?? []).map((value) => value.trim())); - const structuralDescriptorResult = await collectSurfaceDescriptors({ - workspaceRoot, - contract, - surfaceFilters, - surfaceRootMap, - }); - if (structuralDescriptorResult.warnings.length > 0) { - if (!isJson) { - printHeader(pc.yellow("⚠ Surface descriptor warnings"), textReporter); + let descriptorsWithFlowArtifacts; + if (options.descriptorOverrides && options.descriptorOverrides.length > 0) { + descriptorsWithFlowArtifacts = options.descriptorOverrides.filter((descriptor) => surfaceFilters.size === 0 ? true : surfaceFilters.has(descriptor.surfaceId)); + } + else { + const structuralDescriptorResult = await collectSurfaceDescriptors({ + workspaceRoot, + contract, + surfaceFilters, + surfaceRootMap, + }); + if (structuralDescriptorResult.warnings.length > 0) { + if (!isJson) { + printHeader(pc.yellow("⚠ Surface descriptor warnings"), textReporter); + for (const warning of structuralDescriptorResult.warnings) { + textReporter.warn(pc.yellow(` • ${warning.message}`)); + } + } for (const warning of structuralDescriptorResult.warnings) { - textReporter.warn(pc.yellow(` • ${warning.message}`)); + findings.push(issueToFinding(warning, "warning")); } } - for (const warning of structuralDescriptorResult.warnings) { - findings.push(issueToFinding(warning, "warning")); - } - } - if (structuralDescriptorResult.errors.length > 0) { - if (!isJson) { - printHeader(pc.red("✖ Surface descriptor errors"), textReporter); + if (structuralDescriptorResult.errors.length > 0) { + if (!isJson) { + printHeader(pc.red("✖ Surface descriptor errors"), textReporter); + for (const error of structuralDescriptorResult.errors) { + textReporter.error(pc.red(` • ${error.message}`)); + } + } for (const error of structuralDescriptorResult.errors) { - textReporter.error(pc.red(` • ${error.message}`)); + findings.push(issueToFinding(error, "error")); } + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); + } + const flowDescriptorResult = await loadFlowDescriptorArtifacts({ + workspaceRoot, + contract, + surfaceFilters, + flowDescriptorPathMap, + }); + if (!flowDescriptorResult.ok) { + const message = `Failed to load flow descriptor artifact: ${flowDescriptorResult.error}`; + if (!isJson) { + printHeader(pc.red("✖ Flow descriptor artifact load failed"), textReporter); + textReporter.error(pc.red(flowDescriptorResult.error)); + } + findings.push({ + code: "flow-descriptor.load-error", + severity: "error", + category: "E0", + message, + surface: flowDescriptorResult.surfaceId, + location: flowDescriptorResult.path, + }); + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); } - for (const error of structuralDescriptorResult.errors) { - findings.push(issueToFinding(error, "error")); - } - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, contract.version ?? initialContractVersion); - } - const flowDescriptorResult = await loadFlowDescriptorArtifacts({ - workspaceRoot, - contract, - surfaceFilters, - flowDescriptorPathMap, - }); - if (!flowDescriptorResult.ok) { - const message = `Failed to load flow descriptor artifact: ${flowDescriptorResult.error}`; - if (!isJson) { - printHeader(pc.red("✖ Flow descriptor artifact load failed"), textReporter); - textReporter.error(pc.red(flowDescriptorResult.error)); - } - findings.push({ - code: "flow-descriptor.load-error", - severity: "error", - category: "E0", - message, - surface: flowDescriptorResult.surfaceId, - location: flowDescriptorResult.path, + descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map((descriptor) => { + const flowDescriptorPath = flowDescriptorResult.paths.get(descriptor.surfaceId); + return { + ...descriptor, + flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), + flowDescriptorPath, + }; }); - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, contract.version ?? initialContractVersion); } - const descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map((descriptor) => { - const flowDescriptorPath = flowDescriptorResult.paths.get(descriptor.surfaceId); - return { - ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath, - }; - }); const summary = evaluateContractCompliance(contract, descriptorsWithFlowArtifacts); const violationFindings = mapViolationsToFindings(summary); findings.push(...violationFindings); diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 52fb695..3281ea6 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -9,7 +9,8 @@ import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; import { runInitCommand } from "./commands/init.js"; -import { runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, } from "./commands/auth.js"; +import { runAnalyzeCommand } from "./commands/analyze.js"; +import { runAuthCaptureCommand, runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, } from "./commands/auth.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); program @@ -225,34 +226,91 @@ program }); process.exitCode = exitCode; }); +program + .command("analyze") + .description("Analyze a web surface and emit first-run onboarding evidence") + .option("--url ", "Surface URL for remote analysis") + .option("--app-root ", "Local app root for local-root analysis") + .option("--extract-mode ", "Analysis mode") + .option("--surface ", "Surface identifier override") + .option("--surface-name ", "Surface display name override") + .option("--surface-kind ", "Optional surface-kind confirmation override") + .option("--auth-profile ", "Replay an existing auth profile during remote analysis") + .option("--out ", "Output path for the analysis artifact") + .option("--out-dir ", "Output directory for generated analysis artifacts") + .action(async (options) => { + const extractMode = options.extractMode === "local-root" + ? "local-root" + : options.extractMode === "remote-url" + ? "remote-url" + : undefined; + process.exitCode = await runAnalyzeCommand({ + url: options.url, + appRoot: options.appRoot, + extractMode, + surface: options.surface, + surfaceName: options.surfaceName, + surfaceKind: options.surfaceKind, + authProfile: options.authProfile, + out: options.out, + outDir: options.outDir, + }); +}); program .command("init") .description("Interactive onboarding for first-surface extraction") .option("--url ", "Surface URL for onboarding") .option("--surface ", "Surface identifier override") .option("--surface-name ", "Surface display name override") - .option("--extract-mode ", "Extraction mode", "remote-url") - .option("--app-root ", "Local app root (required when extract-mode is local-root)") - .option("--auth-profile ", "Auth profile name for browser-session onboarding") - .option("--non-interactive", "Run without prompts (requires --url)") + .option("--surface-kind ", "Optional surface-kind confirmation override") + .option("--extract-mode ", "Extraction mode") + .option("--app-root ", "Local app root (required for local-root)") + .option("--auth-profile ", "Replay or capture an auth profile for browser-session onboarding") + .option("--non-interactive", "Run without prompts") .option("--out-dir ", "Output directory for generated onboarding artifacts") + .option("--analysis-out ", "Explicit output path for the analysis artifact") + .option("--draft-out ", "Explicit output path for the design-system draft artifact") + .option("--contract-out ", "Explicit output path for the generated contract") + .option("--report-out ", "Explicit output path for the extraction report") .action(async (options) => { - const extractMode = options.extractMode === "local-root" ? "local-root" : "remote-url"; + const extractMode = options.extractMode === "local-root" + ? "local-root" + : options.extractMode === "remote-url" + ? "remote-url" + : undefined; const exitCode = await runInitCommand({ url: options.url, surface: options.surface, surfaceName: options.surfaceName, + surfaceKind: options.surfaceKind, extractMode, appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, outDir: options.outDir, + analysisOut: options.analysisOut, + draftOut: options.draftOut, + contractOut: options.contractOut, + reportOut: options.reportOut, }); process.exitCode = exitCode; }); const auth = program .command("auth") .description("Manage onboarding browser-session auth profiles"); +auth + .command("capture") + .description("Capture or refresh a replayable browser-session auth profile") + .requiredOption("--profile ", "Profile name") + .requiredOption("--url ", "URL on the exact host to capture") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthCaptureCommand({ + profile: options.profile, + url: options.url, + format: options.format === "json" ? "json" : "text", + }); +}); auth .command("list") .description("List local auth profiles") @@ -267,11 +325,13 @@ auth .description("Validate a local auth profile by name") .requiredOption("--profile ", "Profile name") .option("--domain ", "Optional domain scope") + .option("--url ", "Optional URL to test authenticated replay against") .option("--format ", "Output format", "text") .action(async (options) => { process.exitCode = await runAuthTestCommand({ profile: options.profile, domain: options.domain, + url: options.url, format: options.format === "json" ? "json" : "text", }); }); @@ -332,6 +392,7 @@ program .requiredOption("--extracted ", "Path to extraction report or generated contract with x_extracted") .option("--surface ", "Surface id when not inferrable from extracted file") .option("--format ", "Output format (text|json)", "text") + .option("--out ", "Write output to the provided file path instead of stdout") .option("--exit-codes ", "Exit code version (default: v1; v2: 0 success, 10 E0, 30 E2)") .action(async (options) => { const exitCodeVersion = options.exitCodes === "v1" || options.exitCodes === "v2" ? options.exitCodes : undefined; @@ -340,6 +401,7 @@ program extractedPath: options.extracted, surfaceId: options.surface, format: (options.format ?? "text").toLowerCase() === "json" ? "json" : "text", + outputPath: options.out, exitCodes: exitCodeVersion, }); process.exitCode = exitCode; diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts index 82704e7..44fc956 100644 --- a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts @@ -1,5 +1,7 @@ export type AuthMode = "browser-session"; export type AuthStorageMode = "keychain" | "file"; +export type CaptureBrowser = "chromium"; +export type AuthProfileReadiness = "ready" | "missing" | "expired" | "legacy" | "not-ready"; export interface AuthProfile { name: string; domain: string; @@ -7,32 +9,35 @@ export interface AuthProfile { createdAt: string; updatedAt: string; expiresAt: string; - sessionRef: string; + sessionRef?: string; + replayStateRef?: string; + replayReady?: boolean; + capturedAt?: string; + captureBrowser?: CaptureBrowser; } -export interface AuthProfileStore { - list(): Promise; - get(name: string, domain?: string): Promise; - save(input: { - name: string; - domain: string; - ttlHours?: number; - }): Promise; - revoke(input: { - name?: string; - domain?: string; - }): Promise; - revokeAll(): Promise; - mode(): AuthStorageMode; +export interface ReplayableAuthProfile { + profile: AuthProfile; + storageState: string; +} +export interface AuthProfileInspection { + status: AuthProfileReadiness; + profile?: AuthProfile; + storageState?: string; } export declare function isProfileExpired(profile: AuthProfile, now?: Date): boolean; +export declare function isLegacyAuthProfile(profile: AuthProfile): boolean; +export declare function isProfileReplayReady(profile: AuthProfile): boolean; export declare function getAuthStorageMode(): AuthStorageMode; export declare function listAuthProfiles(): Promise; -export declare function saveBrowserSessionProfile(input: { +export declare function findAuthProfile(name: string, domain?: string): Promise; +export declare function inspectAuthProfile(name: string, domain: string): Promise; +export declare function saveReplayAuthProfile(input: { name: string; domain: string; + storageState: string; + captureBrowser: CaptureBrowser; ttlHours?: number; }): Promise; -export declare function findAuthProfile(name: string, domain?: string): Promise; export declare function clearAuthProfiles(input: { all?: boolean; name?: string; diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map index b10286e..b71a818 100644 --- a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"auth-profiles.d.ts","sourceRoot":"","sources":["../../src/utils/auth-profiles.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG,iBAAiB,CAAC;AACzC,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,MAAM,CAAC;AAElD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAOD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAC/B,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAChE,IAAI,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACvF,MAAM,CAAC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7B,IAAI,IAAI,eAAe,CAAC;CACzB;AA6CD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAEtF;AAuPD,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAE/D;AAED,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,WAAW,CAAC,CAEvB;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAMhG;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,CAKlB"} \ No newline at end of file +{"version":3,"file":"auth-profiles.d.ts","sourceRoot":"","sources":["../../src/utils/auth-profiles.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG,iBAAiB,CAAC;AACzC,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,MAAM,CAAC;AAClD,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC;AACxC,MAAM,MAAM,oBAAoB,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE5F,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAcD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,WAAW,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAwYD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAEtF;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAQlE;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAG/D;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAOhG;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBrG;AAED,wBAAsB,qBAAqB,CAAC,KAAK,EAAE;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,WAAW,CAAC,CAuCvB;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,CAuClB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.js b/packages/interfacectl-cli/dist/utils/auth-profiles.js index 8ef4a94..ea02a1b 100644 --- a/packages/interfacectl-cli/dist/utils/auth-profiles.js +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.js @@ -1,37 +1,93 @@ -import { execSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; const DEFAULT_TTL_HOURS = 4; -const KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; -const KEYCHAIN_ACCOUNT = "default"; +const LEGACY_METADATA_KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; +const LEGACY_METADATA_KEYCHAIN_ACCOUNT = "default"; +const REPLAY_STATE_KEYCHAIN_SERVICE = "interfacectl.auth.replay-state"; +const ENCRYPTED_STATE_VERSION = 1; let warnedFallback = false; function normalizeProfileName(name) { return name.trim().toLowerCase(); } +function profileKey(profile) { + return `${normalizeProfileName(profile.name)}::${profile.domain}`; +} function isSensitiveEnvFlagTrue(name) { return process.env[name] === "1" || process.env[name] === "true"; } -function getProfilesPath() { +function getAuthConfigRoot() { const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; if (override && override.trim().length > 0) { - return path.resolve(override); + return path.dirname(path.resolve(override)); } const xdgConfig = process.env.XDG_CONFIG_HOME; const configRoot = xdgConfig && xdgConfig.trim().length > 0 ? xdgConfig : path.join(os.homedir(), ".config"); - return path.join(configRoot, "interfacectl", "auth-profiles.json"); + return path.join(configRoot, "interfacectl"); +} +function getProfilesPath() { + const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-profiles.json"); +} +function getStateDirectory() { + const override = process.env.INTERFACECTL_AUTH_STATE_DIR; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-state"); +} +function getStateKeyPath() { + const override = process.env.INTERFACECTL_AUTH_STATE_KEY_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-state.key"); +} +function isObjectRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} +function toValidProfile(maybeProfile) { + if (!isObjectRecord(maybeProfile)) { + return null; + } + const name = typeof maybeProfile.name === "string" ? normalizeProfileName(maybeProfile.name) : ""; + const domain = typeof maybeProfile.domain === "string" ? maybeProfile.domain : ""; + const mode = maybeProfile.mode === "browser-session" ? "browser-session" : null; + const createdAt = typeof maybeProfile.createdAt === "string" ? maybeProfile.createdAt : ""; + const updatedAt = typeof maybeProfile.updatedAt === "string" ? maybeProfile.updatedAt : createdAt; + const expiresAt = typeof maybeProfile.expiresAt === "string" ? maybeProfile.expiresAt : ""; + if (!name || !domain || !mode || !createdAt || !expiresAt) { + return null; + } + return { + name, + domain, + mode, + createdAt, + updatedAt, + expiresAt, + sessionRef: typeof maybeProfile.sessionRef === "string" ? maybeProfile.sessionRef : undefined, + replayStateRef: typeof maybeProfile.replayStateRef === "string" ? maybeProfile.replayStateRef : undefined, + replayReady: maybeProfile.replayReady === true, + capturedAt: typeof maybeProfile.capturedAt === "string" ? maybeProfile.capturedAt : undefined, + captureBrowser: maybeProfile.captureBrowser === "chromium" ? "chromium" : undefined, + }; } function toValidDocument(maybeDoc) { const parsed = maybeDoc; const profiles = Array.isArray(parsed?.profiles) - ? parsed.profiles.filter((profile) => profile && typeof profile === "object") + ? parsed.profiles.map(toValidProfile).filter((profile) => profile !== null) : []; return { - schemaVersion: Number(parsed?.schemaVersion || 1), + schemaVersion: Number(parsed?.schemaVersion || 2), profiles, }; } @@ -41,234 +97,366 @@ async function writeJsonAtomic(filePath, doc) { await writeFile(tempPath, `${JSON.stringify(doc, null, 2)}\n`, "utf-8"); await rename(tempPath, filePath); } -export function isProfileExpired(profile, now = new Date()) { - return Date.parse(profile.expiresAt) <= now.getTime(); -} -class FileAuthProfileStore { - documentPath; - constructor(documentPath) { - this.documentPath = documentPath; - } - mode() { - return "file"; - } - async readDocument() { - if (!existsSync(this.documentPath)) { - return { schemaVersion: 1, profiles: [] }; +function mergeProfiles(primary, secondary) { + const merged = new Map(); + for (const profile of [...secondary, ...primary]) { + const key = profileKey(profile); + const existing = merged.get(key); + if (!existing) { + merged.set(key, profile); + continue; } - try { - const raw = await readFile(this.documentPath, "utf-8"); - return toValidDocument(JSON.parse(raw)); + const existingReady = isProfileReplayReady(existing); + const nextReady = isProfileReplayReady(profile); + if (nextReady && !existingReady) { + merged.set(key, profile); + continue; } - catch { - return { schemaVersion: 1, profiles: [] }; - } - } - async writeDocument(doc) { - await writeJsonAtomic(this.documentPath, doc); - } - async list() { - const doc = await this.readDocument(); - return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); - } - async get(name, domain) { - const normalized = normalizeProfileName(name); - const profiles = await this.list(); - return profiles.find((profile) => normalizeProfileName(profile.name) === normalized && - (domain ? profile.domain === domain : true)) ?? null; - } - async save(input) { - const doc = await this.readDocument(); - const now = new Date(); - const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; - const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); - const normalizedName = normalizeProfileName(input.name); - const next = { - name: normalizedName, - domain: input.domain, - mode: "browser-session", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - expiresAt, - sessionRef: randomUUID(), - }; - const existingIndex = doc.profiles.findIndex((profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain); - if (existingIndex >= 0) { - next.createdAt = doc.profiles[existingIndex].createdAt; - doc.profiles[existingIndex] = next; + if (existingReady && !nextReady) { + continue; } - else { - doc.profiles.push(next); + if ((Date.parse(profile.updatedAt) || 0) >= (Date.parse(existing.updatedAt) || 0)) { + merged.set(key, profile); } - await this.writeDocument(doc); - console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=file`); - return next; } - async revoke(input) { - const doc = await this.readDocument(); - const before = doc.profiles.length; - if (input.name) { - const normalized = normalizeProfileName(input.name); - doc.profiles = doc.profiles.filter((profile) => normalizeProfileName(profile.name) !== normalized || - (input.domain ? profile.domain !== input.domain : false)); - } - else if (input.domain) { - doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); - } - else { - return 0; - } - const removed = before - doc.profiles.length; - if (removed > 0) { - await this.writeDocument(doc); - console.error(`[auth-event] profile_revoked count=${removed} mode=file`); - } - return removed; - } - async revokeAll() { - const doc = await this.readDocument(); - const removed = doc.profiles.length; - if (removed > 0) { - await this.writeDocument({ schemaVersion: 1, profiles: [] }); - console.error(`[auth-event] profile_revoked_all count=${removed} mode=file`); - } - return removed; + return [...merged.values()].sort((a, b) => { + const nameDiff = a.name.localeCompare(b.name); + return nameDiff !== 0 ? nameDiff : a.domain.localeCompare(b.domain); + }); +} +function runSecurity(args) { + const result = spawnSync("security", args, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }); + return { + ok: result.status === 0, + stdout: result.stdout ?? "", + }; +} +function keychainAvailable() { + if (process.platform !== "darwin") { + return false; } + if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) { + return false; + } + const probe = spawnSync("command", ["-v", "security"], { + shell: true, + encoding: "utf-8", + stdio: "ignore", + }); + return probe.status === 0; } -class KeychainAuthProfileStore { - static exists() { - if (process.platform !== "darwin") - return false; - if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) - return false; - try { - execSync("command -v security", { encoding: "utf-8", stdio: "ignore" }); - return true; +function readLegacyMetadataFromKeychain() { + if (!keychainAvailable()) { + return { schemaVersion: 2, profiles: [] }; + } + const result = runSecurity([ + "find-generic-password", + "-s", + LEGACY_METADATA_KEYCHAIN_SERVICE, + "-a", + LEGACY_METADATA_KEYCHAIN_ACCOUNT, + "-w", + ]); + if (!result.ok) { + return { schemaVersion: 2, profiles: [] }; + } + try { + return toValidDocument(JSON.parse(result.stdout.trim() || "{}")); + } + catch { + return { schemaVersion: 2, profiles: [] }; + } +} +function clearLegacyMetadataFromKeychain() { + if (!keychainAvailable()) { + return; + } + runSecurity([ + "delete-generic-password", + "-s", + LEGACY_METADATA_KEYCHAIN_SERVICE, + "-a", + LEGACY_METADATA_KEYCHAIN_ACCOUNT, + ]); +} +async function readMetadataDocument() { + const metadataPath = getProfilesPath(); + const fileDoc = existsSync(metadataPath) + ? toValidDocument(JSON.parse(await readFile(metadataPath, "utf-8"))) + : { schemaVersion: 2, profiles: [] }; + const legacyDoc = readLegacyMetadataFromKeychain(); + return { + schemaVersion: 2, + profiles: mergeProfiles(fileDoc.profiles, legacyDoc.profiles), + }; +} +async function writeMetadataDocument(doc) { + await writeJsonAtomic(getProfilesPath(), { + schemaVersion: 2, + profiles: doc.profiles, + }); + clearLegacyMetadataFromKeychain(); +} +async function ensureFilePermissions(filePath) { + try { + await chmod(filePath, 0o600); + } + catch { + // Best effort only. + } +} +async function readOrCreateMasterKey() { + const keyPath = getStateKeyPath(); + if (existsSync(keyPath)) { + const raw = await readFile(keyPath, "utf-8"); + return Buffer.from(raw.trim(), "base64"); + } + const key = randomBytes(32); + await mkdir(path.dirname(keyPath), { recursive: true }); + await writeFile(keyPath, key.toString("base64"), "utf-8"); + await ensureFilePermissions(keyPath); + return key; +} +function encryptState(payload, key) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf-8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return JSON.stringify({ + version: ENCRYPTED_STATE_VERSION, + iv: iv.toString("base64"), + authTag: authTag.toString("base64"), + ciphertext: ciphertext.toString("base64"), + }); +} +function decryptState(payload, key) { + try { + const parsed = JSON.parse(payload); + if (parsed.version !== ENCRYPTED_STATE_VERSION) { + return null; } - catch { - return false; + const iv = Buffer.from(String(parsed.iv), "base64"); + const authTag = Buffer.from(String(parsed.authTag), "base64"); + const ciphertext = Buffer.from(String(parsed.ciphertext), "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8"); + } + catch { + return null; + } +} +class FileReplayStateStore { + mode() { + return "file"; + } + statePath(ref) { + return path.join(getStateDirectory(), `${ref}.json.enc`); + } + async load(ref) { + const target = this.statePath(ref); + if (!existsSync(target)) { + return null; } + const key = await readOrCreateMasterKey(); + const encrypted = await readFile(target, "utf-8"); + return decryptState(encrypted, key); + } + async save(ref, payload) { + const target = this.statePath(ref); + const key = await readOrCreateMasterKey(); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, encryptState(payload, key), "utf-8"); + await ensureFilePermissions(target); } + async delete(ref) { + await rm(this.statePath(ref), { force: true }); + } +} +class KeychainReplayStateStore { static isAvailable() { - return KeychainAuthProfileStore.exists(); + return keychainAvailable(); } mode() { return "keychain"; } - readDocumentSync() { - try { - const raw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }); - return toValidDocument(JSON.parse(raw.trim() || "{}")); - } - catch { - return { schemaVersion: 1, profiles: [] }; - } + async load(ref) { + const result = runSecurity([ + "find-generic-password", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + "-w", + ]); + return result.ok ? result.stdout : null; } - writeDocumentSync(doc) { - const payload = JSON.stringify(doc); - execSync(`security add-generic-password -U -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w '${payload.replace(/'/g, "'\\''")}'`, { stdio: ["pipe", "ignore", "ignore"] }); - } - async list() { - const doc = this.readDocumentSync(); - return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); - } - async get(name, domain) { - const normalized = normalizeProfileName(name); - const profiles = await this.list(); - return profiles.find((profile) => normalizeProfileName(profile.name) === normalized && - (domain ? profile.domain === domain : true)) ?? null; - } - async save(input) { - const doc = this.readDocumentSync(); - const now = new Date(); - const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; - const normalizedName = normalizeProfileName(input.name); - const next = { - name: normalizedName, - domain: input.domain, - mode: "browser-session", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - expiresAt: new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(), - sessionRef: randomUUID(), - }; - const existingIndex = doc.profiles.findIndex((profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain); - if (existingIndex >= 0) { - next.createdAt = doc.profiles[existingIndex].createdAt; - doc.profiles[existingIndex] = next; + async save(ref, payload) { + const result = runSecurity([ + "add-generic-password", + "-U", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + "-w", + payload, + ]); + if (!result.ok) { + throw new Error(`Failed to write replay state for auth profile "${ref}" to keychain.`); } - else { - doc.profiles.push(next); - } - this.writeDocumentSync(doc); - console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=keychain`); - return next; } - async revoke(input) { - const doc = this.readDocumentSync(); - const before = doc.profiles.length; - if (input.name) { - const normalized = normalizeProfileName(input.name); - doc.profiles = doc.profiles.filter((profile) => normalizeProfileName(profile.name) !== normalized || - (input.domain ? profile.domain !== input.domain : false)); - } - else if (input.domain) { - doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); - } - else { - return 0; - } - const removed = before - doc.profiles.length; - if (removed > 0) { - this.writeDocumentSync(doc); - console.error(`[auth-event] profile_revoked count=${removed} mode=keychain`); - } - return removed; - } - async revokeAll() { - const doc = this.readDocumentSync(); - const removed = doc.profiles.length; - if (removed > 0) { - this.writeDocumentSync({ schemaVersion: 1, profiles: [] }); - console.error(`[auth-event] profile_revoked_all count=${removed} mode=keychain`); - } - return removed; + async delete(ref) { + runSecurity([ + "delete-generic-password", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + ]); } } -let storeSingleton = null; -function resolveStore() { - if (storeSingleton) - return storeSingleton; - if (KeychainAuthProfileStore.isAvailable()) { - storeSingleton = new KeychainAuthProfileStore(); - return storeSingleton; +let replayStateStoreSingleton = null; +function resolveReplayStateStore() { + if (replayStateStoreSingleton) { + return replayStateStoreSingleton; + } + if (KeychainReplayStateStore.isAvailable()) { + replayStateStoreSingleton = new KeychainReplayStateStore(); + return replayStateStoreSingleton; } if (!warnedFallback) { warnedFallback = true; - console.error("Warning: keychain storage unavailable; using local file storage for opaque auth session references."); + console.error("Warning: keychain storage unavailable; using encrypted local file storage for replayable auth state."); } - storeSingleton = new FileAuthProfileStore(getProfilesPath()); - return storeSingleton; + replayStateStoreSingleton = new FileReplayStateStore(); + return replayStateStoreSingleton; +} +function dedupeProfiles(profiles) { + return mergeProfiles(profiles, []); +} +function findProfileInDocument(doc, name, domain) { + const normalized = normalizeProfileName(name); + return doc.profiles.find((profile) => normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true)) ?? null; +} +export function isProfileExpired(profile, now = new Date()) { + return Date.parse(profile.expiresAt) <= now.getTime(); +} +export function isLegacyAuthProfile(profile) { + return Boolean(profile.sessionRef) && !profile.replayStateRef; +} +export function isProfileReplayReady(profile) { + return Boolean(!isLegacyAuthProfile(profile) && + profile.replayReady === true && + profile.replayStateRef && + profile.capturedAt && + profile.captureBrowser === "chromium"); } export function getAuthStorageMode() { - return resolveStore().mode(); + return resolveReplayStateStore().mode(); } export async function listAuthProfiles() { - return resolveStore().list(); -} -export async function saveBrowserSessionProfile(input) { - return resolveStore().save(input); + const doc = await readMetadataDocument(); + return dedupeProfiles(doc.profiles); } export async function findAuthProfile(name, domain) { - const profile = await resolveStore().get(name, domain); + const doc = await readMetadataDocument(); + const profile = findProfileInDocument(doc, name, domain); if (profile && isProfileExpired(profile)) { console.error(`[auth-event] profile_expired profile=${profile.name} domain=${profile.domain}`); } return profile; } +export async function inspectAuthProfile(name, domain) { + const profile = await findAuthProfile(name, domain); + if (!profile) { + return { status: "missing" }; + } + if (isProfileExpired(profile)) { + return { status: "expired", profile }; + } + if (isLegacyAuthProfile(profile)) { + return { status: "legacy", profile }; + } + if (!isProfileReplayReady(profile) || !profile.replayStateRef) { + return { status: "not-ready", profile }; + } + const storageState = await resolveReplayStateStore().load(profile.replayStateRef); + if (!storageState) { + return { status: "not-ready", profile }; + } + return { + status: "ready", + profile, + storageState, + }; +} +export async function saveReplayAuthProfile(input) { + const doc = await readMetadataDocument(); + const now = new Date(); + const normalizedName = normalizeProfileName(input.name); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); + const replayStateRef = randomUUID(); + const existing = findProfileInDocument(doc, normalizedName, input.domain); + await resolveReplayStateStore().save(replayStateRef, input.storageState); + if (existing?.replayStateRef && existing.replayStateRef !== replayStateRef) { + await resolveReplayStateStore().delete(existing.replayStateRef); + } + const next = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: existing?.createdAt ?? now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + replayStateRef, + replayReady: true, + capturedAt: now.toISOString(), + captureBrowser: input.captureBrowser, + }; + const profiles = dedupeProfiles(doc.profiles.filter((profile) => profileKey(profile) !== profileKey(next)).concat(next)); + await writeMetadataDocument({ + schemaVersion: 2, + profiles, + }); + console.error(`[auth-event] profile_captured profile=${next.name} domain=${next.domain} storage=${getAuthStorageMode()}`); + return next; +} export async function clearAuthProfiles(input) { - if (input.all) { - return resolveStore().revokeAll(); + const doc = await readMetadataDocument(); + const shouldRemove = (profile) => { + if (input.all) { + return true; + } + if (input.name) { + return normalizeProfileName(profile.name) === normalizeProfileName(input.name) && + (input.domain ? profile.domain === input.domain : true); + } + if (input.domain) { + return profile.domain === input.domain; + } + return false; + }; + const removedProfiles = doc.profiles.filter(shouldRemove); + const remainingProfiles = doc.profiles.filter((profile) => !shouldRemove(profile)); + for (const profile of removedProfiles) { + if (profile.replayStateRef) { + await resolveReplayStateStore().delete(profile.replayStateRef); + } + } + if (removedProfiles.length > 0 || input.all) { + await writeMetadataDocument({ + schemaVersion: 2, + profiles: dedupeProfiles(remainingProfiles), + }); + } + if (removedProfiles.length > 0) { + console.error(`[auth-event] profile_revoked count=${removedProfiles.length} storage=${getAuthStorageMode()}`); } - return resolveStore().revoke({ name: input.name, domain: input.domain }); + return removedProfiles.length; } diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts b/packages/interfacectl-cli/dist/utils/browser-session.d.ts new file mode 100644 index 0000000..262aa4c --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts @@ -0,0 +1,21 @@ +export interface RemoteBrowserObservation { + finalUrl: string; + html: string; + cssContents: Array<{ + source: string; + content: string; + }>; + loginDetected: boolean; + accessDeniedDetected: boolean; +} +export declare function captureBrowserStorageState(options: { + url: string; +}): Promise<{ + finalUrl: string; + storageState: string; +}>; +export declare function observeRemotePage(options: { + url: string; + storageState?: string; +}): Promise; +//# sourceMappingURL=browser-session.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map new file mode 100644 index 0000000..31b4408 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"browser-session.d.ts","sourceRoot":"","sources":["../../src/utils/browser-session.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,aAAa,EAAE,OAAO,CAAC;IACvB,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAsCD,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC,CAgCD;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CA+DpC"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/browser-session.js b/packages/interfacectl-cli/dist/utils/browser-session.js new file mode 100644 index 0000000..c8fb8b4 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/browser-session.js @@ -0,0 +1,115 @@ +import readline from "node:readline/promises"; +import { stdin as input, stderr as promptOutput } from "node:process"; +import { chromium } from "playwright"; +function isEnvTrue(name) { + return process.env[name] === "1" || process.env[name] === "true"; +} +function toLaunchError(error) { + const message = error instanceof Error ? error.message : String(error); + if (/Executable doesn't exist|browserType\.launch/i.test(message)) { + return new Error(`Playwright Chromium is not installed. Run "pnpm exec playwright install chromium" in /Users/mike/SurfacesPlatform/interfacectl.`); + } + return error instanceof Error ? error : new Error(message); +} +async function waitForPageSettle(page) { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); + await page.waitForTimeout(300); +} +function detectAuthGate(html, finalUrl) { + const url = new URL(finalUrl); + const lowerHtml = html.toLowerCase(); + const loginDetected = /(login|signin|sign-in|auth|session)/i.test(url.pathname) || + /]+type=["']password["']/i.test(html) || + / { + throw toLaunchError(error); + }); + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await page.goto(options.url, { waitUntil: "load" }); + await waitForPageSettle(page); + const rl = readline.createInterface({ input, output: promptOutput }); + try { + await rl.question(`Browser session is open at ${new URL(options.url).hostname}. Complete login, then press Enter to capture the session.`); + } + finally { + rl.close(); + } + await waitForPageSettle(page); + const finalUrl = page.url(); + const storageState = JSON.stringify(await context.storageState()); + return { + finalUrl, + storageState, + }; + } + finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} +export async function observeRemotePage(options) { + const browser = await chromium.launch({ headless: true }).catch((error) => { + throw toLaunchError(error); + }); + const storageState = options.storageState + ? JSON.parse(options.storageState) + : undefined; + const context = options.storageState + ? await browser.newContext({ storageState }) + : await browser.newContext(); + const page = await context.newPage(); + const stylesheetBodies = new Map(); + page.on("response", (response) => { + const responseUrl = response.url(); + const resourceType = response.request().resourceType(); + const contentType = response.headers()["content-type"] ?? ""; + const looksLikeCss = resourceType === "stylesheet" || + contentType.includes("text/css") || + /\.css(?:[?#].*)?$/i.test(new URL(responseUrl).pathname); + if (!looksLikeCss || stylesheetBodies.has(responseUrl)) { + return; + } + stylesheetBodies.set(responseUrl, response.text() + .then((content) => ({ source: responseUrl, content })) + .catch(() => null)); + }); + try { + await page.goto(options.url, { waitUntil: "load" }); + await waitForPageSettle(page); + const html = await page.content(); + const finalUrl = page.url(); + const finalOrigin = new URL(finalUrl).origin; + const cssContents = (await Promise.all([...stylesheetBodies.values()])) + .filter((entry) => entry !== null) + .filter((entry) => { + try { + return new URL(entry.source).origin === finalOrigin; + } + catch { + return false; + } + }) + .sort((a, b) => a.source.localeCompare(b.source)); + const authGate = detectAuthGate(html, finalUrl); + return { + finalUrl, + html, + cssContents, + loginDetected: authGate.loginDetected, + accessDeniedDetected: authGate.accessDeniedDetected, + }; + } + finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} diff --git a/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts new file mode 100644 index 0000000..7e4142a --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts @@ -0,0 +1,208 @@ +import { type InterfaceContract, type SurfaceDescriptor, type SurfacePrimitiveDescriptor, type TokenMetadata } from "@surfaces/interfacectl-validator"; +export type WebSurfaceKind = "marketing" | "application" | "unknown"; +export type FirstRunMode = "adopt" | "synthesize"; +export type AnalysisSourceMode = "local-root" | "remote-url"; +type FindingSeverity = "info" | "warning"; +interface ColorValueSummary { + canonical: string; + count: number; + sources: string[]; +} +interface FontValueSummary { + value: string; + count: number; + sources: string[]; +} +interface MotionValueSummary { + durationMs: number; + timingFunction: string; + count: number; + sources: string[]; +} +interface IconSourceSummary { + value: string; + count: number; + sources: string[]; +} +export interface AnalysisFinding { + code: string; + severity: FindingSeverity; + category: "classification" | "typography" | "color" | "layout" | "motion" | "icons" | "structure"; + message: string; +} +export interface AnalysisEvidence { + key: string; + label: string; + weight: number; + supports: Exclude; + value: string; + message: string; +} +export interface SurfaceAnalysisArtifact { + schemaVersion: 1; + surfaceId: string; + surfaceName: string; + source: { + mode: AnalysisSourceMode; + appRoot?: string; + url?: string; + }; + extracted: { + routes: string[]; + hasShell: boolean; + authAware: boolean; + designSystemComponents: string[]; + sections: string[]; + sectionCount: number; + fonts: FontValueSummary[]; + colors: ColorValueSummary[]; + motion: MotionValueSummary[]; + iconSources: IconSourceSummary[]; + primitives: SurfacePrimitiveDescriptor[]; + layout: { + maxContentWidth: number | null; + containers: string[]; + chrome: { + maxBorderRadiusPx: number | null; + shadowKinds: string[]; + }; + landingSignals: { + sectionOrder: string[]; + topLevelSections: string[]; + nestedSections: string[]; + pageBackgroundMode: "solid" | "custom" | "unknown"; + heroSignal: boolean; + copyRoleCount: number; + ctaCount: number; + }; + }; + tokens: { + typography: TokenMetadata[]; + layout: TokenMetadata[]; + motion: TokenMetadata[]; + }; + }; + classification: { + inferredKind: WebSurfaceKind; + confirmedKind: WebSurfaceKind; + confidence: number; + requiresConfirmation: boolean; + scores: Record; + supporting: AnalysisEvidence[]; + opposing: AnalysisEvidence[]; + }; + existingSystem: { + score: number; + mode: FirstRunMode; + reasons: string[]; + }; + inconsistencies: { + findings: AnalysisFinding[]; + }; + proposedContract: { + phase0: { + authPosture: "public" | "auth-aware" | "auth-first"; + requiresShell: boolean; + expectsAuthRoutes: boolean; + expectsDesignSystem: boolean; + }; + sectionSeedMode: "observed" | "placeholder"; + seedCounts: { + typographyTokens: number; + layoutTokens: number; + motionTokens: number; + colors: number; + iconSources: number; + sections: number; + }; + suggestedMarketingProfile: boolean; + }; + warnings: Array<{ + code: string; + message: string; + }>; +} +export interface DesignSystemDraftArtifact { + schemaVersion: 1; + surfaceId: string; + surfaceName: string; + webSurfaceKind: WebSurfaceKind; + confidence: number; + mode: FirstRunMode; + summary: { + tokenCount: number; + inconsistencyCount: number; + existingSystemScore: number; + }; + categories: { + typography: { + canonicalTokens: TokenMetadata[]; + observedFamilies: string[]; + roleCoverage: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + color: { + canonicalValues: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + layout: { + canonicalTokens: TokenMetadata[]; + maxContentWidth: number | null; + containers: string[]; + radiusPx: number | null; + shadowKinds: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + motion: { + canonicalTokens: TokenMetadata[]; + durationsMs: number[]; + timingFunctions: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + icons: { + allowedSources: string[]; + outliers: string[]; + }; + structure: { + sections: string[]; + primitives: SurfacePrimitiveDescriptor[]; + surfacePatterns: string[]; + outliers: string[]; + }; + }; + manualFollowUp: string[]; + warnings: Array<{ + code: string; + message: string; + }>; +} +export interface AnalyzeSurfaceOptions { + workspaceRoot: string; + surfaceId: string; + surfaceName: string; + sourceMode: AnalysisSourceMode; + appRoot?: string; + url?: string; + surfaceKindOverride?: WebSurfaceKind; + authMode?: "none" | "browser-session"; + authProfileName?: string; + authStorageState?: string; +} +export interface AnalyzeSurfaceResult { + analysis: SurfaceAnalysisArtifact; + draft: DesignSystemDraftArtifact; + contract: InterfaceContract; + extractionReport: Record; + descriptor: SurfaceDescriptor; +} +export declare function analyzeSurface(options: AnalyzeSurfaceOptions): Promise; +export declare function stringifyStableArtifact(payload: unknown): string; +export {}; +//# sourceMappingURL=first-run-analysis.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map new file mode 100644 index 0000000..abda90e --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"first-run-analysis.d.ts","sourceRoot":"","sources":["../../src/utils/first-run-analysis.ts"],"names":[],"mappings":"AAMA,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,0BAA0B,EAE/B,KAAK,aAAa,EAGnB,MAAM,kCAAkC,CAAC;AAkB1C,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,aAAa,GAAG,SAAS,CAAC;AACrE,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,YAAY,CAAC;AAClD,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,YAAY,CAAC;AAC7D,KAAK,eAAe,GAAG,MAAM,GAAG,SAAS,CAAC;AAE1C,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,UAAU,gBAAgB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,UAAU,kBAAkB;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,UAAU,iBAAiB;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,eAAe,CAAC;IAC1B,QAAQ,EAAE,gBAAgB,GAAG,YAAY,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAC;IAClG,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE;QACN,IAAI,EAAE,kBAAkB,CAAC;QACzB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;IACF,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,SAAS,EAAE,OAAO,CAAC;QACnB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAC1B,MAAM,EAAE,iBAAiB,EAAE,CAAC;QAC5B,MAAM,EAAE,kBAAkB,EAAE,CAAC;QAC7B,WAAW,EAAE,iBAAiB,EAAE,CAAC;QACjC,UAAU,EAAE,0BAA0B,EAAE,CAAC;QACzC,MAAM,EAAE;YACN,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;YAC/B,UAAU,EAAE,MAAM,EAAE,CAAC;YACrB,MAAM,EAAE;gBACN,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;gBACjC,WAAW,EAAE,MAAM,EAAE,CAAC;aACvB,CAAC;YACF,cAAc,EAAE;gBACd,YAAY,EAAE,MAAM,EAAE,CAAC;gBACvB,gBAAgB,EAAE,MAAM,EAAE,CAAC;gBAC3B,cAAc,EAAE,MAAM,EAAE,CAAC;gBACzB,kBAAkB,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;gBACnD,UAAU,EAAE,OAAO,CAAC;gBACpB,aAAa,EAAE,MAAM,CAAC;gBACtB,QAAQ,EAAE,MAAM,CAAC;aAClB,CAAC;SACH,CAAC;QACF,MAAM,EAAE;YACN,UAAU,EAAE,aAAa,EAAE,CAAC;YAC5B,MAAM,EAAE,aAAa,EAAE,CAAC;YACxB,MAAM,EAAE,aAAa,EAAE,CAAC;SACzB,CAAC;KACH,CAAC;IACF,cAAc,EAAE;QACd,YAAY,EAAE,cAAc,CAAC;QAC7B,aAAa,EAAE,cAAc,CAAC;QAC9B,UAAU,EAAE,MAAM,CAAC;QACnB,oBAAoB,EAAE,OAAO,CAAC;QAC9B,MAAM,EAAE,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QACvC,UAAU,EAAE,gBAAgB,EAAE,CAAC;QAC/B,QAAQ,EAAE,gBAAgB,EAAE,CAAC;KAC9B,CAAC;IACF,cAAc,EAAE;QACd,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,YAAY,CAAC;QACnB,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;IACF,eAAe,EAAE;QACf,QAAQ,EAAE,eAAe,EAAE,CAAC;KAC7B,CAAC;IACF,gBAAgB,EAAE;QAChB,MAAM,EAAE;YACN,WAAW,EAAE,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAC;YACpD,aAAa,EAAE,OAAO,CAAC;YACvB,iBAAiB,EAAE,OAAO,CAAC;YAC3B,mBAAmB,EAAE,OAAO,CAAC;SAC9B,CAAC;QACF,eAAe,EAAE,UAAU,GAAG,aAAa,CAAC;QAC5C,UAAU,EAAE;YACV,gBAAgB,EAAE,MAAM,CAAC;YACzB,YAAY,EAAE,MAAM,CAAC;YACrB,YAAY,EAAE,MAAM,CAAC;YACrB,MAAM,EAAE,MAAM,CAAC;YACf,WAAW,EAAE,MAAM,CAAC;YACpB,QAAQ,EAAE,MAAM,CAAC;SAClB,CAAC;QACF,yBAAyB,EAAE,OAAO,CAAC;KACpC,CAAC;IACF,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,cAAc,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAC;QACnB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,mBAAmB,EAAE,MAAM,CAAC;KAC7B,CAAC;IACF,UAAU,EAAE;QACV,UAAU,EAAE;YACV,eAAe,EAAE,aAAa,EAAE,CAAC;YACjC,gBAAgB,EAAE,MAAM,EAAE,CAAC;YAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO,EAAE,MAAM,EAAE,CAAC;YAClB,cAAc,EAAE,MAAM,EAAE,CAAC;YACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;QACF,KAAK,EAAE;YACL,eAAe,EAAE,MAAM,EAAE,CAAC;YAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;YAClB,cAAc,EAAE,MAAM,EAAE,CAAC;YACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;QACF,MAAM,EAAE;YACN,eAAe,EAAE,aAAa,EAAE,CAAC;YACjC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;YAC/B,UAAU,EAAE,MAAM,EAAE,CAAC;YACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;YACxB,WAAW,EAAE,MAAM,EAAE,CAAC;YACtB,cAAc,EAAE,MAAM,EAAE,CAAC;YACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;QACF,MAAM,EAAE;YACN,eAAe,EAAE,aAAa,EAAE,CAAC;YACjC,WAAW,EAAE,MAAM,EAAE,CAAC;YACtB,eAAe,EAAE,MAAM,EAAE,CAAC;YAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;YAClB,cAAc,EAAE,MAAM,EAAE,CAAC;YACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;QACF,KAAK,EAAE;YACL,cAAc,EAAE,MAAM,EAAE,CAAC;YACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;QACF,SAAS,EAAE;YACT,QAAQ,EAAE,MAAM,EAAE,CAAC;YACnB,UAAU,EAAE,0BAA0B,EAAE,CAAC;YACzC,eAAe,EAAE,MAAM,EAAE,CAAC;YAC1B,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;KACH,CAAC;IACF,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,kBAAkB,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,mBAAmB,CAAC,EAAE,cAAc,CAAC;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,uBAAuB,CAAC;IAClC,KAAK,EAAE,yBAAyB,CAAC;IACjC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,UAAU,EAAE,iBAAiB,CAAC;CAC/B;AA+5CD,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAuG/B;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAEhE"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/first-run-analysis.js b/packages/interfacectl-cli/dist/utils/first-run-analysis.js new file mode 100644 index 0000000..0c1de75 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.js @@ -0,0 +1,1325 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { extractContractFromNextApp, stableStringify, } from "@surfaces/interfacectl-extractor"; +import { normalizeColorValues, } from "@surfaces/interfacectl-validator"; +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; +import { collectTokenDefinitionsFromContent, normalizeObservedTokens, } from "./token-normalization.js"; +import { redactSensitiveUrl } from "./redaction.js"; +import { seedChromePolicyFromObservedDescriptors } from "./chrome-policy-seeding.js"; +import { seedColorPolicyFromObservedDescriptors } from "./color-policy-seeding.js"; +import { seedIconPolicyFromObservedDescriptors } from "./icon-policy-seeding.js"; +import { seedObservedUiContract, } from "./observed-ui-seeding.js"; +import { observeRemotePage } from "./browser-session.js"; +const DEFAULT_ANALYSIS_SCHEMA_VERSION = 1; +const DEFAULT_CONTRACT_VERSION = "0.1.0"; +const PLACEHOLDER_SECTION_ID = "extracted.placeholder"; +const AUTH_ROUTE_SET = new Set([ + "/auth/login", + "/auth/callback", + "/auth/session", + "/auth/logout", +]); +const APPLICATION_ROUTE_HINT = /(account|settings|workspace|dashboard|admin|billing|projects|tasks|team|users)/i; +const AUTH_ROUTE_HINT = /(login|logout|signin|signout|session|register|auth)/i; +const CTA_TEXT_HINT = /\b(get started|learn more|request demo|contact sales|sign up|start now|try now|book demo|download|install)\b/i; +const STYLESHEET_LINK_REGEX = /]*rel=(?:"[^"]*stylesheet[^"]*"|'[^']*stylesheet[^']*')[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>/gi; +const INLINE_STYLE_BLOCK_REGEX = /]*>([\s\S]*?)<\/style>/gi; +const HREF_REGEX = /\bhref=(?:"([^"]+)"|'([^']+)')/gi; +const FONT_FAMILY_REGEX = /font-family\s*:\s*([^;]+);/gi; +const COLOR_DECL_REGEX = /(?:color|background-color|background|border-color|border-top-color|border-right-color|border-bottom-color|border-left-color|outline-color|text-decoration-color|caret-color|column-rule-color)\s*:\s*([^;]+);/gi; +const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi; +const TIMING_DECL_REGEX = /(animation|transition)-timing-function\s*:\s*([^;]+);/gi; +const TRANSITION_DECL_REGEX = /transition[^:]*:\s*([^;]+);/gi; +const MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*(px|rem|em)/gi; +const BORDER_RADIUS_REGEX = /border-radius\s*:\s*([0-9.]+)\s*(px|rem|em)/gi; +const BOX_SHADOW_REGEX = /box-shadow\s*:\s*([^;]+);/gi; +const TAG_REGEX = /<\/?([A-Za-z][\w.:-]*)\b[^>]*>/g; +const COPY_ROLE_REGEX = /data-contract-copy-role\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_ATTRIBUTE_REGEX = /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +function uniqueSorted(values) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} +function uniqueSortedNumbers(values) { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b); +} +function toStableSourcePath(root, candidate) { + if (!root) { + return candidate; + } + return path.relative(root, candidate) || "."; +} +function countByValue(values) { + const counts = new Map(); + for (const entry of values) { + const normalized = entry.value.trim(); + if (!normalized) { + continue; + } + const bucket = counts.get(normalized) ?? { count: 0, sources: new Set() }; + bucket.count += 1; + if (entry.source) { + bucket.sources.add(entry.source); + } + counts.set(normalized, bucket); + } + return [...counts.entries()] + .map(([value, bucket]) => ({ + value, + count: bucket.count, + sources: [...bucket.sources].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} +function parseLengthToPx(rawValue) { + if (!rawValue) + return null; + const normalized = rawValue.trim(); + const pxMatch = normalized.match(/^([0-9.]+)\s*px$/i); + if (pxMatch) { + return Number.parseFloat(pxMatch[1]); + } + const remMatch = normalized.match(/^([0-9.]+)\s*rem$/i); + if (remMatch) { + return Number.parseFloat(remMatch[1]) * 16; + } + const emMatch = normalized.match(/^([0-9.]+)\s*em$/i); + if (emMatch) { + return Number.parseFloat(emMatch[1]) * 16; + } + const numberMatch = normalized.match(/^([0-9.]+)$/); + if (numberMatch) { + return Number.parseFloat(numberMatch[1]); + } + return null; +} +function parseDurationToMs(rawValue) { + if (!rawValue) + return null; + const normalized = rawValue.trim(); + const msMatch = normalized.match(/^([0-9.]+)\s*ms$/i); + if (msMatch) { + return Number.parseFloat(msMatch[1]); + } + const secMatch = normalized.match(/^([0-9.]+)\s*s$/i); + if (secMatch) { + return Number.parseFloat(secMatch[1]) * 1000; + } + return null; +} +function classifyShadow(rawValue) { + if (!rawValue) + return null; + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "none" || normalized === "0" || normalized === "0px") { + return "none"; + } + const inset = /\binset\b/.test(normalized); + const hasContent = normalized.length > 0; + if (!hasContent) + return null; + if (inset) + return "inset"; + return "outer"; +} +function inferIntentFromSectionId(sectionId) { + const tokens = sectionId.split("."); + return tokens[tokens.length - 1] || "section"; +} +function defaultDescriptionFromSection(sectionId) { + return `Observed section for ${sectionId}.`; +} +function buildDescriptorSeedContract(surfaceId, surfaceName) { + return { + contractId: `${surfaceId}.analysis`, + version: DEFAULT_CONTRACT_VERSION, + description: "Temporary analysis contract for descriptor collection.", + surfaces: [ + { + id: surfaceId, + displayName: surfaceName, + type: "web", + requiredSections: [PLACEHOLDER_SECTION_ID], + allowedFonts: ["sans-serif"], + layout: { + maxContentWidth: 1120, + landingPattern: { + policy: "warn", + }, + }, + }, + ], + sections: [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "placeholder", + description: "Placeholder section for analysis.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: [], + }, + }; +} +function metadataFromPolicy(policy) { + return [...(policy?.tokenMetadata ?? [])].sort((a, b) => a.token.localeCompare(b.token)); +} +function summarizeFonts(descriptor) { + return descriptor.fonts + .map((font) => ({ + value: font.value, + count: 1, + sources: font.source ? [font.source] : [], + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} +function summarizeColors(descriptor) { + return descriptor.colors + .map((color) => ({ + canonical: normalizeColorValues([color.value])[0] ?? color.value, + count: 1, + sources: color.source ? [color.source] : [], + })) + .sort((a, b) => a.canonical.localeCompare(b.canonical)); +} +function summarizeMotion(descriptor) { + return descriptor.motion + .map((motion) => ({ + durationMs: motion.durationMs, + timingFunction: motion.timingFunction, + count: 1, + sources: motion.source ? [motion.source] : [], + })) + .sort((a, b) => a.durationMs === b.durationMs + ? a.timingFunction.localeCompare(b.timingFunction) + : a.durationMs - b.durationMs); +} +function summarizeIcons(descriptor) { + return (descriptor.icons ?? []) + .map((icon) => ({ + value: icon.value, + count: 1, + sources: icon.source ? [icon.source] : [], + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} +function buildPhase0Seed(observation) { + const routes = new Set(observation.routes); + const hasAllAuthRoutes = [...AUTH_ROUTE_SET].every((route) => routes.has(route)); + const authPosture = hasAllAuthRoutes ? "auth-first" : observation.authAware ? "auth-aware" : "public"; + return { + authPosture, + requiresShell: observation.hasShell, + expectsAuthRoutes: hasAllAuthRoutes, + expectsDesignSystem: observation.designSystemComponents.length > 0, + }; +} +function countAliases(metadata) { + return metadata + .flatMap((entry) => entry.aliases) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); +} +function buildFindings(observation) { + const findings = []; + const fonts = observation.descriptor.fonts.map((font) => font.value); + if (fonts.length > 2) { + findings.push({ + code: "typography.multiple-families", + severity: "warning", + category: "typography", + message: `Detected ${fonts.length} font families; review whether all are intentional system choices.`, + }); + } + const motionDurations = uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)); + if (motionDurations.length > 2) { + findings.push({ + code: "motion.multiple-durations", + severity: "warning", + category: "motion", + message: `Detected ${motionDurations.length} distinct motion durations; consolidate repeated timing choices.`, + }); + } + const colorValues = observation.descriptor.colors.map((color) => color.value); + const rawColorCount = colorValues.filter((value) => !value.startsWith("var(")).length; + if (rawColorCount > 3) { + findings.push({ + code: "color.raw-literals-heavy", + severity: "warning", + category: "color", + message: `Detected ${rawColorCount} raw color literals; consider canonicalizing them into stable tokens or approved values.`, + }); + } + const shadowKinds = observation.descriptor.layout.chrome?.shadowKinds ?? []; + if (shadowKinds.includes("outer")) { + findings.push({ + code: "layout.outer-shadow-present", + severity: "info", + category: "layout", + message: "Observed outer shadows on layout chrome; decide whether they belong in the draft system or should remain exceptions.", + }); + } + if (observation.descriptor.sections.length === 0) { + findings.push({ + code: "structure.sections-missing", + severity: "warning", + category: "structure", + message: "No explicit contract sections were detected; contract seeding will fall back to a placeholder section.", + }); + } + return findings.sort((a, b) => a.code.localeCompare(b.code)); +} +function calculateExistingSystem(observation, findings) { + let score = 0; + const reasons = []; + const tokenCount = metadataFromPolicy(observation.tokenPolicies.typography).length + + metadataFromPolicy(observation.tokenPolicies.layout).length + + metadataFromPolicy(observation.tokenPolicies.motion).length; + if (tokenCount >= 3) { + score += 0.35; + reasons.push("Repeated token references were detected across multiple UI categories."); + } + if (observation.designSystemComponents.length > 0) { + score += 0.2; + reasons.push("Shared design-system component imports were detected."); + } + if (observation.colorAllowedValues.some((value) => value.startsWith("var("))) { + score += 0.15; + reasons.push("Color usage already includes reusable variable-based values."); + } + if ((observation.surfaceIcons?.allowedSources?.length ?? 0) === 1) { + score += 0.1; + reasons.push("Icon usage is already consistent around one source library."); + } + if (findings.length <= 2) { + score += 0.2; + reasons.push("The observed system has a low inconsistency count."); + } + const normalizedScore = Math.max(0, Math.min(1, Number(score.toFixed(2)))); + return { + score: normalizedScore, + mode: normalizedScore >= 0.55 ? "adopt" : "synthesize", + reasons, + }; +} +function buildClassification(observation) { + const marketing = []; + const application = []; + const routeCount = observation.routes.length; + const primitiveCounts = new Map((observation.descriptor.primitives ?? []).map((entry) => [entry.role, entry.count])); + const landing = observation.descriptor.layout.landingPattern; + const copyRoleCount = observation.copyRoleCount; + if (routeCount <= 4) { + marketing.push({ + key: "route.low-complexity", + label: "Low route complexity", + weight: 2, + supports: "marketing", + value: String(routeCount), + message: `Only ${routeCount} routes were detected.`, + }); + } + if (routeCount >= 6) { + application.push({ + key: "route.multi-page", + label: "Multi-route structure", + weight: 2, + supports: "application", + value: String(routeCount), + message: `Detected ${routeCount} routes, suggesting task-oriented application structure.`, + }); + } + if (!observation.authAware) { + marketing.push({ + key: "auth.none", + label: "No auth routes", + weight: 1, + supports: "marketing", + value: "false", + message: "No auth route family was detected.", + }); + } + else { + application.push({ + key: "auth.present", + label: "Auth routes present", + weight: 3, + supports: "application", + value: "true", + message: "Auth-aware routing was detected.", + }); + } + if (primitiveCounts.get("sidebar")) { + application.push({ + key: "primitive.sidebar", + label: "Sidebar primitive", + weight: 3, + supports: "application", + value: String(primitiveCounts.get("sidebar")), + message: "Sidebar primitives strongly suggest an application surface.", + }); + } + if (primitiveCounts.get("auth-shell")) { + application.push({ + key: "primitive.auth-shell", + label: "Auth shell", + weight: 3, + supports: "application", + value: String(primitiveCounts.get("auth-shell")), + message: "Auth-shell primitives were detected.", + }); + } + if (landing && landing.topLevelSections.length >= 3) { + marketing.push({ + key: "landing.top-level", + label: "Landing section structure", + weight: 2, + supports: "marketing", + value: landing.topLevelSections.join(", "), + message: "Top-level landing-style sections were detected.", + }); + } + if (copyRoleCount >= 3) { + marketing.push({ + key: "copy-role.dense", + label: "Copy-role density", + weight: 2, + supports: "marketing", + value: String(copyRoleCount), + message: "Copy-role markers suggest a marketing-oriented page structure.", + }); + } + if (observation.ctaCount >= 2) { + marketing.push({ + key: "cta.present", + label: "CTA-oriented structure", + weight: 2, + supports: "marketing", + value: String(observation.ctaCount), + message: "Repeated CTA signals suggest a marketing-oriented conversion flow.", + }); + } + if (observation.heroSignal) { + marketing.push({ + key: "hero.present", + label: "Hero signal", + weight: 1, + supports: "marketing", + value: "true", + message: "A likely hero pattern was detected near the top of the surface.", + }); + } + if (observation.routes.some((route) => APPLICATION_ROUTE_HINT.test(route))) { + application.push({ + key: "route.application-family", + label: "Application route families", + weight: 2, + supports: "application", + value: observation.routes.filter((route) => APPLICATION_ROUTE_HINT.test(route)).join(", "), + message: "Detected account/settings/workspace-style routes.", + }); + } + if (observation.routes.some((route) => AUTH_ROUTE_HINT.test(route))) { + application.push({ + key: "route.auth-hint", + label: "Auth route hint", + weight: 1, + supports: "application", + value: observation.routes.filter((route) => AUTH_ROUTE_HINT.test(route)).join(", "), + message: "Detected auth-oriented route names.", + }); + } + if ((primitiveCounts.get("navigation") ?? 0) > 0 && (primitiveCounts.get("sidebar") ?? 0) === 0 && !observation.authAware) { + marketing.push({ + key: "primitive.top-nav-only", + label: "Top-nav without app shell", + weight: 1, + supports: "marketing", + value: String(primitiveCounts.get("navigation") ?? 0), + message: "Navigation appears without stronger application-shell signals.", + }); + } + const marketingScore = marketing.reduce((total, entry) => total + entry.weight, 0); + const applicationScore = application.reduce((total, entry) => total + entry.weight, 0); + const topScore = Math.max(marketingScore, applicationScore); + const kind = topScore < 3 + ? "unknown" + : marketingScore === applicationScore || Math.abs(marketingScore - applicationScore) <= 1 + ? "unknown" + : marketingScore > applicationScore + ? "marketing" + : "application"; + const confidence = kind === "unknown" + ? 0.4 + : Math.min(0.95, 0.55 + Math.abs(marketingScore - applicationScore) * 0.08); + const supporting = (kind === "marketing" ? marketing : kind === "application" ? application : [ + ...marketing, + ...application, + ]) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + .slice(0, 5); + const opposing = (kind === "marketing" ? application : kind === "application" ? marketing : []) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + .slice(0, 3); + return { + inferredKind: kind, + confidence: Number(confidence.toFixed(2)), + requiresConfirmation: kind === "unknown" || confidence < 0.7, + scores: { + marketing: marketingScore, + application: applicationScore, + unknown: kind === "unknown" ? 1 : 0, + }, + supporting, + opposing, + }; +} +function createMarketingProfiles(surfaceId, observation) { + const layoutDescriptor = observation.descriptor.layout.landingPattern; + const typographyDescriptor = observation.descriptor.marketingTypography; + if (!layoutDescriptor && !typographyDescriptor) { + return undefined; + } + const layoutProfileId = "starter-marketing-layout"; + const typographyProfileId = "starter-marketing-typography"; + const layoutProfiles = layoutDescriptor + ? [ + { + id: layoutProfileId, + description: `Starter marketing layout profile for ${surfaceId}.`, + heroContainerMode: layoutDescriptor.heroContainerMode ?? "open-flow", + heroVisualPlacement: layoutDescriptor.heroVisualPlacement ?? "none", + sectionDividerMode: layoutDescriptor.sectionDividerMode ?? "none", + sectionSpacingProfile: layoutDescriptor.sectionSpacingProfile ?? "compact", + }, + ] + : undefined; + const typographyProfiles = typographyDescriptor && typographyDescriptor.roles.length > 0 + ? [ + { + id: typographyProfileId, + description: `Starter marketing typography profile for ${surfaceId}.`, + roles: typographyDescriptor.roles + .filter((role) => role.tokens.length > 0) + .map((role) => ({ + role: role.role, + allowedTokens: uniqueSorted(role.tokens.map((token) => token.value)), + })) + .sort((a, b) => a.role.localeCompare(b.role)), + }, + ] + : undefined; + if (!layoutProfiles && !typographyProfiles) { + return undefined; + } + return { + layout: layoutProfiles, + typography: typographyProfiles, + }; +} +function applyAnalysisToContract(baseContract, observation, analysis) { + const sections = observation.descriptor.sections.length > 0 + ? observation.descriptor.sections.map((section) => ({ + id: section.id, + intent: inferIntentFromSectionId(section.id), + description: defaultDescriptionFromSection(section.id), + })) + : baseContract.sections; + const requiredSections = observation.descriptor.sections.length > 0 + ? uniqueSorted(observation.descriptor.sections.map((section) => section.id)) + : baseContract.surfaces[0]?.requiredSections ?? [PLACEHOLDER_SECTION_ID]; + const marketingProfiles = analysis.classification.confirmedKind === "marketing" + ? createMarketingProfiles(baseContract.surfaces[0]?.id ?? analysis.surfaceId, observation) + : undefined; + const landingPattern = observation.descriptor.layout.landingPattern; + const surface = baseContract.surfaces[0]; + const nextSurface = { + ...surface, + requiredSections, + layout: { + ...surface.layout, + landingPattern: analysis.classification.confirmedKind === "marketing" && landingPattern + ? { + policy: "warn", + requireTopLevelSections: landingPattern.topLevelSections.length > 0 + ? landingPattern.topLevelSections + : undefined, + sectionOrder: landingPattern.sectionOrder.length > 0 + ? landingPattern.sectionOrder + : undefined, + pageBackgroundMode: landingPattern.pageBackgroundMode === "unknown" + ? undefined + : landingPattern.pageBackgroundMode, + marketingLayoutPolicy: marketingProfiles?.layout?.length ? "warn" : undefined, + marketingLayoutProfile: marketingProfiles?.layout?.[0]?.id, + } + : surface.layout.landingPattern, + }, + marketingTypographyPolicy: analysis.classification.confirmedKind === "marketing" && + marketingProfiles?.typography?.length + ? "warn" + : surface.marketingTypographyPolicy, + marketingTypographyProfile: analysis.classification.confirmedKind === "marketing" + ? marketingProfiles?.typography?.[0]?.id + : undefined, + phase0: analysis.proposedContract.phase0, + }; + const nextContract = { + ...baseContract, + sections, + marketingProfiles, + surfaces: [nextSurface], + x_extracted: { + ...(baseContract.x_extracted ?? {}), + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + }, + }; + return nextContract; +} +function buildDraftArtifact(analysis, observation) { + const typographyTokens = metadataFromPolicy(observation.tokenPolicies.typography); + const layoutTokens = metadataFromPolicy(observation.tokenPolicies.layout); + const motionTokens = metadataFromPolicy(observation.tokenPolicies.motion); + const canonicalColors = analysis.extracted.colors + .filter((entry) => entry.count > 0) + .map((entry) => entry.canonical); + const typographyFamilies = analysis.extracted.fonts.map((entry) => entry.value); + const roleCoverage = observation.descriptor.marketingTypography?.roles.map((role) => role.role) ?? []; + const structurePatterns = []; + if (analysis.classification.confirmedKind === "marketing") { + structurePatterns.push("landing"); + } + if (analysis.classification.confirmedKind === "application") { + structurePatterns.push("task-oriented"); + } + if (analysis.extracted.hasShell) { + structurePatterns.push("shell"); + } + const manualFollowUp = analysis.inconsistencies.findings.map((finding) => finding.message); + if (analysis.classification.requiresConfirmation) { + manualFollowUp.push("Review the inferred surface kind before tightening policy levels."); + } + if (analysis.proposedContract.sectionSeedMode === "placeholder") { + manualFollowUp.push("Add stable section markers to improve future section-based seeding."); + } + return { + schemaVersion: DEFAULT_ANALYSIS_SCHEMA_VERSION, + surfaceId: analysis.surfaceId, + surfaceName: analysis.surfaceName, + webSurfaceKind: analysis.classification.confirmedKind, + confidence: analysis.classification.confidence, + mode: analysis.existingSystem.mode, + summary: { + tokenCount: typographyTokens.length + layoutTokens.length + motionTokens.length, + inconsistencyCount: analysis.inconsistencies.findings.length, + existingSystemScore: analysis.existingSystem.score, + }, + categories: { + typography: { + canonicalTokens: typographyTokens, + observedFamilies: typographyFamilies, + roleCoverage, + aliases: countAliases(typographyTokens), + semanticGroups: roleCoverage.length > 0 ? ["copy-roles"] : ["type-scale"], + outliers: typographyFamilies.length > 2 ? typographyFamilies.slice(2) : [], + }, + color: { + canonicalValues: canonicalColors, + aliases: [], + semanticGroups: canonicalColors.filter((value) => /background|surface|foreground/i.test(value)).length > 0 + ? ["surface", "text", "accent"] + : ["palette"], + outliers: canonicalColors.length > 6 ? canonicalColors.slice(6) : [], + }, + layout: { + canonicalTokens: layoutTokens, + maxContentWidth: analysis.extracted.layout.maxContentWidth, + containers: analysis.extracted.layout.containers, + radiusPx: analysis.extracted.layout.chrome.maxBorderRadiusPx, + shadowKinds: analysis.extracted.layout.chrome.shadowKinds, + semanticGroups: ["container", "shape", "spacing"], + outliers: analysis.extracted.layout.chrome.shadowKinds.length > 1 + ? analysis.extracted.layout.chrome.shadowKinds.slice(1) + : [], + }, + motion: { + canonicalTokens: motionTokens, + durationsMs: analysis.extracted.motion.map((entry) => entry.durationMs), + timingFunctions: uniqueSorted(analysis.extracted.motion.map((entry) => entry.timingFunction)), + aliases: countAliases(motionTokens), + semanticGroups: ["transition", "animation"], + outliers: analysis.extracted.motion.length > 2 + ? analysis.extracted.motion.slice(2).map((entry) => `${entry.durationMs}ms/${entry.timingFunction}`) + : [], + }, + icons: { + allowedSources: analysis.extracted.iconSources.map((entry) => entry.value), + outliers: analysis.extracted.iconSources.length > 1 + ? analysis.extracted.iconSources.slice(1).map((entry) => entry.value) + : [], + }, + structure: { + sections: analysis.extracted.sections, + primitives: analysis.extracted.primitives, + surfacePatterns: structurePatterns, + outliers: analysis.classification.confirmedKind === "unknown" ? ["mixed-signals"] : [], + }, + }, + manualFollowUp: uniqueSorted(manualFollowUp), + warnings: analysis.warnings, + }; +} +async function analyzeLocalSource(options) { + if (!options.appRoot) { + throw new Error("Missing appRoot for local-root analysis."); + } + const appRoot = path.resolve(options.workspaceRoot, options.appRoot); + const { contract: extractedContract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId: options.surfaceId, + }); + const descriptorContract = buildDescriptorSeedContract(options.surfaceId, options.surfaceName); + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: options.workspaceRoot, + contract: descriptorContract, + surfaceFilters: new Set([options.surfaceId]), + surfaceRootMap: new Map([[options.surfaceId, appRoot]]), + }); + const descriptor = descriptorResult.descriptors.find((entry) => entry.surfaceId === options.surfaceId); + if (!descriptor) { + throw new Error(`Failed to collect descriptor for surface "${options.surfaceId}".`); + } + const uiSeeded = await seedObservedUiContract({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: extractedContract, + }); + const colorSeeded = await seedColorPolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: uiSeeded.contract, + }); + const iconSeeded = await seedIconPolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: colorSeeded.contract, + }); + const chromeSeeded = await seedChromePolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: iconSeeded.contract, + }); + return { + routes: report.extracted.routes, + hasShell: report.extracted.hasShell, + authAware: report.extracted.authAware, + designSystemComponents: report.extracted.designSystemComponents, + descriptor, + ctaCount: 0, + copyRoleCount: descriptor.marketingTypography?.roles.length ?? 0, + heroSignal: Boolean(descriptor.layout.landingPattern?.heroContainerMode || + descriptor.marketingTypography?.roles.some((role) => role.role === "heroTitle")), + warnings: [ + ...report.warnings, + ...descriptorResult.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ...descriptorResult.errors.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ...uiSeeded.warnings, + ...colorSeeded.warnings, + ...iconSeeded.warnings, + ...chromeSeeded.warnings, + ], + tokenPolicies: chromeSeeded.contract.tokens ?? {}, + colorAllowedValues: chromeSeeded.contract.color.allowedValues, + surfaceIcons: chromeSeeded.contract.surfaces[0]?.icons, + sourceAppRoot: appRoot, + }; +} +function extractAttributeValuesFromTags(html, regex) { + regex.lastIndex = 0; + const matches = []; + let match; + while ((match = regex.exec(html)) !== null) { + const value = match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""; + if (value) { + matches.push(value); + } + } + return uniqueSorted(matches); +} +function parseRemotePrimitives(html, source) { + const counts = new Map(); + const record = (role, count) => { + if (count <= 0) + return; + counts.set(role, count); + }; + record("navigation", (html.match(/ ({ role, count, sources: [source] })) + .sort((a, b) => a.role.localeCompare(b.role)); +} +function extractRemoteLinks(rawHtml, sourceUrl) { + const routes = new Set([sourceUrl.pathname || "/"]); + const authHints = new Set(); + HREF_REGEX.lastIndex = 0; + let match; + while ((match = HREF_REGEX.exec(rawHtml)) !== null) { + const href = match[1] ?? match[2] ?? ""; + if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { + continue; + } + try { + const resolved = new URL(href, sourceUrl); + if (resolved.origin !== sourceUrl.origin) { + continue; + } + if (/\.(css|js|png|jpg|jpeg|gif|svg|webp|ico)$/i.test(resolved.pathname)) { + continue; + } + const route = resolved.pathname.replace(/\/+$/, "") || "/"; + routes.add(route); + if (AUTH_ROUTE_HINT.test(route)) { + authHints.add(route); + } + } + catch { + continue; + } + } + return { + routes: [...routes].sort((a, b) => a.localeCompare(b)), + authHints: [...authHints].sort((a, b) => a.localeCompare(b)), + }; +} +function collectInlineCssContents(sourceUrl, html) { + const results = []; + INLINE_STYLE_BLOCK_REGEX.lastIndex = 0; + let styleMatch; + while ((styleMatch = INLINE_STYLE_BLOCK_REGEX.exec(html)) !== null) { + if (styleMatch[1]?.trim()) { + results.push({ + source: `${sourceUrl.origin}/`, + content: styleMatch[1], + }); + } + } + return results; +} +function collectRemoteTokenPolicies(cssContents) { + const definitions = new Map(); + const typography = new Map(); + const layout = new Map(); + const motion = new Map(); + const collectObserved = (content, source, regex, category, target) => { + regex.lastIndex = 0; + let match; + while ((match = regex.exec(content)) !== null) { + const rawValue = match[1]?.trim(); + if (!rawValue || !rawValue.startsWith("var(")) { + continue; + } + const entry = target.get(`${source}:${rawValue}:${category}`) ?? { + observedValue: rawValue, + source, + attributes: new Set(), + }; + entry.attributes.add(category); + target.set(`${source}:${rawValue}:${category}`, entry); + } + }; + for (const css of cssContents) { + collectTokenDefinitionsFromContent(css.content, css.source, definitions); + collectObserved(css.content, css.source, /font-(?:family|size|weight|line-height|letter-spacing)\s*:\s*([^;]+);/gi, "typography", typography); + collectObserved(css.content, css.source, /(?:padding|margin|max-width|min-width|width|height|gap|border-radius)\s*:\s*([^;]+);/gi, "layout", layout); + collectObserved(css.content, css.source, /(?:transition|animation)[^:]*:\s*([^;]+);/gi, "motion", motion); + } + const toPolicy = (category, values) => { + const normalized = normalizeObservedTokens(category, new Map([...values.values()].map((entry, index) => [`${entry.source}:${entry.observedValue}:${index}`, entry])), definitions); + const metadata = buildTokenMetadata(normalized.tokens); + if (metadata.length === 0) { + return undefined; + } + return { + policy: "warn", + allowedTokens: metadata.map((entry) => entry.token), + tokenMetadata: metadata, + }; + }; + return { + typography: toPolicy("typography", typography), + layout: toPolicy("layout", layout), + motion: toPolicy("motion", motion), + }; +} +function buildTokenMetadata(tokens) { + const metadata = new Map(); + for (const token of tokens) { + const canonical = token.value.trim(); + if (!canonical) + continue; + const bucket = metadata.get(canonical) ?? { + normalizedValue: token.normalizedValue ?? token.observedValue ?? token.value, + attributes: new Set(), + aliases: new Set(), + }; + for (const attribute of token.attributes ?? []) { + bucket.attributes.add(attribute); + } + if (token.observedValue && token.observedValue !== canonical) { + bucket.aliases.add(token.observedValue); + } + metadata.set(canonical, bucket); + } + return [...metadata.entries()] + .map(([token, entry]) => ({ + token, + normalizedValue: entry.normalizedValue, + attributes: [...entry.attributes].sort((a, b) => a.localeCompare(b)), + aliases: [...entry.aliases].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.token.localeCompare(b.token)); +} +async function analyzeRemoteSource(options) { + if (!options.url) { + throw new Error("Missing url for remote-url analysis."); + } + const sourceUrl = new URL(options.url); + const observation = await observeRemotePage({ + url: sourceUrl.toString(), + storageState: options.authStorageState, + }); + const finalUrl = new URL(observation.finalUrl); + const html = observation.html; + const cssContents = [ + ...collectInlineCssContents(finalUrl, html), + ...observation.cssContents, + ].sort((a, b) => a.source.localeCompare(b.source)); + const routeInfo = extractRemoteLinks(html, finalUrl); + const primitives = parseRemotePrimitives(html, finalUrl.toString()); + const fonts = countByValue(cssContents.flatMap(({ source, content }) => { + const families = []; + FONT_FAMILY_REGEX.lastIndex = 0; + let match; + while ((match = FONT_FAMILY_REGEX.exec(content)) !== null) { + for (const token of match[1].split(",")) { + const value = token.trim().replace(/^["']|["']$/g, ""); + if (value) { + families.push({ value, source }); + } + } + } + return families; + })).map((entry) => ({ + value: entry.value, + count: entry.count, + sources: entry.sources, + })); + const colors = countByValue(cssContents.flatMap(({ source, content }) => { + const values = []; + COLOR_DECL_REGEX.lastIndex = 0; + let match; + while ((match = COLOR_DECL_REGEX.exec(content)) !== null) { + const rawValue = match[1]?.trim(); + if (!rawValue) + continue; + for (const color of normalizeColorValues([rawValue])) { + values.push({ value: color, source }); + } + } + return values; + })).map((entry) => ({ + canonical: entry.value, + count: entry.count, + sources: entry.sources, + })); + const motions = countByValue(cssContents.flatMap(({ source, content }) => { + const values = []; + DURATION_DECL_REGEX.lastIndex = 0; + let match; + while ((match = DURATION_DECL_REGEX.exec(content)) !== null) { + const duration = parseDurationToMs(match[2]); + if (duration !== null) { + values.push({ value: `${duration}::linear`, source }); + } + } + TRANSITION_DECL_REGEX.lastIndex = 0; + while ((match = TRANSITION_DECL_REGEX.exec(content)) !== null) { + const text = match[1]; + const durationMatch = text.match(/([0-9.]+\s*(?:ms|s))/i); + const timingMatch = text.match(/\b(linear|ease|ease-in|ease-out|ease-in-out|cubic-bezier\([^)]*\))\b/i); + const duration = parseDurationToMs(durationMatch?.[1]); + if (duration !== null) { + values.push({ value: `${duration}::${(timingMatch?.[1] ?? "linear").trim()}`, source }); + } + } + TIMING_DECL_REGEX.lastIndex = 0; + while ((match = TIMING_DECL_REGEX.exec(content)) !== null) { + const timing = match[2]?.trim(); + if (timing) { + values.push({ value: `0::${timing}`, source }); + } + } + return values; + })).map((entry) => { + const [durationPart, timingFunction] = entry.value.split("::"); + return { + durationMs: Number.parseFloat(durationPart), + timingFunction, + count: entry.count, + sources: entry.sources, + }; + }).filter((entry) => entry.durationMs > 0 || entry.timingFunction.length > 0); + const maxWidths = []; + const radii = []; + const shadowKinds = new Set(); + let pageBackgroundMode = "unknown"; + for (const css of cssContents) { + MAX_WIDTH_REGEX.lastIndex = 0; + let match; + while ((match = MAX_WIDTH_REGEX.exec(css.content)) !== null) { + const px = parseLengthToPx(`${match[1]}${match[2]}`); + if (px !== null) + maxWidths.push(px); + } + BORDER_RADIUS_REGEX.lastIndex = 0; + while ((match = BORDER_RADIUS_REGEX.exec(css.content)) !== null) { + const px = parseLengthToPx(`${match[1]}${match[2]}`); + if (px !== null) + radii.push(px); + } + BOX_SHADOW_REGEX.lastIndex = 0; + while ((match = BOX_SHADOW_REGEX.exec(css.content)) !== null) { + const shadowKind = classifyShadow(match[1]); + if (shadowKind) + shadowKinds.add(shadowKind); + } + if (/background(?:-color)?\s*:\s*(#[0-9a-f]{3,8}|var\(--background\)|white|rgb\()/i.test(css.content)) { + pageBackgroundMode = "solid"; + } + else if (/background(?:-image)?\s*:\s*(linear-gradient|radial-gradient|url\()/i.test(css.content)) { + pageBackgroundMode = "custom"; + } + } + const copyRoleCount = extractAttributeValuesFromTags(html, COPY_ROLE_REGEX).length; + const sections = extractAttributeValuesFromTags(html, SECTION_ATTRIBUTE_REGEX); + const heroSignal = /]*>([^<]{0,120}) CTA_TEXT_HINT.test(entry)) + .length; + const tokenPolicies = collectRemoteTokenPolicies(cssContents); + const loginOrDeniedDetected = observation.loginDetected || observation.accessDeniedDetected; + if (options.authStorageState && finalUrl.hostname !== sourceUrl.hostname) { + throw new Error(`Authenticated replay for ${sourceUrl.hostname} redirected to ${finalUrl.hostname}. Capture a profile for the final host and retry.`); + } + if (options.authStorageState && loginOrDeniedDetected) { + throw new Error(observation.accessDeniedDetected + ? `Authenticated replay reached an access-denied page at ${redactSensitiveUrl(finalUrl.toString())}.` + : `Authenticated replay still resolved to a login page at ${redactSensitiveUrl(finalUrl.toString())}. Re-capture the auth profile and retry.`); + } + const descriptor = { + surfaceId: options.surfaceId, + sections: sections.map((section) => ({ id: section, source: redactSensitiveUrl(finalUrl.toString()) })), + fonts: fonts.map((entry) => ({ value: entry.value, source: entry.sources[0] })), + colors: colors.map((entry) => ({ value: entry.canonical, source: entry.sources[0] })), + icons: [], + tokenUsage: { + typography: metadataToDescriptors(metadataFromPolicy(tokenPolicies.typography)), + layout: metadataToDescriptors(metadataFromPolicy(tokenPolicies.layout)), + motion: metadataToDescriptors(metadataFromPolicy(tokenPolicies.motion)), + }, + marketingTypography: copyRoleCount > 0 + ? { + roles: [], + source: redactSensitiveUrl(finalUrl.toString()), + } + : undefined, + layout: { + maxContentWidth: maxWidths.length > 0 ? Math.max(...maxWidths) : null, + containers: uniqueSorted([ + ...(html.match(/\bclass=(?:"[^"]*\bcontainer\b[^"]*"|'[^']*\bcontainer\b[^']*')/gi) ?? []).map(() => "container"), + ]), + chrome: { + targets: [], + maxBorderRadiusPx: radii.length > 0 ? Math.max(...radii) : null, + shadowKinds: [...shadowKinds].sort((a, b) => a.localeCompare(b)), + }, + landingPattern: sections.length > 0 || heroSignal + ? { + sectionOrder: sections, + topLevelSections: sections, + nestedSections: [], + pageBackgroundMode, + source: redactSensitiveUrl(finalUrl.toString()), + } + : undefined, + }, + motion: motions.map((entry) => ({ + durationMs: entry.durationMs, + timingFunction: entry.timingFunction, + source: entry.sources[0], + })), + primitives, + }; + return { + routes: routeInfo.routes, + hasShell: primitives.some((entry) => entry.role === "navigation" || entry.role === "header"), + authAware: routeInfo.authHints.length > 0, + designSystemComponents: [], + descriptor, + ctaCount, + copyRoleCount, + heroSignal, + warnings: [ + ...(cssContents.length === 0 + ? [{ code: "remote.css.none-detected", message: "No same-origin CSS was fetched for remote analysis; design-system extraction will be partial." }] + : []), + ...(loginOrDeniedDetected && !options.authStorageState + ? [{ + code: observation.accessDeniedDetected ? "remote.auth.access-denied-detected" : "remote.auth.login-detected", + message: observation.accessDeniedDetected + ? "Remote analysis resolved to an access-denied page; results may reflect the gate instead of the target surface." + : "Remote analysis resolved to a login page; provide --auth-profile for authenticated replay if this surface is protected.", + }] + : []), + ], + tokenPolicies, + colorAllowedValues: colors.map((entry) => entry.canonical), + surfaceIcons: undefined, + }; +} +function metadataToDescriptors(metadata) { + return metadata.map((entry) => ({ + value: entry.token, + observedValue: entry.aliases[0] ?? entry.token, + normalizedValue: entry.normalizedValue, + attributes: entry.attributes, + })); +} +function buildBaseContract(surfaceId, surfaceName, sourceRef, observation) { + const requiredContainers = uniqueSorted(observation.descriptor.layout.containers ?? []); + const sections = observation.descriptor.sections.length > 0 + ? observation.descriptor.sections.map((section) => ({ + id: section.id, + intent: inferIntentFromSectionId(section.id), + description: defaultDescriptionFromSection(section.id), + })) + : [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "placeholder", + description: "Placeholder section; explicit section markers were not detected.", + }, + ]; + const requiredSections = observation.descriptor.sections.length > 0 + ? uniqueSorted(observation.descriptor.sections.map((section) => section.id)) + : [PLACEHOLDER_SECTION_ID]; + return { + contractId: `${surfaceId}.generated`, + version: DEFAULT_CONTRACT_VERSION, + description: "Generated by interfacectl first-run onboarding.", + surfaces: [ + { + id: surfaceId, + displayName: surfaceName, + type: "web", + requiredSections, + allowedFonts: observation.descriptor.fonts.length > 0 + ? uniqueSorted(observation.descriptor.fonts.map((font) => font.value)) + : ["sans-serif"], + layout: { + maxContentWidth: observation.descriptor.layout.maxContentWidth ?? 1120, + requiredContainers: requiredContainers.length > 0 ? requiredContainers : undefined, + chromePolicy: observation.descriptor.layout.chrome?.targets?.length + ? { + policy: "off", + targets: observation.descriptor.layout.chrome.targets, + maxBorderRadiusPx: observation.descriptor.layout.chrome.maxBorderRadiusPx ?? 0, + allowOuterShadow: observation.descriptor.layout.chrome.shadowKinds.includes("outer") || + observation.descriptor.layout.chrome.shadowKinds.includes("mixed"), + allowInsetShadow: observation.descriptor.layout.chrome.shadowKinds.includes("inset") || + observation.descriptor.layout.chrome.shadowKinds.includes("mixed"), + } + : undefined, + }, + icons: observation.surfaceIcons, + }, + ], + sections, + constraints: { + motion: { + allowedDurationsMs: uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)).length > 0 + ? uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)) + : [120], + allowedTimingFunctions: uniqueSorted(observation.descriptor.motion.map((motion) => motion.timingFunction)).length > 0 + ? uniqueSorted(observation.descriptor.motion.map((motion) => motion.timingFunction)) + : ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: observation.colorAllowedValues.length > 0 + ? uniqueSorted(observation.colorAllowedValues) + : [], + }, + tokens: observation.tokenPolicies, + x_extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + sourceRef, + }, + }; +} +function buildExtractionReport(options, observation, warnings) { + const sourceUrl = options.sourceMode === "remote-url" && options.url + ? redactSensitiveUrl(options.url) + : options.appRoot + ? pathToFileURL(path.resolve(options.workspaceRoot, options.appRoot)).toString() + : undefined; + return { + surfaceId: options.surfaceId, + appRoot: options.sourceMode === "local-root" && options.appRoot + ? path.resolve(options.workspaceRoot, options.appRoot) + : options.url, + warnings, + extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + designSystemComponents: observation.designSystemComponents, + authAware: observation.authAware, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + }, + onboarding: { + sourceUrl, + authMode: options.authMode ?? "none", + extractMode: options.sourceMode, + profileName: options.authProfileName, + profileDomain: options.url ? new URL(options.url).hostname : undefined, + detection: { + adapter: options.sourceMode === "local-root" ? "next-app-static-analysis" : "remote-url-observer", + framework: options.sourceMode === "local-root" ? "nextjs" : "unknown", + profile: "web-first-run", + }, + }, + }; +} +export async function analyzeSurface(options) { + const observation = options.sourceMode === "local-root" + ? await analyzeLocalSource(options) + : await analyzeRemoteSource(options); + const findings = buildFindings(observation); + const classification = buildClassification(observation); + const confirmedKind = options.surfaceKindOverride ?? classification.inferredKind; + const phase0 = buildPhase0Seed(observation); + const analysis = { + schemaVersion: DEFAULT_ANALYSIS_SCHEMA_VERSION, + surfaceId: options.surfaceId, + surfaceName: options.surfaceName, + source: { + mode: options.sourceMode, + appRoot: options.sourceMode === "local-root" && observation.sourceAppRoot + ? toStableSourcePath(options.workspaceRoot, observation.sourceAppRoot) + : undefined, + url: options.url ? redactSensitiveUrl(options.url) : undefined, + }, + extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + sections: uniqueSorted(observation.descriptor.sections.map((section) => section.id)), + sectionCount: observation.descriptor.sections.length, + fonts: summarizeFonts(observation.descriptor), + colors: summarizeColors(observation.descriptor), + motion: summarizeMotion(observation.descriptor), + iconSources: summarizeIcons(observation.descriptor), + primitives: observation.descriptor.primitives ?? [], + layout: { + maxContentWidth: observation.descriptor.layout.maxContentWidth ?? null, + containers: observation.descriptor.layout.containers ?? [], + chrome: { + maxBorderRadiusPx: observation.descriptor.layout.chrome?.maxBorderRadiusPx ?? null, + shadowKinds: observation.descriptor.layout.chrome?.shadowKinds ?? [], + }, + landingSignals: { + sectionOrder: observation.descriptor.layout.landingPattern?.sectionOrder ?? [], + topLevelSections: observation.descriptor.layout.landingPattern?.topLevelSections ?? [], + nestedSections: observation.descriptor.layout.landingPattern?.nestedSections ?? [], + pageBackgroundMode: observation.descriptor.layout.landingPattern?.pageBackgroundMode ?? "unknown", + heroSignal: observation.heroSignal, + copyRoleCount: observation.copyRoleCount, + ctaCount: observation.ctaCount, + }, + }, + tokens: { + typography: metadataFromPolicy(observation.tokenPolicies.typography), + layout: metadataFromPolicy(observation.tokenPolicies.layout), + motion: metadataFromPolicy(observation.tokenPolicies.motion), + }, + }, + classification: { + ...classification, + confirmedKind, + }, + existingSystem: calculateExistingSystem(observation, findings), + inconsistencies: { + findings, + }, + proposedContract: { + phase0, + sectionSeedMode: observation.descriptor.sections.length > 0 ? "observed" : "placeholder", + seedCounts: { + typographyTokens: metadataFromPolicy(observation.tokenPolicies.typography).length, + layoutTokens: metadataFromPolicy(observation.tokenPolicies.layout).length, + motionTokens: metadataFromPolicy(observation.tokenPolicies.motion).length, + colors: observation.colorAllowedValues.length, + iconSources: observation.surfaceIcons?.allowedSources.length ?? 0, + sections: observation.descriptor.sections.length, + }, + suggestedMarketingProfile: confirmedKind === "marketing" && + (Boolean(observation.descriptor.layout.landingPattern) || + (observation.descriptor.marketingTypography?.roles.length ?? 0) > 0), + }, + warnings: observation.warnings.sort((a, b) => a.code.localeCompare(b.code)), + }; + const baseContract = buildBaseContract(options.surfaceId, options.surfaceName, options.url ? redactSensitiveUrl(options.url) : options.appRoot ?? options.surfaceId, observation); + const contract = applyAnalysisToContract(baseContract, observation, analysis); + const extractionReport = buildExtractionReport(options, observation, analysis.warnings); + const draft = buildDraftArtifact(analysis, observation); + return { + analysis, + draft, + contract, + extractionReport, + descriptor: observation.descriptor, + }; +} +export function stringifyStableArtifact(payload) { + return `${stableStringify(payload)}\n`; +} diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts b/packages/interfacectl-cli/dist/utils/onboarding.d.ts index f94879e..1a349a9 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.d.ts +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts @@ -1,4 +1,5 @@ type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; +export type OnboardingRunSource = "bootstrap" | "generation" | "ci" | "runtime"; export interface BootstrapExtractionReport { surfaceId: string; appRoot: string; @@ -29,6 +30,7 @@ export interface BootstrapExtractionReport { } export declare function suggestSurfaceIdFromUrl(rawUrl: string): string; export declare function suggestSurfaceName(surfaceId: string): string; +export declare function suggestSurfaceIdFromPath(rawPath: string): string; export declare function buildBootstrapContract(input: { surfaceId: string; surfaceName: string; @@ -45,6 +47,17 @@ export declare function writeBootstrapArtifacts(input: { contractPath: string; reportPath: string; }>; +export declare function emitOnboardingRunArtifact(input: { + rootDir: string; + surfaceId: string; + source: OnboardingRunSource; + status: ValidationOutcome; + findingCodes: string[]; + extractionPath: string; + reportPath: string; +}): Promise<{ + runId: string; +}>; export declare function emitBootstrapRunArtifact(input: { rootDir: string; surfaceId: string; diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map index 9475784..d2b185e 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/onboarding.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAkD9D,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACrC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;QACzC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC;YAChB,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,MAAM,CAAC;SACjB,CAAC;KACH,CAAC;CACH;AAkCD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAgB9D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+C1B;AAED,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,yBAAyB,CAAC;CACnC,GAAG,OAAO,CAAC;IACV,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CASD;AAwBD,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAqD7B"} \ No newline at end of file +{"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/onboarding.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAC9D,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,YAAY,GAAG,IAAI,GAAG,SAAS,CAAC;AAiDhF,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACrC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;QACzC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC;YAChB,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,MAAM,CAAC;SACjB,CAAC;KACH,CAAC;CACH;AAkCD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAgB9D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQhE;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+C1B;AAED,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,yBAAyB,CAAC;CACnC,GAAG,OAAO,CAAC;IACV,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CASD;AAwBD,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAqD7B;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAK7B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/onboarding.js b/packages/interfacectl-cli/dist/utils/onboarding.js index 229a808..d4dc582 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.js +++ b/packages/interfacectl-cli/dist/utils/onboarding.js @@ -56,6 +56,15 @@ export function suggestSurfaceName(surfaceId) { .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) .join(" "); } +export function suggestSurfaceIdFromPath(rawPath) { + const candidate = path.basename(rawPath); + const normalized = candidate + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return normalized.length > 0 ? normalized : "surface"; +} export function buildBootstrapContract(input) { const url = new URL(input.sourceUrl); return { @@ -131,7 +140,7 @@ async function readCanonicalContract(rootDir) { return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; } } -export async function emitBootstrapRunArtifact(input) { +export async function emitOnboardingRunArtifact(input) { const generatedDir = path.resolve(input.rootDir, "contracts", "generated"); const runsPath = path.join(generatedDir, "contract-runs.json"); const lineagePath = path.join(generatedDir, "contract-lineage.json"); @@ -150,7 +159,7 @@ export async function emitBootstrapRunArtifact(input) { runId, createdAt, surfaceId: input.surfaceId, - source: "bootstrap", + source: input.source, contract: canonical, artifacts: { extractionPath: toRelative(input.rootDir, input.extractionPath), @@ -182,3 +191,9 @@ export async function emitBootstrapRunArtifact(input) { await writeJsonAtomic(lineagePath, lineageDoc); return { runId }; } +export async function emitBootstrapRunArtifact(input) { + return emitOnboardingRunArtifact({ + ...input, + source: "bootstrap", + }); +} diff --git a/packages/interfacectl-cli/package.json b/packages/interfacectl-cli/package.json index 1b5fcb0..d7146c7 100644 --- a/packages/interfacectl-cli/package.json +++ b/packages/interfacectl-cli/package.json @@ -22,7 +22,8 @@ "@surfaces/interfacectl-validator": "^0.1.0", "commander": "^12.1.0", "globby": "^14.0.2", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "playwright": "^1.58.2" }, "devDependencies": { "@types/node": "^20.14.9", diff --git a/packages/interfacectl-cli/src/commands/analyze.ts b/packages/interfacectl-cli/src/commands/analyze.ts new file mode 100644 index 0000000..4112ffb --- /dev/null +++ b/packages/interfacectl-cli/src/commands/analyze.ts @@ -0,0 +1,141 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { inspectAuthProfile } from "../utils/auth-profiles.js"; +import { + analyzeSurface, + stringifyStableArtifact, + type AnalysisSourceMode, + type WebSurfaceKind, +} from "../utils/first-run-analysis.js"; +import { suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName } from "../utils/onboarding.js"; +import { redactSensitiveText } from "../utils/redaction.js"; + +type ExtractMode = AnalysisSourceMode; + +export interface AnalyzeCommandOptions { + url?: string; + appRoot?: string; + extractMode?: ExtractMode; + surface?: string; + surfaceName?: string; + surfaceKind?: WebSurfaceKind; + authProfile?: string; + out?: string; + outDir?: string; +} + +const DEFAULT_OUT_DIR = "contracts/generated"; + +function normalizeSurfaceId(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function inferSourceMode(options: Pick): ExtractMode { + if (options.extractMode === "local-root" || options.extractMode === "remote-url") { + return options.extractMode; + } + if (options.appRoot && !options.url) { + return "local-root"; + } + if (options.appRoot) { + return "local-root"; + } + return "remote-url"; +} + +function resolveOutputPath(rootDir: string, surfaceId: string, options: AnalyzeCommandOptions): string { + if (options.out) { + return path.resolve(rootDir, options.out); + } + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, DEFAULT_OUT_DIR); + return path.join(outDir, `${surfaceId}.analysis.json`); +} + +export async function runAnalyzeCommand(options: AnalyzeCommandOptions): Promise { + const rootDir = process.cwd(); + + try { + const sourceMode = inferSourceMode(options); + if (sourceMode === "remote-url" && !options.url) { + throw new Error("Missing required --url for remote-url analysis."); + } + if (sourceMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for local-root analysis."); + } + + if (sourceMode === "local-root") { + const appRoot = path.resolve(rootDir, options.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + throw new Error(`Local app root is missing app/: ${appRoot}`); + } + } + + const surfaceSuggestion = + options.surface ?? + ( + sourceMode === "remote-url" && options.url + ? suggestSurfaceIdFromUrl(options.url) + : suggestSurfaceIdFromPath(options.appRoot ?? "surface") + ); + const surfaceId = normalizeSurfaceId(surfaceSuggestion); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + + let authMode: "none" | "browser-session" = "none"; + let authProfileName: string | undefined; + let authStorageState: string | undefined; + if (sourceMode === "remote-url" && options.authProfile && options.url) { + const url = new URL(options.url); + const inspection = await inspectAuthProfile(options.authProfile, url.hostname); + if (inspection.status !== "ready" || !inspection.profile || !inspection.storageState) { + const reason = inspection.status === "missing" + ? "was not found" + : inspection.status === "expired" + ? "is expired" + : inspection.status === "legacy" + ? "is legacy and must be re-captured" + : "is not replay-ready and must be re-captured"; + throw new Error(`Auth profile "${options.authProfile}" for ${url.hostname} ${reason}.`); + } + authMode = "browser-session"; + authProfileName = inspection.profile.name; + authStorageState = inspection.storageState; + } + + const result = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId, + surfaceName, + sourceMode, + appRoot: options.appRoot, + url: options.url, + surfaceKindOverride: options.surfaceKind, + authMode, + authProfileName, + authStorageState, + }); + + const outputPath = resolveOutputPath(rootDir, surfaceId, options); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, stringifyStableArtifact(result.analysis), "utf-8"); + + console.log(`Wrote analysis: ${outputPath}`); + console.log( + `Inferred surface kind: ${result.analysis.classification.inferredKind} (${result.analysis.classification.confidence.toFixed(2)})`, + ); + if (result.analysis.classification.requiresConfirmation && !options.surfaceKind) { + console.log("Note: classification is low confidence; pass --surface-kind to confirm seeding intent."); + } + return 0; + } catch (error) { + console.error(redactSensitiveText((error as Error).message)); + return 1; + } +} diff --git a/packages/interfacectl-cli/src/commands/auth.ts b/packages/interfacectl-cli/src/commands/auth.ts index d0c3491..e742b2c 100644 --- a/packages/interfacectl-cli/src/commands/auth.ts +++ b/packages/interfacectl-cli/src/commands/auth.ts @@ -1,18 +1,40 @@ +import { captureBrowserStorageState, observeRemotePage } from "../utils/browser-session.js"; import { clearAuthProfiles, findAuthProfile, getAuthStorageMode, + inspectAuthProfile, + isLegacyAuthProfile, isProfileExpired, + isProfileReplayReady, listAuthProfiles, + saveReplayAuthProfile, } from "../utils/auth-profiles.js"; export interface AuthCommandOptions { profile?: string; domain?: string; + url?: string; all?: boolean; format?: "text" | "json"; } +function buildProfileStatus(profile: Awaited>): string { + if (!profile) { + return "missing"; + } + if (isProfileExpired(profile)) { + return "expired"; + } + if (isLegacyAuthProfile(profile)) { + return "legacy"; + } + if (isProfileReplayReady(profile)) { + return "ready"; + } + return "not-ready"; +} + export async function runAuthListCommand(): Promise { return runAuthListCommandWithOptions({}); } @@ -26,7 +48,8 @@ export async function runAuthListCommandWithOptions(options: AuthCommandOptions) storageMode, profiles: profiles.map((profile) => ({ ...profile, - status: isProfileExpired(profile) ? "expired" : "active", + replayReady: isProfileReplayReady(profile), + status: buildProfileStatus(profile), })), }; console.log(JSON.stringify(payload, null, 2)); @@ -37,14 +60,78 @@ export async function runAuthListCommandWithOptions(options: AuthCommandOptions) return 0; } for (const profile of profiles) { - const status = isProfileExpired(profile) ? "expired" : "active"; console.log( - `${profile.name} (${profile.domain}) mode=${profile.mode} status=${status} expires=${profile.expiresAt}`, + `${profile.name} (${profile.domain}) status=${buildProfileStatus(profile)} replayReady=${isProfileReplayReady(profile)} capturedAt=${profile.capturedAt ?? "n/a"} expires=${profile.expiresAt} storage=${storageMode}`, ); } return 0; } +export async function runAuthCaptureCommand(options: AuthCommandOptions): Promise { + if (!options.profile || !options.url) { + const error = "Missing required --profile and/or --url for auth capture."; + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error }, null, 2)); + return 1; + } + console.error(error); + return 1; + } + + try { + const requestedUrl = new URL(options.url); + const captured = await captureBrowserStorageState({ + url: requestedUrl.toString(), + }); + const finalUrl = new URL(captured.finalUrl); + if (finalUrl.hostname !== requestedUrl.hostname) { + throw new Error( + `Capture finished on ${finalUrl.hostname}, but the requested host was ${requestedUrl.hostname}. Capture a profile for the final host instead.`, + ); + } + + const profile = await saveReplayAuthProfile({ + name: options.profile, + domain: finalUrl.hostname, + storageState: captured.storageState, + captureBrowser: "chromium", + }); + + if (options.format === "json") { + console.log( + JSON.stringify( + { + ok: true, + storageMode: getAuthStorageMode(), + profile: { + ...profile, + replayReady: true, + status: buildProfileStatus(profile), + }, + finalUrl: finalUrl.toString(), + }, + null, + 2, + ), + ); + return 0; + } + + console.log(`Captured auth profile: ${profile.name} (${profile.domain})`); + console.log(`Final URL: ${finalUrl.toString()}`); + console.log(`Storage: ${getAuthStorageMode()}`); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + return 1; + } + console.error(message); + return 1; + } +} + export async function runAuthTestCommand(options: AuthCommandOptions): Promise { if (!options.profile) { if (options.format === "json") { @@ -54,24 +141,46 @@ export async function runAuthTestCommand(options: AuthCommandOptions): Promise { diff --git a/packages/interfacectl-cli/src/commands/init.ts b/packages/interfacectl-cli/src/commands/init.ts index b75b54f..267074c 100644 --- a/packages/interfacectl-cli/src/commands/init.ts +++ b/packages/interfacectl-cli/src/commands/init.ts @@ -1,45 +1,99 @@ -import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { extractContractFromNextApp, stableStringify } from "@surfaces/interfacectl-extractor"; import { - type InterfaceContract, getBundledContractSchema, validateContractStructure, + type DriftViolation, } from "@surfaces/interfacectl-validator"; +import { runValidateCommand } from "./validate.js"; +import { runValidateExtractedCommand } from "./validate-extracted.js"; import { - findAuthProfile, getAuthStorageMode, - isProfileExpired, - saveBrowserSessionProfile, + inspectAuthProfile, + saveReplayAuthProfile, } from "../utils/auth-profiles.js"; -import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js"; -import { redactSensitiveText, redactSensitiveUrl } from "../utils/redaction.js"; +import { captureBrowserStorageState } from "../utils/browser-session.js"; import { - buildBootstrapContract, - emitBootstrapRunArtifact, - suggestSurfaceIdFromUrl, - suggestSurfaceName, - type BootstrapExtractionReport, - writeBootstrapArtifacts, -} from "../utils/onboarding.js"; + analyzeSurface, + stringifyStableArtifact, + type AnalysisSourceMode, + type SurfaceAnalysisArtifact, + type WebSurfaceKind, +} from "../utils/first-run-analysis.js"; +import { emitOnboardingRunArtifact, suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName } from "../utils/onboarding.js"; +import { redactSensitiveText } from "../utils/redaction.js"; -type ExtractMode = "remote-url" | "local-root"; +type ExtractMode = AnalysisSourceMode; + +interface ValidateJsonFinding { + code: string; + severity: "error" | "warning"; + category: string; + surface?: string; + message: string; +} + +interface ValidateJsonResult { + summary?: { + errors: number; + warnings: number; + }; + findings?: ValidateJsonFinding[]; +} + +interface ValidateExtractedJsonFinding { + surfaceId: string; + code: string; + category: string; + message: string; +} + +interface ValidateExtractedJsonResult { + ok: boolean; + findings: ValidateExtractedJsonFinding[]; + message?: string; +} export interface InitOptions { url?: string; surface?: string; surfaceName?: string; + surfaceKind?: WebSurfaceKind; authProfile?: string; extractMode?: ExtractMode; appRoot?: string; nonInteractive?: boolean; outDir?: string; + analysisOut?: string; + draftOut?: string; + contractOut?: string; + reportOut?: string; +} + +interface AuthCaptureResult { + authMode: "none" | "browser-session"; + profileName?: string; + storageState?: string; +} + +interface ResolvedInitInputs { + sourceMode: ExtractMode; + url?: string; + appRoot?: string; + surfaceId: string; + surfaceName: string; + surfaceKind?: WebSurfaceKind; + requiresAuth: boolean; + authProfileName: string | null; } +const DEFAULT_OUT_DIR = "contracts/generated"; +const VALID_SURFACE_KINDS = new Set(["marketing", "application", "unknown"]); + function normalizeSurfaceId(raw: string): string { return raw .trim() @@ -49,109 +103,134 @@ function normalizeSurfaceId(raw: string): string { .replace(/^-|-$/g, ""); } -function browserOpenCommand(url: string): { cmd: string; args: string[] } { - if (process.platform === "darwin") { - return { cmd: "open", args: [url] }; +function inferSourceMode(options: Pick): ExtractMode { + if (options.extractMode === "local-root" || options.extractMode === "remote-url") { + return options.extractMode; } - if (process.platform === "win32") { - return { cmd: "cmd", args: ["/c", "start", "", url] }; + if (options.appRoot && !options.url) { + return "local-root"; } - return { cmd: "xdg-open", args: [url] }; + if (options.appRoot) { + return "local-root"; + } + return "remote-url"; } async function maybeCaptureAuthProfile(inputValue: { requiresAuth: boolean; profileName: string | null; url: string; -}): Promise<{ profileName?: string; authMode: "none" | "browser-session" }> { + nonInteractive: boolean; +}): Promise { if (!inputValue.requiresAuth) { return { authMode: "none" }; } const parsed = new URL(inputValue.url); const profileName = inputValue.profileName ?? `${parsed.hostname}-default`; - const existing = await findAuthProfile(profileName, parsed.hostname); - if (existing && !isProfileExpired(existing)) { - return { authMode: "browser-session", profileName: existing.name }; + const inspection = await inspectAuthProfile(profileName, parsed.hostname); + if (inspection.status === "ready" && inspection.profile && inspection.storageState) { + return { + authMode: "browser-session", + profileName: inspection.profile.name, + storageState: inspection.storageState, + }; } - const openTarget = browserOpenCommand(inputValue.url); - const child = spawn(openTarget.cmd, openTarget.args, { - stdio: "ignore", - detached: true, - }); - child.unref(); + if (inputValue.nonInteractive) { + const reason = inspection.status === "missing" + ? "was not found" + : inspection.status === "expired" + ? "is expired" + : inspection.status === "legacy" + ? "is legacy and must be re-captured" + : "is not replay-ready and must be re-captured"; + throw new Error( + `Auth profile "${profileName}" for ${parsed.hostname} ${reason}. Capture it interactively first or omit --auth-profile.`, + ); + } - const rl = readline.createInterface({ input, output }); - try { - await rl.question( - `Opened browser for ${parsed.hostname}. Complete login, then press Enter to continue.`, + const captured = await captureBrowserStorageState({ + url: parsed.toString(), + }); + const finalUrl = new URL(captured.finalUrl); + if (finalUrl.hostname !== parsed.hostname) { + throw new Error( + `Capture finished on ${finalUrl.hostname}, but the requested host was ${parsed.hostname}. Capture a profile for the final host instead.`, ); - } finally { - rl.close(); } - const profile = await saveBrowserSessionProfile({ + const profile = await saveReplayAuthProfile({ name: profileName, domain: parsed.hostname, + storageState: captured.storageState, + captureBrowser: "chromium", }); - return { authMode: "browser-session", profileName: profile.name }; + return { + authMode: "browser-session", + profileName: profile.name, + storageState: captured.storageState, + }; } -async function promptInteractive(options: InitOptions): Promise<{ - url: string; - extractMode: ExtractMode; - appRoot?: string; - surfaceId: string; - surfaceName: string; - requiresAuth: boolean; - authProfileName: string | null; -}> { +async function promptInteractive(options: InitOptions): Promise { const rl = readline.createInterface({ input, output }); try { - const url = options.url ?? (await rl.question("What is the first surface URL? ")).trim(); - const parsed = new URL(url); - const requiresAuthAnswer = ( - await rl.question("Is this surface behind login? (y/N) ") - ).trim().toLowerCase(); - const requiresAuth = requiresAuthAnswer === "y" || requiresAuthAnswer === "yes"; - const extractModeInput = ( - options.extractMode ?? + const inferredMode = inferSourceMode(options); + const rawMode = ( ( - (await rl.question("Extraction mode (remote-url/local-root) [remote-url]: ")).trim() || - "remote-url" - ) + options.extractMode ?? + ( + await rl.question( + `Source mode (local-root/remote-url) [${inferredMode}]: `, + ) + ).trim() + ) || + inferredMode ).toLowerCase(); - const extractMode: ExtractMode = - extractModeInput === "local-root" ? "local-root" : "remote-url"; - const suggestedSurface = options.surface ?? suggestSurfaceIdFromUrl(parsed.toString()); - const providedSurface = ( - await rl.question(`Surface id [${suggestedSurface}]: `) - ).trim(); - const surfaceId = normalizeSurfaceId(providedSurface || suggestedSurface); - const suggestedName = options.surfaceName ?? suggestSurfaceName(surfaceId); - const providedName = (await rl.question(`Surface name [${suggestedName}]: `)).trim(); - const surfaceName = providedName || suggestedName; + const sourceMode: ExtractMode = rawMode === "remote-url" ? "remote-url" : "local-root"; + + const url = sourceMode === "remote-url" + ? new URL(options.url ?? (await rl.question("Surface URL: ")).trim()).toString() + : options.url?.trim() || undefined; + const appRoot = sourceMode === "local-root" + ? (options.appRoot ?? (await rl.question("Local app root: "))).trim() + : undefined; + + const suggestedSurfaceId = options.surface ?? ( + sourceMode === "remote-url" && url + ? suggestSurfaceIdFromUrl(url) + : suggestSurfaceIdFromPath(appRoot ?? "surface") + ); + const rawSurfaceId = (await rl.question(`Surface id [${suggestedSurfaceId}]: `)).trim(); + const surfaceId = normalizeSurfaceId(rawSurfaceId || suggestedSurfaceId); + const suggestedSurfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const rawSurfaceName = (await rl.question(`Surface name [${suggestedSurfaceName}]: `)).trim(); + const surfaceName = rawSurfaceName || suggestedSurfaceName; + const requiresAuth = sourceMode === "remote-url" + ? ["y", "yes"].includes( + ( + await rl.question( + `Does ${new URL(url ?? "https://example.com").hostname} require login? (y/N) `, + ) + ).trim().toLowerCase(), + ) + : false; const authProfileName = requiresAuth ? ( await rl.question( - `Auth profile name [${options.authProfile ?? `${parsed.hostname}-default`}]: `, + `Auth profile name [${options.authProfile ?? `${new URL(url!).hostname}-default`}]: `, ) - ).trim() || options.authProfile || `${parsed.hostname}-default` + ).trim() || options.authProfile || `${new URL(url!).hostname}-default` : null; - const appRoot = extractMode === "local-root" - ? ( - options.appRoot ?? - (await rl.question("Local app root (directory containing app/): ")) - ).trim() - : undefined; return { - url: parsed.toString(), - extractMode, + sourceMode, + url, appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, surfaceId, surfaceName, + surfaceKind: options.surfaceKind, requiresAuth, authProfileName, }; @@ -160,215 +239,381 @@ async function promptInteractive(options: InitOptions): Promise<{ } } -async function resolveInputs(options: InitOptions): Promise<{ - url: string; - extractMode: ExtractMode; - appRoot?: string; - surfaceId: string; - surfaceName: string; - requiresAuth: boolean; - authProfileName: string | null; -}> { +async function resolveInputs(options: InitOptions): Promise { if (!options.nonInteractive) { return promptInteractive(options); } - if (!options.url) { - throw new Error("Missing required --url in --non-interactive mode."); - } - const parsed = new URL(options.url); - const extractMode = options.extractMode ?? "remote-url"; - const surfaceId = normalizeSurfaceId(options.surface ?? suggestSurfaceIdFromUrl(parsed.toString())); - const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); - const requiresAuth = Boolean(options.authProfile); - if (extractMode === "local-root" && !options.appRoot) { - throw new Error("Missing required --app-root for --extract-mode local-root."); + const sourceMode = inferSourceMode(options); + if (sourceMode === "remote-url" && !options.url) { + throw new Error("Missing required --url for remote-url onboarding."); + } + if (sourceMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for local-root onboarding."); } + const surfaceSuggestion = + options.surface ?? + ( + sourceMode === "remote-url" && options.url + ? suggestSurfaceIdFromUrl(options.url) + : suggestSurfaceIdFromPath(options.appRoot ?? "surface") + ); + const surfaceId = normalizeSurfaceId(surfaceSuggestion); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + return { - url: parsed.toString(), - extractMode, + sourceMode, + url: options.url ? new URL(options.url).toString() : undefined, appRoot: options.appRoot, surfaceId, surfaceName, - requiresAuth, - authProfileName: options.authProfile ?? null, + surfaceKind: options.surfaceKind, + requiresAuth: sourceMode === "remote-url" && Boolean(options.authProfile), + authProfileName: sourceMode === "remote-url" ? options.authProfile ?? null : null, }; } -async function writeJson(pathname: string, value: unknown): Promise { - await mkdir(path.dirname(pathname), { recursive: true }); - await writeFile(pathname, `${stableStringify(value)}\n`, "utf-8"); +async function promptSurfaceKind(analysis: SurfaceAnalysisArtifact): Promise { + const rl = readline.createInterface({ input, output }); + try { + console.log( + `Surface kind needs confirmation. interfacectl inferred "${analysis.classification.inferredKind}" (${analysis.classification.confidence.toFixed(2)} confidence).`, + ); + for (const evidence of analysis.classification.supporting.slice(0, 3)) { + console.log(` support: ${evidence.message}`); + } + for (const evidence of analysis.classification.opposing.slice(0, 2)) { + console.log(` counter: ${evidence.message}`); + } + while (true) { + const answer = ( + await rl.question( + `Confirm surface kind [${analysis.classification.inferredKind}]: `, + ) + ).trim().toLowerCase(); + const value = (answer || analysis.classification.inferredKind) as WebSurfaceKind; + if (VALID_SURFACE_KINDS.has(value)) { + return value; + } + console.log("Expected one of: marketing, application, unknown."); + } + } finally { + rl.close(); + } +} + +function resolveArtifactPaths( + rootDir: string, + surfaceId: string, + options: InitOptions, +): { + outDir: string; + analysisPath: string; + draftPath: string; + contractPath: string; + reportPath: string; +} { + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, DEFAULT_OUT_DIR); + const resolvePath = (explicit: string | undefined, fileName: string) => + explicit ? path.resolve(rootDir, explicit) : path.join(outDir, fileName); + + return { + outDir, + analysisPath: resolvePath(options.analysisOut, `${surfaceId}.analysis.json`), + draftPath: resolvePath(options.draftOut, `${surfaceId}.design-system.draft.json`), + contractPath: resolvePath(options.contractOut, `${surfaceId}.contract.json`), + reportPath: resolvePath(options.reportOut, `${surfaceId}.extraction.json`), + }; +} + +async function writeArtifact(filePath: string, payload: unknown): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, stringifyStableArtifact(payload), "utf-8"); +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf-8")) as T; +} + +function relativeDisplay(rootDir: string, filePath: string): string { + return path.relative(rootDir, filePath) || "."; +} + +function collectFlagMessages( + analysis: SurfaceAnalysisArtifact, + validateResult: ValidateJsonResult, + validateExtractedResult: ValidateExtractedJsonResult, +): string[] { + const flagged = [ + ...analysis.warnings.map((warning) => warning.message), + ...analysis.inconsistencies.findings.map((finding) => finding.message), + ...(validateResult.findings ?? []).map((finding) => finding.message), + ...validateExtractedResult.findings.map((finding) => finding.message), + ]; + return [...new Set(flagged)].slice(0, 8); +} + +function collectFindingCodes( + analysis: SurfaceAnalysisArtifact, + validateResult: ValidateJsonResult, + validateExtractedResult: ValidateExtractedJsonResult, +): string[] { + return [ + ...analysis.warnings.map((warning) => `analysis.${warning.code}`), + ...analysis.inconsistencies.findings.map((finding) => `analysis.${finding.code}`), + ...(validateResult.findings ?? []).map((finding) => `validate.${finding.code}`), + ...validateExtractedResult.findings.map((finding) => `validate-extracted.${finding.code}`), + ].sort((a, b) => a.localeCompare(b)); +} + +function summarizeAdopted(analysis: SurfaceAnalysisArtifact): string[] { + const reasons = analysis.existingSystem.reasons.slice(0, 3); + if (analysis.existingSystem.mode === "adopt") { + return reasons.length > 0 + ? reasons + : ["Observed enough repeated system structure to formalize an existing design system."]; + } + return [ + `No stable existing system was detected; interfacectl drafted a first system from repeated norms (${analysis.existingSystem.score.toFixed(2)} score).`, + ]; +} + +function summarizeNormalized(analysis: SurfaceAnalysisArtifact): string[] { + const seedCounts = analysis.proposedContract.seedCounts; + const items = [ + `${seedCounts.typographyTokens} typography token seed(s)`, + `${seedCounts.layoutTokens} layout token seed(s)`, + `${seedCounts.motionTokens} motion token seed(s)`, + `${seedCounts.colors} color value(s)`, + `${seedCounts.sections} section marker(s)`, + ]; + if (analysis.proposedContract.suggestedMarketingProfile) { + items.push("starter marketing profile suggestions"); + } + return items; +} + +function logStage(step: number, total: number, message: string): void { + console.log(`[${step}/${total}] ${message}`); +} + +function hasBlockingValidationError( + validateResult: ValidateJsonResult, + validateExtractedResult: ValidateExtractedJsonResult, +): boolean { + return ( + (validateResult.findings ?? []).some((finding) => finding.category === "E0") || + validateExtractedResult.findings.some((finding) => finding.category === "E0") + ); } export async function runInitCommand(options: InitOptions): Promise { + const rootDir = process.cwd(); + const storageMode = getAuthStorageMode(); + try { - const rootDir = process.cwd(); const resolved = await resolveInputs(options); - const authCapture = await maybeCaptureAuthProfile({ - requiresAuth: resolved.requiresAuth, - profileName: resolved.authProfileName, - url: resolved.url, - }); - const startTime = new Date().toISOString(); - if (resolved.extractMode === "local-root") { + if (resolved.sourceMode === "local-root") { const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); if (!existsSync(path.join(appRoot, "app"))) { console.error(`Local app root is missing app/: ${appRoot}`); return 1; } - const outDir = options.outDir - ? path.resolve(rootDir, options.outDir) - : path.resolve(rootDir, "contracts", "generated"); - const contractPath = path.join(outDir, `${resolved.surfaceId}.contract.json`); - const reportPath = path.join(outDir, `${resolved.surfaceId}.extraction.json`); - - const { contract: extractedContract, report } = await extractContractFromNextApp({ - appRoot, - surfaceId: resolved.surfaceId, - }); - const seeded = await seedColorPolicyFromObservedDescriptors({ - workspaceRoot: rootDir, - appRoot, - surfaceId: resolved.surfaceId, - contract: extractedContract as unknown as InterfaceContract, - }); - const contract = seeded.contract; - const reportWithSeedWarnings = { - ...report, - warnings: [...report.warnings, ...seeded.warnings], - }; - - const structure = validateContractStructure(contract, getBundledContractSchema() as object); - if (!structure.ok) { - console.error("Generated contract failed schema validation:"); - for (const issue of structure.errors) { - console.error(` ${issue}`); - } + } + + logStage(1, 5, "Discovering source"); + const authCapture = + resolved.sourceMode === "remote-url" && resolved.url + ? await maybeCaptureAuthProfile({ + requiresAuth: resolved.requiresAuth, + profileName: resolved.authProfileName, + url: resolved.url, + nonInteractive: Boolean(options.nonInteractive), + }) + : { authMode: "none" as const, storageState: undefined }; + + logStage(2, 5, "Analyzing surface kind and UI system"); + let analysisResult = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: resolved.surfaceKind, + authMode: authCapture.authMode, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + }); + + if (!resolved.surfaceKind && analysisResult.analysis.classification.requiresConfirmation) { + if (options.nonInteractive) { + console.error( + `Surface kind inference was low confidence (${analysisResult.analysis.classification.inferredKind}, ${analysisResult.analysis.classification.confidence.toFixed(2)}). Re-run with --surface-kind marketing|application|unknown.`, + ); return 1; } - const reportWithOnboarding = { - ...reportWithSeedWarnings, - onboarding: { - sourceUrl: redactSensitiveUrl(resolved.url), + const confirmedKind = await promptSurfaceKind(analysisResult.analysis); + if (confirmedKind !== analysisResult.analysis.classification.confirmedKind) { + analysisResult = await analyzeSurface({ + workspaceRoot: rootDir, + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: confirmedKind, authMode: authCapture.authMode, - extractMode: resolved.extractMode, - profileName: authCapture.profileName, - profileDomain: new URL(resolved.url).hostname, - startedAt: startTime, - completedAt: new Date().toISOString(), - detection: { - adapter: "next-app-static-extractor", - framework: "nextjs", - profile: "codebase", - }, - }, - }; - await writeJson(contractPath, contract); - await writeJson(reportPath, reportWithOnboarding); - - const status = reportWithOnboarding.warnings.length > 0 ? "warn" : "pass"; - const findingCodes = reportWithOnboarding.warnings.map((warning) => `extract.${warning.code}`); - const run = await emitBootstrapRunArtifact({ + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + }); + } + } + + logStage(3, 5, "Seeding contract and draft design system"); + const structure = validateContractStructure( + analysisResult.contract, + getBundledContractSchema() as object, + ); + if (!structure.ok) { + console.error("Generated contract failed schema validation:"); + for (const issue of structure.errors) { + console.error(` ${issue}`); + } + return 1; + } + + const artifacts = resolveArtifactPaths(rootDir, resolved.surfaceId, options); + await writeArtifact(artifacts.analysisPath, analysisResult.analysis); + await writeArtifact(artifacts.draftPath, analysisResult.draft); + await writeArtifact(artifacts.contractPath, analysisResult.contract); + await writeArtifact(artifacts.reportPath, analysisResult.extractionReport); + + logStage(4, 5, "Validating generated outputs"); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-validate-")); + try { + const validatePath = path.join(tempDir, "validate.json"); + const validateExtractedPath = path.join(tempDir, "validate-extracted.json"); + + const validateExitCode = await runValidateCommand({ + contractPath: artifacts.contractPath, + workspaceRoot: rootDir, + surfaceFilters: [resolved.surfaceId], + descriptorOverrides: [analysisResult.descriptor], + outputFormat: "json", + outputPath: validatePath, + exitCodes: "v2", + }); + const validateExtractedExitCode = await runValidateExtractedCommand({ + contractPath: artifacts.contractPath, + extractedPath: artifacts.reportPath, + surfaceId: resolved.surfaceId, + format: "json", + outputPath: validateExtractedPath, + exitCodes: "v2", + }); + + const validateResult = await readJsonFile(validatePath); + const validateExtractedResult = await readJsonFile( + validateExtractedPath, + ); + + logStage(5, 5, "Writing onboarding lineage"); + const findingCodes = collectFindingCodes( + analysisResult.analysis, + validateResult, + validateExtractedResult, + ); + const blockingValidationError = hasBlockingValidationError( + validateResult, + validateExtractedResult, + ); + const status = + blockingValidationError + ? "fail" + : findingCodes.length > 0 + ? "warn" + : "pass"; + const run = await emitOnboardingRunArtifact({ rootDir, surfaceId: resolved.surfaceId, + source: "generation", status, findingCodes, - extractionPath: contractPath, - reportPath, + extractionPath: artifacts.contractPath, + reportPath: artifacts.reportPath, }); + const adopted = summarizeAdopted(analysisResult.analysis); + const normalized = summarizeNormalized(analysisResult.analysis); + const flagged = collectFlagMessages( + analysisResult.analysis, + validateResult, + validateExtractedResult, + ); + console.log(`Onboarding completed for ${resolved.surfaceId}.`); - console.log(`Wrote contract: ${contractPath}`); - console.log(`Wrote report: ${reportPath}`); + console.log(`Wrote analysis: ${artifacts.analysisPath}`); + console.log(`Wrote draft: ${artifacts.draftPath}`); + console.log(`Wrote contract: ${artifacts.contractPath}`); + console.log(`Wrote report: ${artifacts.reportPath}`); console.log(`Run id: ${run.runId}`); console.log(`Auth storage: ${storageMode}`); if (storageMode === "file") { - console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + console.log( + "Warning: keychain unavailable; using local file storage for opaque session references.", + ); + } + if (authCapture.profileName) { + console.log(`Auth profile: ${authCapture.profileName}`); + } + console.log(""); + console.log("adopted"); + for (const line of adopted) { + console.log(` - ${line}`); + } + console.log("normalized"); + for (const line of normalized) { + console.log(` - ${line}`); + } + console.log("flagged"); + if (flagged.length === 0) { + console.log(" - No onboarding findings."); + } else { + for (const line of flagged) { + console.log(` - ${line}`); + } + } + console.log("next steps"); + console.log( + ` - interfacectl validate-extracted --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --extracted ${relativeDisplay(rootDir, artifacts.reportPath)} --surface ${resolved.surfaceId}`, + ); + if (resolved.sourceMode === "local-root") { + console.log( + ` - Add surfaceRoots.${resolved.surfaceId} = "${relativeDisplay(rootDir, path.resolve(rootDir, resolved.appRoot ?? "."))}" in interfacectl.config.json for repeatable source-backed validation.`, + ); + console.log( + ` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`, + ); + } else { + console.log( + ` - Re-run with --app-root to enable source-backed validate once the local web app checkout is available.`, + ); } - console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); - return 0; - } - - const url = new URL(resolved.url); - const authAware = authCapture.authMode === "browser-session"; - const bootstrapContract = buildBootstrapContract({ - surfaceId: resolved.surfaceId, - surfaceName: resolved.surfaceName, - sourceUrl: redactSensitiveUrl(url.toString()), - authAware, - }); - const warnings = [ - { - code: "remote-url.bootstrap-only", - message: - "Remote URL mode creates bootstrap extraction metadata only. Use local-root mode for full static extraction.", - }, - { - code: "color-seed.remote-unavailable", - message: - "Color allowlist was not seeded from code in remote-url mode; generated contract uses an empty allowlist with warn policy.", - }, - ]; - const report: BootstrapExtractionReport = { - surfaceId: resolved.surfaceId, - appRoot: url.origin, - warnings, - extracted: { - routes: [url.pathname || "/"], - hasShell: false, - designSystemComponents: [], - authAware, - }, - onboarding: { - sourceUrl: redactSensitiveUrl(url.toString()), - authMode: authCapture.authMode, - extractMode: "remote-url", - profileName: authCapture.profileName, - profileDomain: url.hostname, - startedAt: startTime, - completedAt: new Date().toISOString(), - detection: { - adapter: "remote-url-bootstrap", - framework: "unknown", - profile: "bootstrap", - }, - }, - }; - const written = await writeBootstrapArtifacts({ - rootDir, - outDir: options.outDir, - surfaceId: resolved.surfaceId, - contract: bootstrapContract, - report, - }); - const run = await emitBootstrapRunArtifact({ - rootDir, - surfaceId: resolved.surfaceId, - status: "warn", - findingCodes: ["extract.remote-url.bootstrap-only"], - extractionPath: written.contractPath, - reportPath: written.reportPath, - }); - console.log(`Onboarding completed for ${resolved.surfaceId}.`); - console.log(`Wrote contract: ${written.contractPath}`); - console.log(`Wrote report: ${written.reportPath}`); - if (authCapture.profileName) { - console.log(`Auth profile: ${authCapture.profileName}`); - } - console.log(`Run id: ${run.runId}`); - console.log(`Auth storage: ${storageMode}`); - if (storageMode === "file") { - console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + return blockingValidationError || validateExitCode === 10 || validateExtractedExitCode === 10 + ? 1 + : 0; + } finally { + await rm(tempDir, { recursive: true, force: true }); } - console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); - return 0; } catch (error) { console.error(redactSensitiveText((error as Error).message)); return 1; } } - const storageMode = getAuthStorageMode(); diff --git a/packages/interfacectl-cli/src/commands/validate-extracted.ts b/packages/interfacectl-cli/src/commands/validate-extracted.ts index 7057b09..c43d2e4 100644 --- a/packages/interfacectl-cli/src/commands/validate-extracted.ts +++ b/packages/interfacectl-cli/src/commands/validate-extracted.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { getExitCodeVersion } from "../utils/exit-codes.js"; const AUTH_ROUTES = ["/auth/login", "/auth/callback", "/auth/session", "/auth/logout"]; @@ -25,6 +25,7 @@ export interface ValidateExtractedOptions { extractedPath: string; surfaceId?: string; format?: "text" | "json"; + outputPath?: string; exitCodes?: "v1" | "v2"; } @@ -202,6 +203,22 @@ export async function runValidateExtractedCommand( const cwd = process.cwd(); const exitCodeVersion = getExitCodeVersion({ exitCodes: options.exitCodes }); const format = (options.format ?? "text").toLowerCase() === "json" ? "json" : "text"; + const outputPath = options.outputPath + ? path.resolve(cwd, options.outputPath) + : undefined; + + const emit = async (contents: string, stream: "stdout" | "stderr" = "stdout"): Promise => { + if (outputPath) { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, contents, "utf-8"); + return; + } + if (stream === "stderr") { + process.stderr.write(contents); + return; + } + process.stdout.write(contents); + }; let contract: Record; try { @@ -211,23 +228,21 @@ export async function runValidateExtractedCommand( } catch (err) { const message = err instanceof Error ? err.message : String(err); if (format === "json") { - console.log( - JSON.stringify( - { - ok: false, - findings: [ - { - surfaceId: "", - code: "phase0.load.contract", - category: "E0", - message: `Failed to load contract: ${message}`, - }, - ], - }, - null, - 2, - ), - ); + await emit(`${JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: "", + code: "phase0.load.contract", + category: "E0", + message: `Failed to load contract: ${message}`, + }, + ], + }, + null, + 2, + )}\n`); } else { console.error(`Failed to load contract: ${message}`); } @@ -244,23 +259,21 @@ export async function runValidateExtractedCommand( } catch (err) { const message = err instanceof Error ? err.message : String(err); if (format === "json") { - console.log( - JSON.stringify( - { - ok: false, - findings: [ - { - surfaceId: options.surfaceId ?? "", - code: "phase0.load.extracted", - category: "E0", - message: `Failed to load extracted file: ${message}`, - }, - ], - }, - null, - 2, - ), - ); + await emit(`${JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: `Failed to load extracted file: ${message}`, + }, + ], + }, + null, + 2, + )}\n`); } else { console.error( "Extracted file must be an extraction report (surfaceId + extracted) or a generated contract with x_extracted. Use --surface when surfaceId cannot be inferred.", @@ -272,24 +285,22 @@ export async function runValidateExtractedCommand( if (!extractedData) { if (format === "json") { - console.log( - JSON.stringify( - { - ok: false, - findings: [ - { - surfaceId: options.surfaceId ?? "", - code: "phase0.load.extracted", - category: "E0", - message: - "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", - }, - ], - }, - null, - 2, - ), - ); + await emit(`${JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: + "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", + }, + ], + }, + null, + 2, + )}\n`); } else { console.error( "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", @@ -301,9 +312,11 @@ export async function runValidateExtractedCommand( const phase0 = getPhase0ForSurface(contract, extractedData.surfaceId); if (!phase0) { if (format === "json") { - console.log( - JSON.stringify({ ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, null, 2), - ); + await emit(`${JSON.stringify( + { ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, + null, + 2, + )}\n`); } else { console.log(`No phase0 block for surface ${extractedData.surfaceId}; nothing to compare.`); } @@ -316,7 +329,7 @@ export async function runValidateExtractedCommand( const exitCode = ok ? 0 : exitCodeVersion === "v2" ? 30 : 1; if (format === "json") { - console.log(JSON.stringify({ ok, findings: sorted }, null, 2)); + await emit(`${JSON.stringify({ ok, findings: sorted }, null, 2)}\n`); return exitCode; } diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index f6223eb..540591b 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -6,6 +6,7 @@ import { evaluateContractCompliance, getBundledContractSchema, type InterfaceContract, + type SurfaceDescriptor, type ValidationSummary, type DriftViolationType, type SurfaceFlowDescriptor, @@ -62,6 +63,7 @@ export interface ValidateCommandOptions { schemaPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; + descriptorOverrides?: SurfaceDescriptor[]; outputFormat?: OutputFormat; outputPath?: string; configPath?: string; @@ -262,78 +264,86 @@ export async function runValidateCommand( (options.surfaceFilters ?? []).map((value) => value.trim()), ); - const structuralDescriptorResult = await collectSurfaceDescriptors({ - workspaceRoot, - contract, - surfaceFilters, - surfaceRootMap, - }); + let descriptorsWithFlowArtifacts: SurfaceDescriptor[]; - if (structuralDescriptorResult.warnings.length > 0) { - if (!isJson) { - printHeader( - pc.yellow("⚠ Surface descriptor warnings"), - textReporter, - ); + if (options.descriptorOverrides && options.descriptorOverrides.length > 0) { + descriptorsWithFlowArtifacts = options.descriptorOverrides.filter((descriptor) => + surfaceFilters.size === 0 ? true : surfaceFilters.has(descriptor.surfaceId), + ); + } else { + const structuralDescriptorResult = await collectSurfaceDescriptors({ + workspaceRoot, + contract, + surfaceFilters, + surfaceRootMap, + }); + + if (structuralDescriptorResult.warnings.length > 0) { + if (!isJson) { + printHeader( + pc.yellow("⚠ Surface descriptor warnings"), + textReporter, + ); + for (const warning of structuralDescriptorResult.warnings) { + textReporter.warn(pc.yellow(` • ${warning.message}`)); + } + } for (const warning of structuralDescriptorResult.warnings) { - textReporter.warn(pc.yellow(` • ${warning.message}`)); + findings.push(issueToFinding(warning, "warning")); } } - for (const warning of structuralDescriptorResult.warnings) { - findings.push(issueToFinding(warning, "warning")); - } - } - if (structuralDescriptorResult.errors.length > 0) { - if (!isJson) { - printHeader(pc.red("✖ Surface descriptor errors"), textReporter); + if (structuralDescriptorResult.errors.length > 0) { + if (!isJson) { + printHeader(pc.red("✖ Surface descriptor errors"), textReporter); + for (const error of structuralDescriptorResult.errors) { + textReporter.error(pc.red(` • ${error.message}`)); + } + } for (const error of structuralDescriptorResult.errors) { - textReporter.error(pc.red(` • ${error.message}`)); + findings.push(issueToFinding(error, "error")); } + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); } - for (const error of structuralDescriptorResult.errors) { - findings.push(issueToFinding(error, "error")); - } - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, contract.version ?? initialContractVersion); - } - const flowDescriptorResult = await loadFlowDescriptorArtifacts({ - workspaceRoot, - contract, - surfaceFilters, - flowDescriptorPathMap, - }); - if (!flowDescriptorResult.ok) { - const message = `Failed to load flow descriptor artifact: ${flowDescriptorResult.error}`; - if (!isJson) { - printHeader(pc.red("✖ Flow descriptor artifact load failed"), textReporter); - textReporter.error(pc.red(flowDescriptorResult.error)); - } - findings.push({ - code: "flow-descriptor.load-error", - severity: "error", - category: "E0", - message, - surface: flowDescriptorResult.surfaceId, - location: flowDescriptorResult.path, + const flowDescriptorResult = await loadFlowDescriptorArtifacts({ + workspaceRoot, + contract, + surfaceFilters, + flowDescriptorPathMap, }); - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, contract.version ?? initialContractVersion); - } + if (!flowDescriptorResult.ok) { + const message = `Failed to load flow descriptor artifact: ${flowDescriptorResult.error}`; + if (!isJson) { + printHeader(pc.red("✖ Flow descriptor artifact load failed"), textReporter); + textReporter.error(pc.red(flowDescriptorResult.error)); + } + findings.push({ + code: "flow-descriptor.load-error", + severity: "error", + category: "E0", + message, + surface: flowDescriptorResult.surfaceId, + location: flowDescriptorResult.path, + }); + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); + } - const descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map( - (descriptor) => { - const flowDescriptorPath = flowDescriptorResult.paths.get( - descriptor.surfaceId, - ); - return { - ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath, - }; - }, - ); + descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map( + (descriptor) => { + const flowDescriptorPath = flowDescriptorResult.paths.get( + descriptor.surfaceId, + ); + return { + ...descriptor, + flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), + flowDescriptorPath, + }; + }, + ); + } const summary = evaluateContractCompliance( contract, diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 1ddc2e5..96dbd36 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -10,7 +10,9 @@ import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; import { runInitCommand } from "./commands/init.js"; +import { runAnalyzeCommand } from "./commands/analyze.js"; import { + runAuthCaptureCommand, runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, @@ -331,29 +333,75 @@ program process.exitCode = exitCode; }); +program + .command("analyze") + .description("Analyze a web surface and emit first-run onboarding evidence") + .option("--url ", "Surface URL for remote analysis") + .option("--app-root ", "Local app root for local-root analysis") + .option("--extract-mode ", "Analysis mode") + .option("--surface ", "Surface identifier override") + .option("--surface-name ", "Surface display name override") + .option("--surface-kind ", "Optional surface-kind confirmation override") + .option("--auth-profile ", "Replay an existing auth profile during remote analysis") + .option("--out ", "Output path for the analysis artifact") + .option("--out-dir ", "Output directory for generated analysis artifacts") + .action(async (options) => { + const extractMode = + options.extractMode === "local-root" + ? "local-root" + : options.extractMode === "remote-url" + ? "remote-url" + : undefined; + process.exitCode = await runAnalyzeCommand({ + url: options.url, + appRoot: options.appRoot, + extractMode, + surface: options.surface, + surfaceName: options.surfaceName, + surfaceKind: options.surfaceKind, + authProfile: options.authProfile, + out: options.out, + outDir: options.outDir, + }); + }); + program .command("init") .description("Interactive onboarding for first-surface extraction") .option("--url ", "Surface URL for onboarding") .option("--surface ", "Surface identifier override") .option("--surface-name ", "Surface display name override") - .option("--extract-mode ", "Extraction mode", "remote-url") - .option("--app-root ", "Local app root (required when extract-mode is local-root)") - .option("--auth-profile ", "Auth profile name for browser-session onboarding") - .option("--non-interactive", "Run without prompts (requires --url)") + .option("--surface-kind ", "Optional surface-kind confirmation override") + .option("--extract-mode ", "Extraction mode") + .option("--app-root ", "Local app root (required for local-root)") + .option("--auth-profile ", "Replay or capture an auth profile for browser-session onboarding") + .option("--non-interactive", "Run without prompts") .option("--out-dir ", "Output directory for generated onboarding artifacts") + .option("--analysis-out ", "Explicit output path for the analysis artifact") + .option("--draft-out ", "Explicit output path for the design-system draft artifact") + .option("--contract-out ", "Explicit output path for the generated contract") + .option("--report-out ", "Explicit output path for the extraction report") .action(async (options) => { const extractMode = - options.extractMode === "local-root" ? "local-root" : "remote-url"; + options.extractMode === "local-root" + ? "local-root" + : options.extractMode === "remote-url" + ? "remote-url" + : undefined; const exitCode = await runInitCommand({ url: options.url, surface: options.surface, surfaceName: options.surfaceName, + surfaceKind: options.surfaceKind, extractMode, appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, outDir: options.outDir, + analysisOut: options.analysisOut, + draftOut: options.draftOut, + contractOut: options.contractOut, + reportOut: options.reportOut, }); process.exitCode = exitCode; }); @@ -362,6 +410,20 @@ const auth = program .command("auth") .description("Manage onboarding browser-session auth profiles"); +auth + .command("capture") + .description("Capture or refresh a replayable browser-session auth profile") + .requiredOption("--profile ", "Profile name") + .requiredOption("--url ", "URL on the exact host to capture") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthCaptureCommand({ + profile: options.profile, + url: options.url, + format: options.format === "json" ? "json" : "text", + }); + }); + auth .command("list") .description("List local auth profiles") @@ -377,11 +439,13 @@ auth .description("Validate a local auth profile by name") .requiredOption("--profile ", "Profile name") .option("--domain ", "Optional domain scope") + .option("--url ", "Optional URL to test authenticated replay against") .option("--format ", "Output format", "text") .action(async (options) => { process.exitCode = await runAuthTestCommand({ profile: options.profile, domain: options.domain, + url: options.url, format: options.format === "json" ? "json" : "text", }); }); @@ -446,6 +510,7 @@ program .requiredOption("--extracted ", "Path to extraction report or generated contract with x_extracted") .option("--surface ", "Surface id when not inferrable from extracted file") .option("--format ", "Output format (text|json)", "text") + .option("--out ", "Write output to the provided file path instead of stdout") .option("--exit-codes ", "Exit code version (default: v1; v2: 0 success, 10 E0, 30 E2)") .action(async (options) => { const exitCodeVersion = @@ -455,6 +520,7 @@ program extractedPath: options.extracted, surfaceId: options.surface, format: (options.format ?? "text").toLowerCase() === "json" ? "json" : "text", + outputPath: options.out, exitCodes: exitCodeVersion, }); process.exitCode = exitCode; diff --git a/packages/interfacectl-cli/src/utils/auth-profiles.ts b/packages/interfacectl-cli/src/utils/auth-profiles.ts index 1edb4d3..e7f1101 100644 --- a/packages/interfacectl-cli/src/utils/auth-profiles.ts +++ b/packages/interfacectl-cli/src/utils/auth-profiles.ts @@ -1,12 +1,14 @@ -import { execSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; export type AuthMode = "browser-session"; export type AuthStorageMode = "keychain" | "file"; +export type CaptureBrowser = "chromium"; +export type AuthProfileReadiness = "ready" | "missing" | "expired" | "legacy" | "not-ready"; export interface AuthProfile { name: string; @@ -15,7 +17,11 @@ export interface AuthProfile { createdAt: string; updatedAt: string; expiresAt: string; - sessionRef: string; + sessionRef?: string; + replayStateRef?: string; + replayReady?: boolean; + capturedAt?: string; + captureBrowser?: CaptureBrowser; } interface AuthProfilesDocument { @@ -23,47 +29,121 @@ interface AuthProfilesDocument { profiles: AuthProfile[]; } -export interface AuthProfileStore { - list(): Promise; - get(name: string, domain?: string): Promise; - save(input: { name: string; domain: string; ttlHours?: number }): Promise; - revoke(input: { name?: string; domain?: string }): Promise; - revokeAll(): Promise; +interface ReplayStateStore { + load(ref: string): Promise; + save(ref: string, payload: string): Promise; + delete(ref: string): Promise; mode(): AuthStorageMode; } +export interface ReplayableAuthProfile { + profile: AuthProfile; + storageState: string; +} + +export interface AuthProfileInspection { + status: AuthProfileReadiness; + profile?: AuthProfile; + storageState?: string; +} + const DEFAULT_TTL_HOURS = 4; -const KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; -const KEYCHAIN_ACCOUNT = "default"; +const LEGACY_METADATA_KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; +const LEGACY_METADATA_KEYCHAIN_ACCOUNT = "default"; +const REPLAY_STATE_KEYCHAIN_SERVICE = "interfacectl.auth.replay-state"; +const ENCRYPTED_STATE_VERSION = 1; let warnedFallback = false; function normalizeProfileName(name: string): string { return name.trim().toLowerCase(); } +function profileKey(profile: Pick): string { + return `${normalizeProfileName(profile.name)}::${profile.domain}`; +} + function isSensitiveEnvFlagTrue(name: string): boolean { return process.env[name] === "1" || process.env[name] === "true"; } -function getProfilesPath(): string { +function getAuthConfigRoot(): string { const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; if (override && override.trim().length > 0) { - return path.resolve(override); + return path.dirname(path.resolve(override)); } const xdgConfig = process.env.XDG_CONFIG_HOME; const configRoot = xdgConfig && xdgConfig.trim().length > 0 ? xdgConfig : path.join(os.homedir(), ".config"); - return path.join(configRoot, "interfacectl", "auth-profiles.json"); + return path.join(configRoot, "interfacectl"); +} + +function getProfilesPath(): string { + const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-profiles.json"); +} + +function getStateDirectory(): string { + const override = process.env.INTERFACECTL_AUTH_STATE_DIR; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-state"); +} + +function getStateKeyPath(): string { + const override = process.env.INTERFACECTL_AUTH_STATE_KEY_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(getAuthConfigRoot(), "auth-state.key"); +} + +function isObjectRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toValidProfile(maybeProfile: unknown): AuthProfile | null { + if (!isObjectRecord(maybeProfile)) { + return null; + } + + const name = typeof maybeProfile.name === "string" ? normalizeProfileName(maybeProfile.name) : ""; + const domain = typeof maybeProfile.domain === "string" ? maybeProfile.domain : ""; + const mode = maybeProfile.mode === "browser-session" ? "browser-session" : null; + const createdAt = typeof maybeProfile.createdAt === "string" ? maybeProfile.createdAt : ""; + const updatedAt = typeof maybeProfile.updatedAt === "string" ? maybeProfile.updatedAt : createdAt; + const expiresAt = typeof maybeProfile.expiresAt === "string" ? maybeProfile.expiresAt : ""; + + if (!name || !domain || !mode || !createdAt || !expiresAt) { + return null; + } + + return { + name, + domain, + mode, + createdAt, + updatedAt, + expiresAt, + sessionRef: typeof maybeProfile.sessionRef === "string" ? maybeProfile.sessionRef : undefined, + replayStateRef: typeof maybeProfile.replayStateRef === "string" ? maybeProfile.replayStateRef : undefined, + replayReady: maybeProfile.replayReady === true, + capturedAt: typeof maybeProfile.capturedAt === "string" ? maybeProfile.capturedAt : undefined, + captureBrowser: maybeProfile.captureBrowser === "chromium" ? "chromium" : undefined, + }; } function toValidDocument(maybeDoc: unknown): AuthProfilesDocument { const parsed = maybeDoc as Partial; const profiles = Array.isArray(parsed?.profiles) - ? parsed.profiles.filter((profile) => profile && typeof profile === "object") as AuthProfile[] + ? parsed.profiles.map(toValidProfile).filter((profile): profile is AuthProfile => profile !== null) : []; return { - schemaVersion: Number(parsed?.schemaVersion || 1), + schemaVersion: Number(parsed?.schemaVersion || 2), profiles, }; } @@ -75,286 +155,439 @@ async function writeJsonAtomic(filePath: string, doc: AuthProfilesDocument): Pro await rename(tempPath, filePath); } -export function isProfileExpired(profile: AuthProfile, now: Date = new Date()): boolean { - return Date.parse(profile.expiresAt) <= now.getTime(); -} +function mergeProfiles(primary: AuthProfile[], secondary: AuthProfile[]): AuthProfile[] { + const merged = new Map(); + for (const profile of [...secondary, ...primary]) { + const key = profileKey(profile); + const existing = merged.get(key); + if (!existing) { + merged.set(key, profile); + continue; + } -class FileAuthProfileStore implements AuthProfileStore { - private readonly documentPath: string; + const existingReady = isProfileReplayReady(existing); + const nextReady = isProfileReplayReady(profile); + if (nextReady && !existingReady) { + merged.set(key, profile); + continue; + } + if (existingReady && !nextReady) { + continue; + } - constructor(documentPath: string) { - this.documentPath = documentPath; + if ((Date.parse(profile.updatedAt) || 0) >= (Date.parse(existing.updatedAt) || 0)) { + merged.set(key, profile); + } } - mode(): AuthStorageMode { - return "file"; - } + return [...merged.values()].sort((a, b) => { + const nameDiff = a.name.localeCompare(b.name); + return nameDiff !== 0 ? nameDiff : a.domain.localeCompare(b.domain); + }); +} - private async readDocument(): Promise { - if (!existsSync(this.documentPath)) { - return { schemaVersion: 1, profiles: [] }; - } - try { - const raw = await readFile(this.documentPath, "utf-8"); - return toValidDocument(JSON.parse(raw)); - } catch { - return { schemaVersion: 1, profiles: [] }; - } - } +function runSecurity(args: string[]): { ok: boolean; stdout: string } { + const result = spawnSync("security", args, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }); + return { + ok: result.status === 0, + stdout: result.stdout ?? "", + }; +} - private async writeDocument(doc: AuthProfilesDocument): Promise { - await writeJsonAtomic(this.documentPath, doc); +function keychainAvailable(): boolean { + if (process.platform !== "darwin") { + return false; } + if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) { + return false; + } + const probe = spawnSync("command", ["-v", "security"], { + shell: true, + encoding: "utf-8", + stdio: "ignore", + }); + return probe.status === 0; +} - async list(): Promise { - const doc = await this.readDocument(); - return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); +function readLegacyMetadataFromKeychain(): AuthProfilesDocument { + if (!keychainAvailable()) { + return { schemaVersion: 2, profiles: [] }; + } + const result = runSecurity([ + "find-generic-password", + "-s", + LEGACY_METADATA_KEYCHAIN_SERVICE, + "-a", + LEGACY_METADATA_KEYCHAIN_ACCOUNT, + "-w", + ]); + if (!result.ok) { + return { schemaVersion: 2, profiles: [] }; + } + try { + return toValidDocument(JSON.parse(result.stdout.trim() || "{}")); + } catch { + return { schemaVersion: 2, profiles: [] }; } +} - async get(name: string, domain?: string): Promise { - const normalized = normalizeProfileName(name); - const profiles = await this.list(); - return profiles.find( - (profile) => - normalizeProfileName(profile.name) === normalized && - (domain ? profile.domain === domain : true), - ) ?? null; +function clearLegacyMetadataFromKeychain(): void { + if (!keychainAvailable()) { + return; } + runSecurity([ + "delete-generic-password", + "-s", + LEGACY_METADATA_KEYCHAIN_SERVICE, + "-a", + LEGACY_METADATA_KEYCHAIN_ACCOUNT, + ]); +} - async save(input: { name: string; domain: string; ttlHours?: number }): Promise { - const doc = await this.readDocument(); - const now = new Date(); - const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; - const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); - const normalizedName = normalizeProfileName(input.name); +async function readMetadataDocument(): Promise { + const metadataPath = getProfilesPath(); + const fileDoc = existsSync(metadataPath) + ? toValidDocument(JSON.parse(await readFile(metadataPath, "utf-8"))) + : { schemaVersion: 2, profiles: [] }; + const legacyDoc = readLegacyMetadataFromKeychain(); - const next: AuthProfile = { - name: normalizedName, - domain: input.domain, - mode: "browser-session", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - expiresAt, - sessionRef: randomUUID(), - }; + return { + schemaVersion: 2, + profiles: mergeProfiles(fileDoc.profiles, legacyDoc.profiles), + }; +} - const existingIndex = doc.profiles.findIndex( - (profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain, - ); - if (existingIndex >= 0) { - next.createdAt = doc.profiles[existingIndex].createdAt; - doc.profiles[existingIndex] = next; - } else { - doc.profiles.push(next); - } +async function writeMetadataDocument(doc: AuthProfilesDocument): Promise { + await writeJsonAtomic(getProfilesPath(), { + schemaVersion: 2, + profiles: doc.profiles, + }); + clearLegacyMetadataFromKeychain(); +} - await this.writeDocument(doc); - console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=file`); - return next; +async function ensureFilePermissions(filePath: string): Promise { + try { + await chmod(filePath, 0o600); + } catch { + // Best effort only. } +} - async revoke(input: { name?: string; domain?: string }): Promise { - const doc = await this.readDocument(); - const before = doc.profiles.length; +async function readOrCreateMasterKey(): Promise { + const keyPath = getStateKeyPath(); + if (existsSync(keyPath)) { + const raw = await readFile(keyPath, "utf-8"); + return Buffer.from(raw.trim(), "base64"); + } - if (input.name) { - const normalized = normalizeProfileName(input.name); - doc.profiles = doc.profiles.filter( - (profile) => - normalizeProfileName(profile.name) !== normalized || - (input.domain ? profile.domain !== input.domain : false), - ); - } else if (input.domain) { - doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); - } else { - return 0; - } + const key = randomBytes(32); + await mkdir(path.dirname(keyPath), { recursive: true }); + await writeFile(keyPath, key.toString("base64"), "utf-8"); + await ensureFilePermissions(keyPath); + return key; +} - const removed = before - doc.profiles.length; - if (removed > 0) { - await this.writeDocument(doc); - console.error(`[auth-event] profile_revoked count=${removed} mode=file`); - } - return removed; - } +function encryptState(payload: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf-8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return JSON.stringify({ + version: ENCRYPTED_STATE_VERSION, + iv: iv.toString("base64"), + authTag: authTag.toString("base64"), + ciphertext: ciphertext.toString("base64"), + }); +} - async revokeAll(): Promise { - const doc = await this.readDocument(); - const removed = doc.profiles.length; - if (removed > 0) { - await this.writeDocument({ schemaVersion: 1, profiles: [] }); - console.error(`[auth-event] profile_revoked_all count=${removed} mode=file`); +function decryptState(payload: string, key: Buffer): string | null { + try { + const parsed = JSON.parse(payload) as Record; + if (parsed.version !== ENCRYPTED_STATE_VERSION) { + return null; } - return removed; + const iv = Buffer.from(String(parsed.iv), "base64"); + const authTag = Buffer.from(String(parsed.authTag), "base64"); + const ciphertext = Buffer.from(String(parsed.ciphertext), "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8"); + } catch { + return null; } } -class KeychainAuthProfileStore implements AuthProfileStore { - private static exists(): boolean { - if (process.platform !== "darwin") return false; - if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) return false; - try { - execSync("command -v security", { encoding: "utf-8", stdio: "ignore" }); - return true; - } catch { - return false; +class FileReplayStateStore implements ReplayStateStore { + mode(): AuthStorageMode { + return "file"; + } + + private statePath(ref: string): string { + return path.join(getStateDirectory(), `${ref}.json.enc`); + } + + async load(ref: string): Promise { + const target = this.statePath(ref); + if (!existsSync(target)) { + return null; } + const key = await readOrCreateMasterKey(); + const encrypted = await readFile(target, "utf-8"); + return decryptState(encrypted, key); } - static isAvailable(): boolean { - return KeychainAuthProfileStore.exists(); + async save(ref: string, payload: string): Promise { + const target = this.statePath(ref); + const key = await readOrCreateMasterKey(); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, encryptState(payload, key), "utf-8"); + await ensureFilePermissions(target); } - mode(): AuthStorageMode { - return "keychain"; + async delete(ref: string): Promise { + await rm(this.statePath(ref), { force: true }); } +} - private readDocumentSync(): AuthProfilesDocument { - try { - const raw = execSync( - `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w`, - { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }, - ); - return toValidDocument(JSON.parse(raw.trim() || "{}")); - } catch { - return { schemaVersion: 1, profiles: [] }; - } +class KeychainReplayStateStore implements ReplayStateStore { + static isAvailable(): boolean { + return keychainAvailable(); } - private writeDocumentSync(doc: AuthProfilesDocument): void { - const payload = JSON.stringify(doc); - execSync( - `security add-generic-password -U -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w '${payload.replace(/'/g, "'\\''")}'`, - { stdio: ["pipe", "ignore", "ignore"] }, - ); + mode(): AuthStorageMode { + return "keychain"; } - async list(): Promise { - const doc = this.readDocumentSync(); - return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); - } - - async get(name: string, domain?: string): Promise { - const normalized = normalizeProfileName(name); - const profiles = await this.list(); - return profiles.find( - (profile) => - normalizeProfileName(profile.name) === normalized && - (domain ? profile.domain === domain : true), - ) ?? null; - } - - async save(input: { name: string; domain: string; ttlHours?: number }): Promise { - const doc = this.readDocumentSync(); - const now = new Date(); - const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; - const normalizedName = normalizeProfileName(input.name); - const next: AuthProfile = { - name: normalizedName, - domain: input.domain, - mode: "browser-session", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - expiresAt: new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(), - sessionRef: randomUUID(), - }; - const existingIndex = doc.profiles.findIndex( - (profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain, - ); - if (existingIndex >= 0) { - next.createdAt = doc.profiles[existingIndex].createdAt; - doc.profiles[existingIndex] = next; - } else { - doc.profiles.push(next); - } - this.writeDocumentSync(doc); - console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=keychain`); - return next; + async load(ref: string): Promise { + const result = runSecurity([ + "find-generic-password", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + "-w", + ]); + return result.ok ? result.stdout : null; } - async revoke(input: { name?: string; domain?: string }): Promise { - const doc = this.readDocumentSync(); - const before = doc.profiles.length; - if (input.name) { - const normalized = normalizeProfileName(input.name); - doc.profiles = doc.profiles.filter( - (profile) => - normalizeProfileName(profile.name) !== normalized || - (input.domain ? profile.domain !== input.domain : false), - ); - } else if (input.domain) { - doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); - } else { - return 0; - } - const removed = before - doc.profiles.length; - if (removed > 0) { - this.writeDocumentSync(doc); - console.error(`[auth-event] profile_revoked count=${removed} mode=keychain`); + async save(ref: string, payload: string): Promise { + const result = runSecurity([ + "add-generic-password", + "-U", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + "-w", + payload, + ]); + if (!result.ok) { + throw new Error(`Failed to write replay state for auth profile "${ref}" to keychain.`); } - return removed; } - async revokeAll(): Promise { - const doc = this.readDocumentSync(); - const removed = doc.profiles.length; - if (removed > 0) { - this.writeDocumentSync({ schemaVersion: 1, profiles: [] }); - console.error(`[auth-event] profile_revoked_all count=${removed} mode=keychain`); - } - return removed; + async delete(ref: string): Promise { + runSecurity([ + "delete-generic-password", + "-s", + REPLAY_STATE_KEYCHAIN_SERVICE, + "-a", + ref, + ]); } } -let storeSingleton: AuthProfileStore | null = null; +let replayStateStoreSingleton: ReplayStateStore | null = null; -function resolveStore(): AuthProfileStore { - if (storeSingleton) return storeSingleton; - if (KeychainAuthProfileStore.isAvailable()) { - storeSingleton = new KeychainAuthProfileStore(); - return storeSingleton; +function resolveReplayStateStore(): ReplayStateStore { + if (replayStateStoreSingleton) { + return replayStateStoreSingleton; + } + if (KeychainReplayStateStore.isAvailable()) { + replayStateStoreSingleton = new KeychainReplayStateStore(); + return replayStateStoreSingleton; } if (!warnedFallback) { warnedFallback = true; console.error( - "Warning: keychain storage unavailable; using local file storage for opaque auth session references.", + "Warning: keychain storage unavailable; using encrypted local file storage for replayable auth state.", ); } - storeSingleton = new FileAuthProfileStore(getProfilesPath()); - return storeSingleton; + replayStateStoreSingleton = new FileReplayStateStore(); + return replayStateStoreSingleton; } -export function getAuthStorageMode(): AuthStorageMode { - return resolveStore().mode(); +function dedupeProfiles(profiles: AuthProfile[]): AuthProfile[] { + return mergeProfiles(profiles, []); } -export async function listAuthProfiles(): Promise { - return resolveStore().list(); +function findProfileInDocument( + doc: AuthProfilesDocument, + name: string, + domain?: string, +): AuthProfile | null { + const normalized = normalizeProfileName(name); + return doc.profiles.find( + (profile) => + normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true), + ) ?? null; } -export async function saveBrowserSessionProfile(input: { - name: string; - domain: string; - ttlHours?: number; -}): Promise { - return resolveStore().save(input); +export function isProfileExpired(profile: AuthProfile, now: Date = new Date()): boolean { + return Date.parse(profile.expiresAt) <= now.getTime(); +} + +export function isLegacyAuthProfile(profile: AuthProfile): boolean { + return Boolean(profile.sessionRef) && !profile.replayStateRef; +} + +export function isProfileReplayReady(profile: AuthProfile): boolean { + return Boolean( + !isLegacyAuthProfile(profile) && + profile.replayReady === true && + profile.replayStateRef && + profile.capturedAt && + profile.captureBrowser === "chromium", + ); +} + +export function getAuthStorageMode(): AuthStorageMode { + return resolveReplayStateStore().mode(); +} + +export async function listAuthProfiles(): Promise { + const doc = await readMetadataDocument(); + return dedupeProfiles(doc.profiles); } export async function findAuthProfile(name: string, domain?: string): Promise { - const profile = await resolveStore().get(name, domain); + const doc = await readMetadataDocument(); + const profile = findProfileInDocument(doc, name, domain); if (profile && isProfileExpired(profile)) { console.error(`[auth-event] profile_expired profile=${profile.name} domain=${profile.domain}`); } return profile; } +export async function inspectAuthProfile(name: string, domain: string): Promise { + const profile = await findAuthProfile(name, domain); + if (!profile) { + return { status: "missing" }; + } + if (isProfileExpired(profile)) { + return { status: "expired", profile }; + } + if (isLegacyAuthProfile(profile)) { + return { status: "legacy", profile }; + } + if (!isProfileReplayReady(profile) || !profile.replayStateRef) { + return { status: "not-ready", profile }; + } + + const storageState = await resolveReplayStateStore().load(profile.replayStateRef); + if (!storageState) { + return { status: "not-ready", profile }; + } + + return { + status: "ready", + profile, + storageState, + }; +} + +export async function saveReplayAuthProfile(input: { + name: string; + domain: string; + storageState: string; + captureBrowser: CaptureBrowser; + ttlHours?: number; +}): Promise { + const doc = await readMetadataDocument(); + const now = new Date(); + const normalizedName = normalizeProfileName(input.name); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); + const replayStateRef = randomUUID(); + const existing = findProfileInDocument(doc, normalizedName, input.domain); + + await resolveReplayStateStore().save(replayStateRef, input.storageState); + + if (existing?.replayStateRef && existing.replayStateRef !== replayStateRef) { + await resolveReplayStateStore().delete(existing.replayStateRef); + } + + const next: AuthProfile = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: existing?.createdAt ?? now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + replayStateRef, + replayReady: true, + capturedAt: now.toISOString(), + captureBrowser: input.captureBrowser, + }; + + const profiles = dedupeProfiles( + doc.profiles.filter((profile) => profileKey(profile) !== profileKey(next)).concat(next), + ); + await writeMetadataDocument({ + schemaVersion: 2, + profiles, + }); + console.error( + `[auth-event] profile_captured profile=${next.name} domain=${next.domain} storage=${getAuthStorageMode()}`, + ); + return next; +} + export async function clearAuthProfiles(input: { all?: boolean; name?: string; domain?: string; }): Promise { - if (input.all) { - return resolveStore().revokeAll(); + const doc = await readMetadataDocument(); + const shouldRemove = (profile: AuthProfile): boolean => { + if (input.all) { + return true; + } + if (input.name) { + return normalizeProfileName(profile.name) === normalizeProfileName(input.name) && + (input.domain ? profile.domain === input.domain : true); + } + if (input.domain) { + return profile.domain === input.domain; + } + return false; + }; + + const removedProfiles = doc.profiles.filter(shouldRemove); + const remainingProfiles = doc.profiles.filter((profile) => !shouldRemove(profile)); + + for (const profile of removedProfiles) { + if (profile.replayStateRef) { + await resolveReplayStateStore().delete(profile.replayStateRef); + } } - return resolveStore().revoke({ name: input.name, domain: input.domain }); + + if (removedProfiles.length > 0 || input.all) { + await writeMetadataDocument({ + schemaVersion: 2, + profiles: dedupeProfiles(remainingProfiles), + }); + } + + if (removedProfiles.length > 0) { + console.error( + `[auth-event] profile_revoked count=${removedProfiles.length} storage=${getAuthStorageMode()}`, + ); + } + + return removedProfiles.length; } diff --git a/packages/interfacectl-cli/src/utils/browser-session.ts b/packages/interfacectl-cli/src/utils/browser-session.ts new file mode 100644 index 0000000..97b4488 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/browser-session.ts @@ -0,0 +1,154 @@ +import readline from "node:readline/promises"; +import { stdin as input, stderr as promptOutput } from "node:process"; +import { chromium, type BrowserContextOptions } from "playwright"; + +export interface RemoteBrowserObservation { + finalUrl: string; + html: string; + cssContents: Array<{ source: string; content: string }>; + loginDetected: boolean; + accessDeniedDetected: boolean; +} + +function isEnvTrue(name: string): boolean { + return process.env[name] === "1" || process.env[name] === "true"; +} + +function toLaunchError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + if (/Executable doesn't exist|browserType\.launch/i.test(message)) { + return new Error( + `Playwright Chromium is not installed. Run "pnpm exec playwright install chromium" in /Users/mike/SurfacesPlatform/interfacectl.`, + ); + } + return error instanceof Error ? error : new Error(message); +} + +async function waitForPageSettle(page: { waitForLoadState: Function; waitForTimeout: Function }): Promise { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); + await page.waitForTimeout(300); +} + +function detectAuthGate(html: string, finalUrl: string): { + loginDetected: boolean; + accessDeniedDetected: boolean; +} { + const url = new URL(finalUrl); + const lowerHtml = html.toLowerCase(); + const loginDetected = + /(login|signin|sign-in|auth|session)/i.test(url.pathname) || + /]+type=["']password["']/i.test(html) || + / { + const headless = isEnvTrue("INTERFACECTL_PLAYWRIGHT_HEADLESS"); + const browser = await chromium.launch({ headless }).catch((error) => { + throw toLaunchError(error); + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto(options.url, { waitUntil: "load" }); + await waitForPageSettle(page); + + const rl = readline.createInterface({ input, output: promptOutput }); + try { + await rl.question( + `Browser session is open at ${new URL(options.url).hostname}. Complete login, then press Enter to capture the session.`, + ); + } finally { + rl.close(); + } + + await waitForPageSettle(page); + const finalUrl = page.url(); + const storageState = JSON.stringify(await context.storageState()); + return { + finalUrl, + storageState, + }; + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +export async function observeRemotePage(options: { + url: string; + storageState?: string; +}): Promise { + const browser = await chromium.launch({ headless: true }).catch((error) => { + throw toLaunchError(error); + }); + const storageState = options.storageState + ? (JSON.parse(options.storageState) as NonNullable) + : undefined; + const context = options.storageState + ? await browser.newContext({ storageState }) + : await browser.newContext(); + const page = await context.newPage(); + const stylesheetBodies = new Map>(); + + page.on("response", (response) => { + const responseUrl = response.url(); + const resourceType = response.request().resourceType(); + const contentType = response.headers()["content-type"] ?? ""; + const looksLikeCss = + resourceType === "stylesheet" || + contentType.includes("text/css") || + /\.css(?:[?#].*)?$/i.test(new URL(responseUrl).pathname); + if (!looksLikeCss || stylesheetBodies.has(responseUrl)) { + return; + } + + stylesheetBodies.set( + responseUrl, + response.text() + .then((content) => ({ source: responseUrl, content })) + .catch(() => null), + ); + }); + + try { + await page.goto(options.url, { waitUntil: "load" }); + await waitForPageSettle(page); + + const html = await page.content(); + const finalUrl = page.url(); + const finalOrigin = new URL(finalUrl).origin; + const cssContents = (await Promise.all([...stylesheetBodies.values()])) + .filter((entry): entry is { source: string; content: string } => entry !== null) + .filter((entry) => { + try { + return new URL(entry.source).origin === finalOrigin; + } catch { + return false; + } + }) + .sort((a, b) => a.source.localeCompare(b.source)); + const authGate = detectAuthGate(html, finalUrl); + + return { + finalUrl, + html, + cssContents, + loginDetected: authGate.loginDetected, + accessDeniedDetected: authGate.accessDeniedDetected, + }; + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} diff --git a/packages/interfacectl-cli/src/utils/first-run-analysis.ts b/packages/interfacectl-cli/src/utils/first-run-analysis.ts new file mode 100644 index 0000000..4af039a --- /dev/null +++ b/packages/interfacectl-cli/src/utils/first-run-analysis.ts @@ -0,0 +1,1789 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + extractContractFromNextApp, + stableStringify, +} from "@surfaces/interfacectl-extractor"; +import { + normalizeColorValues, + type ContractSurface, + type InterfaceContract, + type SurfaceDescriptor, + type SurfacePrimitiveDescriptor, + type SurfaceTokenDescriptor, + type TokenMetadata, + type TokenPolicy, + type TokenCategory, +} from "@surfaces/interfacectl-validator"; +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; +import { + collectTokenDefinitionsFromContent, + normalizeObservedTokens, + type RawObservedToken, + type TokenDefinition, +} from "./token-normalization.js"; +import { redactSensitiveUrl } from "./redaction.js"; +import { seedChromePolicyFromObservedDescriptors } from "./chrome-policy-seeding.js"; +import { seedColorPolicyFromObservedDescriptors } from "./color-policy-seeding.js"; +import { seedIconPolicyFromObservedDescriptors } from "./icon-policy-seeding.js"; +import { + seedObservedUiContract, + type ObservedUiSeedingResult, +} from "./observed-ui-seeding.js"; +import { observeRemotePage } from "./browser-session.js"; + +export type WebSurfaceKind = "marketing" | "application" | "unknown"; +export type FirstRunMode = "adopt" | "synthesize"; +export type AnalysisSourceMode = "local-root" | "remote-url"; +type FindingSeverity = "info" | "warning"; + +interface ColorValueSummary { + canonical: string; + count: number; + sources: string[]; +} + +interface FontValueSummary { + value: string; + count: number; + sources: string[]; +} + +interface MotionValueSummary { + durationMs: number; + timingFunction: string; + count: number; + sources: string[]; +} + +interface IconSourceSummary { + value: string; + count: number; + sources: string[]; +} + +export interface AnalysisFinding { + code: string; + severity: FindingSeverity; + category: "classification" | "typography" | "color" | "layout" | "motion" | "icons" | "structure"; + message: string; +} + +export interface AnalysisEvidence { + key: string; + label: string; + weight: number; + supports: Exclude; + value: string; + message: string; +} + +export interface SurfaceAnalysisArtifact { + schemaVersion: 1; + surfaceId: string; + surfaceName: string; + source: { + mode: AnalysisSourceMode; + appRoot?: string; + url?: string; + }; + extracted: { + routes: string[]; + hasShell: boolean; + authAware: boolean; + designSystemComponents: string[]; + sections: string[]; + sectionCount: number; + fonts: FontValueSummary[]; + colors: ColorValueSummary[]; + motion: MotionValueSummary[]; + iconSources: IconSourceSummary[]; + primitives: SurfacePrimitiveDescriptor[]; + layout: { + maxContentWidth: number | null; + containers: string[]; + chrome: { + maxBorderRadiusPx: number | null; + shadowKinds: string[]; + }; + landingSignals: { + sectionOrder: string[]; + topLevelSections: string[]; + nestedSections: string[]; + pageBackgroundMode: "solid" | "custom" | "unknown"; + heroSignal: boolean; + copyRoleCount: number; + ctaCount: number; + }; + }; + tokens: { + typography: TokenMetadata[]; + layout: TokenMetadata[]; + motion: TokenMetadata[]; + }; + }; + classification: { + inferredKind: WebSurfaceKind; + confirmedKind: WebSurfaceKind; + confidence: number; + requiresConfirmation: boolean; + scores: Record; + supporting: AnalysisEvidence[]; + opposing: AnalysisEvidence[]; + }; + existingSystem: { + score: number; + mode: FirstRunMode; + reasons: string[]; + }; + inconsistencies: { + findings: AnalysisFinding[]; + }; + proposedContract: { + phase0: { + authPosture: "public" | "auth-aware" | "auth-first"; + requiresShell: boolean; + expectsAuthRoutes: boolean; + expectsDesignSystem: boolean; + }; + sectionSeedMode: "observed" | "placeholder"; + seedCounts: { + typographyTokens: number; + layoutTokens: number; + motionTokens: number; + colors: number; + iconSources: number; + sections: number; + }; + suggestedMarketingProfile: boolean; + }; + warnings: Array<{ code: string; message: string }>; +} + +export interface DesignSystemDraftArtifact { + schemaVersion: 1; + surfaceId: string; + surfaceName: string; + webSurfaceKind: WebSurfaceKind; + confidence: number; + mode: FirstRunMode; + summary: { + tokenCount: number; + inconsistencyCount: number; + existingSystemScore: number; + }; + categories: { + typography: { + canonicalTokens: TokenMetadata[]; + observedFamilies: string[]; + roleCoverage: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + color: { + canonicalValues: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + layout: { + canonicalTokens: TokenMetadata[]; + maxContentWidth: number | null; + containers: string[]; + radiusPx: number | null; + shadowKinds: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + motion: { + canonicalTokens: TokenMetadata[]; + durationsMs: number[]; + timingFunctions: string[]; + aliases: string[]; + semanticGroups: string[]; + outliers: string[]; + }; + icons: { + allowedSources: string[]; + outliers: string[]; + }; + structure: { + sections: string[]; + primitives: SurfacePrimitiveDescriptor[]; + surfacePatterns: string[]; + outliers: string[]; + }; + }; + manualFollowUp: string[]; + warnings: Array<{ code: string; message: string }>; +} + +export interface AnalyzeSurfaceOptions { + workspaceRoot: string; + surfaceId: string; + surfaceName: string; + sourceMode: AnalysisSourceMode; + appRoot?: string; + url?: string; + surfaceKindOverride?: WebSurfaceKind; + authMode?: "none" | "browser-session"; + authProfileName?: string; + authStorageState?: string; +} + +export interface AnalyzeSurfaceResult { + analysis: SurfaceAnalysisArtifact; + draft: DesignSystemDraftArtifact; + contract: InterfaceContract; + extractionReport: Record; + descriptor: SurfaceDescriptor; +} + +interface NormalizedObservation { + routes: string[]; + hasShell: boolean; + authAware: boolean; + designSystemComponents: string[]; + descriptor: SurfaceDescriptor; + ctaCount: number; + copyRoleCount: number; + heroSignal: boolean; + warnings: Array<{ code: string; message: string }>; + tokenPolicies: { + typography?: TokenPolicy; + layout?: TokenPolicy; + motion?: TokenPolicy; + }; + colorAllowedValues: string[]; + surfaceIcons?: ContractSurface["icons"]; + sourceAppRoot?: string; +} + +interface RemoteObservation { + html: string; + cssContents: Array<{ source: string; content: string }>; + sameOriginRoutes: string[]; + authRouteHints: string[]; + ctaCount: number; + copyRoleCount: number; + heroSignal: boolean; + fontCounts: FontValueSummary[]; + colorCounts: ColorValueSummary[]; + motionCounts: MotionValueSummary[]; + tokenPolicies: { + typography?: TokenPolicy; + layout?: TokenPolicy; + motion?: TokenPolicy; + }; + maxContentWidth: number | null; + containers: string[]; + shadowKinds: string[]; + maxBorderRadiusPx: number | null; + pageBackgroundMode: "solid" | "custom" | "unknown"; + primitives: SurfacePrimitiveDescriptor[]; + sections: string[]; + warnings: Array<{ code: string; message: string }>; +} + +const DEFAULT_ANALYSIS_SCHEMA_VERSION = 1; +const DEFAULT_CONTRACT_VERSION = "0.1.0"; +const PLACEHOLDER_SECTION_ID = "extracted.placeholder"; +const AUTH_ROUTE_SET = new Set([ + "/auth/login", + "/auth/callback", + "/auth/session", + "/auth/logout", +]); +const APPLICATION_ROUTE_HINT = /(account|settings|workspace|dashboard|admin|billing|projects|tasks|team|users)/i; +const AUTH_ROUTE_HINT = /(login|logout|signin|signout|session|register|auth)/i; +const CTA_TEXT_HINT = + /\b(get started|learn more|request demo|contact sales|sign up|start now|try now|book demo|download|install)\b/i; +const STYLESHEET_LINK_REGEX = + /]*rel=(?:"[^"]*stylesheet[^"]*"|'[^']*stylesheet[^']*')[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>/gi; +const INLINE_STYLE_BLOCK_REGEX = /]*>([\s\S]*?)<\/style>/gi; +const HREF_REGEX = /\bhref=(?:"([^"]+)"|'([^']+)')/gi; +const FONT_FAMILY_REGEX = /font-family\s*:\s*([^;]+);/gi; +const COLOR_DECL_REGEX = + /(?:color|background-color|background|border-color|border-top-color|border-right-color|border-bottom-color|border-left-color|outline-color|text-decoration-color|caret-color|column-rule-color)\s*:\s*([^;]+);/gi; +const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi; +const TIMING_DECL_REGEX = + /(animation|transition)-timing-function\s*:\s*([^;]+);/gi; +const TRANSITION_DECL_REGEX = /transition[^:]*:\s*([^;]+);/gi; +const MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*(px|rem|em)/gi; +const BORDER_RADIUS_REGEX = /border-radius\s*:\s*([0-9.]+)\s*(px|rem|em)/gi; +const BOX_SHADOW_REGEX = /box-shadow\s*:\s*([^;]+);/gi; +const TAG_REGEX = /<\/?([A-Za-z][\w.:-]*)\b[^>]*>/g; +const COPY_ROLE_REGEX = + /data-contract-copy-role\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_ATTRIBUTE_REGEX = + /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => + a.localeCompare(b), + ); +} + +function uniqueSortedNumbers(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b); +} + +function toStableSourcePath(root: string | undefined, candidate: string): string { + if (!root) { + return candidate; + } + return path.relative(root, candidate) || "."; +} + +function countByValue(values: Array<{ value: string; source?: string }>): Array<{ + value: string; + count: number; + sources: string[]; +}> { + const counts = new Map }>(); + for (const entry of values) { + const normalized = entry.value.trim(); + if (!normalized) { + continue; + } + const bucket = counts.get(normalized) ?? { count: 0, sources: new Set() }; + bucket.count += 1; + if (entry.source) { + bucket.sources.add(entry.source); + } + counts.set(normalized, bucket); + } + return [...counts.entries()] + .map(([value, bucket]) => ({ + value, + count: bucket.count, + sources: [...bucket.sources].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} + +function parseLengthToPx(rawValue: string | undefined): number | null { + if (!rawValue) return null; + const normalized = rawValue.trim(); + const pxMatch = normalized.match(/^([0-9.]+)\s*px$/i); + if (pxMatch) { + return Number.parseFloat(pxMatch[1]); + } + const remMatch = normalized.match(/^([0-9.]+)\s*rem$/i); + if (remMatch) { + return Number.parseFloat(remMatch[1]) * 16; + } + const emMatch = normalized.match(/^([0-9.]+)\s*em$/i); + if (emMatch) { + return Number.parseFloat(emMatch[1]) * 16; + } + const numberMatch = normalized.match(/^([0-9.]+)$/); + if (numberMatch) { + return Number.parseFloat(numberMatch[1]); + } + return null; +} + +function parseDurationToMs(rawValue: string | undefined): number | null { + if (!rawValue) return null; + const normalized = rawValue.trim(); + const msMatch = normalized.match(/^([0-9.]+)\s*ms$/i); + if (msMatch) { + return Number.parseFloat(msMatch[1]); + } + const secMatch = normalized.match(/^([0-9.]+)\s*s$/i); + if (secMatch) { + return Number.parseFloat(secMatch[1]) * 1000; + } + return null; +} + +function classifyShadow(rawValue: string | undefined): string | null { + if (!rawValue) return null; + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "none" || normalized === "0" || normalized === "0px") { + return "none"; + } + const inset = /\binset\b/.test(normalized); + const hasContent = normalized.length > 0; + if (!hasContent) return null; + if (inset) return "inset"; + return "outer"; +} + +function inferIntentFromSectionId(sectionId: string): string { + const tokens = sectionId.split("."); + return tokens[tokens.length - 1] || "section"; +} + +function defaultDescriptionFromSection(sectionId: string): string { + return `Observed section for ${sectionId}.`; +} + +function buildDescriptorSeedContract(surfaceId: string, surfaceName: string): InterfaceContract { + return { + contractId: `${surfaceId}.analysis`, + version: DEFAULT_CONTRACT_VERSION, + description: "Temporary analysis contract for descriptor collection.", + surfaces: [ + { + id: surfaceId, + displayName: surfaceName, + type: "web", + requiredSections: [PLACEHOLDER_SECTION_ID], + allowedFonts: ["sans-serif"], + layout: { + maxContentWidth: 1120, + landingPattern: { + policy: "warn", + }, + }, + }, + ], + sections: [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "placeholder", + description: "Placeholder section for analysis.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: [], + }, + }; +} + +function metadataFromPolicy(policy: TokenPolicy | undefined): TokenMetadata[] { + return [...(policy?.tokenMetadata ?? [])].sort((a, b) => a.token.localeCompare(b.token)); +} + +function summarizeFonts(descriptor: SurfaceDescriptor): FontValueSummary[] { + return descriptor.fonts + .map((font) => ({ + value: font.value, + count: 1, + sources: font.source ? [font.source] : [], + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} + +function summarizeColors(descriptor: SurfaceDescriptor): ColorValueSummary[] { + return descriptor.colors + .map((color) => ({ + canonical: normalizeColorValues([color.value])[0] ?? color.value, + count: 1, + sources: color.source ? [color.source] : [], + })) + .sort((a, b) => a.canonical.localeCompare(b.canonical)); +} + +function summarizeMotion(descriptor: SurfaceDescriptor): MotionValueSummary[] { + return descriptor.motion + .map((motion) => ({ + durationMs: motion.durationMs, + timingFunction: motion.timingFunction, + count: 1, + sources: motion.source ? [motion.source] : [], + })) + .sort((a, b) => + a.durationMs === b.durationMs + ? a.timingFunction.localeCompare(b.timingFunction) + : a.durationMs - b.durationMs, + ); +} + +function summarizeIcons(descriptor: SurfaceDescriptor): IconSourceSummary[] { + return (descriptor.icons ?? []) + .map((icon) => ({ + value: icon.value, + count: 1, + sources: icon.source ? [icon.source] : [], + })) + .sort((a, b) => a.value.localeCompare(b.value)); +} + +function buildPhase0Seed(observation: NormalizedObservation): { + authPosture: "public" | "auth-aware" | "auth-first"; + requiresShell: boolean; + expectsAuthRoutes: boolean; + expectsDesignSystem: boolean; +} { + const routes = new Set(observation.routes); + const hasAllAuthRoutes = [...AUTH_ROUTE_SET].every((route) => routes.has(route)); + const authPosture = + hasAllAuthRoutes ? "auth-first" : observation.authAware ? "auth-aware" : "public"; + + return { + authPosture, + requiresShell: observation.hasShell, + expectsAuthRoutes: hasAllAuthRoutes, + expectsDesignSystem: observation.designSystemComponents.length > 0, + }; +} + +function countAliases(metadata: TokenMetadata[]): string[] { + return metadata + .flatMap((entry) => entry.aliases) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); +} + +function buildFindings(observation: NormalizedObservation): AnalysisFinding[] { + const findings: AnalysisFinding[] = []; + const fonts = observation.descriptor.fonts.map((font) => font.value); + if (fonts.length > 2) { + findings.push({ + code: "typography.multiple-families", + severity: "warning", + category: "typography", + message: `Detected ${fonts.length} font families; review whether all are intentional system choices.`, + }); + } + + const motionDurations = uniqueSortedNumbers( + observation.descriptor.motion.map((motion) => motion.durationMs), + ); + if (motionDurations.length > 2) { + findings.push({ + code: "motion.multiple-durations", + severity: "warning", + category: "motion", + message: `Detected ${motionDurations.length} distinct motion durations; consolidate repeated timing choices.`, + }); + } + + const colorValues = observation.descriptor.colors.map((color) => color.value); + const rawColorCount = colorValues.filter((value) => !value.startsWith("var(")).length; + if (rawColorCount > 3) { + findings.push({ + code: "color.raw-literals-heavy", + severity: "warning", + category: "color", + message: `Detected ${rawColorCount} raw color literals; consider canonicalizing them into stable tokens or approved values.`, + }); + } + + const shadowKinds = observation.descriptor.layout.chrome?.shadowKinds ?? []; + if (shadowKinds.includes("outer")) { + findings.push({ + code: "layout.outer-shadow-present", + severity: "info", + category: "layout", + message: "Observed outer shadows on layout chrome; decide whether they belong in the draft system or should remain exceptions.", + }); + } + + if (observation.descriptor.sections.length === 0) { + findings.push({ + code: "structure.sections-missing", + severity: "warning", + category: "structure", + message: "No explicit contract sections were detected; contract seeding will fall back to a placeholder section.", + }); + } + + return findings.sort((a, b) => a.code.localeCompare(b.code)); +} + +function calculateExistingSystem(observation: NormalizedObservation, findings: AnalysisFinding[]): { + score: number; + mode: FirstRunMode; + reasons: string[]; +} { + let score = 0; + const reasons: string[] = []; + const tokenCount = + metadataFromPolicy(observation.tokenPolicies.typography).length + + metadataFromPolicy(observation.tokenPolicies.layout).length + + metadataFromPolicy(observation.tokenPolicies.motion).length; + if (tokenCount >= 3) { + score += 0.35; + reasons.push("Repeated token references were detected across multiple UI categories."); + } + if (observation.designSystemComponents.length > 0) { + score += 0.2; + reasons.push("Shared design-system component imports were detected."); + } + if (observation.colorAllowedValues.some((value) => value.startsWith("var("))) { + score += 0.15; + reasons.push("Color usage already includes reusable variable-based values."); + } + if ((observation.surfaceIcons?.allowedSources?.length ?? 0) === 1) { + score += 0.1; + reasons.push("Icon usage is already consistent around one source library."); + } + if (findings.length <= 2) { + score += 0.2; + reasons.push("The observed system has a low inconsistency count."); + } + const normalizedScore = Math.max(0, Math.min(1, Number(score.toFixed(2)))); + return { + score: normalizedScore, + mode: normalizedScore >= 0.55 ? "adopt" : "synthesize", + reasons, + }; +} + +function buildClassification(observation: NormalizedObservation): { + inferredKind: WebSurfaceKind; + confidence: number; + requiresConfirmation: boolean; + scores: Record; + supporting: AnalysisEvidence[]; + opposing: AnalysisEvidence[]; +} { + const marketing: AnalysisEvidence[] = []; + const application: AnalysisEvidence[] = []; + const routeCount = observation.routes.length; + const primitiveCounts = new Map( + (observation.descriptor.primitives ?? []).map((entry) => [entry.role, entry.count]), + ); + const landing = observation.descriptor.layout.landingPattern; + const copyRoleCount = observation.copyRoleCount; + + if (routeCount <= 4) { + marketing.push({ + key: "route.low-complexity", + label: "Low route complexity", + weight: 2, + supports: "marketing", + value: String(routeCount), + message: `Only ${routeCount} routes were detected.`, + }); + } + + if (routeCount >= 6) { + application.push({ + key: "route.multi-page", + label: "Multi-route structure", + weight: 2, + supports: "application", + value: String(routeCount), + message: `Detected ${routeCount} routes, suggesting task-oriented application structure.`, + }); + } + + if (!observation.authAware) { + marketing.push({ + key: "auth.none", + label: "No auth routes", + weight: 1, + supports: "marketing", + value: "false", + message: "No auth route family was detected.", + }); + } else { + application.push({ + key: "auth.present", + label: "Auth routes present", + weight: 3, + supports: "application", + value: "true", + message: "Auth-aware routing was detected.", + }); + } + + if (primitiveCounts.get("sidebar")) { + application.push({ + key: "primitive.sidebar", + label: "Sidebar primitive", + weight: 3, + supports: "application", + value: String(primitiveCounts.get("sidebar")), + message: "Sidebar primitives strongly suggest an application surface.", + }); + } + + if (primitiveCounts.get("auth-shell")) { + application.push({ + key: "primitive.auth-shell", + label: "Auth shell", + weight: 3, + supports: "application", + value: String(primitiveCounts.get("auth-shell")), + message: "Auth-shell primitives were detected.", + }); + } + + if (landing && landing.topLevelSections.length >= 3) { + marketing.push({ + key: "landing.top-level", + label: "Landing section structure", + weight: 2, + supports: "marketing", + value: landing.topLevelSections.join(", "), + message: "Top-level landing-style sections were detected.", + }); + } + + if (copyRoleCount >= 3) { + marketing.push({ + key: "copy-role.dense", + label: "Copy-role density", + weight: 2, + supports: "marketing", + value: String(copyRoleCount), + message: "Copy-role markers suggest a marketing-oriented page structure.", + }); + } + + if (observation.ctaCount >= 2) { + marketing.push({ + key: "cta.present", + label: "CTA-oriented structure", + weight: 2, + supports: "marketing", + value: String(observation.ctaCount), + message: "Repeated CTA signals suggest a marketing-oriented conversion flow.", + }); + } + + if (observation.heroSignal) { + marketing.push({ + key: "hero.present", + label: "Hero signal", + weight: 1, + supports: "marketing", + value: "true", + message: "A likely hero pattern was detected near the top of the surface.", + }); + } + + if (observation.routes.some((route) => APPLICATION_ROUTE_HINT.test(route))) { + application.push({ + key: "route.application-family", + label: "Application route families", + weight: 2, + supports: "application", + value: observation.routes.filter((route) => APPLICATION_ROUTE_HINT.test(route)).join(", "), + message: "Detected account/settings/workspace-style routes.", + }); + } + + if (observation.routes.some((route) => AUTH_ROUTE_HINT.test(route))) { + application.push({ + key: "route.auth-hint", + label: "Auth route hint", + weight: 1, + supports: "application", + value: observation.routes.filter((route) => AUTH_ROUTE_HINT.test(route)).join(", "), + message: "Detected auth-oriented route names.", + }); + } + + if ((primitiveCounts.get("navigation") ?? 0) > 0 && (primitiveCounts.get("sidebar") ?? 0) === 0 && !observation.authAware) { + marketing.push({ + key: "primitive.top-nav-only", + label: "Top-nav without app shell", + weight: 1, + supports: "marketing", + value: String(primitiveCounts.get("navigation") ?? 0), + message: "Navigation appears without stronger application-shell signals.", + }); + } + + const marketingScore = marketing.reduce((total, entry) => total + entry.weight, 0); + const applicationScore = application.reduce((total, entry) => total + entry.weight, 0); + const topScore = Math.max(marketingScore, applicationScore); + const kind = + topScore < 3 + ? "unknown" + : marketingScore === applicationScore || Math.abs(marketingScore - applicationScore) <= 1 + ? "unknown" + : marketingScore > applicationScore + ? "marketing" + : "application"; + + const confidence = + kind === "unknown" + ? 0.4 + : Math.min(0.95, 0.55 + Math.abs(marketingScore - applicationScore) * 0.08); + const supporting = (kind === "marketing" ? marketing : kind === "application" ? application : [ + ...marketing, + ...application, + ]) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + .slice(0, 5); + const opposing = (kind === "marketing" ? application : kind === "application" ? marketing : []) + .sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key)) + .slice(0, 3); + + return { + inferredKind: kind, + confidence: Number(confidence.toFixed(2)), + requiresConfirmation: kind === "unknown" || confidence < 0.7, + scores: { + marketing: marketingScore, + application: applicationScore, + unknown: kind === "unknown" ? 1 : 0, + }, + supporting, + opposing, + }; +} + +function createMarketingProfiles( + surfaceId: string, + observation: NormalizedObservation, +): InterfaceContract["marketingProfiles"] | undefined { + const layoutDescriptor = observation.descriptor.layout.landingPattern; + const typographyDescriptor = observation.descriptor.marketingTypography; + if (!layoutDescriptor && !typographyDescriptor) { + return undefined; + } + + const layoutProfileId = "starter-marketing-layout"; + const typographyProfileId = "starter-marketing-typography"; + + const layoutProfiles = layoutDescriptor + ? [ + { + id: layoutProfileId, + description: `Starter marketing layout profile for ${surfaceId}.`, + heroContainerMode: layoutDescriptor.heroContainerMode ?? "open-flow", + heroVisualPlacement: layoutDescriptor.heroVisualPlacement ?? "none", + sectionDividerMode: layoutDescriptor.sectionDividerMode ?? "none", + sectionSpacingProfile: layoutDescriptor.sectionSpacingProfile ?? "compact", + }, + ] + : undefined; + + const typographyProfiles = + typographyDescriptor && typographyDescriptor.roles.length > 0 + ? [ + { + id: typographyProfileId, + description: `Starter marketing typography profile for ${surfaceId}.`, + roles: typographyDescriptor.roles + .filter((role) => role.tokens.length > 0) + .map((role) => ({ + role: role.role, + allowedTokens: uniqueSorted(role.tokens.map((token) => token.value)), + })) + .sort((a, b) => a.role.localeCompare(b.role)), + }, + ] + : undefined; + + if (!layoutProfiles && !typographyProfiles) { + return undefined; + } + + return { + layout: layoutProfiles, + typography: typographyProfiles, + }; +} + +function applyAnalysisToContract( + baseContract: InterfaceContract, + observation: NormalizedObservation, + analysis: SurfaceAnalysisArtifact, +): InterfaceContract { + const sections = + observation.descriptor.sections.length > 0 + ? observation.descriptor.sections.map((section) => ({ + id: section.id, + intent: inferIntentFromSectionId(section.id), + description: defaultDescriptionFromSection(section.id), + })) + : baseContract.sections; + const requiredSections = + observation.descriptor.sections.length > 0 + ? uniqueSorted(observation.descriptor.sections.map((section) => section.id)) + : baseContract.surfaces[0]?.requiredSections ?? [PLACEHOLDER_SECTION_ID]; + const marketingProfiles = + analysis.classification.confirmedKind === "marketing" + ? createMarketingProfiles(baseContract.surfaces[0]?.id ?? analysis.surfaceId, observation) + : undefined; + const landingPattern = observation.descriptor.layout.landingPattern; + const surface = baseContract.surfaces[0]; + type Phase0Seed = SurfaceAnalysisArtifact["proposedContract"]["phase0"]; + const nextSurface: ContractSurface & { phase0?: Phase0Seed } = { + ...surface, + requiredSections, + layout: { + ...surface.layout, + landingPattern: + analysis.classification.confirmedKind === "marketing" && landingPattern + ? { + policy: "warn", + requireTopLevelSections: landingPattern.topLevelSections.length > 0 + ? landingPattern.topLevelSections + : undefined, + sectionOrder: landingPattern.sectionOrder.length > 0 + ? landingPattern.sectionOrder + : undefined, + pageBackgroundMode: + landingPattern.pageBackgroundMode === "unknown" + ? undefined + : landingPattern.pageBackgroundMode, + marketingLayoutPolicy: marketingProfiles?.layout?.length ? "warn" : undefined, + marketingLayoutProfile: marketingProfiles?.layout?.[0]?.id, + } + : surface.layout.landingPattern, + }, + marketingTypographyPolicy: + analysis.classification.confirmedKind === "marketing" && + marketingProfiles?.typography?.length + ? "warn" + : surface.marketingTypographyPolicy, + marketingTypographyProfile: + analysis.classification.confirmedKind === "marketing" + ? marketingProfiles?.typography?.[0]?.id + : undefined, + phase0: analysis.proposedContract.phase0, + }; + + const nextContract: InterfaceContract = { + ...baseContract, + sections, + marketingProfiles, + surfaces: [nextSurface], + x_extracted: { + ...(baseContract.x_extracted ?? {}), + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + }, + }; + + return nextContract; +} + +function buildDraftArtifact( + analysis: SurfaceAnalysisArtifact, + observation: NormalizedObservation, +): DesignSystemDraftArtifact { + const typographyTokens = metadataFromPolicy(observation.tokenPolicies.typography); + const layoutTokens = metadataFromPolicy(observation.tokenPolicies.layout); + const motionTokens = metadataFromPolicy(observation.tokenPolicies.motion); + const canonicalColors = analysis.extracted.colors + .filter((entry) => entry.count > 0) + .map((entry) => entry.canonical); + const typographyFamilies = analysis.extracted.fonts.map((entry) => entry.value); + const roleCoverage = observation.descriptor.marketingTypography?.roles.map((role) => role.role) ?? []; + const structurePatterns: string[] = []; + if (analysis.classification.confirmedKind === "marketing") { + structurePatterns.push("landing"); + } + if (analysis.classification.confirmedKind === "application") { + structurePatterns.push("task-oriented"); + } + if (analysis.extracted.hasShell) { + structurePatterns.push("shell"); + } + const manualFollowUp = analysis.inconsistencies.findings.map((finding) => finding.message); + if (analysis.classification.requiresConfirmation) { + manualFollowUp.push("Review the inferred surface kind before tightening policy levels."); + } + if (analysis.proposedContract.sectionSeedMode === "placeholder") { + manualFollowUp.push("Add stable section markers to improve future section-based seeding."); + } + + return { + schemaVersion: DEFAULT_ANALYSIS_SCHEMA_VERSION, + surfaceId: analysis.surfaceId, + surfaceName: analysis.surfaceName, + webSurfaceKind: analysis.classification.confirmedKind, + confidence: analysis.classification.confidence, + mode: analysis.existingSystem.mode, + summary: { + tokenCount: typographyTokens.length + layoutTokens.length + motionTokens.length, + inconsistencyCount: analysis.inconsistencies.findings.length, + existingSystemScore: analysis.existingSystem.score, + }, + categories: { + typography: { + canonicalTokens: typographyTokens, + observedFamilies: typographyFamilies, + roleCoverage, + aliases: countAliases(typographyTokens), + semanticGroups: roleCoverage.length > 0 ? ["copy-roles"] : ["type-scale"], + outliers: typographyFamilies.length > 2 ? typographyFamilies.slice(2) : [], + }, + color: { + canonicalValues: canonicalColors, + aliases: [], + semanticGroups: canonicalColors.filter((value) => /background|surface|foreground/i.test(value)).length > 0 + ? ["surface", "text", "accent"] + : ["palette"], + outliers: canonicalColors.length > 6 ? canonicalColors.slice(6) : [], + }, + layout: { + canonicalTokens: layoutTokens, + maxContentWidth: analysis.extracted.layout.maxContentWidth, + containers: analysis.extracted.layout.containers, + radiusPx: analysis.extracted.layout.chrome.maxBorderRadiusPx, + shadowKinds: analysis.extracted.layout.chrome.shadowKinds, + semanticGroups: ["container", "shape", "spacing"], + outliers: + analysis.extracted.layout.chrome.shadowKinds.length > 1 + ? analysis.extracted.layout.chrome.shadowKinds.slice(1) + : [], + }, + motion: { + canonicalTokens: motionTokens, + durationsMs: analysis.extracted.motion.map((entry) => entry.durationMs), + timingFunctions: uniqueSorted(analysis.extracted.motion.map((entry) => entry.timingFunction)), + aliases: countAliases(motionTokens), + semanticGroups: ["transition", "animation"], + outliers: analysis.extracted.motion.length > 2 + ? analysis.extracted.motion.slice(2).map((entry) => `${entry.durationMs}ms/${entry.timingFunction}`) + : [], + }, + icons: { + allowedSources: analysis.extracted.iconSources.map((entry) => entry.value), + outliers: + analysis.extracted.iconSources.length > 1 + ? analysis.extracted.iconSources.slice(1).map((entry) => entry.value) + : [], + }, + structure: { + sections: analysis.extracted.sections, + primitives: analysis.extracted.primitives, + surfacePatterns: structurePatterns, + outliers: analysis.classification.confirmedKind === "unknown" ? ["mixed-signals"] : [], + }, + }, + manualFollowUp: uniqueSorted(manualFollowUp), + warnings: analysis.warnings, + }; +} + +async function analyzeLocalSource(options: AnalyzeSurfaceOptions): Promise { + if (!options.appRoot) { + throw new Error("Missing appRoot for local-root analysis."); + } + const appRoot = path.resolve(options.workspaceRoot, options.appRoot); + const { contract: extractedContract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId: options.surfaceId, + }); + const descriptorContract = buildDescriptorSeedContract(options.surfaceId, options.surfaceName); + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: options.workspaceRoot, + contract: descriptorContract, + surfaceFilters: new Set([options.surfaceId]), + surfaceRootMap: new Map([[options.surfaceId, appRoot]]), + }); + const descriptor = descriptorResult.descriptors.find((entry) => entry.surfaceId === options.surfaceId); + if (!descriptor) { + throw new Error(`Failed to collect descriptor for surface "${options.surfaceId}".`); + } + + const uiSeeded = await seedObservedUiContract({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: extractedContract as unknown as InterfaceContract, + }); + const colorSeeded = await seedColorPolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: uiSeeded.contract, + }); + const iconSeeded = await seedIconPolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: colorSeeded.contract, + }); + const chromeSeeded = await seedChromePolicyFromObservedDescriptors({ + workspaceRoot: options.workspaceRoot, + appRoot, + surfaceId: options.surfaceId, + contract: iconSeeded.contract, + }); + + return { + routes: report.extracted.routes, + hasShell: report.extracted.hasShell, + authAware: report.extracted.authAware, + designSystemComponents: report.extracted.designSystemComponents, + descriptor, + ctaCount: 0, + copyRoleCount: descriptor.marketingTypography?.roles.length ?? 0, + heroSignal: Boolean( + descriptor.layout.landingPattern?.heroContainerMode || + descriptor.marketingTypography?.roles.some((role) => role.role === "heroTitle"), + ), + warnings: [ + ...report.warnings, + ...descriptorResult.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ...descriptorResult.errors.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ...uiSeeded.warnings, + ...colorSeeded.warnings, + ...iconSeeded.warnings, + ...chromeSeeded.warnings, + ], + tokenPolicies: chromeSeeded.contract.tokens ?? {}, + colorAllowedValues: chromeSeeded.contract.color.allowedValues, + surfaceIcons: chromeSeeded.contract.surfaces[0]?.icons, + sourceAppRoot: appRoot, + }; +} + +function extractAttributeValuesFromTags(html: string, regex: RegExp): string[] { + regex.lastIndex = 0; + const matches: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(html)) !== null) { + const value = match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""; + if (value) { + matches.push(value); + } + } + return uniqueSorted(matches); +} + +function parseRemotePrimitives(html: string, source: string): SurfacePrimitiveDescriptor[] { + const counts = new Map(); + const record = (role: string, count: number) => { + if (count <= 0) return; + counts.set(role, count); + }; + record("navigation", (html.match(/ ({ role, count, sources: [source] })) + .sort((a, b) => a.role.localeCompare(b.role)); +} + +function extractRemoteLinks(rawHtml: string, sourceUrl: URL): { routes: string[]; authHints: string[] } { + const routes = new Set([sourceUrl.pathname || "/"]); + const authHints = new Set(); + HREF_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = HREF_REGEX.exec(rawHtml)) !== null) { + const href = match[1] ?? match[2] ?? ""; + if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { + continue; + } + try { + const resolved = new URL(href, sourceUrl); + if (resolved.origin !== sourceUrl.origin) { + continue; + } + if (/\.(css|js|png|jpg|jpeg|gif|svg|webp|ico)$/i.test(resolved.pathname)) { + continue; + } + const route = resolved.pathname.replace(/\/+$/, "") || "/"; + routes.add(route); + if (AUTH_ROUTE_HINT.test(route)) { + authHints.add(route); + } + } catch { + continue; + } + } + return { + routes: [...routes].sort((a, b) => a.localeCompare(b)), + authHints: [...authHints].sort((a, b) => a.localeCompare(b)), + }; +} + +function collectInlineCssContents(sourceUrl: URL, html: string): Array<{ source: string; content: string }> { + const results: Array<{ source: string; content: string }> = []; + INLINE_STYLE_BLOCK_REGEX.lastIndex = 0; + let styleMatch: RegExpExecArray | null; + while ((styleMatch = INLINE_STYLE_BLOCK_REGEX.exec(html)) !== null) { + if (styleMatch[1]?.trim()) { + results.push({ + source: `${sourceUrl.origin}/`, + content: styleMatch[1], + }); + } + } + return results; +} + +function collectRemoteTokenPolicies( + cssContents: Array<{ source: string; content: string }>, +): { + typography?: TokenPolicy; + layout?: TokenPolicy; + motion?: TokenPolicy; +} { + const definitions = new Map(); + const typography = new Map(); + const layout = new Map(); + const motion = new Map(); + const collectObserved = ( + content: string, + source: string, + regex: RegExp, + category: TokenCategory, + target: Map, + ) => { + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + const rawValue = match[1]?.trim(); + if (!rawValue || !rawValue.startsWith("var(")) { + continue; + } + const entry = target.get(`${source}:${rawValue}:${category}`) ?? { + observedValue: rawValue, + source, + attributes: new Set(), + }; + entry.attributes.add(category); + target.set(`${source}:${rawValue}:${category}`, entry); + } + }; + + for (const css of cssContents) { + collectTokenDefinitionsFromContent(css.content, css.source, definitions); + collectObserved(css.content, css.source, /font-(?:family|size|weight|line-height|letter-spacing)\s*:\s*([^;]+);/gi, "typography", typography); + collectObserved(css.content, css.source, /(?:padding|margin|max-width|min-width|width|height|gap|border-radius)\s*:\s*([^;]+);/gi, "layout", layout); + collectObserved(css.content, css.source, /(?:transition|animation)[^:]*:\s*([^;]+);/gi, "motion", motion); + } + + const toPolicy = (category: TokenCategory, values: Map): TokenPolicy | undefined => { + const normalized = normalizeObservedTokens( + category, + new Map( + [...values.values()].map((entry, index) => [`${entry.source}:${entry.observedValue}:${index}`, entry]), + ), + definitions, + ); + const metadata = buildTokenMetadata(normalized.tokens); + if (metadata.length === 0) { + return undefined; + } + return { + policy: "warn", + allowedTokens: metadata.map((entry) => entry.token), + tokenMetadata: metadata, + }; + }; + + return { + typography: toPolicy("typography", typography), + layout: toPolicy("layout", layout), + motion: toPolicy("motion", motion), + }; +} + +function buildTokenMetadata(tokens: SurfaceTokenDescriptor[]): TokenMetadata[] { + const metadata = new Map; aliases: Set }>(); + for (const token of tokens) { + const canonical = token.value.trim(); + if (!canonical) continue; + const bucket = metadata.get(canonical) ?? { + normalizedValue: token.normalizedValue ?? token.observedValue ?? token.value, + attributes: new Set(), + aliases: new Set(), + }; + for (const attribute of token.attributes ?? []) { + bucket.attributes.add(attribute); + } + if (token.observedValue && token.observedValue !== canonical) { + bucket.aliases.add(token.observedValue); + } + metadata.set(canonical, bucket); + } + return [...metadata.entries()] + .map(([token, entry]) => ({ + token, + normalizedValue: entry.normalizedValue, + attributes: [...entry.attributes].sort((a, b) => a.localeCompare(b)), + aliases: [...entry.aliases].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.token.localeCompare(b.token)); +} + +async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise { + if (!options.url) { + throw new Error("Missing url for remote-url analysis."); + } + const sourceUrl = new URL(options.url); + const observation = await observeRemotePage({ + url: sourceUrl.toString(), + storageState: options.authStorageState, + }); + const finalUrl = new URL(observation.finalUrl); + const html = observation.html; + const cssContents = [ + ...collectInlineCssContents(finalUrl, html), + ...observation.cssContents, + ].sort((a, b) => a.source.localeCompare(b.source)); + const routeInfo = extractRemoteLinks(html, finalUrl); + const primitives = parseRemotePrimitives(html, finalUrl.toString()); + const fonts = countByValue( + cssContents.flatMap(({ source, content }) => { + const families: Array<{ value: string; source: string }> = []; + FONT_FAMILY_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = FONT_FAMILY_REGEX.exec(content)) !== null) { + for (const token of match[1].split(",")) { + const value = token.trim().replace(/^["']|["']$/g, ""); + if (value) { + families.push({ value, source }); + } + } + } + return families; + }), + ).map((entry) => ({ + value: entry.value, + count: entry.count, + sources: entry.sources, + })); + const colors = countByValue( + cssContents.flatMap(({ source, content }) => { + const values: Array<{ value: string; source: string }> = []; + COLOR_DECL_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = COLOR_DECL_REGEX.exec(content)) !== null) { + const rawValue = match[1]?.trim(); + if (!rawValue) continue; + for (const color of normalizeColorValues([rawValue])) { + values.push({ value: color, source }); + } + } + return values; + }), + ).map((entry) => ({ + canonical: entry.value, + count: entry.count, + sources: entry.sources, + })); + const motions = countByValue( + cssContents.flatMap(({ source, content }) => { + const values: Array<{ value: string; source: string }> = []; + DURATION_DECL_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = DURATION_DECL_REGEX.exec(content)) !== null) { + const duration = parseDurationToMs(match[2]); + if (duration !== null) { + values.push({ value: `${duration}::linear`, source }); + } + } + TRANSITION_DECL_REGEX.lastIndex = 0; + while ((match = TRANSITION_DECL_REGEX.exec(content)) !== null) { + const text = match[1]; + const durationMatch = text.match(/([0-9.]+\s*(?:ms|s))/i); + const timingMatch = text.match(/\b(linear|ease|ease-in|ease-out|ease-in-out|cubic-bezier\([^)]*\))\b/i); + const duration = parseDurationToMs(durationMatch?.[1]); + if (duration !== null) { + values.push({ value: `${duration}::${(timingMatch?.[1] ?? "linear").trim()}`, source }); + } + } + TIMING_DECL_REGEX.lastIndex = 0; + while ((match = TIMING_DECL_REGEX.exec(content)) !== null) { + const timing = match[2]?.trim(); + if (timing) { + values.push({ value: `0::${timing}`, source }); + } + } + return values; + }), + ).map((entry) => { + const [durationPart, timingFunction] = entry.value.split("::"); + return { + durationMs: Number.parseFloat(durationPart), + timingFunction, + count: entry.count, + sources: entry.sources, + }; + }).filter((entry) => entry.durationMs > 0 || entry.timingFunction.length > 0); + + const maxWidths: number[] = []; + const radii: number[] = []; + const shadowKinds = new Set(); + let pageBackgroundMode: "solid" | "custom" | "unknown" = "unknown"; + for (const css of cssContents) { + MAX_WIDTH_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MAX_WIDTH_REGEX.exec(css.content)) !== null) { + const px = parseLengthToPx(`${match[1]}${match[2]}`); + if (px !== null) maxWidths.push(px); + } + BORDER_RADIUS_REGEX.lastIndex = 0; + while ((match = BORDER_RADIUS_REGEX.exec(css.content)) !== null) { + const px = parseLengthToPx(`${match[1]}${match[2]}`); + if (px !== null) radii.push(px); + } + BOX_SHADOW_REGEX.lastIndex = 0; + while ((match = BOX_SHADOW_REGEX.exec(css.content)) !== null) { + const shadowKind = classifyShadow(match[1]); + if (shadowKind) shadowKinds.add(shadowKind); + } + if (/background(?:-color)?\s*:\s*(#[0-9a-f]{3,8}|var\(--background\)|white|rgb\()/i.test(css.content)) { + pageBackgroundMode = "solid"; + } else if (/background(?:-image)?\s*:\s*(linear-gradient|radial-gradient|url\()/i.test(css.content)) { + pageBackgroundMode = "custom"; + } + } + + const copyRoleCount = extractAttributeValuesFromTags(html, COPY_ROLE_REGEX).length; + const sections = extractAttributeValuesFromTags(html, SECTION_ATTRIBUTE_REGEX); + const heroSignal = /]*>([^<]{0,120}) CTA_TEXT_HINT.test(entry)) + .length; + const tokenPolicies = collectRemoteTokenPolicies(cssContents); + + const loginOrDeniedDetected = observation.loginDetected || observation.accessDeniedDetected; + if (options.authStorageState && finalUrl.hostname !== sourceUrl.hostname) { + throw new Error( + `Authenticated replay for ${sourceUrl.hostname} redirected to ${finalUrl.hostname}. Capture a profile for the final host and retry.`, + ); + } + if (options.authStorageState && loginOrDeniedDetected) { + throw new Error( + observation.accessDeniedDetected + ? `Authenticated replay reached an access-denied page at ${redactSensitiveUrl(finalUrl.toString())}.` + : `Authenticated replay still resolved to a login page at ${redactSensitiveUrl(finalUrl.toString())}. Re-capture the auth profile and retry.`, + ); + } + const descriptor: SurfaceDescriptor = { + surfaceId: options.surfaceId, + sections: sections.map((section) => ({ id: section, source: redactSensitiveUrl(finalUrl.toString()) })), + fonts: fonts.map((entry) => ({ value: entry.value, source: entry.sources[0] })), + colors: colors.map((entry) => ({ value: entry.canonical, source: entry.sources[0] })), + icons: [], + tokenUsage: { + typography: metadataToDescriptors(metadataFromPolicy(tokenPolicies.typography)), + layout: metadataToDescriptors(metadataFromPolicy(tokenPolicies.layout)), + motion: metadataToDescriptors(metadataFromPolicy(tokenPolicies.motion)), + }, + marketingTypography: + copyRoleCount > 0 + ? { + roles: [], + source: redactSensitiveUrl(finalUrl.toString()), + } + : undefined, + layout: { + maxContentWidth: maxWidths.length > 0 ? Math.max(...maxWidths) : null, + containers: uniqueSorted([ + ...(html.match(/\bclass=(?:"[^"]*\bcontainer\b[^"]*"|'[^']*\bcontainer\b[^']*')/gi) ?? []).map(() => "container"), + ]), + chrome: { + targets: [], + maxBorderRadiusPx: radii.length > 0 ? Math.max(...radii) : null, + shadowKinds: [...shadowKinds].sort((a, b) => a.localeCompare(b)) as Array<"none" | "outer" | "inset" | "mixed">, + }, + landingPattern: + sections.length > 0 || heroSignal + ? { + sectionOrder: sections, + topLevelSections: sections, + nestedSections: [], + pageBackgroundMode, + source: redactSensitiveUrl(finalUrl.toString()), + } + : undefined, + }, + motion: motions.map((entry) => ({ + durationMs: entry.durationMs, + timingFunction: entry.timingFunction, + source: entry.sources[0], + })), + primitives, + }; + + return { + routes: routeInfo.routes, + hasShell: primitives.some((entry) => entry.role === "navigation" || entry.role === "header"), + authAware: routeInfo.authHints.length > 0, + designSystemComponents: [], + descriptor, + ctaCount, + copyRoleCount, + heroSignal, + warnings: [ + ...(cssContents.length === 0 + ? [{ code: "remote.css.none-detected", message: "No same-origin CSS was fetched for remote analysis; design-system extraction will be partial." }] + : []), + ...(loginOrDeniedDetected && !options.authStorageState + ? [{ + code: observation.accessDeniedDetected ? "remote.auth.access-denied-detected" : "remote.auth.login-detected", + message: + observation.accessDeniedDetected + ? "Remote analysis resolved to an access-denied page; results may reflect the gate instead of the target surface." + : "Remote analysis resolved to a login page; provide --auth-profile for authenticated replay if this surface is protected.", + }] + : []), + ], + tokenPolicies, + colorAllowedValues: colors.map((entry) => entry.canonical), + surfaceIcons: undefined, + }; +} + +function metadataToDescriptors(metadata: TokenMetadata[]): SurfaceTokenDescriptor[] { + return metadata.map((entry) => ({ + value: entry.token, + observedValue: entry.aliases[0] ?? entry.token, + normalizedValue: entry.normalizedValue, + attributes: entry.attributes, + })); +} + +function buildBaseContract( + surfaceId: string, + surfaceName: string, + sourceRef: string, + observation: NormalizedObservation, +): InterfaceContract { + const requiredContainers = uniqueSorted(observation.descriptor.layout.containers ?? []); + const sections = + observation.descriptor.sections.length > 0 + ? observation.descriptor.sections.map((section) => ({ + id: section.id, + intent: inferIntentFromSectionId(section.id), + description: defaultDescriptionFromSection(section.id), + })) + : [ + { + id: PLACEHOLDER_SECTION_ID, + intent: "placeholder", + description: "Placeholder section; explicit section markers were not detected.", + }, + ]; + + const requiredSections = + observation.descriptor.sections.length > 0 + ? uniqueSorted(observation.descriptor.sections.map((section) => section.id)) + : [PLACEHOLDER_SECTION_ID]; + + return { + contractId: `${surfaceId}.generated`, + version: DEFAULT_CONTRACT_VERSION, + description: "Generated by interfacectl first-run onboarding.", + surfaces: [ + { + id: surfaceId, + displayName: surfaceName, + type: "web", + requiredSections, + allowedFonts: + observation.descriptor.fonts.length > 0 + ? uniqueSorted(observation.descriptor.fonts.map((font) => font.value)) + : ["sans-serif"], + layout: { + maxContentWidth: observation.descriptor.layout.maxContentWidth ?? 1120, + requiredContainers: requiredContainers.length > 0 ? requiredContainers : undefined, + chromePolicy: observation.descriptor.layout.chrome?.targets?.length + ? { + policy: "off", + targets: observation.descriptor.layout.chrome.targets, + maxBorderRadiusPx: observation.descriptor.layout.chrome.maxBorderRadiusPx ?? 0, + allowOuterShadow: observation.descriptor.layout.chrome.shadowKinds.includes("outer") || + observation.descriptor.layout.chrome.shadowKinds.includes("mixed"), + allowInsetShadow: observation.descriptor.layout.chrome.shadowKinds.includes("inset") || + observation.descriptor.layout.chrome.shadowKinds.includes("mixed"), + } + : undefined, + }, + icons: observation.surfaceIcons, + }, + ], + sections, + constraints: { + motion: { + allowedDurationsMs: uniqueSortedNumbers( + observation.descriptor.motion.map((motion) => motion.durationMs), + ).length > 0 + ? uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)) + : [120], + allowedTimingFunctions: + uniqueSorted(observation.descriptor.motion.map((motion) => motion.timingFunction)).length > 0 + ? uniqueSorted(observation.descriptor.motion.map((motion) => motion.timingFunction)) + : ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: + observation.colorAllowedValues.length > 0 + ? uniqueSorted(observation.colorAllowedValues) + : [], + }, + tokens: observation.tokenPolicies, + x_extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + sourceRef, + }, + }; +} + +function buildExtractionReport( + options: AnalyzeSurfaceOptions, + observation: NormalizedObservation, + warnings: Array<{ code: string; message: string }>, +): Record { + const sourceUrl = + options.sourceMode === "remote-url" && options.url + ? redactSensitiveUrl(options.url) + : options.appRoot + ? pathToFileURL(path.resolve(options.workspaceRoot, options.appRoot)).toString() + : undefined; + + return { + surfaceId: options.surfaceId, + appRoot: + options.sourceMode === "local-root" && options.appRoot + ? path.resolve(options.workspaceRoot, options.appRoot) + : options.url, + warnings, + extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + designSystemComponents: observation.designSystemComponents, + authAware: observation.authAware, + iconSources: observation.surfaceIcons?.allowedSources ?? [], + }, + onboarding: { + sourceUrl, + authMode: options.authMode ?? "none", + extractMode: options.sourceMode, + profileName: options.authProfileName, + profileDomain: options.url ? new URL(options.url).hostname : undefined, + detection: { + adapter: options.sourceMode === "local-root" ? "next-app-static-analysis" : "remote-url-observer", + framework: options.sourceMode === "local-root" ? "nextjs" : "unknown", + profile: "web-first-run", + }, + }, + }; +} + +export async function analyzeSurface( + options: AnalyzeSurfaceOptions, +): Promise { + const observation = + options.sourceMode === "local-root" + ? await analyzeLocalSource(options) + : await analyzeRemoteSource(options); + const findings = buildFindings(observation); + const classification = buildClassification(observation); + const confirmedKind = options.surfaceKindOverride ?? classification.inferredKind; + const phase0 = buildPhase0Seed(observation); + const analysis: SurfaceAnalysisArtifact = { + schemaVersion: DEFAULT_ANALYSIS_SCHEMA_VERSION, + surfaceId: options.surfaceId, + surfaceName: options.surfaceName, + source: { + mode: options.sourceMode, + appRoot: + options.sourceMode === "local-root" && observation.sourceAppRoot + ? toStableSourcePath(options.workspaceRoot, observation.sourceAppRoot) + : undefined, + url: options.url ? redactSensitiveUrl(options.url) : undefined, + }, + extracted: { + routes: observation.routes, + hasShell: observation.hasShell, + authAware: observation.authAware, + designSystemComponents: observation.designSystemComponents, + sections: uniqueSorted(observation.descriptor.sections.map((section) => section.id)), + sectionCount: observation.descriptor.sections.length, + fonts: summarizeFonts(observation.descriptor), + colors: summarizeColors(observation.descriptor), + motion: summarizeMotion(observation.descriptor), + iconSources: summarizeIcons(observation.descriptor), + primitives: observation.descriptor.primitives ?? [], + layout: { + maxContentWidth: observation.descriptor.layout.maxContentWidth ?? null, + containers: observation.descriptor.layout.containers ?? [], + chrome: { + maxBorderRadiusPx: observation.descriptor.layout.chrome?.maxBorderRadiusPx ?? null, + shadowKinds: observation.descriptor.layout.chrome?.shadowKinds ?? [], + }, + landingSignals: { + sectionOrder: observation.descriptor.layout.landingPattern?.sectionOrder ?? [], + topLevelSections: observation.descriptor.layout.landingPattern?.topLevelSections ?? [], + nestedSections: observation.descriptor.layout.landingPattern?.nestedSections ?? [], + pageBackgroundMode: + observation.descriptor.layout.landingPattern?.pageBackgroundMode ?? "unknown", + heroSignal: observation.heroSignal, + copyRoleCount: observation.copyRoleCount, + ctaCount: observation.ctaCount, + }, + }, + tokens: { + typography: metadataFromPolicy(observation.tokenPolicies.typography), + layout: metadataFromPolicy(observation.tokenPolicies.layout), + motion: metadataFromPolicy(observation.tokenPolicies.motion), + }, + }, + classification: { + ...classification, + confirmedKind, + }, + existingSystem: calculateExistingSystem(observation, findings), + inconsistencies: { + findings, + }, + proposedContract: { + phase0, + sectionSeedMode: observation.descriptor.sections.length > 0 ? "observed" : "placeholder", + seedCounts: { + typographyTokens: metadataFromPolicy(observation.tokenPolicies.typography).length, + layoutTokens: metadataFromPolicy(observation.tokenPolicies.layout).length, + motionTokens: metadataFromPolicy(observation.tokenPolicies.motion).length, + colors: observation.colorAllowedValues.length, + iconSources: observation.surfaceIcons?.allowedSources.length ?? 0, + sections: observation.descriptor.sections.length, + }, + suggestedMarketingProfile: + confirmedKind === "marketing" && + ( + Boolean(observation.descriptor.layout.landingPattern) || + (observation.descriptor.marketingTypography?.roles.length ?? 0) > 0 + ), + }, + warnings: observation.warnings.sort((a, b) => a.code.localeCompare(b.code)), + }; + + const baseContract = buildBaseContract( + options.surfaceId, + options.surfaceName, + options.url ? redactSensitiveUrl(options.url) : options.appRoot ?? options.surfaceId, + observation, + ); + const contract = applyAnalysisToContract(baseContract, observation, analysis); + const extractionReport = buildExtractionReport(options, observation, analysis.warnings); + const draft = buildDraftArtifact(analysis, observation); + + return { + analysis, + draft, + contract, + extractionReport, + descriptor: observation.descriptor, + }; +} + +export function stringifyStableArtifact(payload: unknown): string { + return `${stableStringify(payload)}\n`; +} diff --git a/packages/interfacectl-cli/src/utils/onboarding.ts b/packages/interfacectl-cli/src/utils/onboarding.ts index 1d99b59..6c40310 100644 --- a/packages/interfacectl-cli/src/utils/onboarding.ts +++ b/packages/interfacectl-cli/src/utils/onboarding.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { stableStringify } from "@surfaces/interfacectl-extractor"; type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; -type RunSource = "bootstrap" | "generation" | "ci" | "runtime"; +export type OnboardingRunSource = "bootstrap" | "generation" | "ci" | "runtime"; interface ContractRunsDocument { schemaVersion: number; @@ -16,7 +16,7 @@ interface ContractRun { runId: string; createdAt: string; surfaceId: string; - source: RunSource; + source: OnboardingRunSource; contract: { id: string; version: string; @@ -39,7 +39,7 @@ interface ContractLineageDocument { surfaces: Record 0 ? normalized : "surface"; +} + export function buildBootstrapContract(input: { surfaceId: string; surfaceName: string; @@ -234,9 +244,10 @@ async function readCanonicalContract(rootDir: string): Promise<{ } } -export async function emitBootstrapRunArtifact(input: { +export async function emitOnboardingRunArtifact(input: { rootDir: string; surfaceId: string; + source: OnboardingRunSource; status: ValidationOutcome; findingCodes: string[]; extractionPath: string; @@ -262,7 +273,7 @@ export async function emitBootstrapRunArtifact(input: { runId, createdAt, surfaceId: input.surfaceId, - source: "bootstrap", + source: input.source, contract: canonical, artifacts: { extractionPath: toRelative(input.rootDir, input.extractionPath), @@ -295,3 +306,17 @@ export async function emitBootstrapRunArtifact(input: { await writeJsonAtomic(lineagePath, lineageDoc); return { runId }; } + +export async function emitBootstrapRunArtifact(input: { + rootDir: string; + surfaceId: string; + status: ValidationOutcome; + findingCodes: string[]; + extractionPath: string; + reportPath: string; +}): Promise<{ runId: string }> { + return emitOnboardingRunArtifact({ + ...input, + source: "bootstrap", + }); +} diff --git a/packages/interfacectl-cli/test/first-run-experience.test.mjs b/packages/interfacectl-cli/test/first-run-experience.test.mjs new file mode 100644 index 0000000..370fe9b --- /dev/null +++ b/packages/interfacectl-cli/test/first-run-experience.test.mjs @@ -0,0 +1,320 @@ +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); + +async function run(args, options = {}) { + const child = spawn("node", [cliPath, ...args], { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + const [exitCode] = await once(child, "exit"); + return { exitCode: Number(exitCode), stdout, stderr }; +} + +async function writeRootLayout(appDir, bodyContents) { + await writeFile( + path.join(appDir, "layout.tsx"), + `import "./globals.css"; +export default function RootLayout({ children }) { + return ( + + + ${bodyContents} + {children} + + + ); +} +`, + "utf-8", + ); +} + +async function writeGlobals(appDir, css) { + await writeFile(path.join(appDir, "globals.css"), css, "utf-8"); +} + +async function createMarketingApp(rootDir) { + const appRoot = path.join(rootDir, "apps", "marketing-site"); + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + + await writeRootLayout(appDir, "

"); + await writeFile( + path.join(appDir, "page.tsx"), + `export default function Page() { + return ( +
+
+

+ Surface onboarding that starts with your UI. +

+

+ Extract the system you have and draft the one you need. +

+ + Get started + +
+
+

Teams use this to formalize design systems from live surfaces.

+
+
+ Book demo +
+
+ ); +} +`, + "utf-8", + ); + await writeGlobals( + appDir, + `:root { + --font-display: "Soehne", sans-serif; + --type-body: 1rem; + --space-6: 24px; + --surface-bg: #ffffff; + --surface-text: #111111; + --motion-standard: 180ms; +} + +body { + background: var(--surface-bg); + color: var(--surface-text); + transition: opacity var(--motion-standard) ease-in-out; +} + +section { + max-width: 72rem; + border-radius: 24px; +} +`, + ); + + return appRoot; +} + +async function createApplicationApp(rootDir) { + const appRoot = path.join(rootDir, "apps", "product-app"); + const appDir = path.join(appRoot, "app"); + await mkdir(path.join(appDir, "auth", "login"), { recursive: true }); + await mkdir(path.join(appDir, "auth", "callback"), { recursive: true }); + await mkdir(path.join(appDir, "auth", "session"), { recursive: true }); + await mkdir(path.join(appDir, "auth", "logout"), { recursive: true }); + await mkdir(path.join(appDir, "dashboard"), { recursive: true }); + await mkdir(path.join(appDir, "settings"), { recursive: true }); + await mkdir(path.join(appDir, "workspace"), { recursive: true }); + await mkdir(path.join(appDir, "billing"), { recursive: true }); + await mkdir(path.join(appDir, "users"), { recursive: true }); + + await writeRootLayout( + appDir, + ` + `, + ); + await writeFile( + path.join(appDir, "layout.tsx"), + `import "./globals.css"; +import Navigation from "@surfaces/ui/components/Navigation"; + +export default function RootLayout({ children }) { + return ( + + + + + {children} + + + ); +} +`, + "utf-8", + ); + await writeFile( + path.join(appDir, "page.tsx"), + `export default function Home() { + return
Home
; +} +`, + "utf-8", + ); + const routePage = (sectionId) => `export default function Page() { + return
Route
; +} +`; + await writeFile(path.join(appDir, "dashboard", "page.tsx"), routePage("app.dashboard"), "utf-8"); + await writeFile(path.join(appDir, "settings", "page.tsx"), routePage("app.settings"), "utf-8"); + await writeFile(path.join(appDir, "workspace", "page.tsx"), routePage("app.workspace"), "utf-8"); + await writeFile(path.join(appDir, "billing", "page.tsx"), routePage("app.billing"), "utf-8"); + await writeFile(path.join(appDir, "users", "page.tsx"), routePage("app.users"), "utf-8"); + await writeFile(path.join(appDir, "auth", "login", "page.tsx"), routePage("auth.login"), "utf-8"); + await writeFile(path.join(appDir, "auth", "callback", "page.tsx"), routePage("auth.callback"), "utf-8"); + await writeFile(path.join(appDir, "auth", "session", "page.tsx"), routePage("auth.session"), "utf-8"); + await writeFile(path.join(appDir, "auth", "logout", "page.tsx"), routePage("auth.logout"), "utf-8"); + await writeGlobals( + appDir, + `:root { + --font-sans: "Soehne Buch", sans-serif; + --type-body: 1rem; + --space-4: 16px; + --container-app: 1120px; + --motion-fast: 160ms; + --motion-ease: ease-out; + --color-text: #102030; + --color-bg: #f4f6f8; +} + +body { + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-bg); +} + +main { + font-size: var(--type-body); + line-height: var(--type-body); + transition: opacity var(--motion-fast) var(--motion-ease); +} + +aside { + width: var(--container-app); +} +`, + ); + + return appRoot; +} + +async function createMixedSignalsApp(rootDir) { + const appRoot = path.join(rootDir, "apps", "mixed-signals"); + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + await writeFile( + path.join(appDir, "layout.tsx"), + `import "./globals.css"; +export default function RootLayout({ children }) { + return ( + + + + + {children} + + + ); +} +`, + "utf-8", + ); + await writeFile( + path.join(appDir, "page.tsx"), + `export default function Page() { + return
Mixed signals
; +} +`, + "utf-8", + ); + await writeGlobals( + appDir, + `body { font-family: "Soehne", sans-serif; color: #222222; background: #ffffff; } +main { transition: opacity 200ms ease; max-width: 60rem; } +`, + ); + return appRoot; +} + +test("analyze: local-root marketing surface emits marketing analysis", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-analyze-marketing-")); + try { + const appRoot = await createMarketingApp(cwd); + const result = await run( + ["analyze", "--app-root", appRoot, "--surface", "marketing-site"], + { cwd }, + ); + assert.equal(result.exitCode, 0, result.stderr); + + const analysis = JSON.parse( + await readFile( + path.join(cwd, "contracts", "generated", "marketing-site.analysis.json"), + "utf-8", + ), + ); + + assert.equal(analysis.classification.inferredKind, "marketing"); + assert.equal(analysis.proposedContract.suggestedMarketingProfile, true); + assert.ok(analysis.extracted.sectionCount >= 3); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("init: local-root application surface writes contract, draft, and extraction artifacts", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-application-")); + try { + const appRoot = await createApplicationApp(cwd); + const result = await run( + ["init", "--non-interactive", "--app-root", appRoot, "--surface", "product-app"], + { cwd }, + ); + assert.equal(result.exitCode, 0, result.stderr); + + const generatedDir = path.join(cwd, "contracts", "generated"); + const analysis = JSON.parse( + await readFile(path.join(generatedDir, "product-app.analysis.json"), "utf-8"), + ); + const draft = JSON.parse( + await readFile(path.join(generatedDir, "product-app.design-system.draft.json"), "utf-8"), + ); + const contract = JSON.parse( + await readFile(path.join(generatedDir, "product-app.contract.json"), "utf-8"), + ); + const extraction = JSON.parse( + await readFile(path.join(generatedDir, "product-app.extraction.json"), "utf-8"), + ); + + assert.equal(analysis.classification.confirmedKind, "application"); + assert.equal(draft.webSurfaceKind, "application"); + assert.equal(draft.mode, "adopt"); + assert.equal(contract.surfaces[0].phase0.authPosture, "auth-first"); + assert.equal(contract.surfaces[0].phase0.requiresShell, true); + assert.equal(extraction.onboarding.extractMode, "local-root"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("init: non-interactive mixed-signal surface requires explicit surface kind", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-mixed-")); + try { + const appRoot = await createMixedSignalsApp(cwd); + const result = await run( + ["init", "--non-interactive", "--app-root", appRoot, "--surface", "mixed-signals"], + { cwd }, + ); + assert.equal(result.exitCode, 1); + assert.match(`${result.stdout}\n${result.stderr}`, /--surface-kind/); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/init-auth.test.mjs b/packages/interfacectl-cli/test/init-auth.test.mjs index 366f321..04b386c 100644 --- a/packages/interfacectl-cli/test/init-auth.test.mjs +++ b/packages/interfacectl-cli/test/init-auth.test.mjs @@ -1,4 +1,5 @@ import { test } from "node:test"; +import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { once } from "node:events"; import assert from "node:assert/strict"; @@ -17,6 +18,10 @@ async function run(args, options = {}) { cwd: options.cwd, env: { ...process.env, ...(options.env ?? {}) }, }); + if (options.input) { + child.stdin.write(options.input); + child.stdin.end(); + } let stdout = ""; let stderr = ""; child.stdout.on("data", (d) => { stdout += d.toString(); }); @@ -25,10 +30,141 @@ async function run(args, options = {}) { return { exitCode: Number(exitCode), stdout, stderr }; } -test("init: non-interactive remote-url writes onboarding artifacts and run metadata", async () => { +function parseCookies(rawCookieHeader) { + return (rawCookieHeader ?? "") + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .reduce((acc, entry) => { + const [key, ...rest] = entry.split("="); + acc[key] = rest.join("="); + return acc; + }, {}); +} + +function createProtectedServer() { + return createServer((req, res) => { + const cookies = parseCookies(req.headers.cookie); + const authenticated = cookies.surface_session === "1"; + + if (req.url === "/session/start") { + res.writeHead(302, { + location: "/app", + "set-cookie": "surface_session=1; Path=/; HttpOnly", + }); + res.end(); + return; + } + + if (req.url === "/styles.css") { + if (!authenticated) { + res.writeHead(302, { location: "/login" }); + res.end(); + return; + } + res.writeHead(200, { "content-type": "text/css" }); + res.end(` + body { font-family: "Founders Grotesk", sans-serif; color: #102030; background: #f6f8fb; } + main { max-width: 72rem; transition: opacity 180ms ease-in-out; } + aside { width: 18rem; border-radius: 16px; } + `); + return; + } + + if (req.url === "/app") { + if (!authenticated) { + res.writeHead(302, { location: "/login" }); + res.end(); + return; + } + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + +
+ +
+

Protected dashboard

+
Session
+
+ + + `); + return; + } + + if (req.url === "/login") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + +
+

Sign in

+
+
+ + + `); + return; + } + + res.writeHead(404); + res.end("not found"); + }); +} + +test("init: non-interactive remote-url writes first-run artifacts and run metadata", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-remote-")); const profilePath = path.join(cwd, "auth-profiles.json"); + const server = createServer((req, res) => { + if (req.url === "/styles.css") { + res.writeHead(200, { "content-type": "text/css" }); + res.end(` + body { font-family: "Founders Grotesk", sans-serif; color: #101820; background: #ffffff; } + .hero { max-width: 72rem; padding: 3rem; border-radius: 24px; } + .cta { transition: opacity 200ms ease-in-out; } + `); + return; + } + + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + +
+
+
+

Launch surfaces faster.

+

Ship a first contract and starter system in one pass.

+ Get started +
+
+ +
+
+ Learn more +
+
+ + + `); + }); try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + await mkdir(path.join(cwd, "contracts"), { recursive: true }); await writeFile( path.join(cwd, "contracts", "surfaces.web.contract.json"), @@ -49,14 +185,22 @@ test("init: non-interactive remote-url writes onboarding artifacts and run metad "init", "--non-interactive", "--url", - "https://customer.example.com/products", + `${baseUrl}/`, "--surface", "customer-products", + "--surface-kind", + "marketing", ], { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, ); assert.equal(result.exitCode, 0, result.stderr); const generatedDir = path.join(cwd, "contracts", "generated"); + const analysis = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.analysis.json"), "utf-8"), + ); + const draft = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.design-system.draft.json"), "utf-8"), + ); const contract = JSON.parse( await readFile(path.join(generatedDir, "customer-products.contract.json"), "utf-8"), ); @@ -70,34 +214,118 @@ test("init: non-interactive remote-url writes onboarding artifacts and run metad await readFile(path.join(generatedDir, "contract-lineage.json"), "utf-8"), ); + assert.equal(analysis.classification.confirmedKind, "marketing"); + assert.equal(draft.webSurfaceKind, "marketing"); assert.equal(contract.surfaces[0].id, "customer-products"); assert.equal(extraction.onboarding.extractMode, "remote-url"); assert.equal(extraction.onboarding.authMode, "none"); assert.equal(runs.schemaVersion, 1); - assert.equal(runs.runs[0].source, "bootstrap"); - assert.equal(lineage.surfaces["customer-products"].lastSource, "bootstrap"); + assert.equal(runs.runs[0].source, "generation"); + assert.equal(lineage.surfaces["customer-products"].lastSource, "generation"); } finally { + server.closeAllConnections?.(); + server.close(); await rm(cwd, { recursive: true, force: true }); } }); -test("auth: list/test/clear operate on local profile store", async () => { +test("auth: capture/list/test/clear operate on replayable local profile store", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-")); const profilePath = path.join(cwd, "auth-profiles.json"); + const server = createProtectedServer(); try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const capture = await run( + ["auth", "capture", "--profile", "demo", "--url", `${baseUrl}/session/start`, "--format", "json"], + { + cwd, + env: { + ...forceFileStorageEnv, + INTERFACECTL_PLAYWRIGHT_HEADLESS: "1", + INTERFACECTL_AUTH_PROFILES_PATH: profilePath, + }, + input: "\n", + }, + ); + assert.equal(capture.exitCode, 0, capture.stderr); + const capturePayload = JSON.parse(capture.stdout); + assert.equal(capturePayload.ok, true); + assert.equal(capturePayload.profile.replayReady, true); + assert.ok(capturePayload.profile.replayStateRef); + + const list = await run( + ["auth", "list", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(list.exitCode, 0, list.stderr); + const listPayload = JSON.parse(list.stdout); + assert.equal(listPayload.ok, true); + assert.equal(listPayload.profiles.length, 1); + assert.equal(listPayload.profiles[0].name, "demo"); + assert.equal(listPayload.profiles[0].replayReady, true); + + const testProfile = await run( + ["auth", "test", "--profile", "demo", "--url", `${baseUrl}/app`, "--format", "json"], + { + cwd, + env: { + ...forceFileStorageEnv, + INTERFACECTL_AUTH_PROFILES_PATH: profilePath, + }, + }, + ); + assert.equal(testProfile.exitCode, 0, testProfile.stderr); + assert.equal(JSON.parse(testProfile.stdout).ok, true); + + const clear = await run( + ["auth", "revoke", "--profile", "demo", "--domain", "127.0.0.1", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(clear.exitCode, 0, clear.stderr); + assert.equal(JSON.parse(clear.stdout).removed, 1); + + const listAfter = await run( + ["auth", "list", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(listAfter.exitCode, 0, listAfter.stderr); + assert.equal(JSON.parse(listAfter.stdout).profiles.length, 0); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auth: legacy profile is surfaced as non-replayable and analyze with auth-profile fails", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-legacy-")); + const profilePath = path.join(cwd, "auth-profiles.json"); + const server = createProtectedServer(); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + await writeFile( profilePath, JSON.stringify({ schemaVersion: 1, profiles: [ { - name: "demo", - domain: "customer.example.com", + name: "legacy", + domain: "127.0.0.1", mode: "browser-session", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 3600_000).toISOString(), - sessionRef: "session-1", + sessionRef: "session-legacy", }, ], }, null, 2), @@ -110,31 +338,80 @@ test("auth: list/test/clear operate on local profile store", async () => { ); assert.equal(list.exitCode, 0, list.stderr); const listPayload = JSON.parse(list.stdout); - assert.equal(listPayload.ok, true); - assert.equal(listPayload.profiles.length, 1); - assert.equal(listPayload.profiles[0].name, "demo"); + assert.equal(listPayload.profiles[0].status, "legacy"); + assert.equal(listPayload.profiles[0].replayReady, false); const testProfile = await run( - ["auth", "test", "--profile", "demo", "--domain", "customer.example.com", "--format", "json"], + ["auth", "test", "--profile", "legacy", "--domain", "127.0.0.1", "--format", "json"], { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, ); - assert.equal(testProfile.exitCode, 0, testProfile.stderr); - assert.equal(JSON.parse(testProfile.stdout).ok, true); + assert.equal(testProfile.exitCode, 1); + assert.match(JSON.parse(testProfile.stdout).error, /re-captured/i); - const clear = await run( - ["auth", "revoke", "--profile", "demo", "--domain", "customer.example.com", "--format", "json"], + const analyze = await run( + ["analyze", "--url", `${baseUrl}/app`, "--surface", "private-app", "--auth-profile", "legacy"], { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, ); - assert.equal(clear.exitCode, 0, clear.stderr); - assert.equal(JSON.parse(clear.stdout).removed, 1); + assert.equal(analyze.exitCode, 1); + assert.match(`${analyze.stdout}\n${analyze.stderr}`, /re-captured/i); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); - const listAfter = await run( - ["auth", "list", "--format", "json"], +test("analyze: protected remote surface warns anonymously and succeeds with replayed auth profile", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-remote-")); + const profilePath = path.join(cwd, "auth-profiles.json"); + const server = createProtectedServer(); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const anonymous = await run( + ["analyze", "--url", `${baseUrl}/app`, "--surface", "private-app"], { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, ); - assert.equal(listAfter.exitCode, 0, listAfter.stderr); - assert.equal(JSON.parse(listAfter.stdout).profiles.length, 0); + assert.equal(anonymous.exitCode, 0, anonymous.stderr); + const anonymousAnalysis = JSON.parse( + await readFile(path.join(cwd, "contracts", "generated", "private-app.analysis.json"), "utf-8"), + ); + assert.equal( + anonymousAnalysis.warnings.some((warning) => warning.code === "remote.auth.login-detected"), + true, + ); + + const capture = await run( + ["auth", "capture", "--profile", "demo", "--url", `${baseUrl}/session/start`, "--format", "json"], + { + cwd, + env: { + ...forceFileStorageEnv, + INTERFACECTL_PLAYWRIGHT_HEADLESS: "1", + INTERFACECTL_AUTH_PROFILES_PATH: profilePath, + }, + input: "\n", + }, + ); + assert.equal(capture.exitCode, 0, capture.stderr); + + const authenticated = await run( + ["analyze", "--url", `${baseUrl}/app`, "--surface", "private-app", "--auth-profile", "demo"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(authenticated.exitCode, 0, authenticated.stderr); + const authenticatedAnalysis = JSON.parse( + await readFile(path.join(cwd, "contracts", "generated", "private-app.analysis.json"), "utf-8"), + ); + assert.equal(authenticatedAnalysis.classification.inferredKind, "application"); + assert.equal(authenticatedAnalysis.extracted.hasShell, true); } finally { + server.closeAllConnections?.(); + server.close(); await rm(cwd, { recursive: true, force: true }); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63d035a..145d043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: picocolors: specifier: ^1.0.0 version: 1.1.1 + playwright: + specifier: ^1.58.2 + version: 1.58.2 devDependencies: '@types/node': specifier: ^20.14.9 @@ -271,6 +274,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -411,6 +419,16 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -798,6 +816,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -914,6 +935,14 @@ snapshots: pify@4.0.1: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + prettier@2.8.8: {} quansync@0.2.11: {} From 6689bc7a4b440f5909b759c648817f1a0bc262ee Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 11 Mar 2026 15:34:59 -0700 Subject: [PATCH 2/2] ci: install playwright chromium for tests --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dac962..3410049 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,7 @@ jobs: run: bash scripts/verify-source-files.sh - name: Build packages run: pnpm run build + - name: Install Playwright Chromium + run: pnpm --filter @surfaces/interfacectl-cli exec playwright install chromium - name: Run tests run: pnpm run test