From cfaf58d491482fbfac0c02d87cb142714b6abc56 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 11 Mar 2026 22:35:56 -0700 Subject: [PATCH 1/3] feat(cli): improve first-run web onboarding --- API.md | 6 +- README.md | 14 +- .../dist/commands/analyze.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/analyze.js | 3 + .../interfacectl-cli/dist/commands/auth.js | 8 +- .../interfacectl-cli/dist/commands/init.d.ts | 15 +- .../dist/commands/init.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/init.js | 690 ++++++++----- packages/interfacectl-cli/dist/index.js | 14 +- .../dist/utils/bare-onboarding.d.ts | 16 + .../dist/utils/bare-onboarding.d.ts.map | 1 + .../dist/utils/bare-onboarding.js | 65 ++ .../dist/utils/browser-session.d.ts | 24 + .../dist/utils/browser-session.d.ts.map | 2 +- .../dist/utils/browser-session.js | 210 +++- .../interfacectl-cli/dist/utils/compare.js | 2 +- .../dist/utils/first-run-analysis.d.ts | 8 + .../dist/utils/first-run-analysis.d.ts.map | 2 +- .../dist/utils/first-run-analysis.js | 265 +++-- .../dist/utils/init-interactive.d.ts | 29 + .../dist/utils/init-interactive.d.ts.map | 1 + .../dist/utils/init-interactive.js | 146 +++ .../interfacectl-cli/src/commands/analyze.ts | 5 + .../interfacectl-cli/src/commands/auth.ts | 8 +- .../interfacectl-cli/src/commands/init.ts | 921 +++++++++++------- packages/interfacectl-cli/src/index.ts | 16 +- .../src/utils/bare-onboarding.ts | 102 ++ .../src/utils/browser-session.ts | 314 +++++- .../interfacectl-cli/src/utils/compare.ts | 2 +- .../src/utils/first-run-analysis.ts | 332 +++++-- .../src/utils/init-interactive.ts | 225 +++++ .../test/bare-onboarding.test.mjs | 683 +++++++++++++ .../interfacectl-cli/test/init-auth.test.mjs | 300 +++++- packages/interfacectl-validator/dist/index.js | 2 +- packages/interfacectl-validator/src/index.ts | 2 +- 35 files changed, 3610 insertions(+), 827 deletions(-) create mode 100644 packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/bare-onboarding.js create mode 100644 packages/interfacectl-cli/dist/utils/init-interactive.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/init-interactive.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/init-interactive.js create mode 100644 packages/interfacectl-cli/src/utils/bare-onboarding.ts create mode 100644 packages/interfacectl-cli/src/utils/init-interactive.ts create mode 100644 packages/interfacectl-cli/test/bare-onboarding.test.mjs diff --git a/API.md b/API.md index b845dec..3321e42 100644 --- a/API.md +++ b/API.md @@ -4,6 +4,8 @@ `interfacectl` is a command-line tool for managing interface contracts in the Surfaces ecosystem. It validates, compares, and enforces compliance between defined interface contracts and actual implementation artifacts across multiple surfaces. +In an interactive terminal, running bare `interfacectl` with no arguments opens the first-run onboarding screen. In non-interactive contexts, bare `interfacectl` keeps the existing help output and exit behavior so scripts and CI remain stable. + ## Generation-time gating `interfacectl validate` is the canonical command for contract compliance. Use it to gate changes before merge or deployment. For deterministic, category-based exit codes, use `--exit-codes v2` or set `INTERFACECTL_EXIT_CODES=v2`. The command `enforce --mode fail` runs a structural diff and applies a policy threshold. It is optional and useful when you want to block on diff severity separately from compliance. For the minimal contract format and where contract semantics live in the repo, see [docs/contract-baseline.md](docs/contract-baseline.md). @@ -26,7 +28,7 @@ interfacectl init [options] - 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 +- validates preview artifacts before write and, in interactive mode, asks for confirmation before writing anything **Options:** @@ -40,6 +42,7 @@ interfacectl init [options] | `--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` | +| `--continue-on-gate` | Allow provisional output when remote onboarding resolves to a login or access-denied page | `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` | @@ -57,6 +60,7 @@ interfacectl init [options] - 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. +- If a remote URL resolves to a login or access-denied page, interactive mode stops and offers next actions before any artifacts are written. Non-interactive mode must pass `--continue-on-gate` to accept provisional output. - First-party or dogfood surfaces are not used as baselines for inference or starter recommendations. --- diff --git a/README.md b/README.md index 6bec58a..86a3422 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,17 @@ Install the CLI package as a development dependency: pnpm add -D @surfaces/interfacectl-cli ``` +In an interactive terminal, the first step is simply: + +```bash +interfacectl +``` + ## Quick Start -For a first-time web surface experience, start with `init`: +In an interactive shell, `interfacectl` opens the terminal onboarding screen and immediately asks whether you want to inspect a local app root or a live URL. + +For explicit command-driven onboarding, start with `init`: ```bash interfacectl init --app-root apps/my-app --surface my-app @@ -59,7 +67,7 @@ The CLI provides two first-run commands, browser-session auth helpers, and the v ### `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. +First-run onboarding for web surfaces. `init` analyzes either a local app root or a URL, validates a draft contract and design-system artifact in preview first, and in interactive mode only writes artifacts after confirmation. If a remote URL resolves to a login or access-denied page, interactive mode stops and offers capture-auth / continue-anyway / switch-to-local-root choices before anything is written. ```bash interfacectl init [options] @@ -73,6 +81,7 @@ Outputs: - `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`. +Non-interactive URL onboarding fails fast on login/access-denied pages unless `--continue-on-gate` is supplied for provisional output. ### `analyze` @@ -96,6 +105,7 @@ Notes: - Profiles are exact-host scoped in v1. - Remote analysis never silently falls back to anonymous access when `--auth-profile` is explicitly supplied. +- Interactive onboarding writes artifacts only after a preview confirmation step. - If Chromium is missing locally, install it with `pnpm --filter @surfaces/interfacectl-cli exec playwright install chromium`. ### `validate` diff --git a/packages/interfacectl-cli/dist/commands/analyze.d.ts.map b/packages/interfacectl-cli/dist/commands/analyze.d.ts.map index 2388de1..6f9776c 100644 --- a/packages/interfacectl-cli/dist/commands/analyze.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/analyze.d.ts.map @@ -1 +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 +{"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,CAoFvF"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/analyze.js b/packages/interfacectl-cli/dist/commands/analyze.js index 65481b2..f3e6a82 100644 --- a/packages/interfacectl-cli/dist/commands/analyze.js +++ b/packages/interfacectl-cli/dist/commands/analyze.js @@ -94,6 +94,9 @@ export async function runAnalyzeCommand(options) { 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.sourceHealth.status !== "ok") { + console.log(`Source access: ${result.analysis.sourceHealth.status} (${result.analysis.sourceHealth.confidence}) at ${result.analysis.sourceHealth.finalUrl ?? options.url}`); + } if (result.analysis.classification.requiresConfirmation && !options.surfaceKind) { console.log("Note: classification is low confidence; pass --surface-kind to confirm seeding intent."); } diff --git a/packages/interfacectl-cli/dist/commands/auth.js b/packages/interfacectl-cli/dist/commands/auth.js index cc96b08..991fd88 100644 --- a/packages/interfacectl-cli/dist/commands/auth.js +++ b/packages/interfacectl-cli/dist/commands/auth.js @@ -163,8 +163,7 @@ export async function runAuthTestCommand(options) { storageState: inspection.storageState, }); const ok = new URL(observation.finalUrl).hostname === inspection.profile.domain && - !observation.loginDetected && - !observation.accessDeniedDetected; + observation.sourceHealth.status === "ok"; if (options.format === "json") { console.log(JSON.stringify({ ok, @@ -177,16 +176,17 @@ export async function runAuthTestCommand(options) { finalUrl: observation.finalUrl, loginDetected: observation.loginDetected, accessDeniedDetected: observation.accessDeniedDetected, + sourceHealth: observation.sourceHealth, }, 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) { + if (observation.sourceHealth.status === "login") { console.error("The replayed session still resolved to a login page."); } - if (observation.accessDeniedDetected) { + if (observation.sourceHealth.status === "access-denied") { console.error("The replayed session resolved to an access-denied page."); } return 1; diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts b/packages/interfacectl-cli/dist/commands/init.d.ts index 420d7b3..a6f8c2d 100644 --- a/packages/interfacectl-cli/dist/commands/init.d.ts +++ b/packages/interfacectl-cli/dist/commands/init.d.ts @@ -1,14 +1,8 @@ -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; +import { type InteractiveInitOptions } from "../utils/init-interactive.js"; +export interface InitOptions extends InteractiveInitOptions { nonInteractive?: boolean; + verbose?: boolean; + continueOnGate?: boolean; outDir?: string; analysisOut?: string; draftOut?: string; @@ -16,5 +10,4 @@ export interface InitOptions { reportOut?: string; } export declare function runInitCommand(options: InitOptions): Promise; -export {}; //# sourceMappingURL=init.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts.map b/packages/interfacectl-cli/dist/commands/init.d.ts.map index 177919f..4599bd7 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":"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 +{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AA2BA,OAAO,EASL,KAAK,sBAAsB,EAE5B,MAAM,8BAA8B,CAAC;AAgCtC,MAAM,WAAW,WAAY,SAAQ,sBAAsB;IACzD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,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;AAsiBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CA8O1E"} \ 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 804cc76..bdf121a 100644 --- a/packages/interfacectl-cli/dist/commands/init.js +++ b/packages/interfacectl-cli/dist/commands/init.js @@ -2,38 +2,16 @@ import { existsSync } from "node:fs"; 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 { getBundledContractSchema, validateContractStructure, } from "@surfaces/interfacectl-validator"; 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 { captureBrowserStorageState, observeRemotePage } from "../utils/browser-session.js"; import { analyzeSurface, stringifyStableArtifact, } from "../utils/first-run-analysis.js"; -import { emitOnboardingRunArtifact, suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName } from "../utils/onboarding.js"; +import { emitOnboardingRunArtifact, suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName, } from "../utils/onboarding.js"; +import { inferSourceMode, normalizeSurfaceId, promptGateResolution, promptInteractiveInitInputs, promptSurfaceKindConfirmation, promptWriteConfirmation, } from "../utils/init-interactive.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() - .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"; -} async function maybeCaptureAuthProfile(inputValue) { if (!inputValue.requiresAuth) { return { authMode: "none" }; @@ -77,52 +55,9 @@ async function maybeCaptureAuthProfile(inputValue) { storageState: captured.storageState, }; } -async function promptInteractive(options) { - const rl = readline.createInterface({ input, output }); - try { - 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 ?? `${new URL(url).hostname}-default`}]: `)).trim() || options.authProfile || `${new URL(url).hostname}-default` - : null; - return { - sourceMode, - url, - appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, - surfaceId, - surfaceName, - surfaceKind: options.surfaceKind, - requiresAuth, - authProfileName, - }; - } - finally { - rl.close(); - } -} async function resolveInputs(options) { if (!options.nonInteractive) { - return promptInteractive(options); + return promptInteractiveInitInputs(options); } const sourceMode = inferSourceMode(options); if (sourceMode === "remote-url" && !options.url) { @@ -148,29 +83,6 @@ async function resolveInputs(options) { 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) @@ -203,6 +115,9 @@ function collectFlagMessages(analysis, validateResult, validateExtractedResult) ]; return [...new Set(flagged)].slice(0, 8); } +function uniqueMessages(items) { + return [...new Set(items)]; +} function collectFindingCodes(analysis, validateResult, validateExtractedResult) { return [ ...analysis.warnings.map((warning) => `analysis.${warning.code}`), @@ -211,19 +126,8 @@ function collectFindingCodes(analysis, validateResult, validateExtractedResult) ...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; +function summarizeTechnicalDraft(analysis) { + const { seedCounts } = analysis.proposedContract; const items = [ `${seedCounts.typographyTokens} typography token seed(s)`, `${seedCounts.layoutTokens} layout token seed(s)`, @@ -236,6 +140,81 @@ function summarizeNormalized(analysis) { } return items; } +function summarizeSurfaceKind(analysis) { + if (analysis.classification.confirmedKind === "marketing") { + return "We detected a marketing site."; + } + if (analysis.classification.confirmedKind === "application") { + return "We detected an application surface."; + } + return "We could not confidently classify the surface, so the draft stays generic."; +} +function summarizeExistingSystem(analysis) { + if (analysis.existingSystem.mode === "adopt") { + return "We found reusable patterns that look like an existing system."; + } + return "We did not find a complete existing system, so we will draft one from repeated patterns."; +} +function summarizeSourceAccess(analysis) { + if (analysis.sourceHealth.confidence === "full" && analysis.sourceHealth.status === "ok") { + return "We successfully analyzed the source."; + } + return "We analyzed a limited view of the source, so results are provisional."; +} +function summarizeDraft(analysis) { + const { seedCounts } = analysis.proposedContract; + const createItems = []; + const reviewItems = []; + if (seedCounts.typographyTokens > 0) { + createItems.push(`Typography foundations from ${seedCounts.typographyTokens} repeated styles.`); + } + if (seedCounts.layoutTokens > 0) { + createItems.push(`Layout foundations from ${seedCounts.layoutTokens} repeated patterns.`); + } + else { + reviewItems.push("We could not confidently infer layout foundations yet."); + } + if (seedCounts.motionTokens > 0) { + createItems.push(`Motion foundations from ${seedCounts.motionTokens} repeated timings.`); + } + if (seedCounts.colors > 0) { + createItems.push(`Color foundation with ${seedCounts.colors} reusable values.`); + } + if (seedCounts.sections > 0) { + createItems.push(`Detected ${seedCounts.sections} reusable page sections.`); + } + if (analysis.proposedContract.suggestedMarketingProfile) { + createItems.push("Landing-page guidance will be drafted from the detected structure."); + } + return { + createItems, + reviewItems, + }; +} +function rewritePreviewMessage(message, verbose = false) { + if (verbose) { + return message; + } + const layoutProfileMatch = message.match(/^Surface "([^"]+)" must declare marketing layout profile "([^"]+)"\.$/); + if (layoutProfileMatch) { + return "Landing-page structure was detected, so the draft will include landing layout guidance."; + } + const typographyProfileMatch = message.match(/^Surface "([^"]+)" must declare marketing typography profile "([^"]+)"\.$/); + if (typographyProfileMatch) { + return "Marketing typography signals were detected, so the draft will include typography guidance."; + } + const rawColorMatch = message.match(/^Detected (\d+) raw color literals; consider canonicalizing them into stable tokens or approved values\.$/); + if (rawColorMatch) { + return `We found ${rawColorMatch[1]} one-off color values that should probably be normalized.`; + } + if (message.includes("access-denied page")) { + return "We reached an access-denied page instead of the target surface, so the results are provisional."; + } + if (message.includes("login page")) { + return "We reached a login page instead of the target surface, so the results are provisional."; + } + return message; +} function logStage(step, total, message) { console.log(`[${step}/${total}] ${message}`); } @@ -243,165 +222,380 @@ function hasBlockingValidationError(validateResult, validateExtractedResult) { return ((validateResult.findings ?? []).some((finding) => finding.category === "E0") || validateExtractedResult.findings.some((finding) => finding.category === "E0")); } +function isLimitedRemoteSource(analysis) { + return analysis.source.mode === "remote-url" && analysis.sourceHealth.status !== "ok"; +} +function buildProvisionalWarning(analysis) { + const detail = analysis.sourceHealth.status === "access-denied" + ? "the remote URL resolved to an access-denied page" + : analysis.sourceHealth.status === "login" + ? "the remote URL resolved to a login page" + : "remote source access is limited"; + return { + code: "remote.source.provisional", + message: `Writing provisional onboarding artifacts because ${detail}.`, + }; +} +function applyProvisionalWarning(analysisResult) { + const warning = buildProvisionalWarning(analysisResult.analysis); + const dedupeWarnings = (items) => [...new Map(items.map((item) => [`${item.code}:${item.message}`, item])).values()] + .sort((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message)); + return { + ...analysisResult, + analysis: { + ...analysisResult.analysis, + warnings: dedupeWarnings([...analysisResult.analysis.warnings, warning]), + }, + draft: { + ...analysisResult.draft, + warnings: dedupeWarnings([...analysisResult.draft.warnings, warning]), + }, + }; +} +async function validateTempArtifacts(input) { + const analysisPath = path.join(input.tempDir, `${input.surfaceId}.analysis.json`); + const draftPath = path.join(input.tempDir, `${input.surfaceId}.design-system.draft.json`); + const contractPath = path.join(input.tempDir, `${input.surfaceId}.contract.json`); + const reportPath = path.join(input.tempDir, `${input.surfaceId}.extraction.json`); + const validatePath = path.join(input.tempDir, "validate.json"); + const validateExtractedPath = path.join(input.tempDir, "validate-extracted.json"); + await writeArtifact(analysisPath, input.analysisResult.analysis); + await writeArtifact(draftPath, input.analysisResult.draft); + await writeArtifact(contractPath, input.analysisResult.contract); + await writeArtifact(reportPath, input.analysisResult.extractionReport); + const validateExitCode = await runValidateCommand({ + contractPath, + workspaceRoot: input.rootDir, + surfaceFilters: [input.surfaceId], + descriptorOverrides: [input.analysisResult.descriptor], + outputFormat: "json", + outputPath: validatePath, + exitCodes: "v2", + }); + const validateExtractedExitCode = await runValidateExtractedCommand({ + contractPath, + extractedPath: reportPath, + surfaceId: input.surfaceId, + format: "json", + outputPath: validateExtractedPath, + exitCodes: "v2", + }); + return { + analysisPath, + draftPath, + contractPath, + reportPath, + validateExitCode, + validateExtractedExitCode, + validateResult: await readJsonFile(validatePath), + validateExtractedResult: await readJsonFile(validateExtractedPath), + }; +} +function collectAttentionMessages(analysis, validateResult, validateExtractedResult, verbose = false) { + const gateWarnings = analysis.warnings + .filter((warning) => warning.code.startsWith("remote.auth.") || warning.code === "remote.source.provisional") + .map((warning) => rewritePreviewMessage(warning.message, verbose)); + const otherMessages = collectFlagMessages(analysis, validateResult, validateExtractedResult) + .map((message) => rewritePreviewMessage(message, verbose)) + .filter((message) => !gateWarnings.includes(message)); + const items = uniqueMessages([...gateWarnings, ...otherMessages]); + return verbose ? items.slice(0, 8) : items.slice(0, 3); +} +function printPreviewSummary(input) { + const { analysis, validateResult, validateExtractedResult, provisional, verbose } = input; + const draftSummary = summarizeDraft(analysis); + const technicalDraftItems = summarizeTechnicalDraft(analysis); + const attention = uniqueMessages([ + ...draftSummary.reviewItems, + ...collectAttentionMessages(analysis, validateResult, validateExtractedResult, verbose), + ]).slice(0, verbose ? 8 : 3); + console.log(""); + console.log("What we found"); + console.log(` - ${summarizeSurfaceKind(analysis)}`); + console.log(` - ${summarizeExistingSystem(analysis)}`); + console.log(` - ${summarizeSourceAccess(analysis)}`); + if (verbose) { + console.log(` - Technical detail: surface kind confidence ${analysis.classification.confidence.toFixed(2)}.`); + console.log(` - Technical detail: existing-system mode ${analysis.existingSystem.mode} (${analysis.existingSystem.score.toFixed(2)} score).`); + console.log(` - Technical detail: source access ${analysis.sourceHealth.status} (${analysis.sourceHealth.confidence}).`); + } + console.log("What we'll create"); + for (const item of draftSummary.createItems) { + console.log(` - ${item}`); + } + if (draftSummary.createItems.length === 0) { + console.log(" - We will draft a minimal system from the strongest repeated patterns we found."); + } + if (verbose) { + for (const item of technicalDraftItems) { + console.log(` - Technical detail: ${item}`); + } + } + else if (provisional && !draftSummary.createItems.some((item) => item.includes("Landing-page guidance"))) { + console.log(" - Results will be marked provisional."); + } + console.log("What needs review"); + if (attention.length === 0) { + console.log(" - No immediate issues need review before writing."); + } + else { + for (const item of attention) { + console.log(` - ${item}`); + } + } + console.log("Continue"); + console.log(" - Review this summary, then create the draft artifacts."); + console.log(" - Press Enter to create them now, or q to cancel."); +} +function printWriteSummary(input) { + const { rootDir, resolved, artifacts, provisional, verbose, runId, storageMode, authProfileName, } = input; + console.log(""); + console.log("Created"); + console.log(` - Created a first contract and draft design system for ${resolved.surfaceName}.`); + if (provisional) { + console.log(" - Results are marked provisional because the source view was limited."); + } + console.log("Next"); + console.log(" - Review the generated draft design system and contract."); + if (resolved.sourceMode === "local-root") { + console.log(" - Connect the local app root in interfacectl.config.json for stronger repeatable validation."); + } + else { + console.log(" - Re-run with --app-root once the local checkout is available for stronger validation."); + } + if (verbose) { + 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(` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`); + } + } + console.log("Artifacts"); + const displayPath = (filePath) => verbose ? filePath : relativeDisplay(rootDir, filePath); + console.log(` - analysis: ${displayPath(artifacts.analysisPath)}`); + console.log(` - draft: ${displayPath(artifacts.draftPath)}`); + console.log(` - contract: ${displayPath(artifacts.contractPath)}`); + console.log(` - report: ${displayPath(artifacts.reportPath)}`); + if (verbose) { + console.log("Technical details"); + console.log(` - Run id: ${runId}`); + console.log(` - Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log(" - Keychain unavailable; using encrypted local file storage for replay state."); + } + if (authProfileName) { + console.log(` - Auth profile: ${authProfileName}`); + } + } +} +function gateFailureMessage(analysis) { + if (analysis.sourceHealth.status === "access-denied") { + return "Remote onboarding stopped because we reached an access-denied page instead of the target surface. Capture auth, switch to --app-root, or pass --continue-on-gate for provisional output."; + } + return "Remote onboarding stopped because we reached a login page instead of the target surface. Provide --auth-profile, capture auth interactively, switch to --app-root, or pass --continue-on-gate for provisional output."; +} +function validateLocalAppRoot(rootDir, resolved) { + if (resolved.sourceMode !== "local-root") { + return null; + } + const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + console.error(`Local app root is missing app/: ${appRoot}`); + return 1; + } + return null; +} export async function runInitCommand(options) { const rootDir = process.cwd(); const storageMode = getAuthStorageMode(); try { - const resolved = await resolveInputs(options); - 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; - } - } - 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; + let resolved = await resolveInputs(options); + let pendingAuthCapture; + while (true) { + const localRootValidation = validateLocalAppRoot(rootDir, resolved); + if (localRootValidation !== null) { + return localRootValidation; } - 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, + logStage(1, 6, "Discovering source"); + const authCapture = pendingAuthCapture ?? + (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 }); + pendingAuthCapture = undefined; + logStage(2, 6, "Checking access"); + const remoteObservation = resolved.sourceMode === "remote-url" && resolved.url + ? await observeRemotePage({ url: resolved.url, - surfaceKindOverride: confirmedKind, - authMode: authCapture.authMode, - 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}`); + storageState: authCapture.storageState, + }) + : undefined; + const remoteSourceBlocked = resolved.sourceMode === "remote-url" && + remoteObservation && + remoteObservation.sourceHealth.status !== "ok"; + if (remoteSourceBlocked && authCapture.authMode === "browser-session") { + console.error(remoteObservation.sourceHealth.status === "access-denied" + ? `Authenticated replay reached an access-denied page at ${remoteObservation.sourceHealth.finalUrl}.` + : `Authenticated replay still resolved to a login page at ${remoteObservation.sourceHealth.finalUrl}. Re-capture the auth profile and retry.`); + return 1; } - 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, + logStage(3, 6, "Analyzing surface kind and UI system"); + let analysisResult = await analyzeSurface({ 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: artifacts.contractPath, - reportPath: artifacts.reportPath, + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: resolved.surfaceKind, + authMode: authCapture.authMode, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + remoteObservation, }); - 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 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."); - } - 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}`); + if (remoteSourceBlocked && authCapture.authMode === "none") { + if (options.nonInteractive && options.continueOnGate !== true) { + console.error(gateFailureMessage(analysisResult.analysis)); + return 1; + } + if (!options.nonInteractive) { + const gateResolution = await promptGateResolution(analysisResult.analysis); + if (gateResolution === "quit") { + console.log("Exited onboarding before artifacts were written."); + return 0; + } + if (gateResolution === "switch-local-root") { + resolved = await promptInteractiveInitInputs({ + extractMode: "local-root", + surface: resolved.surfaceId, + surfaceName: resolved.surfaceName, + surfaceKind: resolved.surfaceKind, + appRoot: resolved.appRoot, + }); + pendingAuthCapture = undefined; + continue; + } + if (gateResolution === "capture-auth") { + pendingAuthCapture = await maybeCaptureAuthProfile({ + requiresAuth: true, + profileName: resolved.authProfileName, + url: resolved.url, + nonInteractive: false, + }); + resolved = { + ...resolved, + requiresAuth: true, + authProfileName: pendingAuthCapture.profileName ?? resolved.authProfileName, + }; + continue; + } + } + analysisResult = applyProvisionalWarning(analysisResult); } - console.log("flagged"); - if (flagged.length === 0) { - console.log(" - No onboarding findings."); + 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 confirmedKind = await promptSurfaceKindConfirmation(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, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + remoteObservation, + }); + if (remoteSourceBlocked && authCapture.authMode === "none" && (options.continueOnGate === true || !options.nonInteractive)) { + analysisResult = applyProvisionalWarning(analysisResult); + } + } } - else { - for (const line of flagged) { - console.log(` - ${line}`); + 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; } - 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}`); + logStage(4, 6, "Validating generated outputs"); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-preview-")); + try { + const validation = await validateTempArtifacts({ + tempDir, + rootDir, + surfaceId: resolved.surfaceId, + analysisResult, + }); + const blockingValidationError = hasBlockingValidationError(validation.validateResult, validation.validateExtractedResult); + if (blockingValidationError) { + console.error("Generated outputs failed blocking validation:"); + for (const message of collectAttentionMessages(analysisResult.analysis, validation.validateResult, validation.validateExtractedResult)) { + console.error(` - ${message}`); + } + return 1; + } + logStage(5, 6, "Previewing generated draft"); + if (!options.nonInteractive) { + printPreviewSummary({ + analysis: analysisResult.analysis, + validateResult: validation.validateResult, + validateExtractedResult: validation.validateExtractedResult, + provisional: isLimitedRemoteSource(analysisResult.analysis), + verbose: options.verbose === true, + }); + const confirmedWrite = await promptWriteConfirmation(); + if (!confirmedWrite) { + console.log("Exited onboarding before artifacts were written."); + return 0; + } + } + logStage(6, 6, "Writing onboarding artifacts"); + 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); + const findingCodes = collectFindingCodes(analysisResult.analysis, validation.validateResult, validation.validateExtractedResult); + const status = findingCodes.length > 0 + ? "warn" + : "pass"; + const run = await emitOnboardingRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + source: "generation", + status, + findingCodes, + extractionPath: artifacts.contractPath, + reportPath: artifacts.reportPath, + }); + printWriteSummary({ + rootDir, + resolved, + artifacts, + provisional: isLimitedRemoteSource(analysisResult.analysis), + verbose: options.verbose === true, + runId: run.runId, + storageMode, + authProfileName: authCapture.profileName, + }); + return validation.validateExitCode === 10 || validation.validateExtractedExitCode === 10 + ? 1 + : 0; } - else { - console.log(` - Re-run with --app-root to enable source-backed validate once the local web app checkout is available.`); + finally { + await rm(tempDir, { recursive: true, force: true }); } - return blockingValidationError || validateExitCode === 10 || validateExtractedExitCode === 10 - ? 1 - : 0; - } - finally { - await rm(tempDir, { recursive: true, force: true }); } } catch (error) { diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 3281ea6..61ee1f9 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; +import { runBareWelcomeFlow, shouldLaunchBareWelcomeFlow } from "./utils/bare-onboarding.js"; import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; @@ -267,6 +268,8 @@ program .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("--verbose", "Show technical onboarding detail") + .option("--continue-on-gate", "Allow provisional output when remote onboarding resolves to a login or access-denied page") .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") @@ -287,6 +290,8 @@ program appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, + verbose: options.verbose === true, + continueOnGate: options.continueOnGate === true, outDir: options.outDir, analysisOut: options.analysisOut, draftOut: options.draftOut, @@ -406,7 +411,14 @@ program }); process.exitCode = exitCode; }); -program.parseAsync(process.argv).catch((error) => { +async function main() { + if (shouldLaunchBareWelcomeFlow(process.argv.slice(2))) { + process.exitCode = await runBareWelcomeFlow(); + return; + } + await program.parseAsync(process.argv); +} +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts b/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts new file mode 100644 index 0000000..82ee262 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from "node:stream"; +import { type InitOptions } from "../commands/init.js"; +interface TtyLikeStream { + isTTY?: boolean; +} +interface WelcomeStreams { + input?: Readable & TtyLikeStream; + output?: Writable & TtyLikeStream; +} +interface RunBareWelcomeOptions extends WelcomeStreams { + initRunner?: (options: InitOptions) => Promise; +} +export declare function shouldLaunchBareWelcomeFlow(args?: string[]): boolean; +export declare function runBareWelcomeFlow(options?: RunBareWelcomeOptions): Promise; +export {}; +//# sourceMappingURL=bare-onboarding.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts.map b/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts.map new file mode 100644 index 0000000..e7caab2 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/bare-onboarding.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bare-onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/bare-onboarding.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvE,UAAU,aAAa;IACrB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,UAAU,cAAc;IACtB,KAAK,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACjC,MAAM,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;CACnC;AAED,UAAU,qBAAsB,SAAQ,cAAc;IACpD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACxD;AAaD,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAU3F;AA4CD,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAgB7F"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/bare-onboarding.js b/packages/interfacectl-cli/dist/utils/bare-onboarding.js new file mode 100644 index 0000000..7ca0319 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/bare-onboarding.js @@ -0,0 +1,65 @@ +import readline from "node:readline/promises"; +import { stdin as defaultInput, stdout as defaultOutput } from "node:process"; +import { runInitCommand } from "../commands/init.js"; +const FORCE_BARE_WELCOME_ENV = "INTERFACECTL_FORCE_BARE_WELCOME"; +const ANSI = { + reset: "\u001B[0m", + bold: "\u001B[1m", + cyan: "\u001B[36m", + dim: "\u001B[2m", +}; +function style(text, code, enabled) { + return enabled ? `${code}${text}${ANSI.reset}` : text; +} +export function shouldLaunchBareWelcomeFlow(args = process.argv.slice(2)) { + if (args.length > 0) { + return false; + } + if (process.env[FORCE_BARE_WELCOME_ENV] === "1") { + return true; + } + return Boolean(defaultInput.isTTY && defaultOutput.isTTY); +} +function renderWelcomeScreen(output) { + const ansiEnabled = Boolean(output.isTTY); + output.write(`${style("Surfaces Platform", ANSI.bold, ansiEnabled)}\n`); + output.write(`${style("Extract your first contract and draft your first design system from a web surface.", ANSI.dim, ansiEnabled)}\n\n`); + output.write(`Choose a source: ${style("[1]", ANSI.cyan, ansiEnabled)} Local app root ${style("[2]", ANSI.cyan, ansiEnabled)} Live URL ${style("[q]", ANSI.cyan, ansiEnabled)} Quit\n`); + output.write(`${style("Advanced commands: init, analyze, validate, auth, --help", ANSI.dim, ansiEnabled)}\n\n`); +} +async function promptSourceSelection(input, output) { + const rl = readline.createInterface({ input, output }); + try { + while (true) { + const answer = (await rl.question("> ")).trim().toLowerCase(); + if (answer === "1") { + return "local-root"; + } + if (answer === "2") { + return "remote-url"; + } + if (answer === "q") { + return null; + } + output.write("Expected 1, 2, or q.\n"); + } + } + finally { + rl.close(); + } +} +export async function runBareWelcomeFlow(options = {}) { + const input = options.input ?? defaultInput; + const output = options.output ?? defaultOutput; + const initRunner = options.initRunner ?? runInitCommand; + renderWelcomeScreen(output); + const selectedSource = await promptSourceSelection(input, output); + if (!selectedSource) { + output.write("Exited onboarding.\n"); + return 0; + } + output.write("\n"); + return initRunner({ + extractMode: selectedSource, + }); +} diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts b/packages/interfacectl-cli/dist/utils/browser-session.d.ts index 262aa4c..7a7774b 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.d.ts +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts @@ -1,3 +1,25 @@ +export type SourceHealthStatus = "ok" | "login" | "access-denied"; +export type SourceHealthConfidence = "full" | "limited"; +export interface RemoteSourceHealth { + status: SourceHealthStatus; + confidence: SourceHealthConfidence; + finalUrl: string; + documentStatus: number | null; + authMode: "none" | "browser-session"; +} +export interface RemoteRenderedMotionObservation { + durationMs: number; + timingFunction: string; +} +export interface RemoteRenderedStyleObservation { + fonts: string[]; + colors: string[]; + maxWidths: number[]; + radii: number[]; + shadowKinds: Array<"outer" | "inset" | "mixed">; + motions: RemoteRenderedMotionObservation[]; + containers: string[]; +} export interface RemoteBrowserObservation { finalUrl: string; html: string; @@ -7,6 +29,8 @@ export interface RemoteBrowserObservation { }>; loginDetected: boolean; accessDeniedDetected: boolean; + sourceHealth: RemoteSourceHealth; + renderedStyles: RemoteRenderedStyleObservation; } export declare function captureBrowserStorageState(options: { url: string; diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map index 31b4408..e52c6a9 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map @@ -1 +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 +{"version":3,"file":"browser-session.d.ts","sourceRoot":"","sources":["../../src/utils/browser-session.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,OAAO,GAAG,eAAe,CAAC;AAClE,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;CACtC;AAED,MAAM,WAAW,+BAA+B;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,KAAK,CAAC,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC;IAChD,OAAO,EAAE,+BAA+B,EAAE,CAAC;IAC3C,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAUD,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;IAC9B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,EAAE,8BAA8B,CAAC;CAChD;AA4FD,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,CAqQpC"} \ 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 index c8fb8b4..3d7b847 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.js +++ b/packages/interfacectl-cli/dist/utils/browser-session.js @@ -1,6 +1,10 @@ import readline from "node:readline/promises"; import { stdin as input, stderr as promptOutput } from "node:process"; import { chromium } from "playwright"; +const LOGIN_PATH_PATTERN = /(?:^|\/)(login|log-in|signin|sign-in)(?:\/|$)/i; +const LOGIN_TEXT_PATTERN = /\b(sign in|sign-in|log in|log-in|login to continue|forgot password|enter your password|continue with (google|github|microsoft|okta)|welcome back)\b/i; +const ACCESS_DENIED_TEXT_PATTERN = /\b(access denied|permission denied|not authorized|not authorised|you do not have access|you don't have access|request access|403 forbidden|access forbidden)\b/i; +const STANDALONE_ACCESS_DENIED_HEADING_PATTERN = /^(unauthorized|forbidden|access denied|permission denied)$/i; function isEnvTrue(name) { return process.env[name] === "1" || process.env[name] === "true"; } @@ -16,16 +20,47 @@ async function waitForPageSettle(page) { 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) || - / LOGIN_TEXT_PATTERN.test(text)); + const deniedHeadingDetected = ACCESS_DENIED_TEXT_PATTERN.test(headingText) || + STANDALONE_ACCESS_DENIED_HEADING_PATTERN.test(headingText); + const deniedMainDetected = ACCESS_DENIED_TEXT_PATTERN.test(mainText); + const deniedBodyDetected = mainText.length === 0 && ACCESS_DENIED_TEXT_PATTERN.test(bodyText); + const loginDetected = input.renderedGateObservation.hasPasswordField || + hasLoginForm || + loginHeadingDetected || + ((loginMainDetected || loginBodyDetected) && urlLooksLikeLogin); + const accessDeniedDetected = deniedHeadingDetected || deniedMainDetected || deniedBodyDetected; return { loginDetected, accessDeniedDetected }; } +function classifySourceHealth(input) { + const authGate = detectAuthGate({ + finalUrl: input.finalUrl, + renderedGateObservation: input.renderedGateObservation, + }); + const status = authGate.accessDeniedDetected || input.documentStatus === 401 || input.documentStatus === 403 + ? "access-denied" + : authGate.loginDetected + ? "login" + : "ok"; + return { + status, + confidence: status === "ok" ? "full" : "limited", + finalUrl: input.finalUrl, + documentStatus: input.documentStatus, + authMode: input.authMode, + loginDetected: authGate.loginDetected, + accessDeniedDetected: authGate.accessDeniedDetected || input.documentStatus === 401 || input.documentStatus === 403, + }; +} export async function captureBrowserStorageState(options) { const headless = isEnvTrue("INTERFACECTL_PLAYWRIGHT_HEADLESS"); const browser = await chromium.launch({ headless }).catch((error) => { @@ -83,11 +118,155 @@ export async function observeRemotePage(options) { .catch(() => null)); }); try { - await page.goto(options.url, { waitUntil: "load" }); + const response = 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 renderedObservation = await page.evaluate(() => { + const global = globalThis; + const doc = global.document; + const win = global.window; + if (!doc || !win) { + return { + gateObservation: { + bodyText: "", + mainText: "", + headingText: "", + formTexts: [], + hasPasswordField: false, + }, + renderedStyles: { + fonts: [], + colors: [], + maxWidths: [], + radii: [], + shadowKinds: [], + motions: [], + containers: [], + }, + }; + } + const normalizeText = (value) => String(value ?? "") + .replace(/\s+/g, " ") + .trim(); + const parsePx = (value) => { + if (!value || value === "none" || value === "normal") { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + }; + const splitList = (value) => String(value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + const getVisibleText = (node) => normalizeText(String(node?.innerText ?? node?.textContent ?? "")); + const isVisible = (node) => { + const style = win.getComputedStyle(node); + const rect = typeof node.getBoundingClientRect === "function" + ? node.getBoundingClientRect() + : { width: 0, height: 0 }; + return (style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity ?? "1") > 0 && + rect.width > 0 && + rect.height > 0); + }; + const collectMotionPairs = (durationValue, timingValue) => { + const durations = splitList(durationValue); + const timings = splitList(timingValue); + const pairs = []; + durations.forEach((entry, index) => { + const trimmed = entry.trim().toLowerCase(); + let durationMs = null; + if (trimmed.endsWith("ms")) { + durationMs = Number.parseFloat(trimmed.slice(0, -2)); + } + else if (trimmed.endsWith("s")) { + durationMs = Number.parseFloat(trimmed.slice(0, -1)) * 1000; + } + else if (/^[0-9.]+$/.test(trimmed)) { + durationMs = Number.parseFloat(trimmed); + } + if (!Number.isFinite(durationMs) || durationMs === null || durationMs <= 0) { + return; + } + pairs.push({ + durationMs, + timingFunction: timings[index] ?? timings[0] ?? "linear", + }); + }); + return pairs; + }; + const fonts = []; + const colors = []; + const maxWidths = []; + const radii = []; + const shadowKinds = []; + const motions = []; + const containers = new Set(); + const mainNodes = Array.from(doc.querySelectorAll("main, [role='main']")).filter((node) => isVisible(node)); + const headingNodes = Array.from(doc.querySelectorAll("main h1, main h2, [role='main'] h1, [role='main'] h2, h1, h2")).filter((node) => isVisible(node)); + const visibleForms = Array.from(doc.querySelectorAll("form")).filter((node) => isVisible(node)); + const passwordInputs = Array.from(doc.querySelectorAll("input[type='password']")).filter((node) => isVisible(node)); + const nodes = Array.from(doc.querySelectorAll("body, main, header, nav, footer, aside, section, article, form, div, h1, h2, h3, h4, h5, h6, p, a, button, input, label")); + for (const node of nodes) { + if (!node || typeof node !== "object" || !isVisible(node)) { + continue; + } + const style = win.getComputedStyle(node); + const fontFamily = splitList(style.fontFamily)[0]?.replace(/^["']|["']$/g, ""); + if (fontFamily) { + fonts.push(fontFamily); + } + const color = style.color?.trim(); + if (color && color !== "transparent" && color !== "rgba(0, 0, 0, 0)") { + colors.push(color); + } + const backgroundColor = style.backgroundColor?.trim(); + if (backgroundColor && + backgroundColor !== "transparent" && + backgroundColor !== "rgba(0, 0, 0, 0)") { + colors.push(backgroundColor); + } + const maxWidth = parsePx(style.maxWidth); + if (maxWidth !== null && maxWidth > 0 && maxWidth < 5000) { + maxWidths.push(maxWidth); + } + const radius = parsePx(style.borderRadius); + if (radius !== null && radius > 0) { + radii.push(radius); + } + const boxShadow = style.boxShadow?.trim().toLowerCase(); + if (boxShadow && boxShadow !== "none") { + shadowKinds.push(boxShadow.includes("inset") ? "inset" : "outer"); + } + motions.push(...collectMotionPairs(style.transitionDuration, style.transitionTimingFunction), ...collectMotionPairs(style.animationDuration, style.animationTimingFunction)); + const className = String(node.className ?? ""); + if (/\bcontainer\b/i.test(className)) { + containers.add("container"); + } + } + return { + gateObservation: { + bodyText: getVisibleText(doc.body), + mainText: normalizeText(mainNodes.map((node) => getVisibleText(node)).join(" ")), + headingText: normalizeText(headingNodes.map((node) => getVisibleText(node)).join(" ")), + formTexts: visibleForms.map((node) => getVisibleText(node)).filter(Boolean), + hasPasswordField: passwordInputs.length > 0, + }, + renderedStyles: { + fonts, + colors, + maxWidths, + radii, + shadowKinds, + motions, + containers: [...containers].sort((a, b) => a.localeCompare(b)), + }, + }; + }); const cssContents = (await Promise.all([...stylesheetBodies.values()])) .filter((entry) => entry !== null) .filter((entry) => { @@ -99,13 +278,20 @@ export async function observeRemotePage(options) { } }) .sort((a, b) => a.source.localeCompare(b.source)); - const authGate = detectAuthGate(html, finalUrl); + const sourceHealth = classifySourceHealth({ + finalUrl, + documentStatus: response?.status() ?? null, + authMode: options.storageState ? "browser-session" : "none", + renderedGateObservation: renderedObservation.gateObservation, + }); return { finalUrl, html, cssContents, - loginDetected: authGate.loginDetected, - accessDeniedDetected: authGate.accessDeniedDetected, + loginDetected: sourceHealth.loginDetected, + accessDeniedDetected: sourceHealth.accessDeniedDetected, + sourceHealth, + renderedStyles: renderedObservation.renderedStyles, }; } finally { diff --git a/packages/interfacectl-cli/dist/utils/compare.js b/packages/interfacectl-cli/dist/utils/compare.js index 7fb4dea..5bd3b97 100644 --- a/packages/interfacectl-cli/dist/utils/compare.js +++ b/packages/interfacectl-cli/dist/utils/compare.js @@ -273,7 +273,7 @@ export function compareContractToDescriptor(contract, descriptor, surfaceId) { const allowedDurations = new Set(contract.contract.constraints.motion.allowedDurationsMs); const allowedTimingFunctions = new Set(contract.contract.constraints.motion.allowedTimingFunctions); for (const motion of desc.motion) { - if (!allowedDurations.has(motion.durationMs)) { + if (motion.durationMs >= 1 && !allowedDurations.has(motion.durationMs)) { entries.push({ surfaceId, type: "added", diff --git a/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts index 7e4142a..9a9ef47 100644 --- a/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts @@ -1,4 +1,5 @@ import { type InterfaceContract, type SurfaceDescriptor, type SurfacePrimitiveDescriptor, type TokenMetadata } from "@surfaces/interfacectl-validator"; +import { type RemoteBrowserObservation, type SourceHealthConfidence, type SourceHealthStatus } from "./browser-session.js"; export type WebSurfaceKind = "marketing" | "application" | "unknown"; export type FirstRunMode = "adopt" | "synthesize"; export type AnalysisSourceMode = "local-root" | "remote-url"; @@ -82,6 +83,12 @@ export interface SurfaceAnalysisArtifact { motion: TokenMetadata[]; }; }; + sourceHealth: { + status: SourceHealthStatus; + finalUrl?: string; + authMode: "none" | "browser-session"; + confidence: SourceHealthConfidence; + }; classification: { inferredKind: WebSurfaceKind; confirmedKind: WebSurfaceKind; @@ -194,6 +201,7 @@ export interface AnalyzeSurfaceOptions { authMode?: "none" | "browser-session"; authProfileName?: string; authStorageState?: string; + remoteObservation?: RemoteBrowserObservation; } export interface AnalyzeSurfaceResult { analysis: SurfaceAnalysisArtifact; 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 index abda90e..bcf2d4b 100644 --- a/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"first-run-analysis.d.ts","sourceRoot":"","sources":["../../src/utils/first-run-analysis.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"first-run-analysis.d.ts","sourceRoot":"","sources":["../../src/utils/first-run-analysis.ts"],"names":[],"mappings":"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"} \ 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 index 0c1de75..0416e17 100644 --- a/packages/interfacectl-cli/dist/utils/first-run-analysis.js +++ b/packages/interfacectl-cli/dist/utils/first-run-analysis.js @@ -9,7 +9,7 @@ import { seedChromePolicyFromObservedDescriptors } from "./chrome-policy-seeding 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"; +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"; @@ -42,6 +42,9 @@ function uniqueSorted(values) { function uniqueSortedNumbers(values) { return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b); } +function uniquePositiveSortedNumbers(values) { + return [...new Set(values.filter((value) => Number.isFinite(value) && value >= 1))].sort((a, b) => a - b); +} function toStableSourcePath(root, candidate) { if (!root) { return candidate; @@ -209,6 +212,118 @@ function summarizeIcons(descriptor) { })) .sort((a, b) => a.value.localeCompare(b.value)); } +function summarizeRemoteFontsFromRenderedStyles(source, renderedStyles) { + return countByValue(renderedStyles.fonts.map((value) => ({ value, source }))) + .map((entry) => ({ + value: entry.value, + count: entry.count, + sources: entry.sources, + })); +} +function summarizeRemoteColorsFromRenderedStyles(source, renderedStyles) { + return countByValue(renderedStyles.colors.flatMap((value) => normalizeColorValues([value]).map((canonical) => ({ value: canonical, source })))).map((entry) => ({ + canonical: entry.value, + count: entry.count, + sources: entry.sources, + })); +} +function summarizeRemoteMotionFromRenderedStyles(source, renderedStyles) { + return countByValue(renderedStyles.motions.map((entry) => ({ + value: `${entry.durationMs}::${entry.timingFunction}`, + source, + }))) + .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); +} +function collectRemoteFontsFromCss(cssContents) { + return 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, + })); +} +function collectRemoteColorsFromCss(cssContents) { + return 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, + })); +} +function collectRemoteMotionFromCss(cssContents) { + return 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); +} function buildPhase0Seed(observation) { const routes = new Set(observation.routes); const hasAllAuthRoutes = [...AUTH_ROUTE_SET].every((route) => routes.has(route)); @@ -606,6 +721,11 @@ function buildDraftArtifact(analysis, observation) { structurePatterns.push("shell"); } const manualFollowUp = analysis.inconsistencies.findings.map((finding) => finding.message); + if (analysis.sourceHealth.status !== "ok") { + manualFollowUp.push(analysis.sourceHealth.status === "access-denied" + ? "Capture auth or switch to a local app root to analyze the target surface instead of the gate." + : "Provide authenticated replay or switch to a local app root to analyze the target surface instead of the login view."); + } if (analysis.classification.requiresConfirmation) { manualFollowUp.push("Review the inferred surface kind before tightening policy levels."); } @@ -752,6 +872,11 @@ async function analyzeLocalSource(options) { colorAllowedValues: chromeSeeded.contract.color.allowedValues, surfaceIcons: chromeSeeded.contract.surfaces[0]?.icons, sourceAppRoot: appRoot, + sourceHealth: { + status: "ok", + authMode: "none", + confidence: "full", + }, }; } function extractAttributeValuesFromTags(html, regex) { @@ -908,11 +1033,12 @@ async function analyzeRemoteSource(options) { throw new Error("Missing url for remote-url analysis."); } const sourceUrl = new URL(options.url); - const observation = await observeRemotePage({ + const observation = options.remoteObservation ?? await observeRemotePage({ url: sourceUrl.toString(), storageState: options.authStorageState, }); const finalUrl = new URL(observation.finalUrl); + const redactedFinalUrl = redactSensitiveUrl(finalUrl.toString()); const html = observation.html; const cssContents = [ ...collectInlineCssContents(finalUrl, html), @@ -920,79 +1046,12 @@ async function analyzeRemoteSource(options) { ].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 renderedFonts = summarizeRemoteFontsFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const renderedColors = summarizeRemoteColorsFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const renderedMotions = summarizeRemoteMotionFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const fonts = renderedFonts.length > 0 ? renderedFonts : collectRemoteFontsFromCss(cssContents); + const colors = renderedColors.length > 0 ? renderedColors : collectRemoteColorsFromCss(cssContents); + const motions = renderedMotions.length > 0 ? renderedMotions : collectRemoteMotionFromCss(cssContents); const maxWidths = []; const radii = []; const shadowKinds = new Set(); @@ -1024,6 +1083,11 @@ async function analyzeRemoteSource(options) { pageBackgroundMode = "custom"; } } + const observedMaxWidths = observation.renderedStyles.maxWidths.length > 0 ? observation.renderedStyles.maxWidths : maxWidths; + const observedRadii = observation.renderedStyles.radii.length > 0 ? observation.renderedStyles.radii : radii; + const observedShadowKinds = observation.renderedStyles.shadowKinds.length > 0 + ? observation.renderedStyles.shadowKinds + : [...shadowKinds]; const copyRoleCount = extractAttributeValuesFromTags(html, COPY_ROLE_REGEX).length; const sections = extractAttributeValuesFromTags(html, SECTION_ATTRIBUTE_REGEX); const heroSignal = / CTA_TEXT_HINT.test(entry)) .length; const tokenPolicies = collectRemoteTokenPolicies(cssContents); - const loginOrDeniedDetected = observation.loginDetected || observation.accessDeniedDetected; + const loginOrDeniedDetected = observation.sourceHealth.status !== "ok"; 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 + throw new Error(observation.sourceHealth.status === "access-denied" ? `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] })), + sections: sections.map((section) => ({ id: section, source: redactedFinalUrl })), + fonts: fonts.map((entry) => ({ value: entry.value, source: entry.sources[0] ?? redactedFinalUrl })), + colors: colors.map((entry) => ({ value: entry.canonical, source: entry.sources[0] ?? redactedFinalUrl })), icons: [], tokenUsage: { typography: metadataToDescriptors(metadataFromPolicy(tokenPolicies.typography)), @@ -1054,18 +1118,19 @@ async function analyzeRemoteSource(options) { marketingTypography: copyRoleCount > 0 ? { roles: [], - source: redactSensitiveUrl(finalUrl.toString()), + source: redactedFinalUrl, } : undefined, layout: { - maxContentWidth: maxWidths.length > 0 ? Math.max(...maxWidths) : null, + maxContentWidth: observedMaxWidths.length > 0 ? Math.max(...observedMaxWidths) : null, containers: uniqueSorted([ + ...observation.renderedStyles.containers, ...(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)), + maxBorderRadiusPx: observedRadii.length > 0 ? Math.max(...observedRadii) : null, + shadowKinds: observedShadowKinds.sort((a, b) => a.localeCompare(b)), }, landingPattern: sections.length > 0 || heroSignal ? { @@ -1073,14 +1138,14 @@ async function analyzeRemoteSource(options) { topLevelSections: sections, nestedSections: [], pageBackgroundMode, - source: redactSensitiveUrl(finalUrl.toString()), + source: redactedFinalUrl, } : undefined, }, motion: motions.map((entry) => ({ durationMs: entry.durationMs, timingFunction: entry.timingFunction, - source: entry.sources[0], + source: entry.sources[0] ?? redactedFinalUrl, })), primitives, }; @@ -1099,8 +1164,10 @@ async function analyzeRemoteSource(options) { : []), ...(loginOrDeniedDetected && !options.authStorageState ? [{ - code: observation.accessDeniedDetected ? "remote.auth.access-denied-detected" : "remote.auth.login-detected", - message: observation.accessDeniedDetected + code: observation.sourceHealth.status === "access-denied" + ? "remote.auth.access-denied-detected" + : "remote.auth.login-detected", + message: observation.sourceHealth.status === "access-denied" ? "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.", }] @@ -1109,6 +1176,12 @@ async function analyzeRemoteSource(options) { tokenPolicies, colorAllowedValues: colors.map((entry) => entry.canonical), surfaceIcons: undefined, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: redactedFinalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, }; } function metadataToDescriptors(metadata) { @@ -1171,8 +1244,8 @@ function buildBaseContract(surfaceId, surfaceName, sourceRef, observation) { sections, constraints: { motion: { - allowedDurationsMs: uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)).length > 0 - ? uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)) + allowedDurationsMs: uniquePositiveSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)).length > 0 + ? uniquePositiveSortedNumbers(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)) @@ -1207,6 +1280,12 @@ function buildExtractionReport(options, observation, warnings) { appRoot: options.sourceMode === "local-root" && options.appRoot ? path.resolve(options.workspaceRoot, options.appRoot) : options.url, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: observation.sourceHealth.finalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, warnings, extracted: { routes: observation.routes, @@ -1283,6 +1362,12 @@ export async function analyzeSurface(options) { motion: metadataFromPolicy(observation.tokenPolicies.motion), }, }, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: observation.sourceHealth.finalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, classification: { ...classification, confirmedKind, diff --git a/packages/interfacectl-cli/dist/utils/init-interactive.d.ts b/packages/interfacectl-cli/dist/utils/init-interactive.d.ts new file mode 100644 index 0000000..8ffa346 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/init-interactive.d.ts @@ -0,0 +1,29 @@ +import type { AnalysisSourceMode, SurfaceAnalysisArtifact, WebSurfaceKind } from "./first-run-analysis.js"; +export type ExtractMode = AnalysisSourceMode; +export interface InteractiveInitOptions { + url?: string; + surface?: string; + surfaceName?: string; + surfaceKind?: WebSurfaceKind; + authProfile?: string; + extractMode?: ExtractMode; + appRoot?: string; +} +export interface ResolvedInitInputs { + sourceMode: ExtractMode; + url?: string; + appRoot?: string; + surfaceId: string; + surfaceName: string; + surfaceKind?: WebSurfaceKind; + requiresAuth: boolean; + authProfileName: string | null; +} +export type GateResolutionAction = "capture-auth" | "continue-anyway" | "switch-local-root" | "quit"; +export declare function normalizeSurfaceId(raw: string): string; +export declare function inferSourceMode(options: Pick): ExtractMode; +export declare function promptInteractiveInitInputs(options: InteractiveInitOptions): Promise; +export declare function promptSurfaceKindConfirmation(analysis: SurfaceAnalysisArtifact): Promise; +export declare function promptGateResolution(analysis: SurfaceAnalysisArtifact): Promise; +export declare function promptWriteConfirmation(): Promise; +//# sourceMappingURL=init-interactive.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/init-interactive.d.ts.map b/packages/interfacectl-cli/dist/utils/init-interactive.d.ts.map new file mode 100644 index 0000000..d5bacb0 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/init-interactive.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"init-interactive.d.ts","sourceRoot":"","sources":["../../src/utils/init-interactive.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,uBAAuB,EACvB,cAAc,EACf,MAAM,yBAAyB,CAAC;AAOjC,MAAM,MAAM,WAAW,GAAG,kBAAkB,CAAC;AAE7C,MAAM,WAAW,sBAAsB;IACrC,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;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,WAAW,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,MAAM,oBAAoB,GAC5B,cAAc,GACd,iBAAiB,GACjB,mBAAmB,GACnB,MAAM,CAAC;AAIX,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED,wBAAgB,eAAe,CAC7B,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,aAAa,GAAG,SAAS,GAAG,KAAK,CAAC,GACvE,WAAW,CAWb;AAED,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,kBAAkB,CAAC,CAgE7B;AAED,wBAAsB,6BAA6B,CACjD,QAAQ,EAAE,uBAAuB,GAChC,OAAO,CAAC,cAAc,CAAC,CA2BzB;AAED,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,uBAAuB,GAChC,OAAO,CAAC,oBAAoB,CAAC,CAqC/B;AAED,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC,CAgBhE"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/init-interactive.js b/packages/interfacectl-cli/dist/utils/init-interactive.js new file mode 100644 index 0000000..906d2fb --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/init-interactive.js @@ -0,0 +1,146 @@ +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName, } from "./onboarding.js"; +const VALID_SURFACE_KINDS = new Set(["marketing", "application", "unknown"]); +export function normalizeSurfaceId(raw) { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} +export 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"; +} +export async function promptInteractiveInitInputs(options) { + const rl = readline.createInterface({ input, output }); + try { + 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("Do you need to sign in to see the real page? (y/N) ")).trim().toLowerCase()) + : false; + const authProfileName = requiresAuth + ? (await rl.question(`Auth profile name [${options.authProfile ?? `${new URL(url).hostname}-default`}]: `)).trim() || options.authProfile || `${new URL(url).hostname}-default` + : null; + return { + sourceMode, + url, + appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, + surfaceId, + surfaceName, + surfaceKind: options.surfaceKind, + requiresAuth, + authProfileName, + }; + } + finally { + rl.close(); + } +} +export async function promptSurfaceKindConfirmation(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(); + } +} +export async function promptGateResolution(analysis) { + const rl = readline.createInterface({ input, output }); + try { + const statusMessage = analysis.sourceHealth.status === "access-denied" + ? "We reached an access-denied page instead of the target surface." + : analysis.sourceHealth.status === "login" + ? "We reached a login page instead of the target surface." + : "We reached a limited source instead of the target surface."; + console.log(statusMessage); + if (analysis.sourceHealth.finalUrl) { + console.log(`Current URL: ${analysis.sourceHealth.finalUrl}`); + } + console.log("Choose how to continue:"); + console.log(" [1] Capture auth and retry"); + console.log(" [2] Continue anyway with provisional results"); + console.log(" [3] Switch to local app root"); + console.log(" [q] Quit"); + while (true) { + const answer = (await rl.question("> ")).trim().toLowerCase(); + if (answer === "1") { + return "capture-auth"; + } + if (answer === "2") { + return "continue-anyway"; + } + if (answer === "3") { + return "switch-local-root"; + } + if (answer === "q") { + return "quit"; + } + console.log("Expected 1, 2, 3, or q."); + } + } + finally { + rl.close(); + } +} +export async function promptWriteConfirmation() { + const rl = readline.createInterface({ input, output }); + try { + while (true) { + const answer = (await rl.question("Create these draft artifacts now? ([Y]es/[q]uit) ")).trim().toLowerCase(); + if (!answer || answer === "y" || answer === "yes") { + return true; + } + if (answer === "q" || answer === "n" || answer === "no") { + return false; + } + console.log("Expected yes, no, or q."); + } + } + finally { + rl.close(); + } +} diff --git a/packages/interfacectl-cli/src/commands/analyze.ts b/packages/interfacectl-cli/src/commands/analyze.ts index 4112ffb..876d2fa 100644 --- a/packages/interfacectl-cli/src/commands/analyze.ts +++ b/packages/interfacectl-cli/src/commands/analyze.ts @@ -130,6 +130,11 @@ export async function runAnalyzeCommand(options: AnalyzeCommandOptions): Promise console.log( `Inferred surface kind: ${result.analysis.classification.inferredKind} (${result.analysis.classification.confidence.toFixed(2)})`, ); + if (result.analysis.sourceHealth.status !== "ok") { + console.log( + `Source access: ${result.analysis.sourceHealth.status} (${result.analysis.sourceHealth.confidence}) at ${result.analysis.sourceHealth.finalUrl ?? options.url}`, + ); + } if (result.analysis.classification.requiresConfirmation && !options.surfaceKind) { console.log("Note: classification is low confidence; pass --surface-kind to confirm seeding intent."); } diff --git a/packages/interfacectl-cli/src/commands/auth.ts b/packages/interfacectl-cli/src/commands/auth.ts index e742b2c..47fe33f 100644 --- a/packages/interfacectl-cli/src/commands/auth.ts +++ b/packages/interfacectl-cli/src/commands/auth.ts @@ -222,8 +222,7 @@ export async function runAuthTestCommand(options: AuthCommandOptions): Promise(["marketing", "application", "unknown"]); - -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"; -} async function maybeCaptureAuthProfile(inputValue: { requiresAuth: boolean; @@ -173,75 +144,9 @@ async function maybeCaptureAuthProfile(inputValue: { }; } -async function promptInteractive(options: InitOptions): Promise { - const rl = readline.createInterface({ input, output }); - try { - const inferredMode = inferSourceMode(options); - const rawMode = ( - ( - options.extractMode ?? - ( - await rl.question( - `Source mode (local-root/remote-url) [${inferredMode}]: `, - ) - ).trim() - ) || - inferredMode - ).toLowerCase(); - 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 ?? `${new URL(url!).hostname}-default`}]: `, - ) - ).trim() || options.authProfile || `${new URL(url!).hostname}-default` - : null; - - return { - sourceMode, - url, - appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, - surfaceId, - surfaceName, - surfaceKind: options.surfaceKind, - requiresAuth, - authProfileName, - }; - } finally { - rl.close(); - } -} - async function resolveInputs(options: InitOptions): Promise { if (!options.nonInteractive) { - return promptInteractive(options); + return promptInteractiveInitInputs(options); } const sourceMode = inferSourceMode(options); @@ -274,35 +179,6 @@ async function resolveInputs(options: InitOptions): Promise }; } -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, @@ -356,6 +232,10 @@ function collectFlagMessages( return [...new Set(flagged)].slice(0, 8); } +function uniqueMessages(items: string[]): string[] { + return [...new Set(items)]; +} + function collectFindingCodes( analysis: SurfaceAnalysisArtifact, validateResult: ValidateJsonResult, @@ -369,20 +249,8 @@ function collectFindingCodes( ].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; +function summarizeTechnicalDraft(analysis: SurfaceAnalysisArtifact): string[] { + const { seedCounts } = analysis.proposedContract; const items = [ `${seedCounts.typographyTokens} typography token seed(s)`, `${seedCounts.layoutTokens} layout token seed(s)`, @@ -396,6 +264,96 @@ function summarizeNormalized(analysis: SurfaceAnalysisArtifact): string[] { return items; } +function summarizeSurfaceKind(analysis: SurfaceAnalysisArtifact): string { + if (analysis.classification.confirmedKind === "marketing") { + return "We detected a marketing site."; + } + if (analysis.classification.confirmedKind === "application") { + return "We detected an application surface."; + } + return "We could not confidently classify the surface, so the draft stays generic."; +} + +function summarizeExistingSystem(analysis: SurfaceAnalysisArtifact): string { + if (analysis.existingSystem.mode === "adopt") { + return "We found reusable patterns that look like an existing system."; + } + return "We did not find a complete existing system, so we will draft one from repeated patterns."; +} + +function summarizeSourceAccess(analysis: SurfaceAnalysisArtifact): string { + if (analysis.sourceHealth.confidence === "full" && analysis.sourceHealth.status === "ok") { + return "We successfully analyzed the source."; + } + return "We analyzed a limited view of the source, so results are provisional."; +} + +function summarizeDraft(analysis: SurfaceAnalysisArtifact): { + createItems: string[]; + reviewItems: string[]; +} { + const { seedCounts } = analysis.proposedContract; + const createItems: string[] = []; + const reviewItems: string[] = []; + + if (seedCounts.typographyTokens > 0) { + createItems.push(`Typography foundations from ${seedCounts.typographyTokens} repeated styles.`); + } + if (seedCounts.layoutTokens > 0) { + createItems.push(`Layout foundations from ${seedCounts.layoutTokens} repeated patterns.`); + } else { + reviewItems.push("We could not confidently infer layout foundations yet."); + } + if (seedCounts.motionTokens > 0) { + createItems.push(`Motion foundations from ${seedCounts.motionTokens} repeated timings.`); + } + if (seedCounts.colors > 0) { + createItems.push(`Color foundation with ${seedCounts.colors} reusable values.`); + } + if (seedCounts.sections > 0) { + createItems.push(`Detected ${seedCounts.sections} reusable page sections.`); + } + if (analysis.proposedContract.suggestedMarketingProfile) { + createItems.push("Landing-page guidance will be drafted from the detected structure."); + } + + return { + createItems, + reviewItems, + }; +} + +function rewritePreviewMessage(message: string, verbose = false): string { + if (verbose) { + return message; + } + const layoutProfileMatch = message.match( + /^Surface "([^"]+)" must declare marketing layout profile "([^"]+)"\.$/, + ); + if (layoutProfileMatch) { + return "Landing-page structure was detected, so the draft will include landing layout guidance."; + } + const typographyProfileMatch = message.match( + /^Surface "([^"]+)" must declare marketing typography profile "([^"]+)"\.$/, + ); + if (typographyProfileMatch) { + return "Marketing typography signals were detected, so the draft will include typography guidance."; + } + const rawColorMatch = message.match( + /^Detected (\d+) raw color literals; consider canonicalizing them into stable tokens or approved values\.$/, + ); + if (rawColorMatch) { + return `We found ${rawColorMatch[1]} one-off color values that should probably be normalized.`; + } + if (message.includes("access-denied page")) { + return "We reached an access-denied page instead of the target surface, so the results are provisional."; + } + if (message.includes("login page")) { + return "We reached a login page instead of the target surface, so the results are provisional."; + } + return message; +} + function logStage(step: number, total: number, message: string): void { console.log(`[${step}/${total}] ${message}`); } @@ -410,207 +368,498 @@ function hasBlockingValidationError( ); } +function isLimitedRemoteSource(analysis: SurfaceAnalysisArtifact): boolean { + return analysis.source.mode === "remote-url" && analysis.sourceHealth.status !== "ok"; +} + +function buildProvisionalWarning(analysis: SurfaceAnalysisArtifact): { code: string; message: string } { + const detail = + analysis.sourceHealth.status === "access-denied" + ? "the remote URL resolved to an access-denied page" + : analysis.sourceHealth.status === "login" + ? "the remote URL resolved to a login page" + : "remote source access is limited"; + return { + code: "remote.source.provisional", + message: `Writing provisional onboarding artifacts because ${detail}.`, + }; +} + +function applyProvisionalWarning>>( + analysisResult: T, +): T { + const warning = buildProvisionalWarning(analysisResult.analysis); + const dedupeWarnings = (items: Array<{ code: string; message: string }>) => + [...new Map(items.map((item) => [`${item.code}:${item.message}`, item])).values()] + .sort((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message)); + + return { + ...analysisResult, + analysis: { + ...analysisResult.analysis, + warnings: dedupeWarnings([...analysisResult.analysis.warnings, warning]), + }, + draft: { + ...analysisResult.draft, + warnings: dedupeWarnings([...analysisResult.draft.warnings, warning]), + }, + }; +} + +async function validateTempArtifacts(input: { + tempDir: string; + rootDir: string; + surfaceId: string; + analysisResult: Awaited>; +}): Promise<{ + analysisPath: string; + draftPath: string; + contractPath: string; + reportPath: string; + validateExitCode: number; + validateExtractedExitCode: number; + validateResult: ValidateJsonResult; + validateExtractedResult: ValidateExtractedJsonResult; +}> { + const analysisPath = path.join(input.tempDir, `${input.surfaceId}.analysis.json`); + const draftPath = path.join(input.tempDir, `${input.surfaceId}.design-system.draft.json`); + const contractPath = path.join(input.tempDir, `${input.surfaceId}.contract.json`); + const reportPath = path.join(input.tempDir, `${input.surfaceId}.extraction.json`); + const validatePath = path.join(input.tempDir, "validate.json"); + const validateExtractedPath = path.join(input.tempDir, "validate-extracted.json"); + + await writeArtifact(analysisPath, input.analysisResult.analysis); + await writeArtifact(draftPath, input.analysisResult.draft); + await writeArtifact(contractPath, input.analysisResult.contract); + await writeArtifact(reportPath, input.analysisResult.extractionReport); + + const validateExitCode = await runValidateCommand({ + contractPath, + workspaceRoot: input.rootDir, + surfaceFilters: [input.surfaceId], + descriptorOverrides: [input.analysisResult.descriptor], + outputFormat: "json", + outputPath: validatePath, + exitCodes: "v2", + }); + const validateExtractedExitCode = await runValidateExtractedCommand({ + contractPath, + extractedPath: reportPath, + surfaceId: input.surfaceId, + format: "json", + outputPath: validateExtractedPath, + exitCodes: "v2", + }); + + return { + analysisPath, + draftPath, + contractPath, + reportPath, + validateExitCode, + validateExtractedExitCode, + validateResult: await readJsonFile(validatePath), + validateExtractedResult: await readJsonFile(validateExtractedPath), + }; +} + +function collectAttentionMessages( + analysis: SurfaceAnalysisArtifact, + validateResult: ValidateJsonResult, + validateExtractedResult: ValidateExtractedJsonResult, + verbose = false, +): string[] { + const gateWarnings = analysis.warnings + .filter((warning) => warning.code.startsWith("remote.auth.") || warning.code === "remote.source.provisional") + .map((warning) => rewritePreviewMessage(warning.message, verbose)); + const otherMessages = collectFlagMessages(analysis, validateResult, validateExtractedResult) + .map((message) => rewritePreviewMessage(message, verbose)) + .filter((message) => !gateWarnings.includes(message)); + const items = uniqueMessages([...gateWarnings, ...otherMessages]); + return verbose ? items.slice(0, 8) : items.slice(0, 3); +} + +function printPreviewSummary(input: { + analysis: SurfaceAnalysisArtifact; + validateResult: ValidateJsonResult; + validateExtractedResult: ValidateExtractedJsonResult; + provisional: boolean; + verbose: boolean; +}): void { + const { analysis, validateResult, validateExtractedResult, provisional, verbose } = input; + const draftSummary = summarizeDraft(analysis); + const technicalDraftItems = summarizeTechnicalDraft(analysis); + const attention = uniqueMessages([ + ...draftSummary.reviewItems, + ...collectAttentionMessages(analysis, validateResult, validateExtractedResult, verbose), + ]).slice(0, verbose ? 8 : 3); + + console.log(""); + console.log("What we found"); + console.log(` - ${summarizeSurfaceKind(analysis)}`); + console.log(` - ${summarizeExistingSystem(analysis)}`); + console.log(` - ${summarizeSourceAccess(analysis)}`); + if (verbose) { + console.log( + ` - Technical detail: surface kind confidence ${analysis.classification.confidence.toFixed(2)}.`, + ); + console.log( + ` - Technical detail: existing-system mode ${analysis.existingSystem.mode} (${analysis.existingSystem.score.toFixed(2)} score).`, + ); + console.log( + ` - Technical detail: source access ${analysis.sourceHealth.status} (${analysis.sourceHealth.confidence}).`, + ); + } + + console.log("What we'll create"); + for (const item of draftSummary.createItems) { + console.log(` - ${item}`); + } + if (draftSummary.createItems.length === 0) { + console.log(" - We will draft a minimal system from the strongest repeated patterns we found."); + } + if (verbose) { + for (const item of technicalDraftItems) { + console.log(` - Technical detail: ${item}`); + } + } else if (provisional && !draftSummary.createItems.some((item) => item.includes("Landing-page guidance"))) { + console.log(" - Results will be marked provisional."); + } + + console.log("What needs review"); + if (attention.length === 0) { + console.log(" - No immediate issues need review before writing."); + } else { + for (const item of attention) { + console.log(` - ${item}`); + } + } + + console.log("Continue"); + console.log(" - Review this summary, then create the draft artifacts."); + console.log(" - Press Enter to create them now, or q to cancel."); +} + +function printWriteSummary(input: { + rootDir: string; + resolved: ResolvedInitInputs; + artifacts: ReturnType; + provisional: boolean; + verbose: boolean; + runId: string; + storageMode: string; + authProfileName?: string; +}): void { + const { + rootDir, + resolved, + artifacts, + provisional, + verbose, + runId, + storageMode, + authProfileName, + } = input; + console.log(""); + console.log("Created"); + console.log(` - Created a first contract and draft design system for ${resolved.surfaceName}.`); + if (provisional) { + console.log(" - Results are marked provisional because the source view was limited."); + } + + console.log("Next"); + console.log(" - Review the generated draft design system and contract."); + if (resolved.sourceMode === "local-root") { + console.log( + " - Connect the local app root in interfacectl.config.json for stronger repeatable validation.", + ); + } else { + console.log(" - Re-run with --app-root once the local checkout is available for stronger validation."); + } + if (verbose) { + 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( + ` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`, + ); + } + } + + console.log("Artifacts"); + const displayPath = (filePath: string) => + verbose ? filePath : relativeDisplay(rootDir, filePath); + console.log(` - analysis: ${displayPath(artifacts.analysisPath)}`); + console.log(` - draft: ${displayPath(artifacts.draftPath)}`); + console.log(` - contract: ${displayPath(artifacts.contractPath)}`); + console.log(` - report: ${displayPath(artifacts.reportPath)}`); + + if (verbose) { + console.log("Technical details"); + console.log(` - Run id: ${runId}`); + console.log(` - Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log(" - Keychain unavailable; using encrypted local file storage for replay state."); + } + if (authProfileName) { + console.log(` - Auth profile: ${authProfileName}`); + } + } +} + +function gateFailureMessage(analysis: SurfaceAnalysisArtifact): string { + if (analysis.sourceHealth.status === "access-denied") { + return "Remote onboarding stopped because we reached an access-denied page instead of the target surface. Capture auth, switch to --app-root, or pass --continue-on-gate for provisional output."; + } + return "Remote onboarding stopped because we reached a login page instead of the target surface. Provide --auth-profile, capture auth interactively, switch to --app-root, or pass --continue-on-gate for provisional output."; +} + +function validateLocalAppRoot(rootDir: string, resolved: ResolvedInitInputs): number | null { + if (resolved.sourceMode !== "local-root") { + return null; + } + const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + console.error(`Local app root is missing app/: ${appRoot}`); + return 1; + } + return null; +} + export async function runInitCommand(options: InitOptions): Promise { const rootDir = process.cwd(); const storageMode = getAuthStorageMode(); try { - const resolved = await resolveInputs(options); + let resolved = await resolveInputs(options); + let pendingAuthCapture: AuthCaptureResult | undefined; - 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; + while (true) { + const localRootValidation = validateLocalAppRoot(rootDir, resolved); + if (localRootValidation !== null) { + return localRootValidation; } - } - 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) { + logStage(1, 6, "Discovering source"); + const authCapture = + pendingAuthCapture ?? + ( + 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 } + ); + pendingAuthCapture = undefined; + + logStage(2, 6, "Checking access"); + const remoteObservation = + resolved.sourceMode === "remote-url" && resolved.url + ? await observeRemotePage({ + url: resolved.url, + storageState: authCapture.storageState, + }) + : undefined; + + const remoteSourceBlocked = + resolved.sourceMode === "remote-url" && + remoteObservation && + remoteObservation.sourceHealth.status !== "ok"; + if (remoteSourceBlocked && authCapture.authMode === "browser-session") { 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.`, + remoteObservation.sourceHealth.status === "access-denied" + ? `Authenticated replay reached an access-denied page at ${remoteObservation.sourceHealth.finalUrl}.` + : `Authenticated replay still resolved to a login page at ${remoteObservation.sourceHealth.finalUrl}. Re-capture the auth profile and retry.`, ); return 1; } - 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, - 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, + logStage(3, 6, "Analyzing surface kind and UI system"); + let analysisResult = await analyzeSurface({ 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", + surfaceName: resolved.surfaceName, + sourceMode: resolved.sourceMode, + appRoot: resolved.appRoot, + url: resolved.url, + surfaceKindOverride: resolved.surfaceKind, + authMode: authCapture.authMode, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + remoteObservation, }); - 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: artifacts.contractPath, - reportPath: artifacts.reportPath, - }); + if (remoteSourceBlocked && authCapture.authMode === "none") { + if (options.nonInteractive && options.continueOnGate !== true) { + console.error(gateFailureMessage(analysisResult.analysis)); + return 1; + } - const adopted = summarizeAdopted(analysisResult.analysis); - const normalized = summarizeNormalized(analysisResult.analysis); - const flagged = collectFlagMessages( - analysisResult.analysis, - validateResult, - validateExtractedResult, - ); + if (!options.nonInteractive) { + const gateResolution: GateResolutionAction = await promptGateResolution(analysisResult.analysis); + if (gateResolution === "quit") { + console.log("Exited onboarding before artifacts were written."); + return 0; + } + if (gateResolution === "switch-local-root") { + resolved = await promptInteractiveInitInputs({ + extractMode: "local-root", + surface: resolved.surfaceId, + surfaceName: resolved.surfaceName, + surfaceKind: resolved.surfaceKind, + appRoot: resolved.appRoot, + }); + pendingAuthCapture = undefined; + continue; + } + if (gateResolution === "capture-auth") { + pendingAuthCapture = await maybeCaptureAuthProfile({ + requiresAuth: true, + profileName: resolved.authProfileName, + url: resolved.url!, + nonInteractive: false, + }); + resolved = { + ...resolved, + requiresAuth: true, + authProfileName: pendingAuthCapture.profileName ?? resolved.authProfileName, + }; + continue; + } + } - console.log(`Onboarding completed for ${resolved.surfaceId}.`); - 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.", - ); - } - 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}`); + analysisResult = applyProvisionalWarning(analysisResult); } - console.log("flagged"); - if (flagged.length === 0) { - console.log(" - No onboarding findings."); - } else { - for (const line of flagged) { - console.log(` - ${line}`); + + 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 confirmedKind = await promptSurfaceKindConfirmation(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, + authProfileName: authCapture.profileName, + authStorageState: authCapture.storageState, + remoteObservation, + }); + if (remoteSourceBlocked && authCapture.authMode === "none" && (options.continueOnGate === true || !options.nonInteractive)) { + analysisResult = applyProvisionalWarning(analysisResult); + } } } - console.log("next steps"); - console.log( - ` - interfacectl validate-extracted --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --extracted ${relativeDisplay(rootDir, artifacts.reportPath)} --surface ${resolved.surfaceId}`, + + const structure = validateContractStructure( + analysisResult.contract, + getBundledContractSchema() as object, ); - 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}`, + if (!structure.ok) { + console.error("Generated contract failed schema validation:"); + for (const issue of structure.errors) { + console.error(` ${issue}`); + } + return 1; + } + + logStage(4, 6, "Validating generated outputs"); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-preview-")); + try { + const validation = await validateTempArtifacts({ + tempDir, + rootDir, + surfaceId: resolved.surfaceId, + analysisResult, + }); + const blockingValidationError = hasBlockingValidationError( + validation.validateResult, + validation.validateExtractedResult, ); - } else { - console.log( - ` - Re-run with --app-root to enable source-backed validate once the local web app checkout is available.`, + if (blockingValidationError) { + console.error("Generated outputs failed blocking validation:"); + for (const message of collectAttentionMessages( + analysisResult.analysis, + validation.validateResult, + validation.validateExtractedResult, + )) { + console.error(` - ${message}`); + } + return 1; + } + + logStage(5, 6, "Previewing generated draft"); + if (!options.nonInteractive) { + printPreviewSummary({ + analysis: analysisResult.analysis, + validateResult: validation.validateResult, + validateExtractedResult: validation.validateExtractedResult, + provisional: isLimitedRemoteSource(analysisResult.analysis), + verbose: options.verbose === true, + }); + const confirmedWrite = await promptWriteConfirmation(); + if (!confirmedWrite) { + console.log("Exited onboarding before artifacts were written."); + return 0; + } + } + + logStage(6, 6, "Writing onboarding artifacts"); + 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); + + const findingCodes = collectFindingCodes( + analysisResult.analysis, + validation.validateResult, + validation.validateExtractedResult, ); - } + const status = + findingCodes.length > 0 + ? "warn" + : "pass"; + const run = await emitOnboardingRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + source: "generation", + status, + findingCodes, + extractionPath: artifacts.contractPath, + reportPath: artifacts.reportPath, + }); + + printWriteSummary({ + rootDir, + resolved, + artifacts, + provisional: isLimitedRemoteSource(analysisResult.analysis), + verbose: options.verbose === true, + runId: run.runId, + storageMode, + authProfileName: authCapture.profileName, + }); - return blockingValidationError || validateExitCode === 10 || validateExtractedExitCode === 10 - ? 1 - : 0; - } finally { - await rm(tempDir, { recursive: true, force: true }); + return validation.validateExitCode === 10 || validation.validateExtractedExitCode === 10 + ? 1 + : 0; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } } } catch (error) { console.error(redactSensitiveText((error as Error).message)); diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 96dbd36..7c45bed 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; +import { runBareWelcomeFlow, shouldLaunchBareWelcomeFlow } from "./utils/bare-onboarding.js"; import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; @@ -376,6 +377,8 @@ program .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("--verbose", "Show technical onboarding detail") + .option("--continue-on-gate", "Allow provisional output when remote onboarding resolves to a login or access-denied page") .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") @@ -397,6 +400,8 @@ program appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, + verbose: options.verbose === true, + continueOnGate: options.continueOnGate === true, outDir: options.outDir, analysisOut: options.analysisOut, draftOut: options.draftOut, @@ -526,7 +531,16 @@ program process.exitCode = exitCode; }); -program.parseAsync(process.argv).catch((error) => { +async function main(): Promise { + if (shouldLaunchBareWelcomeFlow(process.argv.slice(2))) { + process.exitCode = await runBareWelcomeFlow(); + return; + } + + await program.parseAsync(process.argv); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/packages/interfacectl-cli/src/utils/bare-onboarding.ts b/packages/interfacectl-cli/src/utils/bare-onboarding.ts new file mode 100644 index 0000000..2e1df8f --- /dev/null +++ b/packages/interfacectl-cli/src/utils/bare-onboarding.ts @@ -0,0 +1,102 @@ +import readline from "node:readline/promises"; +import { stdin as defaultInput, stdout as defaultOutput } from "node:process"; +import type { Readable, Writable } from "node:stream"; +import { runInitCommand, type InitOptions } from "../commands/init.js"; + +const FORCE_BARE_WELCOME_ENV = "INTERFACECTL_FORCE_BARE_WELCOME"; + +interface TtyLikeStream { + isTTY?: boolean; +} + +interface WelcomeStreams { + input?: Readable & TtyLikeStream; + output?: Writable & TtyLikeStream; +} + +interface RunBareWelcomeOptions extends WelcomeStreams { + initRunner?: (options: InitOptions) => Promise; +} + +const ANSI = { + reset: "\u001B[0m", + bold: "\u001B[1m", + cyan: "\u001B[36m", + dim: "\u001B[2m", +}; + +function style(text: string, code: string, enabled: boolean): string { + return enabled ? `${code}${text}${ANSI.reset}` : text; +} + +export function shouldLaunchBareWelcomeFlow(args: string[] = process.argv.slice(2)): boolean { + if (args.length > 0) { + return false; + } + + if (process.env[FORCE_BARE_WELCOME_ENV] === "1") { + return true; + } + + return Boolean(defaultInput.isTTY && defaultOutput.isTTY); +} + +function renderWelcomeScreen(output: Writable & TtyLikeStream): void { + const ansiEnabled = Boolean(output.isTTY); + output.write(`${style("Surfaces Platform", ANSI.bold, ansiEnabled)}\n`); + output.write( + `${style( + "Extract your first contract and draft your first design system from a web surface.", + ANSI.dim, + ansiEnabled, + )}\n\n`, + ); + output.write( + `Choose a source: ${style("[1]", ANSI.cyan, ansiEnabled)} Local app root ${style("[2]", ANSI.cyan, ansiEnabled)} Live URL ${style("[q]", ANSI.cyan, ansiEnabled)} Quit\n`, + ); + output.write( + `${style("Advanced commands: init, analyze, validate, auth, --help", ANSI.dim, ansiEnabled)}\n\n`, + ); +} + +async function promptSourceSelection( + input: Readable & TtyLikeStream, + output: Writable & TtyLikeStream, +): Promise<"local-root" | "remote-url" | null> { + const rl = readline.createInterface({ input, output }); + try { + while (true) { + const answer = (await rl.question("> ")).trim().toLowerCase(); + if (answer === "1") { + return "local-root"; + } + if (answer === "2") { + return "remote-url"; + } + if (answer === "q") { + return null; + } + output.write("Expected 1, 2, or q.\n"); + } + } finally { + rl.close(); + } +} + +export async function runBareWelcomeFlow(options: RunBareWelcomeOptions = {}): Promise { + const input = options.input ?? defaultInput; + const output = options.output ?? defaultOutput; + const initRunner = options.initRunner ?? runInitCommand; + + renderWelcomeScreen(output); + const selectedSource = await promptSourceSelection(input, output); + if (!selectedSource) { + output.write("Exited onboarding.\n"); + return 0; + } + + output.write("\n"); + return initRunner({ + extractMode: selectedSource, + }); +} diff --git a/packages/interfacectl-cli/src/utils/browser-session.ts b/packages/interfacectl-cli/src/utils/browser-session.ts index 97b4488..0220a33 100644 --- a/packages/interfacectl-cli/src/utils/browser-session.ts +++ b/packages/interfacectl-cli/src/utils/browser-session.ts @@ -2,14 +2,57 @@ import readline from "node:readline/promises"; import { stdin as input, stderr as promptOutput } from "node:process"; import { chromium, type BrowserContextOptions } from "playwright"; +export type SourceHealthStatus = "ok" | "login" | "access-denied"; +export type SourceHealthConfidence = "full" | "limited"; + +export interface RemoteSourceHealth { + status: SourceHealthStatus; + confidence: SourceHealthConfidence; + finalUrl: string; + documentStatus: number | null; + authMode: "none" | "browser-session"; +} + +export interface RemoteRenderedMotionObservation { + durationMs: number; + timingFunction: string; +} + +export interface RemoteRenderedStyleObservation { + fonts: string[]; + colors: string[]; + maxWidths: number[]; + radii: number[]; + shadowKinds: Array<"outer" | "inset" | "mixed">; + motions: RemoteRenderedMotionObservation[]; + containers: string[]; +} + +interface RemoteRenderedGateObservation { + bodyText: string; + mainText: string; + headingText: string; + formTexts: string[]; + hasPasswordField: boolean; +} + export interface RemoteBrowserObservation { finalUrl: string; html: string; cssContents: Array<{ source: string; content: string }>; loginDetected: boolean; accessDeniedDetected: boolean; + sourceHealth: RemoteSourceHealth; + renderedStyles: RemoteRenderedStyleObservation; } +const LOGIN_PATH_PATTERN = /(?:^|\/)(login|log-in|signin|sign-in)(?:\/|$)/i; +const LOGIN_TEXT_PATTERN = + /\b(sign in|sign-in|log in|log-in|login to continue|forgot password|enter your password|continue with (google|github|microsoft|okta)|welcome back)\b/i; +const ACCESS_DENIED_TEXT_PATTERN = + /\b(access denied|permission denied|not authorized|not authorised|you do not have access|you don't have access|request access|403 forbidden|access forbidden)\b/i; +const STANDALONE_ACCESS_DENIED_HEADING_PATTERN = /^(unauthorized|forbidden|access denied|permission denied)$/i; + function isEnvTrue(name: string): boolean { return process.env[name] === "1" || process.env[name] === "true"; } @@ -30,22 +73,69 @@ async function waitForPageSettle(page: { waitForLoadState: Function; waitForTime await page.waitForTimeout(300); } -function detectAuthGate(html: string, finalUrl: string): { +function detectAuthGate(input: { + finalUrl: string; + renderedGateObservation: RemoteRenderedGateObservation; +}): { loginDetected: boolean; accessDeniedDetected: boolean; } { - const url = new URL(finalUrl); - const lowerHtml = html.toLowerCase(); + const url = new URL(input.finalUrl); + const urlLooksLikeLogin = LOGIN_PATH_PATTERN.test(url.pathname); + const mainText = input.renderedGateObservation.mainText.trim(); + const bodyText = input.renderedGateObservation.bodyText.trim(); + const headingText = input.renderedGateObservation.headingText.trim(); + const loginHeadingDetected = LOGIN_TEXT_PATTERN.test(headingText); + const loginMainDetected = LOGIN_TEXT_PATTERN.test(mainText); + const loginBodyDetected = mainText.length === 0 && LOGIN_TEXT_PATTERN.test(bodyText); + const hasLoginForm = input.renderedGateObservation.formTexts.some((text) => LOGIN_TEXT_PATTERN.test(text)); + + const deniedHeadingDetected = + ACCESS_DENIED_TEXT_PATTERN.test(headingText) || + STANDALONE_ACCESS_DENIED_HEADING_PATTERN.test(headingText); + const deniedMainDetected = ACCESS_DENIED_TEXT_PATTERN.test(mainText); + const deniedBodyDetected = mainText.length === 0 && ACCESS_DENIED_TEXT_PATTERN.test(bodyText); + const loginDetected = - /(login|signin|sign-in|auth|session)/i.test(url.pathname) || - /]+type=["']password["']/i.test(html) || - / { + const global = globalThis as { + document?: { + body?: unknown; + querySelectorAll: (selectors: string) => ArrayLike; + }; + window?: { + getComputedStyle: (node: unknown) => { + display?: string; + visibility?: string; + opacity?: string; + fontFamily?: string; + color?: string; + backgroundColor?: string; + maxWidth?: string; + borderRadius?: string; + boxShadow?: string; + transitionDuration?: string; + transitionTimingFunction?: string; + animationDuration?: string; + animationTimingFunction?: string; + }; + }; + }; + const doc = global.document; + const win = global.window; + if (!doc || !win) { + return { + gateObservation: { + bodyText: "", + mainText: "", + headingText: "", + formTexts: [], + hasPasswordField: false, + }, + renderedStyles: { + fonts: [], + colors: [], + maxWidths: [], + radii: [], + shadowKinds: [], + motions: [], + containers: [], + }, + }; + } + + const normalizeText = (value: string | undefined): string => + String(value ?? "") + .replace(/\s+/g, " ") + .trim(); + const parsePx = (value: string | undefined): number | null => { + if (!value || value === "none" || value === "normal") { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + }; + const splitList = (value: string | undefined): string[] => + String(value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + const getVisibleText = (node: any): string => normalizeText(String(node?.innerText ?? node?.textContent ?? "")); + const isVisible = (node: any): boolean => { + const style = win.getComputedStyle(node); + const rect = typeof node.getBoundingClientRect === "function" + ? node.getBoundingClientRect() + : { width: 0, height: 0 }; + return ( + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity ?? "1") > 0 && + rect.width > 0 && + rect.height > 0 + ); + }; + const collectMotionPairs = (durationValue: string | undefined, timingValue: string | undefined) => { + const durations = splitList(durationValue); + const timings = splitList(timingValue); + const pairs: Array<{ durationMs: number; timingFunction: string }> = []; + durations.forEach((entry, index) => { + const trimmed = entry.trim().toLowerCase(); + let durationMs: number | null = null; + if (trimmed.endsWith("ms")) { + durationMs = Number.parseFloat(trimmed.slice(0, -2)); + } else if (trimmed.endsWith("s")) { + durationMs = Number.parseFloat(trimmed.slice(0, -1)) * 1000; + } else if (/^[0-9.]+$/.test(trimmed)) { + durationMs = Number.parseFloat(trimmed); + } + if (!Number.isFinite(durationMs) || durationMs === null || durationMs <= 0) { + return; + } + pairs.push({ + durationMs, + timingFunction: timings[index] ?? timings[0] ?? "linear", + }); + }); + return pairs; + }; + + const fonts: string[] = []; + const colors: string[] = []; + const maxWidths: number[] = []; + const radii: number[] = []; + const shadowKinds: Array<"outer" | "inset" | "mixed"> = []; + const motions: Array<{ durationMs: number; timingFunction: string }> = []; + const containers = new Set(); + const mainNodes = Array.from(doc.querySelectorAll("main, [role='main']")).filter((node) => isVisible(node)); + const headingNodes = Array.from( + doc.querySelectorAll("main h1, main h2, [role='main'] h1, [role='main'] h2, h1, h2"), + ).filter((node) => isVisible(node)); + const visibleForms = Array.from(doc.querySelectorAll("form")).filter((node) => isVisible(node)); + const passwordInputs = Array.from(doc.querySelectorAll("input[type='password']")).filter((node) => + isVisible(node), + ); + const nodes = Array.from( + doc.querySelectorAll( + "body, main, header, nav, footer, aside, section, article, form, div, h1, h2, h3, h4, h5, h6, p, a, button, input, label", + ), + ); + + for (const node of nodes as any[]) { + if (!node || typeof node !== "object" || !isVisible(node)) { + continue; + } + const style = win.getComputedStyle(node); + const fontFamily = splitList(style.fontFamily)[0]?.replace(/^["']|["']$/g, ""); + if (fontFamily) { + fonts.push(fontFamily); + } + + const color = style.color?.trim(); + if (color && color !== "transparent" && color !== "rgba(0, 0, 0, 0)") { + colors.push(color); + } + const backgroundColor = style.backgroundColor?.trim(); + if ( + backgroundColor && + backgroundColor !== "transparent" && + backgroundColor !== "rgba(0, 0, 0, 0)" + ) { + colors.push(backgroundColor); + } + + const maxWidth = parsePx(style.maxWidth); + if (maxWidth !== null && maxWidth > 0 && maxWidth < 5000) { + maxWidths.push(maxWidth); + } + + const radius = parsePx(style.borderRadius); + if (radius !== null && radius > 0) { + radii.push(radius); + } + + const boxShadow = style.boxShadow?.trim().toLowerCase(); + if (boxShadow && boxShadow !== "none") { + shadowKinds.push(boxShadow.includes("inset") ? "inset" : "outer"); + } + + motions.push( + ...collectMotionPairs(style.transitionDuration, style.transitionTimingFunction), + ...collectMotionPairs(style.animationDuration, style.animationTimingFunction), + ); + + const className = String((node as { className?: unknown }).className ?? ""); + if (/\bcontainer\b/i.test(className)) { + containers.add("container"); + } + } + + return { + gateObservation: { + bodyText: getVisibleText(doc.body), + mainText: normalizeText(mainNodes.map((node) => getVisibleText(node)).join(" ")), + headingText: normalizeText(headingNodes.map((node) => getVisibleText(node)).join(" ")), + formTexts: visibleForms.map((node) => getVisibleText(node)).filter(Boolean), + hasPasswordField: passwordInputs.length > 0, + }, + renderedStyles: { + fonts, + colors, + maxWidths, + radii, + shadowKinds, + motions, + containers: [...containers].sort((a, b) => a.localeCompare(b)), + }, + }; + }); const cssContents = (await Promise.all([...stylesheetBodies.values()])) .filter((entry): entry is { source: string; content: string } => entry !== null) .filter((entry) => { @@ -138,14 +419,21 @@ export async function observeRemotePage(options: { } }) .sort((a, b) => a.source.localeCompare(b.source)); - const authGate = detectAuthGate(html, finalUrl); + const sourceHealth = classifySourceHealth({ + finalUrl, + documentStatus: response?.status() ?? null, + authMode: options.storageState ? "browser-session" : "none", + renderedGateObservation: renderedObservation.gateObservation, + }); return { finalUrl, html, cssContents, - loginDetected: authGate.loginDetected, - accessDeniedDetected: authGate.accessDeniedDetected, + loginDetected: sourceHealth.loginDetected, + accessDeniedDetected: sourceHealth.accessDeniedDetected, + sourceHealth, + renderedStyles: renderedObservation.renderedStyles, }; } finally { await context.close().catch(() => undefined); diff --git a/packages/interfacectl-cli/src/utils/compare.ts b/packages/interfacectl-cli/src/utils/compare.ts index 3e8523e..cfc6ff0 100644 --- a/packages/interfacectl-cli/src/utils/compare.ts +++ b/packages/interfacectl-cli/src/utils/compare.ts @@ -352,7 +352,7 @@ export function compareContractToDescriptor( ); for (const motion of desc.motion) { - if (!allowedDurations.has(motion.durationMs)) { + if (motion.durationMs >= 1 && !allowedDurations.has(motion.durationMs)) { entries.push({ surfaceId, type: "added", diff --git a/packages/interfacectl-cli/src/utils/first-run-analysis.ts b/packages/interfacectl-cli/src/utils/first-run-analysis.ts index 4af039a..f976b45 100644 --- a/packages/interfacectl-cli/src/utils/first-run-analysis.ts +++ b/packages/interfacectl-cli/src/utils/first-run-analysis.ts @@ -30,7 +30,13 @@ import { seedObservedUiContract, type ObservedUiSeedingResult, } from "./observed-ui-seeding.js"; -import { observeRemotePage } from "./browser-session.js"; +import { + observeRemotePage, + type RemoteBrowserObservation, + type RemoteRenderedStyleObservation, + type SourceHealthConfidence, + type SourceHealthStatus, +} from "./browser-session.js"; export type WebSurfaceKind = "marketing" | "application" | "unknown"; export type FirstRunMode = "adopt" | "synthesize"; @@ -122,6 +128,12 @@ export interface SurfaceAnalysisArtifact { motion: TokenMetadata[]; }; }; + sourceHealth: { + status: SourceHealthStatus; + finalUrl?: string; + authMode: "none" | "browser-session"; + confidence: SourceHealthConfidence; + }; classification: { inferredKind: WebSurfaceKind; confirmedKind: WebSurfaceKind; @@ -230,6 +242,7 @@ export interface AnalyzeSurfaceOptions { authMode?: "none" | "browser-session"; authProfileName?: string; authStorageState?: string; + remoteObservation?: RemoteBrowserObservation; } export interface AnalyzeSurfaceResult { @@ -258,6 +271,12 @@ interface NormalizedObservation { colorAllowedValues: string[]; surfaceIcons?: ContractSurface["icons"]; sourceAppRoot?: string; + sourceHealth: { + status: SourceHealthStatus; + finalUrl?: string; + authMode: "none" | "browser-session"; + confidence: SourceHealthConfidence; + }; } interface RemoteObservation { @@ -329,6 +348,12 @@ function uniqueSortedNumbers(values: number[]): number[] { return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b); } +function uniquePositiveSortedNumbers(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isFinite(value) && value >= 1))].sort( + (a, b) => a - b, + ); +} + function toStableSourcePath(root: string | undefined, candidate: string): string { if (!root) { return candidate; @@ -510,6 +535,150 @@ function summarizeIcons(descriptor: SurfaceDescriptor): IconSourceSummary[] { .sort((a, b) => a.value.localeCompare(b.value)); } +function summarizeRemoteFontsFromRenderedStyles( + source: string, + renderedStyles: RemoteRenderedStyleObservation, +): FontValueSummary[] { + return countByValue(renderedStyles.fonts.map((value) => ({ value, source }))) + .map((entry) => ({ + value: entry.value, + count: entry.count, + sources: entry.sources, + })); +} + +function summarizeRemoteColorsFromRenderedStyles( + source: string, + renderedStyles: RemoteRenderedStyleObservation, +): ColorValueSummary[] { + return countByValue( + renderedStyles.colors.flatMap((value) => + normalizeColorValues([value]).map((canonical) => ({ value: canonical, source })), + ), + ).map((entry) => ({ + canonical: entry.value, + count: entry.count, + sources: entry.sources, + })); +} + +function summarizeRemoteMotionFromRenderedStyles( + source: string, + renderedStyles: RemoteRenderedStyleObservation, +): MotionValueSummary[] { + return countByValue( + renderedStyles.motions.map((entry) => ({ + value: `${entry.durationMs}::${entry.timingFunction}`, + source, + })), + ) + .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); +} + +function collectRemoteFontsFromCss( + cssContents: Array<{ source: string; content: string }>, +): FontValueSummary[] { + return 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, + })); +} + +function collectRemoteColorsFromCss( + cssContents: Array<{ source: string; content: string }>, +): ColorValueSummary[] { + return 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, + })); +} + +function collectRemoteMotionFromCss( + cssContents: Array<{ source: string; content: string }>, +): MotionValueSummary[] { + return 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); +} + function buildPhase0Seed(observation: NormalizedObservation): { authPosture: "public" | "auth-aware" | "auth-first"; requiresShell: boolean; @@ -984,6 +1153,13 @@ function buildDraftArtifact( structurePatterns.push("shell"); } const manualFollowUp = analysis.inconsistencies.findings.map((finding) => finding.message); + if (analysis.sourceHealth.status !== "ok") { + manualFollowUp.push( + analysis.sourceHealth.status === "access-denied" + ? "Capture auth or switch to a local app root to analyze the target surface instead of the gate." + : "Provide authenticated replay or switch to a local app root to analyze the target surface instead of the login view.", + ); + } if (analysis.classification.requiresConfirmation) { manualFollowUp.push("Review the inferred surface kind before tightening policy levels."); } @@ -1138,6 +1314,11 @@ async function analyzeLocalSource(options: AnalyzeSurfaceOptions): Promise 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 renderedFonts = summarizeRemoteFontsFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const renderedColors = summarizeRemoteColorsFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const renderedMotions = summarizeRemoteMotionFromRenderedStyles(redactedFinalUrl, observation.renderedStyles); + const fonts = renderedFonts.length > 0 ? renderedFonts : collectRemoteFontsFromCss(cssContents); + const colors = renderedColors.length > 0 ? renderedColors : collectRemoteColorsFromCss(cssContents); + const motions = renderedMotions.length > 0 ? renderedMotions : collectRemoteMotionFromCss(cssContents); const maxWidths: number[] = []; const radii: number[] = []; @@ -1437,6 +1547,14 @@ async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise 0 ? observation.renderedStyles.maxWidths : maxWidths; + const observedRadii = + observation.renderedStyles.radii.length > 0 ? observation.renderedStyles.radii : radii; + const observedShadowKinds = + observation.renderedStyles.shadowKinds.length > 0 + ? observation.renderedStyles.shadowKinds + : [...shadowKinds]; const copyRoleCount = extractAttributeValuesFromTags(html, COPY_ROLE_REGEX).length; const sections = extractAttributeValuesFromTags(html, SECTION_ATTRIBUTE_REGEX); @@ -1446,7 +1564,7 @@ async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise ({ 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] })), + sections: sections.map((section) => ({ id: section, source: redactedFinalUrl })), + fonts: fonts.map((entry) => ({ value: entry.value, source: entry.sources[0] ?? redactedFinalUrl })), + colors: colors.map((entry) => ({ value: entry.canonical, source: entry.sources[0] ?? redactedFinalUrl })), icons: [], tokenUsage: { typography: metadataToDescriptors(metadataFromPolicy(tokenPolicies.typography)), @@ -1474,18 +1592,19 @@ async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise 0 ? { roles: [], - source: redactSensitiveUrl(finalUrl.toString()), + source: redactedFinalUrl, } : undefined, layout: { - maxContentWidth: maxWidths.length > 0 ? Math.max(...maxWidths) : null, + maxContentWidth: observedMaxWidths.length > 0 ? Math.max(...observedMaxWidths) : null, containers: uniqueSorted([ + ...observation.renderedStyles.containers, ...(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">, + maxBorderRadiusPx: observedRadii.length > 0 ? Math.max(...observedRadii) : null, + shadowKinds: observedShadowKinds.sort((a, b) => a.localeCompare(b)) as Array<"none" | "outer" | "inset" | "mixed">, }, landingPattern: sections.length > 0 || heroSignal @@ -1494,14 +1613,14 @@ async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise ({ durationMs: entry.durationMs, timingFunction: entry.timingFunction, - source: entry.sources[0], + source: entry.sources[0] ?? redactedFinalUrl, })), primitives, }; @@ -1521,9 +1640,12 @@ async function analyzeRemoteSource(options: AnalyzeSurfaceOptions): Promise entry.canonical), surfaceIcons: undefined, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: redactedFinalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, }; } @@ -1606,10 +1734,12 @@ function buildBaseContract( sections, constraints: { motion: { - allowedDurationsMs: uniqueSortedNumbers( + allowedDurationsMs: uniquePositiveSortedNumbers( observation.descriptor.motion.map((motion) => motion.durationMs), ).length > 0 - ? uniqueSortedNumbers(observation.descriptor.motion.map((motion) => motion.durationMs)) + ? uniquePositiveSortedNumbers( + observation.descriptor.motion.map((motion) => motion.durationMs), + ) : [120], allowedTimingFunctions: uniqueSorted(observation.descriptor.motion.map((motion) => motion.timingFunction)).length > 0 @@ -1654,6 +1784,12 @@ function buildExtractionReport( options.sourceMode === "local-root" && options.appRoot ? path.resolve(options.workspaceRoot, options.appRoot) : options.url, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: observation.sourceHealth.finalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, warnings, extracted: { routes: observation.routes, @@ -1736,6 +1872,12 @@ export async function analyzeSurface( motion: metadataFromPolicy(observation.tokenPolicies.motion), }, }, + sourceHealth: { + status: observation.sourceHealth.status, + finalUrl: observation.sourceHealth.finalUrl, + authMode: observation.sourceHealth.authMode, + confidence: observation.sourceHealth.confidence, + }, classification: { ...classification, confirmedKind, diff --git a/packages/interfacectl-cli/src/utils/init-interactive.ts b/packages/interfacectl-cli/src/utils/init-interactive.ts new file mode 100644 index 0000000..30ee2dc --- /dev/null +++ b/packages/interfacectl-cli/src/utils/init-interactive.ts @@ -0,0 +1,225 @@ +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import type { + AnalysisSourceMode, + SurfaceAnalysisArtifact, + WebSurfaceKind, +} from "./first-run-analysis.js"; +import { + suggestSurfaceIdFromPath, + suggestSurfaceIdFromUrl, + suggestSurfaceName, +} from "./onboarding.js"; + +export type ExtractMode = AnalysisSourceMode; + +export interface InteractiveInitOptions { + url?: string; + surface?: string; + surfaceName?: string; + surfaceKind?: WebSurfaceKind; + authProfile?: string; + extractMode?: ExtractMode; + appRoot?: string; +} + +export interface ResolvedInitInputs { + sourceMode: ExtractMode; + url?: string; + appRoot?: string; + surfaceId: string; + surfaceName: string; + surfaceKind?: WebSurfaceKind; + requiresAuth: boolean; + authProfileName: string | null; +} + +export type GateResolutionAction = + | "capture-auth" + | "continue-anyway" + | "switch-local-root" + | "quit"; + +const VALID_SURFACE_KINDS = new Set(["marketing", "application", "unknown"]); + +export function normalizeSurfaceId(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export 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"; +} + +export async function promptInteractiveInitInputs( + options: InteractiveInitOptions, +): Promise { + const rl = readline.createInterface({ input, output }); + try { + const inferredMode = inferSourceMode(options); + const rawMode = ( + ( + options.extractMode ?? + ( + await rl.question( + `Source mode (local-root/remote-url) [${inferredMode}]: `, + ) + ).trim() + ) || + inferredMode + ).toLowerCase(); + 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( + "Do you need to sign in to see the real page? (y/N) ", + ) + ).trim().toLowerCase(), + ) + : false; + const authProfileName = requiresAuth + ? ( + await rl.question( + `Auth profile name [${options.authProfile ?? `${new URL(url!).hostname}-default`}]: `, + ) + ).trim() || options.authProfile || `${new URL(url!).hostname}-default` + : null; + + return { + sourceMode, + url, + appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, + surfaceId, + surfaceName, + surfaceKind: options.surfaceKind, + requiresAuth, + authProfileName, + }; + } finally { + rl.close(); + } +} + +export async function promptSurfaceKindConfirmation( + 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(); + } +} + +export async function promptGateResolution( + analysis: SurfaceAnalysisArtifact, +): Promise { + const rl = readline.createInterface({ input, output }); + try { + const statusMessage = + analysis.sourceHealth.status === "access-denied" + ? "We reached an access-denied page instead of the target surface." + : analysis.sourceHealth.status === "login" + ? "We reached a login page instead of the target surface." + : "We reached a limited source instead of the target surface."; + console.log(statusMessage); + if (analysis.sourceHealth.finalUrl) { + console.log(`Current URL: ${analysis.sourceHealth.finalUrl}`); + } + console.log("Choose how to continue:"); + console.log(" [1] Capture auth and retry"); + console.log(" [2] Continue anyway with provisional results"); + console.log(" [3] Switch to local app root"); + console.log(" [q] Quit"); + while (true) { + const answer = (await rl.question("> ")).trim().toLowerCase(); + if (answer === "1") { + return "capture-auth"; + } + if (answer === "2") { + return "continue-anyway"; + } + if (answer === "3") { + return "switch-local-root"; + } + if (answer === "q") { + return "quit"; + } + console.log("Expected 1, 2, 3, or q."); + } + } finally { + rl.close(); + } +} + +export async function promptWriteConfirmation(): Promise { + const rl = readline.createInterface({ input, output }); + try { + while (true) { + const answer = (await rl.question("Create these draft artifacts now? ([Y]es/[q]uit) ")).trim().toLowerCase(); + if (!answer || answer === "y" || answer === "yes") { + return true; + } + if (answer === "q" || answer === "n" || answer === "no") { + return false; + } + console.log("Expected yes, no, or q."); + } + } finally { + rl.close(); + } +} diff --git a/packages/interfacectl-cli/test/bare-onboarding.test.mjs b/packages/interfacectl-cli/test/bare-onboarding.test.mjs new file mode 100644 index 0000000..6ba2557 --- /dev/null +++ b/packages/interfacectl-cli/test/bare-onboarding.test.mjs @@ -0,0 +1,683 @@ +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"; +import os from "node:os"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); +const { runBareWelcomeFlow } = await import(path.resolve(__dirname, "..", "dist", "utils", "bare-onboarding.js")); +const forceWelcomeEnv = { INTERFACECTL_FORCE_BARE_WELCOME: "1" }; + +async function run(args, options = {}) { + const child = spawn("node", [cliPath, ...args], { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + }); + if (options.input) { + child.stdin.write(options.input); + child.stdin.end(); + } + + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, options.timeoutMs ?? 20000); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode, signal] = await once(child, "exit"); + clearTimeout(timeout); + return { + exitCode: exitCode === null ? null : Number(exitCode), + signal: signal ?? null, + stdout, + stderr, + }; +} + +async function runInteractive(args, options = {}) { + const child = spawn("node", [cliPath, ...args], { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + }); + + let stdout = ""; + let stderr = ""; + let stepIndex = 0; + let stdinClosed = false; + const steps = options.steps ?? []; + + const tryAdvance = () => { + while (stepIndex < steps.length) { + const step = steps[stepIndex]; + const combined = `${stdout}${stderr}`; + if (!step.when.test(combined)) { + return; + } + child.stdin.write(step.input); + stepIndex += 1; + } + if (!stdinClosed && stepIndex === steps.length) { + stdinClosed = true; + child.stdin.end(); + } + }; + + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, options.timeoutMs ?? 20000); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + tryAdvance(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + tryAdvance(); + }); + + const [exitCode, signal] = await once(child, "exit"); + clearTimeout(timeout); + return { + exitCode: exitCode === null ? null : Number(exitCode), + signal: signal ?? null, + stdout, + stderr, + }; +} + +async function createMarketingApp(rootDir, name = "marketing-site") { + const appRoot = path.join(rootDir, "apps", name); + 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 ( +
+
+

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 writeFile( + path.join(appDir, "globals.css"), + `:root { + --font-display: "Soehne", sans-serif; + --space-6: 24px; + --motion-standard: 180ms; +} + +body { + font-family: var(--font-display); + color: #111111; + background: #ffffff; +} + +section { + max-width: 72rem; + border-radius: 24px; + transition: opacity var(--motion-standard) ease-in-out; +} +`, + "utf-8", + ); + + return appRoot; +} + +async function createPreviewApp(rootDir) { + const appRoot = path.join(rootDir, "apps", "preview-app"); + 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 ( +
+

Preview app

+

Preview before writing artifacts.

+
+ ); +} +`, + "utf-8", + ); + await writeFile( + path.join(appDir, "globals.css"), + `body { + font-family: "Soehne", sans-serif; + color: #111111; + background: #ffffff; +} + +main { + max-width: 64rem; + transition: opacity 180ms ease-in-out; +} +`, + "utf-8", + ); + return appRoot; +} + +function createRemoteMarketingServer() { + return 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 +
+
+ + + `); + }); +} + +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 === "/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"); + }); +} + +function createPublicNextLikeServer() { + return 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; border-radius: 24px; } + .shim { border-right: 1px solid rgba(0, 0, 0, .403); } + `); + return; + } + + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + + +
+
+

Public product site

+

Draft a contract from the rendered surface, not the framework payload.

+ Read the docs +
+
+ Get started +
+
+ + + `); + }); +} + +function createCaptureOutput() { + let value = ""; + return { + output: new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + }, + }), + read() { + return value; + }, + }; +} + +test("bare interfacectl in non-tty preserves help output and exit semantics", async () => { + const result = await run([]); + const combined = `${result.stdout}${result.stderr}`; + assert.equal(result.exitCode, 1); + assert.match(combined, /Usage: interfacectl/); + assert.doesNotMatch(combined, /Surfaces Platform/); +}); + +test("bare interfacectl welcome flow exits cleanly on q without writing artifacts", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-bare-quit-")); + try { + const result = await run([], { + cwd, + env: forceWelcomeEnv, + input: "q\n", + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /Surfaces Platform/); + assert.match(result.stdout, /Choose a source: \[1\] Local app root \[2\] Live URL \[q\] Quit/); + assert.match(result.stdout, /Advanced commands: init, analyze, validate, auth, --help/); + assert.match(result.stdout, /Exited onboarding\./); + + const generatedDir = path.join(cwd, "contracts", "generated"); + try { + await access(generatedDir, fsConstants.F_OK); + const entries = await readdir(generatedDir); + assert.equal(entries.length, 0); + } catch (error) { + assert.equal(error?.code, "ENOENT"); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("bare interfacectl welcome routes local selection into interactive init", async () => { + const capture = createCaptureOutput(); + let receivedOptions = null; + + const exitCode = await runBareWelcomeFlow({ + input: Readable.from(["1\n"]), + output: capture.output, + initRunner: async (options) => { + receivedOptions = options; + return 0; + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(receivedOptions, { extractMode: "local-root" }); + assert.match(capture.read(), /Choose a source: \[1\] Local app root \[2\] Live URL \[q\] Quit/); +}); + +test("bare interfacectl welcome routes remote selection into interactive init", async () => { + const capture = createCaptureOutput(); + let receivedOptions = null; + + const exitCode = await runBareWelcomeFlow({ + input: Readable.from(["2\n"]), + output: capture.output, + initRunner: async (options) => { + receivedOptions = options; + return 0; + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(receivedOptions, { extractMode: "remote-url" }); + assert.match(capture.read(), /Surfaces Platform/); +}); + +test("help and explicit commands bypass the bare welcome flow", async () => { + const commands = [ + ["--help"], + ["validate", "--help"], + ["init", "--help"], + ["analyze", "--help"], + ["auth", "--help"], + ]; + + for (const args of commands) { + const result = await run(args, { env: forceWelcomeEnv }); + assert.equal(result.exitCode, 0, `${args.join(" ")}: ${result.stderr}`); + assert.doesNotMatch(result.stdout, /Surfaces Platform/); + } +}); + +test("bare interfacectl remote onboarding stops at gate and quits without writing artifacts", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-bare-gated-quit-")); + 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 result = await runInteractive([], { + cwd, + env: forceWelcomeEnv, + steps: [ + { when: /> $/, input: "2\n" }, + { when: /Surface URL: $/, input: `${baseUrl}/app\n` }, + { when: /Surface id \[[^\]]+\]: $/, input: "\n" }, + { when: /Surface name \[[^\]]+\]: $/, input: "\n" }, + { when: /sign in to see the real page\? \(y\/N\) $/, input: "\n" }, + { when: /We reached a login page instead of the target surface\.[\s\S]*\[q\] Quit/, input: "q\n" }, + ], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /We reached a login page instead of the target surface\./); + assert.match(result.stdout, /\[1\] Capture auth and retry/); + assert.match(result.stdout, /Exited onboarding before artifacts were written\./); + + const generatedDir = path.join(cwd, "contracts", "generated"); + await assert.rejects(access(generatedDir, fsConstants.F_OK), /ENOENT/); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("bare interfacectl remote onboarding can continue with provisional output after gate warning", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-bare-gated-continue-")); + 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 result = await runInteractive([], { + cwd, + env: forceWelcomeEnv, + steps: [ + { when: /> $/, input: "2\n" }, + { when: /Surface URL: $/, input: `${baseUrl}/app\n` }, + { when: /Surface id \[[^\]]+\]: $/, input: "\n" }, + { when: /Surface name \[[^\]]+\]: $/, input: "\n" }, + { when: /sign in to see the real page\? \(y\/N\) $/, input: "\n" }, + { when: /We reached a login page instead of the target surface\.[\s\S]*\[2\] Continue anyway with provisional results/, input: "2\n" }, + { when: /Create these draft artifacts now\?/, input: "y\n" }, + ], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /What we found/); + assert.match(result.stdout, /We analyzed a limited view of the source, so results are provisional\./); + assert.match(result.stdout, /Created/); + assert.match(result.stdout, /Next/); + assert.match(result.stdout, /Artifacts/); + assert.doesNotMatch(result.stdout, /Run id:/); + assert.doesNotMatch(result.stdout, /Auth storage:/); + + const analysis = JSON.parse( + await readFile( + path.join(cwd, "contracts", "generated", "127-0-0-1-app.analysis.json"), + "utf-8", + ), + ); + assert.equal(analysis.sourceHealth.status, "login"); + assert.equal( + analysis.warnings.some((warning) => warning.code === "remote.source.provisional"), + true, + ); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("bare interfacectl remote onboarding does not stop on a public Next-like page with framework auth strings", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-bare-public-next-like-")); + const server = createPublicNextLikeServer(); + 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 result = await runInteractive([], { + cwd, + env: forceWelcomeEnv, + steps: [ + { when: /> $/, input: "2\n" }, + { when: /Surface URL: $/, input: `${baseUrl}/\n` }, + { when: /Surface id \[[^\]]+\]: $/, input: "\n" }, + { when: /Surface name \[[^\]]+\]: $/, input: "\n" }, + { when: /sign in to see the real page\? \(y\/N\) $/, input: "\n" }, + { when: /Create these draft artifacts now\?/, input: "y\n" }, + ], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.doesNotMatch(result.stdout, /Access check stopped onboarding/); + assert.match(result.stdout, /What we found/); + assert.match(result.stdout, /We successfully analyzed the source\./); + assert.match(result.stdout, /What we'll create/); + assert.match(result.stdout, /What needs review/); + assert.match(result.stdout, /Continue/); + assert.doesNotMatch(result.stdout, /existing-system score/); + assert.doesNotMatch(result.stdout, /adopt mode|synthesize mode/); + assert.doesNotMatch(result.stdout, /source access:/); + + const analysis = JSON.parse( + await readFile( + path.join(cwd, "contracts", "generated", "127-0-0-1.analysis.json"), + "utf-8", + ), + ); + assert.equal(analysis.sourceHealth.status, "ok"); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("interactive init can quit at preview without writing artifacts", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-preview-quit-")); + try { + const appRoot = await createPreviewApp(cwd); + const result = await runInteractive([ + "init", + "--extract-mode", + "local-root", + "--app-root", + appRoot, + "--surface-kind", + "application", + ], { + cwd, + steps: [ + { when: /Surface id \[preview-app\]: $/, input: "\n" }, + { when: /Surface name \[Preview App\]: $/, input: "\n" }, + { when: /Create these draft artifacts now\?/, input: "q\n" }, + ], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /Previewing generated draft/); + assert.match(result.stdout, /What we found/); + assert.match(result.stdout, /What we'll create/); + assert.match(result.stdout, /What needs review/); + assert.match(result.stdout, /We could not confidently infer layout foundations yet\./); + assert.doesNotMatch(result.stdout, /0 layout token seed\(s\)/); + assert.doesNotMatch(result.stdout, /Technical detail:/); + assert.match(result.stdout, /Exited onboarding before artifacts were written\./); + await assert.rejects( + access(path.join(cwd, "contracts", "generated"), fsConstants.F_OK), + /ENOENT/, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("interactive init --verbose keeps technical detail in preview and success output", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-verbose-")); + try { + const appRoot = await createPreviewApp(cwd); + const result = await runInteractive([ + "init", + "--extract-mode", + "local-root", + "--app-root", + appRoot, + "--surface-kind", + "application", + "--verbose", + ], { + cwd, + steps: [ + { when: /Surface id \[preview-app\]: $/, input: "verbose-site\n" }, + { when: /Surface name \[Verbose Site\]: $/, input: "\n" }, + { when: /Create these draft artifacts now\?/, input: "y\n" }, + ], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /Technical detail: surface kind confidence \d+\.\d{2}\./); + assert.match(result.stdout, /Technical detail: existing-system mode /); + assert.match(result.stdout, /Technical detail: \d+ typography token seed\(s\)/); + assert.match(result.stdout, /Created/); + assert.match(result.stdout, /Technical details/); + assert.match(result.stdout, /Run id:/); + assert.match(result.stdout, /Auth storage:/); + } 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 04b386c..3732dde 100644 --- a/packages/interfacectl-cli/test/init-auth.test.mjs +++ b/packages/interfacectl-cli/test/init-auth.test.mjs @@ -6,7 +6,8 @@ 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"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -118,6 +119,60 @@ function createProtectedServer() { }); } +function createPublicNextLikeServer() { + return 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; border-radius: 24px; } + .shim { border-right: 1px solid rgba(0, 0, 0, .403); } + `); + return; + } + + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + + +
+
+

Public product site

+

Draft a contract from the rendered surface, not the framework payload.

+ Read the docs +
+
+ + + `); + }); +} + +function createAccessDeniedServer() { + return createServer((req, res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + +
+

Access denied

+

You do not have access to this workspace.

+
+ + + `); + }); +} + 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"); @@ -219,6 +274,8 @@ test("init: non-interactive remote-url writes first-run artifacts and run metada assert.equal(contract.surfaces[0].id, "customer-products"); assert.equal(extraction.onboarding.extractMode, "remote-url"); assert.equal(extraction.onboarding.authMode, "none"); + assert.equal(analysis.sourceHealth.status, "ok"); + assert.equal(extraction.sourceHealth.confidence, "full"); assert.equal(runs.schemaVersion, 1); assert.equal(runs.runs[0].source, "generation"); assert.equal(lineage.surfaces["customer-products"].lastSource, "generation"); @@ -229,6 +286,95 @@ test("init: non-interactive remote-url writes first-run artifacts and run metada } }); +test("init: remote-url ignores zero-duration timing-only motion when seeding contract constraints", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-remote-motion-zero-")); + 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; border-radius: 24px; } + .cta { transition-timing-function: ease-in-out; animation-timing-function: linear; } + `); + return; + } + + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + +
+
+

Launch surfaces faster.

+ 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"), + JSON.stringify({ + contractId: "test-contract", + version: "1.0.0", + surfaces: [], + sections: [], + constraints: { + motion: { allowedDurationsMs: [120], allowedTimingFunctions: ["linear"] }, + }, + }, null, 2), + "utf-8", + ); + + const result = await run( + [ + "init", + "--non-interactive", + "--url", + `${baseUrl}/`, + "--surface", + "timing-only-site", + "--surface-kind", + "marketing", + ], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Motion duration 0ms is not allowed/); + + const contract = JSON.parse( + await readFile( + path.join(cwd, "contracts", "generated", "timing-only-site.contract.json"), + "utf-8", + ), + ); + + assert.deepEqual(contract.constraints.motion.allowedDurationsMs, [120]); + assert.deepEqual(contract.constraints.motion.allowedTimingFunctions, ["ease-in-out", "linear"]); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + 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"); @@ -384,6 +530,8 @@ test("analyze: protected remote surface warns anonymously and succeeds with repl anonymousAnalysis.warnings.some((warning) => warning.code === "remote.auth.login-detected"), true, ); + assert.equal(anonymousAnalysis.sourceHealth.status, "login"); + assert.match(anonymous.stdout, /Source access: login/); const capture = await run( ["auth", "capture", "--profile", "demo", "--url", `${baseUrl}/session/start`, "--format", "json"], @@ -409,6 +557,156 @@ test("analyze: protected remote surface warns anonymously and succeeds with repl ); assert.equal(authenticatedAnalysis.classification.inferredKind, "application"); assert.equal(authenticatedAnalysis.extracted.hasShell, true); + assert.equal(authenticatedAnalysis.sourceHealth.status, "ok"); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("analyze: public Next-like page with framework auth strings remains sourceHealth ok", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-public-next-like-")); + const server = createPublicNextLikeServer(); + 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 result = await run( + ["analyze", "--url", `${baseUrl}/`, "--surface", "public-site"], + { cwd, env: { ...forceFileStorageEnv } }, + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.doesNotMatch(result.stdout, /Source access:/); + + const analysis = JSON.parse( + await readFile(path.join(cwd, "contracts", "generated", "public-site.analysis.json"), "utf-8"), + ); + assert.equal(analysis.sourceHealth.status, "ok"); + assert.equal(analysis.sourceHealth.confidence, "full"); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("analyze: visible access-denied page still classifies as access-denied", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-access-denied-")); + const server = createAccessDeniedServer(); + 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 result = await run( + ["analyze", "--url", `${baseUrl}/`, "--surface", "denied-site"], + { cwd, env: { ...forceFileStorageEnv } }, + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /Source access: access-denied/); + + const analysis = JSON.parse( + await readFile(path.join(cwd, "contracts", "generated", "denied-site.analysis.json"), "utf-8"), + ); + assert.equal(analysis.sourceHealth.status, "access-denied"); + assert.equal(analysis.sourceHealth.confidence, "limited"); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("init: non-interactive gated remote-url fails without continue-on-gate and writes nothing", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-gated-fail-")); + 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 result = await run( + [ + "init", + "--non-interactive", + "--url", + `${baseUrl}/app`, + "--surface", + "private-app", + "--surface-kind", + "application", + ], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(result.exitCode, 1); + assert.match(`${result.stdout}\n${result.stderr}`, /--continue-on-gate|--auth-profile|login page/i); + + await assert.rejects( + access( + path.join(cwd, "contracts", "generated", "private-app.contract.json"), + fsConstants.F_OK, + ), + /ENOENT/, + ); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("init: non-interactive gated remote-url writes provisional artifacts with continue-on-gate", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-gated-continue-")); + 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 result = await run( + [ + "init", + "--non-interactive", + "--continue-on-gate", + "--url", + `${baseUrl}/app`, + "--surface", + "private-app", + "--surface-kind", + "application", + ], + { 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, "private-app.analysis.json"), "utf-8"), + ); + const extraction = JSON.parse( + await readFile(path.join(generatedDir, "private-app.extraction.json"), "utf-8"), + ); + + assert.equal(analysis.sourceHealth.status, "login"); + assert.equal(analysis.sourceHealth.confidence, "limited"); + assert.equal( + analysis.warnings.some((warning) => warning.code === "remote.source.provisional"), + true, + ); + assert.equal(extraction.sourceHealth.status, "login"); + assert.equal(extraction.sourceHealth.confidence, "limited"); } finally { server.closeAllConnections?.(); server.close(); diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index c66940a..e58e2c4 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -743,7 +743,7 @@ export function evaluateSurfaceCompliance(contract, descriptor) { const allowedDurations = new Set(contract.constraints.motion.allowedDurationsMs); const allowedTimingFunctions = new Set(contract.constraints.motion.allowedTimingFunctions); for (const motion of descriptor.motion) { - if (!allowedDurations.has(motion.durationMs)) { + if (motion.durationMs >= 1 && !allowedDurations.has(motion.durationMs)) { violations.push({ surfaceId: descriptor.surfaceId, type: "motion-duration-not-allowed", diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts index c3439e4..f895ae6 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -996,7 +996,7 @@ export function evaluateSurfaceCompliance( ); for (const motion of descriptor.motion) { - if (!allowedDurations.has(motion.durationMs)) { + if (motion.durationMs >= 1 && !allowedDurations.has(motion.durationMs)) { violations.push({ surfaceId: descriptor.surfaceId, type: "motion-duration-not-allowed", From d080d5bc5da266fd076a7c381530f140b6a1ce16 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 11 Mar 2026 22:42:18 -0700 Subject: [PATCH 2/3] test(cli): stabilize onboarding prompt tests --- packages/interfacectl-cli/test/bare-onboarding.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interfacectl-cli/test/bare-onboarding.test.mjs b/packages/interfacectl-cli/test/bare-onboarding.test.mjs index 6ba2557..6d04391 100644 --- a/packages/interfacectl-cli/test/bare-onboarding.test.mjs +++ b/packages/interfacectl-cli/test/bare-onboarding.test.mjs @@ -19,7 +19,7 @@ const forceWelcomeEnv = { INTERFACECTL_FORCE_BARE_WELCOME: "1" }; async function run(args, options = {}) { const child = spawn("node", [cliPath, ...args], { cwd: options.cwd, - env: { ...process.env, ...(options.env ?? {}) }, + env: { ...process.env, NODE_NO_WARNINGS: "1", ...(options.env ?? {}) }, }); if (options.input) { child.stdin.write(options.input); @@ -52,7 +52,7 @@ async function run(args, options = {}) { async function runInteractive(args, options = {}) { const child = spawn("node", [cliPath, ...args], { cwd: options.cwd, - env: { ...process.env, ...(options.env ?? {}) }, + env: { ...process.env, NODE_NO_WARNINGS: "1", ...(options.env ?? {}) }, }); let stdout = ""; From 987854b0eb490023fe57ca8e06eafe35bbd78dad Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 11 Mar 2026 22:48:00 -0700 Subject: [PATCH 3/3] test(cli): handle stderr noise in onboarding prompts --- packages/interfacectl-cli/test/bare-onboarding.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interfacectl-cli/test/bare-onboarding.test.mjs b/packages/interfacectl-cli/test/bare-onboarding.test.mjs index 6d04391..87c233c 100644 --- a/packages/interfacectl-cli/test/bare-onboarding.test.mjs +++ b/packages/interfacectl-cli/test/bare-onboarding.test.mjs @@ -64,8 +64,8 @@ async function runInteractive(args, options = {}) { const tryAdvance = () => { while (stepIndex < steps.length) { const step = steps[stepIndex]; - const combined = `${stdout}${stderr}`; - if (!step.when.test(combined)) { + const matched = step.when.test(stdout) || step.when.test(stderr) || step.when.test(`${stdout}${stderr}`); + if (!matched) { return; } child.stdin.write(step.input);