From 256405d05041c5afc91cbe545667b277c72441d6 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Tue, 10 Feb 2026 15:20:41 -0800 Subject: [PATCH] Add onboarding init/auth hardening and smoke coverage --- docs/plans/onboarding-init-phase-1.md | 60 +++ .../interfacectl-cli/dist/commands/auth.d.ts | 11 + .../dist/commands/auth.d.ts.map | 1 + .../interfacectl-cli/dist/commands/auth.js | 92 +++++ .../interfacectl-cli/dist/commands/init.d.ts | 14 + .../dist/commands/init.d.ts.map | 1 + .../interfacectl-cli/dist/commands/init.js | 271 +++++++++++++ packages/interfacectl-cli/dist/index.js | 82 ++++ .../dist/utils/auth-profiles.d.ts | 41 ++ .../dist/utils/auth-profiles.d.ts.map | 1 + .../dist/utils/auth-profiles.js | 274 +++++++++++++ .../dist/utils/onboarding.d.ts | 59 +++ .../dist/utils/onboarding.d.ts.map | 1 + .../interfacectl-cli/dist/utils/onboarding.js | 180 +++++++++ .../dist/utils/redaction.d.ts | 3 + .../dist/utils/redaction.d.ts.map | 1 + .../interfacectl-cli/dist/utils/redaction.js | 19 + .../interfacectl-cli/src/commands/auth.ts | 135 +++++++ .../interfacectl-cli/src/commands/init.ts | 352 +++++++++++++++++ packages/interfacectl-cli/src/index.ts | 94 ++++- .../src/utils/auth-profiles.ts | 360 ++++++++++++++++++ .../interfacectl-cli/src/utils/onboarding.ts | 293 ++++++++++++++ .../interfacectl-cli/src/utils/redaction.ts | 23 ++ .../interfacectl-cli/test/init-auth.test.mjs | 175 +++++++++ 24 files changed, 2542 insertions(+), 1 deletion(-) create mode 100644 docs/plans/onboarding-init-phase-1.md create mode 100644 packages/interfacectl-cli/dist/commands/auth.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/auth.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/auth.js create mode 100644 packages/interfacectl-cli/dist/commands/init.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/init.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/init.js create mode 100644 packages/interfacectl-cli/dist/utils/auth-profiles.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/auth-profiles.js create mode 100644 packages/interfacectl-cli/dist/utils/onboarding.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/onboarding.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/onboarding.js create mode 100644 packages/interfacectl-cli/dist/utils/redaction.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/redaction.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/redaction.js create mode 100644 packages/interfacectl-cli/src/commands/auth.ts create mode 100644 packages/interfacectl-cli/src/commands/init.ts create mode 100644 packages/interfacectl-cli/src/utils/auth-profiles.ts create mode 100644 packages/interfacectl-cli/src/utils/onboarding.ts create mode 100644 packages/interfacectl-cli/src/utils/redaction.ts create mode 100644 packages/interfacectl-cli/test/init-auth.test.mjs diff --git a/docs/plans/onboarding-init-phase-1.md b/docs/plans/onboarding-init-phase-1.md new file mode 100644 index 0000000..c9fb438 --- /dev/null +++ b/docs/plans/onboarding-init-phase-1.md @@ -0,0 +1,60 @@ +# Feature Plan + +## Summary +Add a new `interfacectl init` onboarding command to bootstrap the first surface from either a remote URL or a local app root. The command introduces interactive prompts, optional non-interactive flags, browser-session auth profile capture metadata, and run artifact emission so onboarding contributes to the same run lineage model used by SurfaceOps. This work targets first-run usability without hardcoding any customer surface identifiers. + +## Strategy alignment +This work strengthens the enforcement path by turning first extraction into a deterministic `Generation time` flow that produces contract/extraction artifacts and records violation/status signals immediately. It keeps onboarding inside contract-aware tooling and preserves existing `CI/CD time` checks by emitting schema-compatible artifacts consumed by current validation scripts. + +Decision filter sentence +"When an agent proposes a change to the user experience, Surfaces determines whether that change is allowed, under what conditions it may proceed, and blocks or corrects it if it violates intent." + +## Contract impact +1. Contract definition +No canonical contract schema changes. New onboarding output uses existing generated contract/extraction artifact shapes and existing run artifact schemas (`contract-runs.json`, `contract-lineage.json`). + +2. Enforcement timing +- `Generation time`: `interfacectl init` produces the first generated artifacts and onboarding summary. +- `CI/CD time`: Existing `interfacectl validate*` and `validate-run-artifacts` continue to gate outputs without additional schema branching. +- `Runtime (edge)`: no change in this phase. + +3. Violation handling +Invalid URL/inputs and unsupported mode combinations are blocked with non-zero exit and explicit errors. Extraction warnings are surfaced as `warn` status in emitted onboarding run entries. + +## Surface impact +- `interfacectl`: primary implementation for first-surface onboarding and auth profile lifecycle. +- `surfaceops.ai`: consumes emitted artifacts/run lineage for visibility (no runtime enforcement change in this phase). +- `surfaces.systems`: unchanged contract semantics. + +## Non-goals +- Full browser automation or cookie/token persistence. +- Runtime (edge) auth/session enforcement changes. +- Changing canonical contract authoring semantics. + +## Acceptance criteria +1. `interfacectl init` exists and supports interactive onboarding prompts. +2. `interfacectl init --non-interactive` works with `--url` and optional `--surface`/`--auth-profile`. +3. `interfacectl init` writes generated contract and extraction report artifacts. +4. Onboarding writes run/lineage artifacts in existing schema with source `bootstrap`. +5. Protected URL path supports browser-session profile capture metadata and reuse checks. +6. `interfacectl auth list`, `interfacectl auth test`, and `interfacectl auth clear` commands are available. +7. No auth tokens/cookies are persisted to run artifacts. +8. Existing CLI tests still pass. + +## Risks and drift checks +1. Risk: onboarding becomes observability-only (summary without artifacts). +Mitigation: require successful artifact write and run-record emit before exit 0. + +2. Risk: auth handling drifts into insecure token persistence. +Mitigation: store opaque session references only; redact sensitive values in output. + +3. Risk: onboarding bypasses contract validation paths. +Mitigation: keep output schema-compatible and rely on existing validation commands/scripts. + +## Implementation outline +1. Add auth profile store utility with domain-scoped session metadata and TTL checks. +2. Add run artifact emitter utility reusing current artifact shapes. +3. Add `init` command with prompt/non-interactive input resolution. +4. Add `auth` subcommands (`list`, `test`, `clear`). +5. Add CLI tests for init/auth workflows. +6. Build and run package tests. diff --git a/packages/interfacectl-cli/dist/commands/auth.d.ts b/packages/interfacectl-cli/dist/commands/auth.d.ts new file mode 100644 index 0000000..a6fa80b --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/auth.d.ts @@ -0,0 +1,11 @@ +export interface AuthCommandOptions { + profile?: string; + domain?: string; + all?: boolean; + format?: "text" | "json"; +} +export declare function runAuthListCommand(): Promise; +export declare function runAuthListCommandWithOptions(options: AuthCommandOptions): Promise; +export declare function runAuthTestCommand(options: AuthCommandOptions): Promise; +export declare function runAuthClearCommand(options: AuthCommandOptions): Promise; +//# sourceMappingURL=auth.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/auth.d.ts.map b/packages/interfacectl-cli/dist/commands/auth.d.ts.map new file mode 100644 index 0000000..2125b83 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/auth.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAED,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAE1D;AAED,wBAAsB,6BAA6B,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0BhG;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqDrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgCtF"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/auth.js b/packages/interfacectl-cli/dist/commands/auth.js new file mode 100644 index 0000000..a828582 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/auth.js @@ -0,0 +1,92 @@ +import { clearAuthProfiles, findAuthProfile, getAuthStorageMode, isProfileExpired, listAuthProfiles, } from "../utils/auth-profiles.js"; +export async function runAuthListCommand() { + return runAuthListCommandWithOptions({}); +} +export async function runAuthListCommandWithOptions(options) { + const profiles = await listAuthProfiles(); + const storageMode = getAuthStorageMode(); + if (options.format === "json") { + const payload = { + ok: true, + storageMode, + profiles: profiles.map((profile) => ({ + ...profile, + status: isProfileExpired(profile) ? "expired" : "active", + })), + }; + console.log(JSON.stringify(payload, null, 2)); + return 0; + } + if (profiles.length === 0) { + console.log(`No auth profiles found. storage=${storageMode}`); + return 0; + } + for (const profile of profiles) { + const status = isProfileExpired(profile) ? "expired" : "active"; + console.log(`${profile.name} (${profile.domain}) mode=${profile.mode} status=${status} expires=${profile.expiresAt}`); + } + return 0; +} +export async function runAuthTestCommand(options) { + if (!options.profile) { + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: "Missing --profile for auth test." }, null, 2)); + return 1; + } + console.error("Missing --profile for auth test."); + return 1; + } + const profile = await findAuthProfile(options.profile, options.domain); + if (!profile) { + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: `Auth profile not found: ${options.profile}` }, null, 2)); + return 1; + } + console.error(`Auth profile not found: ${options.profile}`); + return 1; + } + if (isProfileExpired(profile)) { + if (options.format === "json") { + console.log(JSON.stringify({ + ok: false, + error: `Auth profile expired: ${profile.name} (${profile.domain})`, + storageMode: getAuthStorageMode(), + profile: { ...profile, status: "expired" }, + }, null, 2)); + return 1; + } + console.error(`Auth profile expired: ${profile.name} (${profile.domain})`); + return 1; + } + if (options.format === "json") { + console.log(JSON.stringify({ + ok: true, + storageMode: getAuthStorageMode(), + profile: { ...profile, status: "active" }, + }, null, 2)); + return 0; + } + console.log(`Auth profile OK: ${profile.name} (${profile.domain})`); + return 0; +} +export async function runAuthClearCommand(options) { + if (!options.all && !options.profile && !options.domain) { + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: "Provide --all, --profile , or --domain ." }, null, 2)); + return 1; + } + console.error("Provide --all, --profile , or --domain ."); + return 1; + } + const removed = await clearAuthProfiles({ + all: options.all, + name: options.profile, + domain: options.domain, + }); + if (options.format === "json") { + console.log(JSON.stringify({ ok: true, removed, storageMode: getAuthStorageMode() }, null, 2)); + return 0; + } + console.log(`Removed ${removed} auth profile(s).`); + return 0; +} diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts b/packages/interfacectl-cli/dist/commands/init.d.ts new file mode 100644 index 0000000..287969a --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/init.d.ts @@ -0,0 +1,14 @@ +type ExtractMode = "remote-url" | "local-root"; +export interface InitOptions { + url?: string; + surface?: string; + surfaceName?: string; + authProfile?: string; + extractMode?: ExtractMode; + appRoot?: string; + nonInteractive?: boolean; + outDir?: 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 new file mode 100644 index 0000000..e9126e1 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/init.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAwBA,KAAK,WAAW,GAAG,YAAY,GAAG,YAAY,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAmKD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAwJ1E"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/init.js b/packages/interfacectl-cli/dist/commands/init.js new file mode 100644 index 0000000..b1169cc --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/init.js @@ -0,0 +1,271 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { extractContractFromNextApp, stableStringify } from "@surfaces/interfacectl-extractor"; +import { getBundledContractSchema, validateContractStructure } from "@surfaces/interfacectl-validator"; +import { findAuthProfile, getAuthStorageMode, isProfileExpired, saveBrowserSessionProfile, } from "../utils/auth-profiles.js"; +import { redactSensitiveText, redactSensitiveUrl } from "../utils/redaction.js"; +import { buildBootstrapContract, emitBootstrapRunArtifact, suggestSurfaceIdFromUrl, suggestSurfaceName, writeBootstrapArtifacts, } from "../utils/onboarding.js"; +function normalizeSurfaceId(raw) { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} +function browserOpenCommand(url) { + if (process.platform === "darwin") { + return { cmd: "open", args: [url] }; + } + if (process.platform === "win32") { + return { cmd: "cmd", args: ["/c", "start", "", url] }; + } + return { cmd: "xdg-open", args: [url] }; +} +async function maybeCaptureAuthProfile(inputValue) { + if (!inputValue.requiresAuth) { + return { authMode: "none" }; + } + const parsed = new URL(inputValue.url); + const profileName = inputValue.profileName ?? `${parsed.hostname}-default`; + const existing = await findAuthProfile(profileName, parsed.hostname); + if (existing && !isProfileExpired(existing)) { + return { authMode: "browser-session", profileName: existing.name }; + } + const openTarget = browserOpenCommand(inputValue.url); + const child = spawn(openTarget.cmd, openTarget.args, { + stdio: "ignore", + detached: true, + }); + child.unref(); + const rl = readline.createInterface({ input, output }); + try { + await rl.question(`Opened browser for ${parsed.hostname}. Complete login, then press Enter to continue.`); + } + finally { + rl.close(); + } + const profile = await saveBrowserSessionProfile({ + name: profileName, + domain: parsed.hostname, + }); + return { authMode: "browser-session", profileName: profile.name }; +} +async function promptInteractive(options) { + const rl = readline.createInterface({ input, output }); + try { + const url = options.url ?? (await rl.question("What is the first surface URL? ")).trim(); + const parsed = new URL(url); + const requiresAuthAnswer = (await rl.question("Is this surface behind login? (y/N) ")).trim().toLowerCase(); + const requiresAuth = requiresAuthAnswer === "y" || requiresAuthAnswer === "yes"; + const extractModeInput = (options.extractMode ?? + ((await rl.question("Extraction mode (remote-url/local-root) [remote-url]: ")).trim() || + "remote-url")).toLowerCase(); + const extractMode = extractModeInput === "local-root" ? "local-root" : "remote-url"; + const suggestedSurface = options.surface ?? suggestSurfaceIdFromUrl(parsed.toString()); + const providedSurface = (await rl.question(`Surface id [${suggestedSurface}]: `)).trim(); + const surfaceId = normalizeSurfaceId(providedSurface || suggestedSurface); + const suggestedName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const providedName = (await rl.question(`Surface name [${suggestedName}]: `)).trim(); + const surfaceName = providedName || suggestedName; + const authProfileName = requiresAuth + ? (await rl.question(`Auth profile name [${options.authProfile ?? `${parsed.hostname}-default`}]: `)).trim() || options.authProfile || `${parsed.hostname}-default` + : null; + const appRoot = extractMode === "local-root" + ? (options.appRoot ?? + (await rl.question("Local app root (directory containing app/): "))).trim() + : undefined; + return { + url: parsed.toString(), + extractMode, + appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, + surfaceId, + surfaceName, + requiresAuth, + authProfileName, + }; + } + finally { + rl.close(); + } +} +async function resolveInputs(options) { + if (!options.nonInteractive) { + return promptInteractive(options); + } + if (!options.url) { + throw new Error("Missing required --url in --non-interactive mode."); + } + const parsed = new URL(options.url); + const extractMode = options.extractMode ?? "remote-url"; + const surfaceId = normalizeSurfaceId(options.surface ?? suggestSurfaceIdFromUrl(parsed.toString())); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const requiresAuth = Boolean(options.authProfile); + if (extractMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for --extract-mode local-root."); + } + return { + url: parsed.toString(), + extractMode, + appRoot: options.appRoot, + surfaceId, + surfaceName, + requiresAuth, + authProfileName: options.authProfile ?? null, + }; +} +async function writeJson(pathname, value) { + await mkdir(path.dirname(pathname), { recursive: true }); + await writeFile(pathname, `${stableStringify(value)}\n`, "utf-8"); +} +export async function runInitCommand(options) { + try { + const rootDir = process.cwd(); + const resolved = await resolveInputs(options); + const authCapture = await maybeCaptureAuthProfile({ + requiresAuth: resolved.requiresAuth, + profileName: resolved.authProfileName, + url: resolved.url, + }); + const startTime = new Date().toISOString(); + if (resolved.extractMode === "local-root") { + const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + console.error(`Local app root is missing app/: ${appRoot}`); + return 1; + } + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, "contracts", "generated"); + const contractPath = path.join(outDir, `${resolved.surfaceId}.contract.json`); + const reportPath = path.join(outDir, `${resolved.surfaceId}.extraction.json`); + const { contract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId: resolved.surfaceId, + }); + const structure = validateContractStructure(contract, getBundledContractSchema()); + if (!structure.ok) { + console.error("Generated contract failed schema validation:"); + for (const issue of structure.errors) { + console.error(` ${issue}`); + } + return 1; + } + const reportWithOnboarding = { + ...report, + onboarding: { + sourceUrl: redactSensitiveUrl(resolved.url), + authMode: authCapture.authMode, + extractMode: resolved.extractMode, + profileName: authCapture.profileName, + profileDomain: new URL(resolved.url).hostname, + startedAt: startTime, + completedAt: new Date().toISOString(), + detection: { + adapter: "next-app-static-extractor", + framework: "nextjs", + profile: "codebase", + }, + }, + }; + await writeJson(contractPath, contract); + await writeJson(reportPath, reportWithOnboarding); + const status = report.warnings.length > 0 ? "warn" : "pass"; + const findingCodes = report.warnings.map((warning) => `extract.${warning.code}`); + const run = await emitBootstrapRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + status, + findingCodes, + extractionPath: contractPath, + reportPath, + }); + console.log(`Onboarding completed for ${resolved.surfaceId}.`); + console.log(`Wrote contract: ${contractPath}`); + console.log(`Wrote report: ${reportPath}`); + console.log(`Run id: ${run.runId}`); + console.log(`Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + } + console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); + return 0; + } + const url = new URL(resolved.url); + const authAware = authCapture.authMode === "browser-session"; + const bootstrapContract = buildBootstrapContract({ + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceUrl: redactSensitiveUrl(url.toString()), + authAware, + }); + const warnings = [ + { + code: "remote-url.bootstrap-only", + message: "Remote URL mode creates bootstrap extraction metadata only. Use local-root mode for full static extraction.", + }, + ]; + const report = { + surfaceId: resolved.surfaceId, + appRoot: url.origin, + warnings, + extracted: { + routes: [url.pathname || "/"], + hasShell: false, + designSystemComponents: [], + authAware, + }, + onboarding: { + sourceUrl: redactSensitiveUrl(url.toString()), + authMode: authCapture.authMode, + extractMode: "remote-url", + profileName: authCapture.profileName, + profileDomain: url.hostname, + startedAt: startTime, + completedAt: new Date().toISOString(), + detection: { + adapter: "remote-url-bootstrap", + framework: "unknown", + profile: "bootstrap", + }, + }, + }; + const written = await writeBootstrapArtifacts({ + rootDir, + outDir: options.outDir, + surfaceId: resolved.surfaceId, + contract: bootstrapContract, + report, + }); + const run = await emitBootstrapRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + status: "warn", + findingCodes: ["extract.remote-url.bootstrap-only"], + extractionPath: written.contractPath, + reportPath: written.reportPath, + }); + console.log(`Onboarding completed for ${resolved.surfaceId}.`); + console.log(`Wrote contract: ${written.contractPath}`); + console.log(`Wrote report: ${written.reportPath}`); + if (authCapture.profileName) { + console.log(`Auth profile: ${authCapture.profileName}`); + } + console.log(`Run id: ${run.runId}`); + console.log(`Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + } + console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); + return 0; + } + catch (error) { + console.error(redactSensitiveText(error.message)); + return 1; + } +} +const storageMode = getAuthStorageMode(); diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 4576b48..58b15c2 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -7,6 +7,8 @@ import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; +import { runInitCommand } from "./commands/init.js"; +import { runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, } from "./commands/auth.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); program @@ -202,6 +204,86 @@ program }); process.exitCode = exitCode; }); +program + .command("init") + .description("Interactive onboarding for first-surface extraction") + .option("--url ", "Surface URL for onboarding") + .option("--surface ", "Surface identifier override") + .option("--surface-name ", "Surface display name override") + .option("--extract-mode ", "Extraction mode", "remote-url") + .option("--app-root ", "Local app root (required when extract-mode is local-root)") + .option("--auth-profile ", "Auth profile name for browser-session onboarding") + .option("--non-interactive", "Run without prompts (requires --url)") + .option("--out-dir ", "Output directory for generated onboarding artifacts") + .action(async (options) => { + const extractMode = options.extractMode === "local-root" ? "local-root" : "remote-url"; + const exitCode = await runInitCommand({ + url: options.url, + surface: options.surface, + surfaceName: options.surfaceName, + extractMode, + appRoot: options.appRoot, + authProfile: options.authProfile, + nonInteractive: options.nonInteractive === true, + outDir: options.outDir, + }); + process.exitCode = exitCode; +}); +const auth = program + .command("auth") + .description("Manage onboarding browser-session auth profiles"); +auth + .command("list") + .description("List local auth profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthListCommandWithOptions({ + format: options.format === "json" ? "json" : "text", + }); +}); +auth + .command("test") + .description("Validate a local auth profile by name") + .requiredOption("--profile ", "Profile name") + .option("--domain ", "Optional domain scope") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthTestCommand({ + profile: options.profile, + domain: options.domain, + format: options.format === "json" ? "json" : "text", + }); +}); +auth + .command("clear") + .description("Clear local auth profiles") + .option("--profile ", "Profile name") + .option("--domain ", "Domain scope") + .option("--all", "Clear all profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthClearCommand({ + profile: options.profile, + domain: options.domain, + all: options.all, + format: options.format === "json" ? "json" : "text", + }); +}); +auth + .command("revoke") + .description("Revoke local auth profiles (alias for clear)") + .option("--profile ", "Profile name") + .option("--domain ", "Domain scope") + .option("--all", "Clear all profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthClearCommand({ + profile: options.profile, + domain: options.domain, + all: options.all, + format: options.format === "json" ? "json" : "text", + }); +}); program .command("describe") .description("Produce descriptors with primitives for pre-emit guard (check-generation-boundaries)") diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts new file mode 100644 index 0000000..82704e7 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts @@ -0,0 +1,41 @@ +export type AuthMode = "browser-session"; +export type AuthStorageMode = "keychain" | "file"; +export interface AuthProfile { + name: string; + domain: string; + mode: AuthMode; + createdAt: string; + updatedAt: string; + expiresAt: string; + sessionRef: string; +} +export interface AuthProfileStore { + list(): Promise; + get(name: string, domain?: string): Promise; + save(input: { + name: string; + domain: string; + ttlHours?: number; + }): Promise; + revoke(input: { + name?: string; + domain?: string; + }): Promise; + revokeAll(): Promise; + mode(): AuthStorageMode; +} +export declare function isProfileExpired(profile: AuthProfile, now?: Date): boolean; +export declare function getAuthStorageMode(): AuthStorageMode; +export declare function listAuthProfiles(): Promise; +export declare function saveBrowserSessionProfile(input: { + name: string; + domain: string; + ttlHours?: number; +}): Promise; +export declare function findAuthProfile(name: string, domain?: string): Promise; +export declare function clearAuthProfiles(input: { + all?: boolean; + name?: string; + domain?: string; +}): Promise; +//# sourceMappingURL=auth-profiles.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map new file mode 100644 index 0000000..b10286e --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth-profiles.d.ts","sourceRoot":"","sources":["../../src/utils/auth-profiles.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG,iBAAiB,CAAC;AACzC,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,MAAM,CAAC;AAElD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAOD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAC/B,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAChE,IAAI,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACvF,MAAM,CAAC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7B,IAAI,IAAI,eAAe,CAAC;CACzB;AA6CD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAEtF;AAuPD,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAE/D;AAED,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,WAAW,CAAC,CAEvB;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAMhG;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE;IAC7C,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,CAKlB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/auth-profiles.js b/packages/interfacectl-cli/dist/utils/auth-profiles.js new file mode 100644 index 0000000..8ef4a94 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/auth-profiles.js @@ -0,0 +1,274 @@ +import { execSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +const DEFAULT_TTL_HOURS = 4; +const KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; +const KEYCHAIN_ACCOUNT = "default"; +let warnedFallback = false; +function normalizeProfileName(name) { + return name.trim().toLowerCase(); +} +function isSensitiveEnvFlagTrue(name) { + return process.env[name] === "1" || process.env[name] === "true"; +} +function getProfilesPath() { + const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + const xdgConfig = process.env.XDG_CONFIG_HOME; + const configRoot = xdgConfig && xdgConfig.trim().length > 0 + ? xdgConfig + : path.join(os.homedir(), ".config"); + return path.join(configRoot, "interfacectl", "auth-profiles.json"); +} +function toValidDocument(maybeDoc) { + const parsed = maybeDoc; + const profiles = Array.isArray(parsed?.profiles) + ? parsed.profiles.filter((profile) => profile && typeof profile === "object") + : []; + return { + schemaVersion: Number(parsed?.schemaVersion || 1), + profiles, + }; +} +async function writeJsonAtomic(filePath, doc) { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, `${JSON.stringify(doc, null, 2)}\n`, "utf-8"); + await rename(tempPath, filePath); +} +export function isProfileExpired(profile, now = new Date()) { + return Date.parse(profile.expiresAt) <= now.getTime(); +} +class FileAuthProfileStore { + documentPath; + constructor(documentPath) { + this.documentPath = documentPath; + } + mode() { + return "file"; + } + async readDocument() { + if (!existsSync(this.documentPath)) { + return { schemaVersion: 1, profiles: [] }; + } + try { + const raw = await readFile(this.documentPath, "utf-8"); + return toValidDocument(JSON.parse(raw)); + } + catch { + return { schemaVersion: 1, profiles: [] }; + } + } + async writeDocument(doc) { + await writeJsonAtomic(this.documentPath, doc); + } + async list() { + const doc = await this.readDocument(); + return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); + } + async get(name, domain) { + const normalized = normalizeProfileName(name); + const profiles = await this.list(); + return profiles.find((profile) => normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true)) ?? null; + } + async save(input) { + const doc = await this.readDocument(); + const now = new Date(); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); + const normalizedName = normalizeProfileName(input.name); + const next = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + sessionRef: randomUUID(), + }; + const existingIndex = doc.profiles.findIndex((profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain); + if (existingIndex >= 0) { + next.createdAt = doc.profiles[existingIndex].createdAt; + doc.profiles[existingIndex] = next; + } + else { + doc.profiles.push(next); + } + await this.writeDocument(doc); + console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=file`); + return next; + } + async revoke(input) { + const doc = await this.readDocument(); + const before = doc.profiles.length; + if (input.name) { + const normalized = normalizeProfileName(input.name); + doc.profiles = doc.profiles.filter((profile) => normalizeProfileName(profile.name) !== normalized || + (input.domain ? profile.domain !== input.domain : false)); + } + else if (input.domain) { + doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); + } + else { + return 0; + } + const removed = before - doc.profiles.length; + if (removed > 0) { + await this.writeDocument(doc); + console.error(`[auth-event] profile_revoked count=${removed} mode=file`); + } + return removed; + } + async revokeAll() { + const doc = await this.readDocument(); + const removed = doc.profiles.length; + if (removed > 0) { + await this.writeDocument({ schemaVersion: 1, profiles: [] }); + console.error(`[auth-event] profile_revoked_all count=${removed} mode=file`); + } + return removed; + } +} +class KeychainAuthProfileStore { + static exists() { + if (process.platform !== "darwin") + return false; + if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) + return false; + try { + execSync("command -v security", { encoding: "utf-8", stdio: "ignore" }); + return true; + } + catch { + return false; + } + } + static isAvailable() { + return KeychainAuthProfileStore.exists(); + } + mode() { + return "keychain"; + } + readDocumentSync() { + try { + const raw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }); + return toValidDocument(JSON.parse(raw.trim() || "{}")); + } + catch { + return { schemaVersion: 1, profiles: [] }; + } + } + writeDocumentSync(doc) { + const payload = JSON.stringify(doc); + execSync(`security add-generic-password -U -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w '${payload.replace(/'/g, "'\\''")}'`, { stdio: ["pipe", "ignore", "ignore"] }); + } + async list() { + const doc = this.readDocumentSync(); + return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); + } + async get(name, domain) { + const normalized = normalizeProfileName(name); + const profiles = await this.list(); + return profiles.find((profile) => normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true)) ?? null; + } + async save(input) { + const doc = this.readDocumentSync(); + const now = new Date(); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const normalizedName = normalizeProfileName(input.name); + const next = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(), + sessionRef: randomUUID(), + }; + const existingIndex = doc.profiles.findIndex((profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain); + if (existingIndex >= 0) { + next.createdAt = doc.profiles[existingIndex].createdAt; + doc.profiles[existingIndex] = next; + } + else { + doc.profiles.push(next); + } + this.writeDocumentSync(doc); + console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=keychain`); + return next; + } + async revoke(input) { + const doc = this.readDocumentSync(); + const before = doc.profiles.length; + if (input.name) { + const normalized = normalizeProfileName(input.name); + doc.profiles = doc.profiles.filter((profile) => normalizeProfileName(profile.name) !== normalized || + (input.domain ? profile.domain !== input.domain : false)); + } + else if (input.domain) { + doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); + } + else { + return 0; + } + const removed = before - doc.profiles.length; + if (removed > 0) { + this.writeDocumentSync(doc); + console.error(`[auth-event] profile_revoked count=${removed} mode=keychain`); + } + return removed; + } + async revokeAll() { + const doc = this.readDocumentSync(); + const removed = doc.profiles.length; + if (removed > 0) { + this.writeDocumentSync({ schemaVersion: 1, profiles: [] }); + console.error(`[auth-event] profile_revoked_all count=${removed} mode=keychain`); + } + return removed; + } +} +let storeSingleton = null; +function resolveStore() { + if (storeSingleton) + return storeSingleton; + if (KeychainAuthProfileStore.isAvailable()) { + storeSingleton = new KeychainAuthProfileStore(); + return storeSingleton; + } + if (!warnedFallback) { + warnedFallback = true; + console.error("Warning: keychain storage unavailable; using local file storage for opaque auth session references."); + } + storeSingleton = new FileAuthProfileStore(getProfilesPath()); + return storeSingleton; +} +export function getAuthStorageMode() { + return resolveStore().mode(); +} +export async function listAuthProfiles() { + return resolveStore().list(); +} +export async function saveBrowserSessionProfile(input) { + return resolveStore().save(input); +} +export async function findAuthProfile(name, domain) { + const profile = await resolveStore().get(name, domain); + if (profile && isProfileExpired(profile)) { + console.error(`[auth-event] profile_expired profile=${profile.name} domain=${profile.domain}`); + } + return profile; +} +export async function clearAuthProfiles(input) { + if (input.all) { + return resolveStore().revokeAll(); + } + return resolveStore().revoke({ name: input.name, domain: input.domain }); +} diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts b/packages/interfacectl-cli/dist/utils/onboarding.d.ts new file mode 100644 index 0000000..f94879e --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts @@ -0,0 +1,59 @@ +type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; +export interface BootstrapExtractionReport { + surfaceId: string; + appRoot: string; + warnings: { + code: string; + message: string; + }[]; + extracted: { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; + }; + onboarding: { + sourceUrl: string; + authMode: "none" | "browser-session"; + extractMode: "remote-url" | "local-root"; + profileName?: string; + profileDomain?: string; + startedAt: string; + completedAt: string; + detection: { + adapter: string; + framework: string; + profile: string; + }; + }; +} +export declare function suggestSurfaceIdFromUrl(rawUrl: string): string; +export declare function suggestSurfaceName(surfaceId: string): string; +export declare function buildBootstrapContract(input: { + surfaceId: string; + surfaceName: string; + sourceUrl: string; + authAware: boolean; +}): Record; +export declare function writeBootstrapArtifacts(input: { + rootDir: string; + outDir?: string; + surfaceId: string; + contract: Record; + report: BootstrapExtractionReport; +}): Promise<{ + contractPath: string; + reportPath: string; +}>; +export declare function emitBootstrapRunArtifact(input: { + rootDir: string; + surfaceId: string; + status: ValidationOutcome; + findingCodes: string[]; + extractionPath: string; + reportPath: string; +}): Promise<{ + runId: string; +}>; +export {}; +//# sourceMappingURL=onboarding.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map new file mode 100644 index 0000000..e7be968 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/onboarding.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAkD9D,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACrC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;QACzC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC;YAChB,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,MAAM,CAAC;SACjB,CAAC;KACH,CAAC;CACH;AAkCD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAgB9D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA2C1B;AAED,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,yBAAyB,CAAC;CACnC,GAAG,OAAO,CAAC;IACV,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CASD;AAwBD,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAqD7B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/onboarding.js b/packages/interfacectl-cli/dist/utils/onboarding.js new file mode 100644 index 0000000..deffd99 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/onboarding.js @@ -0,0 +1,180 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stableStringify } from "@surfaces/interfacectl-extractor"; +function statusSummary(status, findingCodes) { + return { + errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, + warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, + }; +} +async function writeJsonAtomic(filePath, payload) { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, `${stableStringify(payload)}\n`, "utf-8"); + await rename(tempPath, filePath); +} +async function readJson(filePath, fallback) { + if (!existsSync(filePath)) + return fallback; + try { + const raw = await readFile(filePath, "utf-8"); + return JSON.parse(raw); + } + catch { + return fallback; + } +} +function sha256FromContent(content) { + return createHash("sha256").update(content).digest("hex"); +} +function toRelative(root, candidate) { + return path.relative(root, candidate); +} +export function suggestSurfaceIdFromUrl(rawUrl) { + const url = new URL(rawUrl); + const host = url.hostname.replace(/^www\./, ""); + const pathToken = url.pathname + .split("/") + .map((token) => token.trim()) + .filter(Boolean)[0]; + const candidate = [host.replace(/\./g, "-"), pathToken] + .filter(Boolean) + .join("-"); + const normalized = candidate + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return normalized.length > 0 ? normalized : "surface"; +} +export function suggestSurfaceName(surfaceId) { + return surfaceId + .split("-") + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(" "); +} +export function buildBootstrapContract(input) { + const url = new URL(input.sourceUrl); + return { + $schema: "https://contracts.surfaces.local/web.surface.contract.schema.json", + contractId: `${input.surfaceId}.generated`, + version: "0.1.0", + description: "Generated by interfacectl init onboarding bootstrap.", + surfaces: [ + { + id: input.surfaceId, + displayName: input.surfaceName, + type: "web", + domain: url.origin, + requiredSections: ["extracted.placeholder"], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 1120 }, + }, + ], + sections: [ + { + id: "extracted.placeholder", + intent: "extracted", + description: "Placeholder; sections are not inferred during URL onboarding bootstrap.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + x_extracted: { + routes: [url.pathname || "/"], + hasShell: false, + designSystemComponents: [], + authAware: input.authAware, + }, + x_onboarding: { + sourceUrl: input.sourceUrl, + mode: "bootstrap", + }, + }; +} +export async function writeBootstrapArtifacts(input) { + const generatedDir = input.outDir + ? path.resolve(input.rootDir, input.outDir) + : path.resolve(input.rootDir, "contracts", "generated"); + const contractPath = path.join(generatedDir, `${input.surfaceId}.contract.json`); + const reportPath = path.join(generatedDir, `${input.surfaceId}.extraction.json`); + await writeJsonAtomic(contractPath, input.contract); + await writeJsonAtomic(reportPath, input.report); + return { contractPath, reportPath }; +} +async function readCanonicalContract(rootDir) { + const canonicalPath = path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); + if (!existsSync(canonicalPath)) { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } + try { + const raw = await readFile(canonicalPath, "utf-8"); + const parsed = JSON.parse(raw); + return { + id: parsed.contractId ?? "unknown", + version: parsed.version ?? "unknown", + sha256: sha256FromContent(raw), + }; + } + catch { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } +} +export async function emitBootstrapRunArtifact(input) { + const generatedDir = path.resolve(input.rootDir, "contracts", "generated"); + const runsPath = path.join(generatedDir, "contract-runs.json"); + const lineagePath = path.join(generatedDir, "contract-lineage.json"); + const runsDoc = await readJson(runsPath, { + schemaVersion: 1, + runs: [], + }); + const lineageDoc = await readJson(lineagePath, { + schemaVersion: 1, + surfaces: {}, + }); + const runId = randomUUID(); + const createdAt = new Date().toISOString(); + const canonical = await readCanonicalContract(input.rootDir); + const runRecord = { + runId, + createdAt, + surfaceId: input.surfaceId, + source: "bootstrap", + contract: canonical, + artifacts: { + extractionPath: toRelative(input.rootDir, input.extractionPath), + reportPath: toRelative(input.rootDir, input.reportPath), + }, + status: input.status, + findingCodes: [...input.findingCodes].sort(), + summary: statusSummary(input.status, input.findingCodes), + }; + const deduped = runsDoc.runs.filter((run) => run.runId !== runId); + deduped.push(runRecord); + deduped.sort((a, b) => { + const timeDiff = Date.parse(a.createdAt) - Date.parse(b.createdAt); + if (timeDiff !== 0) + return timeDiff; + return a.runId.localeCompare(b.runId); + }); + runsDoc.runs = deduped; + lineageDoc.surfaces[input.surfaceId] = { + lastRunId: runRecord.runId, + lastRunAt: runRecord.createdAt, + lastSource: runRecord.source, + lastStatus: runRecord.status, + contract: runRecord.contract, + artifacts: runRecord.artifacts, + findingCodes: runRecord.findingCodes, + }; + await writeJsonAtomic(runsPath, runsDoc); + await writeJsonAtomic(lineagePath, lineageDoc); + return { runId }; +} diff --git a/packages/interfacectl-cli/dist/utils/redaction.d.ts b/packages/interfacectl-cli/dist/utils/redaction.d.ts new file mode 100644 index 0000000..ea7a6bc --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/redaction.d.ts @@ -0,0 +1,3 @@ +export declare function redactSensitiveUrl(rawValue: string): string; +export declare function redactSensitiveText(rawValue: string): string; +//# sourceMappingURL=redaction.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/redaction.d.ts.map b/packages/interfacectl-cli/dist/utils/redaction.d.ts.map new file mode 100644 index 0000000..e6921a8 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/redaction.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"redaction.d.ts","sourceRoot":"","sources":["../../src/utils/redaction.ts"],"names":[],"mappings":"AAEA,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAK5D"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/redaction.js b/packages/interfacectl-cli/dist/utils/redaction.js new file mode 100644 index 0000000..5776530 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/redaction.js @@ -0,0 +1,19 @@ +const SENSITIVE_KEY = /(token|cookie|session|secret|key|jwt|auth|code|state)/i; +export function redactSensitiveUrl(rawValue) { + try { + const parsed = new URL(rawValue); + for (const key of [...parsed.searchParams.keys()]) { + if (SENSITIVE_KEY.test(key)) { + parsed.searchParams.set(key, "REDACTED"); + } + } + parsed.hash = ""; + return parsed.toString(); + } + catch { + return rawValue; + } +} +export function redactSensitiveText(rawValue) { + return rawValue.replace(/([?&](?:token|cookie|session|secret|key|jwt|auth|code|state)=)[^&\s]+/gi, "$1REDACTED"); +} diff --git a/packages/interfacectl-cli/src/commands/auth.ts b/packages/interfacectl-cli/src/commands/auth.ts new file mode 100644 index 0000000..d0c3491 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/auth.ts @@ -0,0 +1,135 @@ +import { + clearAuthProfiles, + findAuthProfile, + getAuthStorageMode, + isProfileExpired, + listAuthProfiles, +} from "../utils/auth-profiles.js"; + +export interface AuthCommandOptions { + profile?: string; + domain?: string; + all?: boolean; + format?: "text" | "json"; +} + +export async function runAuthListCommand(): Promise { + return runAuthListCommandWithOptions({}); +} + +export async function runAuthListCommandWithOptions(options: AuthCommandOptions): Promise { + const profiles = await listAuthProfiles(); + const storageMode = getAuthStorageMode(); + if (options.format === "json") { + const payload = { + ok: true, + storageMode, + profiles: profiles.map((profile) => ({ + ...profile, + status: isProfileExpired(profile) ? "expired" : "active", + })), + }; + console.log(JSON.stringify(payload, null, 2)); + return 0; + } + if (profiles.length === 0) { + console.log(`No auth profiles found. storage=${storageMode}`); + return 0; + } + for (const profile of profiles) { + const status = isProfileExpired(profile) ? "expired" : "active"; + console.log( + `${profile.name} (${profile.domain}) mode=${profile.mode} status=${status} expires=${profile.expiresAt}`, + ); + } + return 0; +} + +export async function runAuthTestCommand(options: AuthCommandOptions): Promise { + if (!options.profile) { + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: "Missing --profile for auth test." }, null, 2)); + return 1; + } + console.error("Missing --profile for auth test."); + return 1; + } + const profile = await findAuthProfile(options.profile, options.domain); + if (!profile) { + if (options.format === "json") { + console.log(JSON.stringify({ ok: false, error: `Auth profile not found: ${options.profile}` }, null, 2)); + return 1; + } + console.error(`Auth profile not found: ${options.profile}`); + return 1; + } + if (isProfileExpired(profile)) { + if (options.format === "json") { + console.log( + JSON.stringify( + { + ok: false, + error: `Auth profile expired: ${profile.name} (${profile.domain})`, + storageMode: getAuthStorageMode(), + profile: { ...profile, status: "expired" }, + }, + null, + 2, + ), + ); + return 1; + } + console.error(`Auth profile expired: ${profile.name} (${profile.domain})`); + return 1; + } + if (options.format === "json") { + console.log( + JSON.stringify( + { + ok: true, + storageMode: getAuthStorageMode(), + profile: { ...profile, status: "active" }, + }, + null, + 2, + ), + ); + return 0; + } + console.log(`Auth profile OK: ${profile.name} (${profile.domain})`); + return 0; +} + +export async function runAuthClearCommand(options: AuthCommandOptions): Promise { + if (!options.all && !options.profile && !options.domain) { + if (options.format === "json") { + console.log( + JSON.stringify( + { ok: false, error: "Provide --all, --profile , or --domain ." }, + null, + 2, + ), + ); + return 1; + } + console.error("Provide --all, --profile , or --domain ."); + return 1; + } + const removed = await clearAuthProfiles({ + all: options.all, + name: options.profile, + domain: options.domain, + }); + if (options.format === "json") { + console.log( + JSON.stringify( + { ok: true, removed, storageMode: getAuthStorageMode() }, + null, + 2, + ), + ); + return 0; + } + console.log(`Removed ${removed} auth profile(s).`); + return 0; +} diff --git a/packages/interfacectl-cli/src/commands/init.ts b/packages/interfacectl-cli/src/commands/init.ts new file mode 100644 index 0000000..5c24232 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/init.ts @@ -0,0 +1,352 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { extractContractFromNextApp, stableStringify } from "@surfaces/interfacectl-extractor"; +import { getBundledContractSchema, validateContractStructure } from "@surfaces/interfacectl-validator"; +import { + findAuthProfile, + getAuthStorageMode, + isProfileExpired, + saveBrowserSessionProfile, +} from "../utils/auth-profiles.js"; +import { redactSensitiveText, redactSensitiveUrl } from "../utils/redaction.js"; +import { + buildBootstrapContract, + emitBootstrapRunArtifact, + suggestSurfaceIdFromUrl, + suggestSurfaceName, + type BootstrapExtractionReport, + writeBootstrapArtifacts, +} from "../utils/onboarding.js"; + +type ExtractMode = "remote-url" | "local-root"; + +export interface InitOptions { + url?: string; + surface?: string; + surfaceName?: string; + authProfile?: string; + extractMode?: ExtractMode; + appRoot?: string; + nonInteractive?: boolean; + outDir?: string; +} + +function normalizeSurfaceId(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function browserOpenCommand(url: string): { cmd: string; args: string[] } { + if (process.platform === "darwin") { + return { cmd: "open", args: [url] }; + } + if (process.platform === "win32") { + return { cmd: "cmd", args: ["/c", "start", "", url] }; + } + return { cmd: "xdg-open", args: [url] }; +} + +async function maybeCaptureAuthProfile(inputValue: { + requiresAuth: boolean; + profileName: string | null; + url: string; +}): Promise<{ profileName?: string; authMode: "none" | "browser-session" }> { + if (!inputValue.requiresAuth) { + return { authMode: "none" }; + } + + const parsed = new URL(inputValue.url); + const profileName = inputValue.profileName ?? `${parsed.hostname}-default`; + const existing = await findAuthProfile(profileName, parsed.hostname); + if (existing && !isProfileExpired(existing)) { + return { authMode: "browser-session", profileName: existing.name }; + } + + const openTarget = browserOpenCommand(inputValue.url); + const child = spawn(openTarget.cmd, openTarget.args, { + stdio: "ignore", + detached: true, + }); + child.unref(); + + const rl = readline.createInterface({ input, output }); + try { + await rl.question( + `Opened browser for ${parsed.hostname}. Complete login, then press Enter to continue.`, + ); + } finally { + rl.close(); + } + + const profile = await saveBrowserSessionProfile({ + name: profileName, + domain: parsed.hostname, + }); + return { authMode: "browser-session", profileName: profile.name }; +} + +async function promptInteractive(options: InitOptions): Promise<{ + url: string; + extractMode: ExtractMode; + appRoot?: string; + surfaceId: string; + surfaceName: string; + requiresAuth: boolean; + authProfileName: string | null; +}> { + const rl = readline.createInterface({ input, output }); + try { + const url = options.url ?? (await rl.question("What is the first surface URL? ")).trim(); + const parsed = new URL(url); + const requiresAuthAnswer = ( + await rl.question("Is this surface behind login? (y/N) ") + ).trim().toLowerCase(); + const requiresAuth = requiresAuthAnswer === "y" || requiresAuthAnswer === "yes"; + const extractModeInput = ( + options.extractMode ?? + ( + (await rl.question("Extraction mode (remote-url/local-root) [remote-url]: ")).trim() || + "remote-url" + ) + ).toLowerCase(); + const extractMode: ExtractMode = + extractModeInput === "local-root" ? "local-root" : "remote-url"; + const suggestedSurface = options.surface ?? suggestSurfaceIdFromUrl(parsed.toString()); + const providedSurface = ( + await rl.question(`Surface id [${suggestedSurface}]: `) + ).trim(); + const surfaceId = normalizeSurfaceId(providedSurface || suggestedSurface); + const suggestedName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const providedName = (await rl.question(`Surface name [${suggestedName}]: `)).trim(); + const surfaceName = providedName || suggestedName; + const authProfileName = requiresAuth + ? ( + await rl.question( + `Auth profile name [${options.authProfile ?? `${parsed.hostname}-default`}]: `, + ) + ).trim() || options.authProfile || `${parsed.hostname}-default` + : null; + const appRoot = extractMode === "local-root" + ? ( + options.appRoot ?? + (await rl.question("Local app root (directory containing app/): ")) + ).trim() + : undefined; + + return { + url: parsed.toString(), + extractMode, + appRoot: appRoot && appRoot.length > 0 ? appRoot : undefined, + surfaceId, + surfaceName, + requiresAuth, + authProfileName, + }; + } finally { + rl.close(); + } +} + +async function resolveInputs(options: InitOptions): Promise<{ + url: string; + extractMode: ExtractMode; + appRoot?: string; + surfaceId: string; + surfaceName: string; + requiresAuth: boolean; + authProfileName: string | null; +}> { + if (!options.nonInteractive) { + return promptInteractive(options); + } + if (!options.url) { + throw new Error("Missing required --url in --non-interactive mode."); + } + const parsed = new URL(options.url); + const extractMode = options.extractMode ?? "remote-url"; + const surfaceId = normalizeSurfaceId(options.surface ?? suggestSurfaceIdFromUrl(parsed.toString())); + const surfaceName = options.surfaceName ?? suggestSurfaceName(surfaceId); + const requiresAuth = Boolean(options.authProfile); + + if (extractMode === "local-root" && !options.appRoot) { + throw new Error("Missing required --app-root for --extract-mode local-root."); + } + + return { + url: parsed.toString(), + extractMode, + appRoot: options.appRoot, + surfaceId, + surfaceName, + requiresAuth, + authProfileName: options.authProfile ?? null, + }; +} + +async function writeJson(pathname: string, value: unknown): Promise { + await mkdir(path.dirname(pathname), { recursive: true }); + await writeFile(pathname, `${stableStringify(value)}\n`, "utf-8"); +} + +export async function runInitCommand(options: InitOptions): Promise { + try { + const rootDir = process.cwd(); + const resolved = await resolveInputs(options); + const authCapture = await maybeCaptureAuthProfile({ + requiresAuth: resolved.requiresAuth, + profileName: resolved.authProfileName, + url: resolved.url, + }); + const startTime = new Date().toISOString(); + + if (resolved.extractMode === "local-root") { + const appRoot = path.resolve(rootDir, resolved.appRoot ?? "."); + if (!existsSync(path.join(appRoot, "app"))) { + console.error(`Local app root is missing app/: ${appRoot}`); + return 1; + } + const outDir = options.outDir + ? path.resolve(rootDir, options.outDir) + : path.resolve(rootDir, "contracts", "generated"); + const contractPath = path.join(outDir, `${resolved.surfaceId}.contract.json`); + const reportPath = path.join(outDir, `${resolved.surfaceId}.extraction.json`); + + const { contract, report } = await extractContractFromNextApp({ + appRoot, + surfaceId: resolved.surfaceId, + }); + const structure = validateContractStructure(contract as unknown, 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 reportWithOnboarding = { + ...report, + onboarding: { + sourceUrl: redactSensitiveUrl(resolved.url), + authMode: authCapture.authMode, + extractMode: resolved.extractMode, + profileName: authCapture.profileName, + profileDomain: new URL(resolved.url).hostname, + startedAt: startTime, + completedAt: new Date().toISOString(), + detection: { + adapter: "next-app-static-extractor", + framework: "nextjs", + profile: "codebase", + }, + }, + }; + await writeJson(contractPath, contract); + await writeJson(reportPath, reportWithOnboarding); + + const status = report.warnings.length > 0 ? "warn" : "pass"; + const findingCodes = report.warnings.map((warning) => `extract.${warning.code}`); + const run = await emitBootstrapRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + status, + findingCodes, + extractionPath: contractPath, + reportPath, + }); + + console.log(`Onboarding completed for ${resolved.surfaceId}.`); + console.log(`Wrote contract: ${contractPath}`); + console.log(`Wrote report: ${reportPath}`); + console.log(`Run id: ${run.runId}`); + console.log(`Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + } + console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); + return 0; + } + + const url = new URL(resolved.url); + const authAware = authCapture.authMode === "browser-session"; + const bootstrapContract = buildBootstrapContract({ + surfaceId: resolved.surfaceId, + surfaceName: resolved.surfaceName, + sourceUrl: redactSensitiveUrl(url.toString()), + authAware, + }); + const warnings = [ + { + code: "remote-url.bootstrap-only", + message: + "Remote URL mode creates bootstrap extraction metadata only. Use local-root mode for full static extraction.", + }, + ]; + const report: BootstrapExtractionReport = { + surfaceId: resolved.surfaceId, + appRoot: url.origin, + warnings, + extracted: { + routes: [url.pathname || "/"], + hasShell: false, + designSystemComponents: [], + authAware, + }, + onboarding: { + sourceUrl: redactSensitiveUrl(url.toString()), + authMode: authCapture.authMode, + extractMode: "remote-url", + profileName: authCapture.profileName, + profileDomain: url.hostname, + startedAt: startTime, + completedAt: new Date().toISOString(), + detection: { + adapter: "remote-url-bootstrap", + framework: "unknown", + profile: "bootstrap", + }, + }, + }; + const written = await writeBootstrapArtifacts({ + rootDir, + outDir: options.outDir, + surfaceId: resolved.surfaceId, + contract: bootstrapContract, + report, + }); + const run = await emitBootstrapRunArtifact({ + rootDir, + surfaceId: resolved.surfaceId, + status: "warn", + findingCodes: ["extract.remote-url.bootstrap-only"], + extractionPath: written.contractPath, + reportPath: written.reportPath, + }); + + console.log(`Onboarding completed for ${resolved.surfaceId}.`); + console.log(`Wrote contract: ${written.contractPath}`); + console.log(`Wrote report: ${written.reportPath}`); + if (authCapture.profileName) { + console.log(`Auth profile: ${authCapture.profileName}`); + } + console.log(`Run id: ${run.runId}`); + console.log(`Auth storage: ${storageMode}`); + if (storageMode === "file") { + console.log("Warning: keychain unavailable; using local file storage for opaque session references."); + } + console.log(`Next: interfacectl validate --root . --surface ${resolved.surfaceId}`); + return 0; + } catch (error) { + console.error(redactSensitiveText((error as Error).message)); + return 1; + } +} + const storageMode = getAuthStorageMode(); diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index b2469a3..bf01b86 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -8,6 +8,12 @@ import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; +import { runInitCommand } from "./commands/init.js"; +import { + runAuthClearCommand, + runAuthListCommandWithOptions, + runAuthTestCommand, +} from "./commands/auth.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); @@ -300,6 +306,93 @@ program process.exitCode = exitCode; }); +program + .command("init") + .description("Interactive onboarding for first-surface extraction") + .option("--url ", "Surface URL for onboarding") + .option("--surface ", "Surface identifier override") + .option("--surface-name ", "Surface display name override") + .option("--extract-mode ", "Extraction mode", "remote-url") + .option("--app-root ", "Local app root (required when extract-mode is local-root)") + .option("--auth-profile ", "Auth profile name for browser-session onboarding") + .option("--non-interactive", "Run without prompts (requires --url)") + .option("--out-dir ", "Output directory for generated onboarding artifacts") + .action(async (options) => { + const extractMode = + options.extractMode === "local-root" ? "local-root" : "remote-url"; + const exitCode = await runInitCommand({ + url: options.url, + surface: options.surface, + surfaceName: options.surfaceName, + extractMode, + appRoot: options.appRoot, + authProfile: options.authProfile, + nonInteractive: options.nonInteractive === true, + outDir: options.outDir, + }); + process.exitCode = exitCode; + }); + +const auth = program + .command("auth") + .description("Manage onboarding browser-session auth profiles"); + +auth + .command("list") + .description("List local auth profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthListCommandWithOptions({ + format: options.format === "json" ? "json" : "text", + }); + }); + +auth + .command("test") + .description("Validate a local auth profile by name") + .requiredOption("--profile ", "Profile name") + .option("--domain ", "Optional domain scope") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthTestCommand({ + profile: options.profile, + domain: options.domain, + format: options.format === "json" ? "json" : "text", + }); + }); + +auth + .command("clear") + .description("Clear local auth profiles") + .option("--profile ", "Profile name") + .option("--domain ", "Domain scope") + .option("--all", "Clear all profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthClearCommand({ + profile: options.profile, + domain: options.domain, + all: options.all, + format: options.format === "json" ? "json" : "text", + }); + }); + +auth + .command("revoke") + .description("Revoke local auth profiles (alias for clear)") + .option("--profile ", "Profile name") + .option("--domain ", "Domain scope") + .option("--all", "Clear all profiles") + .option("--format ", "Output format", "text") + .action(async (options) => { + process.exitCode = await runAuthClearCommand({ + profile: options.profile, + domain: options.domain, + all: options.all, + format: options.format === "json" ? "json" : "text", + }); + }); + program .command("describe") .description("Produce descriptors with primitives for pre-emit guard (check-generation-boundaries)") @@ -346,4 +439,3 @@ program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; }); - diff --git a/packages/interfacectl-cli/src/utils/auth-profiles.ts b/packages/interfacectl-cli/src/utils/auth-profiles.ts new file mode 100644 index 0000000..1edb4d3 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/auth-profiles.ts @@ -0,0 +1,360 @@ +import { execSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type AuthMode = "browser-session"; +export type AuthStorageMode = "keychain" | "file"; + +export interface AuthProfile { + name: string; + domain: string; + mode: AuthMode; + createdAt: string; + updatedAt: string; + expiresAt: string; + sessionRef: string; +} + +interface AuthProfilesDocument { + schemaVersion: number; + profiles: AuthProfile[]; +} + +export interface AuthProfileStore { + list(): Promise; + get(name: string, domain?: string): Promise; + save(input: { name: string; domain: string; ttlHours?: number }): Promise; + revoke(input: { name?: string; domain?: string }): Promise; + revokeAll(): Promise; + mode(): AuthStorageMode; +} + +const DEFAULT_TTL_HOURS = 4; +const KEYCHAIN_SERVICE = "interfacectl.auth.profiles"; +const KEYCHAIN_ACCOUNT = "default"; +let warnedFallback = false; + +function normalizeProfileName(name: string): string { + return name.trim().toLowerCase(); +} + +function isSensitiveEnvFlagTrue(name: string): boolean { + return process.env[name] === "1" || process.env[name] === "true"; +} + +function getProfilesPath(): string { + const override = process.env.INTERFACECTL_AUTH_PROFILES_PATH; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + const xdgConfig = process.env.XDG_CONFIG_HOME; + const configRoot = xdgConfig && xdgConfig.trim().length > 0 + ? xdgConfig + : path.join(os.homedir(), ".config"); + return path.join(configRoot, "interfacectl", "auth-profiles.json"); +} + +function toValidDocument(maybeDoc: unknown): AuthProfilesDocument { + const parsed = maybeDoc as Partial; + const profiles = Array.isArray(parsed?.profiles) + ? parsed.profiles.filter((profile) => profile && typeof profile === "object") as AuthProfile[] + : []; + return { + schemaVersion: Number(parsed?.schemaVersion || 1), + profiles, + }; +} + +async function writeJsonAtomic(filePath: string, doc: AuthProfilesDocument): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, `${JSON.stringify(doc, null, 2)}\n`, "utf-8"); + await rename(tempPath, filePath); +} + +export function isProfileExpired(profile: AuthProfile, now: Date = new Date()): boolean { + return Date.parse(profile.expiresAt) <= now.getTime(); +} + +class FileAuthProfileStore implements AuthProfileStore { + private readonly documentPath: string; + + constructor(documentPath: string) { + this.documentPath = documentPath; + } + + mode(): AuthStorageMode { + return "file"; + } + + private async readDocument(): Promise { + if (!existsSync(this.documentPath)) { + return { schemaVersion: 1, profiles: [] }; + } + try { + const raw = await readFile(this.documentPath, "utf-8"); + return toValidDocument(JSON.parse(raw)); + } catch { + return { schemaVersion: 1, profiles: [] }; + } + } + + private async writeDocument(doc: AuthProfilesDocument): Promise { + await writeJsonAtomic(this.documentPath, doc); + } + + async list(): Promise { + const doc = await this.readDocument(); + return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); + } + + async get(name: string, domain?: string): Promise { + const normalized = normalizeProfileName(name); + const profiles = await this.list(); + return profiles.find( + (profile) => + normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true), + ) ?? null; + } + + async save(input: { name: string; domain: string; ttlHours?: number }): Promise { + const doc = await this.readDocument(); + const now = new Date(); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(); + const normalizedName = normalizeProfileName(input.name); + + const next: AuthProfile = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + sessionRef: randomUUID(), + }; + + const existingIndex = doc.profiles.findIndex( + (profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain, + ); + if (existingIndex >= 0) { + next.createdAt = doc.profiles[existingIndex].createdAt; + doc.profiles[existingIndex] = next; + } else { + doc.profiles.push(next); + } + + await this.writeDocument(doc); + console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=file`); + return next; + } + + async revoke(input: { name?: string; domain?: string }): Promise { + const doc = await this.readDocument(); + const before = doc.profiles.length; + + if (input.name) { + const normalized = normalizeProfileName(input.name); + doc.profiles = doc.profiles.filter( + (profile) => + normalizeProfileName(profile.name) !== normalized || + (input.domain ? profile.domain !== input.domain : false), + ); + } else if (input.domain) { + doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); + } else { + return 0; + } + + const removed = before - doc.profiles.length; + if (removed > 0) { + await this.writeDocument(doc); + console.error(`[auth-event] profile_revoked count=${removed} mode=file`); + } + return removed; + } + + async revokeAll(): Promise { + const doc = await this.readDocument(); + const removed = doc.profiles.length; + if (removed > 0) { + await this.writeDocument({ schemaVersion: 1, profiles: [] }); + console.error(`[auth-event] profile_revoked_all count=${removed} mode=file`); + } + return removed; + } +} + +class KeychainAuthProfileStore implements AuthProfileStore { + private static exists(): boolean { + if (process.platform !== "darwin") return false; + if (isSensitiveEnvFlagTrue("INTERFACECTL_AUTH_DISABLE_KEYCHAIN")) return false; + try { + execSync("command -v security", { encoding: "utf-8", stdio: "ignore" }); + return true; + } catch { + return false; + } + } + + static isAvailable(): boolean { + return KeychainAuthProfileStore.exists(); + } + + mode(): AuthStorageMode { + return "keychain"; + } + + private readDocumentSync(): AuthProfilesDocument { + try { + const raw = execSync( + `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w`, + { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }, + ); + return toValidDocument(JSON.parse(raw.trim() || "{}")); + } catch { + return { schemaVersion: 1, profiles: [] }; + } + } + + private writeDocumentSync(doc: AuthProfilesDocument): void { + const payload = JSON.stringify(doc); + execSync( + `security add-generic-password -U -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w '${payload.replace(/'/g, "'\\''")}'`, + { stdio: ["pipe", "ignore", "ignore"] }, + ); + } + + async list(): Promise { + const doc = this.readDocumentSync(); + return [...doc.profiles].sort((a, b) => a.name.localeCompare(b.name)); + } + + async get(name: string, domain?: string): Promise { + const normalized = normalizeProfileName(name); + const profiles = await this.list(); + return profiles.find( + (profile) => + normalizeProfileName(profile.name) === normalized && + (domain ? profile.domain === domain : true), + ) ?? null; + } + + async save(input: { name: string; domain: string; ttlHours?: number }): Promise { + const doc = this.readDocumentSync(); + const now = new Date(); + const ttlHours = input.ttlHours ?? DEFAULT_TTL_HOURS; + const normalizedName = normalizeProfileName(input.name); + const next: AuthProfile = { + name: normalizedName, + domain: input.domain, + mode: "browser-session", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(), + sessionRef: randomUUID(), + }; + const existingIndex = doc.profiles.findIndex( + (profile) => normalizeProfileName(profile.name) === normalizedName && profile.domain === input.domain, + ); + if (existingIndex >= 0) { + next.createdAt = doc.profiles[existingIndex].createdAt; + doc.profiles[existingIndex] = next; + } else { + doc.profiles.push(next); + } + this.writeDocumentSync(doc); + console.error(`[auth-event] profile_created profile=${next.name} domain=${next.domain} mode=keychain`); + return next; + } + + async revoke(input: { name?: string; domain?: string }): Promise { + const doc = this.readDocumentSync(); + const before = doc.profiles.length; + if (input.name) { + const normalized = normalizeProfileName(input.name); + doc.profiles = doc.profiles.filter( + (profile) => + normalizeProfileName(profile.name) !== normalized || + (input.domain ? profile.domain !== input.domain : false), + ); + } else if (input.domain) { + doc.profiles = doc.profiles.filter((profile) => profile.domain !== input.domain); + } else { + return 0; + } + const removed = before - doc.profiles.length; + if (removed > 0) { + this.writeDocumentSync(doc); + console.error(`[auth-event] profile_revoked count=${removed} mode=keychain`); + } + return removed; + } + + async revokeAll(): Promise { + const doc = this.readDocumentSync(); + const removed = doc.profiles.length; + if (removed > 0) { + this.writeDocumentSync({ schemaVersion: 1, profiles: [] }); + console.error(`[auth-event] profile_revoked_all count=${removed} mode=keychain`); + } + return removed; + } +} + +let storeSingleton: AuthProfileStore | null = null; + +function resolveStore(): AuthProfileStore { + if (storeSingleton) return storeSingleton; + if (KeychainAuthProfileStore.isAvailable()) { + storeSingleton = new KeychainAuthProfileStore(); + return storeSingleton; + } + if (!warnedFallback) { + warnedFallback = true; + console.error( + "Warning: keychain storage unavailable; using local file storage for opaque auth session references.", + ); + } + storeSingleton = new FileAuthProfileStore(getProfilesPath()); + return storeSingleton; +} + +export function getAuthStorageMode(): AuthStorageMode { + return resolveStore().mode(); +} + +export async function listAuthProfiles(): Promise { + return resolveStore().list(); +} + +export async function saveBrowserSessionProfile(input: { + name: string; + domain: string; + ttlHours?: number; +}): Promise { + return resolveStore().save(input); +} + +export async function findAuthProfile(name: string, domain?: string): Promise { + const profile = await resolveStore().get(name, domain); + if (profile && isProfileExpired(profile)) { + console.error(`[auth-event] profile_expired profile=${profile.name} domain=${profile.domain}`); + } + return profile; +} + +export async function clearAuthProfiles(input: { + all?: boolean; + name?: string; + domain?: string; +}): Promise { + if (input.all) { + return resolveStore().revokeAll(); + } + return resolveStore().revoke({ name: input.name, domain: input.domain }); +} diff --git a/packages/interfacectl-cli/src/utils/onboarding.ts b/packages/interfacectl-cli/src/utils/onboarding.ts new file mode 100644 index 0000000..1da46be --- /dev/null +++ b/packages/interfacectl-cli/src/utils/onboarding.ts @@ -0,0 +1,293 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stableStringify } from "@surfaces/interfacectl-extractor"; + +type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; +type RunSource = "bootstrap" | "generation" | "ci" | "runtime"; + +interface ContractRunsDocument { + schemaVersion: number; + runs: ContractRun[]; +} + +interface ContractRun { + runId: string; + createdAt: string; + surfaceId: string; + source: RunSource; + contract: { + id: string; + version: string; + sha256: string; + }; + artifacts: { + extractionPath?: string; + reportPath?: string; + }; + status: ValidationOutcome; + findingCodes: string[]; + summary: { + errorCount: number; + warnCount: number; + }; +} + +interface ContractLineageDocument { + schemaVersion: number; + surfaces: Record; +} + +export interface BootstrapExtractionReport { + surfaceId: string; + appRoot: string; + warnings: { code: string; message: string }[]; + extracted: { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; + }; + onboarding: { + sourceUrl: string; + authMode: "none" | "browser-session"; + extractMode: "remote-url" | "local-root"; + profileName?: string; + profileDomain?: string; + startedAt: string; + completedAt: string; + detection: { + adapter: string; + framework: string; + profile: string; + }; + }; +} + +function statusSummary(status: ValidationOutcome, findingCodes: string[]) { + return { + errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, + warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, + }; +} + +async function writeJsonAtomic(filePath: string, payload: unknown): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, `${stableStringify(payload)}\n`, "utf-8"); + await rename(tempPath, filePath); +} + +async function readJson(filePath: string, fallback: T): Promise { + if (!existsSync(filePath)) return fallback; + try { + const raw = await readFile(filePath, "utf-8"); + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function sha256FromContent(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function toRelative(root: string, candidate: string): string { + return path.relative(root, candidate); +} + +export function suggestSurfaceIdFromUrl(rawUrl: string): string { + const url = new URL(rawUrl); + const host = url.hostname.replace(/^www\./, ""); + const pathToken = url.pathname + .split("/") + .map((token) => token.trim()) + .filter(Boolean)[0]; + const candidate = [host.replace(/\./g, "-"), pathToken] + .filter(Boolean) + .join("-"); + const normalized = candidate + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return normalized.length > 0 ? normalized : "surface"; +} + +export function suggestSurfaceName(surfaceId: string): string { + return surfaceId + .split("-") + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(" "); +} + +export function buildBootstrapContract(input: { + surfaceId: string; + surfaceName: string; + sourceUrl: string; + authAware: boolean; +}): Record { + const url = new URL(input.sourceUrl); + return { + $schema: + "https://contracts.surfaces.local/web.surface.contract.schema.json", + contractId: `${input.surfaceId}.generated`, + version: "0.1.0", + description: "Generated by interfacectl init onboarding bootstrap.", + surfaces: [ + { + id: input.surfaceId, + displayName: input.surfaceName, + type: "web", + domain: url.origin, + requiredSections: ["extracted.placeholder"], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 1120 }, + }, + ], + sections: [ + { + id: "extracted.placeholder", + intent: "extracted", + description: "Placeholder; sections are not inferred during URL onboarding bootstrap.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + x_extracted: { + routes: [url.pathname || "/"], + hasShell: false, + designSystemComponents: [], + authAware: input.authAware, + }, + x_onboarding: { + sourceUrl: input.sourceUrl, + mode: "bootstrap", + }, + }; +} + +export async function writeBootstrapArtifacts(input: { + rootDir: string; + outDir?: string; + surfaceId: string; + contract: Record; + report: BootstrapExtractionReport; +}): Promise<{ + contractPath: string; + reportPath: string; +}> { + const generatedDir = input.outDir + ? path.resolve(input.rootDir, input.outDir) + : path.resolve(input.rootDir, "contracts", "generated"); + const contractPath = path.join(generatedDir, `${input.surfaceId}.contract.json`); + const reportPath = path.join(generatedDir, `${input.surfaceId}.extraction.json`); + await writeJsonAtomic(contractPath, input.contract); + await writeJsonAtomic(reportPath, input.report); + return { contractPath, reportPath }; +} + +async function readCanonicalContract(rootDir: string): Promise<{ + id: string; + version: string; + sha256: string; +}> { + const canonicalPath = path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); + if (!existsSync(canonicalPath)) { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } + try { + const raw = await readFile(canonicalPath, "utf-8"); + const parsed = JSON.parse(raw) as { contractId?: string; version?: string }; + return { + id: parsed.contractId ?? "unknown", + version: parsed.version ?? "unknown", + sha256: sha256FromContent(raw), + }; + } catch { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } +} + +export async function emitBootstrapRunArtifact(input: { + rootDir: string; + surfaceId: string; + status: ValidationOutcome; + findingCodes: string[]; + extractionPath: string; + reportPath: string; +}): Promise<{ runId: string }> { + const generatedDir = path.resolve(input.rootDir, "contracts", "generated"); + const runsPath = path.join(generatedDir, "contract-runs.json"); + const lineagePath = path.join(generatedDir, "contract-lineage.json"); + + const runsDoc = await readJson(runsPath, { + schemaVersion: 1, + runs: [], + }); + const lineageDoc = await readJson(lineagePath, { + schemaVersion: 1, + surfaces: {}, + }); + + const runId = randomUUID(); + const createdAt = new Date().toISOString(); + const canonical = await readCanonicalContract(input.rootDir); + const runRecord: ContractRun = { + runId, + createdAt, + surfaceId: input.surfaceId, + source: "bootstrap", + contract: canonical, + artifacts: { + extractionPath: toRelative(input.rootDir, input.extractionPath), + reportPath: toRelative(input.rootDir, input.reportPath), + }, + status: input.status, + findingCodes: [...input.findingCodes].sort(), + summary: statusSummary(input.status, input.findingCodes), + }; + + const deduped = runsDoc.runs.filter((run) => run.runId !== runId); + deduped.push(runRecord); + deduped.sort((a, b) => { + const timeDiff = Date.parse(a.createdAt) - Date.parse(b.createdAt); + if (timeDiff !== 0) return timeDiff; + return a.runId.localeCompare(b.runId); + }); + runsDoc.runs = deduped; + lineageDoc.surfaces[input.surfaceId] = { + lastRunId: runRecord.runId, + lastRunAt: runRecord.createdAt, + lastSource: runRecord.source, + lastStatus: runRecord.status, + contract: runRecord.contract, + artifacts: runRecord.artifacts, + findingCodes: runRecord.findingCodes, + }; + + await writeJsonAtomic(runsPath, runsDoc); + await writeJsonAtomic(lineagePath, lineageDoc); + return { runId }; +} diff --git a/packages/interfacectl-cli/src/utils/redaction.ts b/packages/interfacectl-cli/src/utils/redaction.ts new file mode 100644 index 0000000..79e033a --- /dev/null +++ b/packages/interfacectl-cli/src/utils/redaction.ts @@ -0,0 +1,23 @@ +const SENSITIVE_KEY = /(token|cookie|session|secret|key|jwt|auth|code|state)/i; + +export function redactSensitiveUrl(rawValue: string): string { + try { + const parsed = new URL(rawValue); + for (const key of [...parsed.searchParams.keys()]) { + if (SENSITIVE_KEY.test(key)) { + parsed.searchParams.set(key, "REDACTED"); + } + } + parsed.hash = ""; + return parsed.toString(); + } catch { + return rawValue; + } +} + +export function redactSensitiveText(rawValue: string): string { + return rawValue.replace( + /([?&](?:token|cookie|session|secret|key|jwt|auth|code|state)=)[^&\s]+/gi, + "$1REDACTED", + ); +} diff --git a/packages/interfacectl-cli/test/init-auth.test.mjs b/packages/interfacectl-cli/test/init-auth.test.mjs new file mode 100644 index 0000000..366f321 --- /dev/null +++ b/packages/interfacectl-cli/test/init-auth.test.mjs @@ -0,0 +1,175 @@ +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); +const forceFileStorageEnv = { INTERFACECTL_AUTH_DISABLE_KEYCHAIN: "1" }; + +async function run(args, options = {}) { + const child = spawn("node", [cliPath, ...args], { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + const [exitCode] = await once(child, "exit"); + return { exitCode: Number(exitCode), stdout, stderr }; +} + +test("init: non-interactive remote-url writes onboarding artifacts and run metadata", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-remote-")); + const profilePath = path.join(cwd, "auth-profiles.json"); + try { + 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", + "https://customer.example.com/products", + "--surface", + "customer-products", + ], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(result.exitCode, 0, result.stderr); + const generatedDir = path.join(cwd, "contracts", "generated"); + const contract = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.contract.json"), "utf-8"), + ); + const extraction = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.extraction.json"), "utf-8"), + ); + const runs = JSON.parse( + await readFile(path.join(generatedDir, "contract-runs.json"), "utf-8"), + ); + const lineage = JSON.parse( + await readFile(path.join(generatedDir, "contract-lineage.json"), "utf-8"), + ); + + assert.equal(contract.surfaces[0].id, "customer-products"); + assert.equal(extraction.onboarding.extractMode, "remote-url"); + assert.equal(extraction.onboarding.authMode, "none"); + assert.equal(runs.schemaVersion, 1); + assert.equal(runs.runs[0].source, "bootstrap"); + assert.equal(lineage.surfaces["customer-products"].lastSource, "bootstrap"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auth: list/test/clear operate on local profile store", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-")); + const profilePath = path.join(cwd, "auth-profiles.json"); + try { + await writeFile( + profilePath, + JSON.stringify({ + schemaVersion: 1, + profiles: [ + { + name: "demo", + domain: "customer.example.com", + mode: "browser-session", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + sessionRef: "session-1", + }, + ], + }, null, 2), + "utf-8", + ); + + const list = await run( + ["auth", "list", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(list.exitCode, 0, list.stderr); + const listPayload = JSON.parse(list.stdout); + assert.equal(listPayload.ok, true); + assert.equal(listPayload.profiles.length, 1); + assert.equal(listPayload.profiles[0].name, "demo"); + + const testProfile = await run( + ["auth", "test", "--profile", "demo", "--domain", "customer.example.com", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(testProfile.exitCode, 0, testProfile.stderr); + assert.equal(JSON.parse(testProfile.stdout).ok, true); + + const clear = await run( + ["auth", "revoke", "--profile", "demo", "--domain", "customer.example.com", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(clear.exitCode, 0, clear.stderr); + assert.equal(JSON.parse(clear.stdout).removed, 1); + + const listAfter = await run( + ["auth", "list", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(listAfter.exitCode, 0, listAfter.stderr); + assert.equal(JSON.parse(listAfter.stdout).profiles.length, 0); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auth test returns non-zero when profile is expired", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-auth-expired-")); + const profilePath = path.join(cwd, "auth-profiles.json"); + try { + await writeFile( + profilePath, + JSON.stringify({ + schemaVersion: 1, + profiles: [ + { + name: "expired", + domain: "customer.example.com", + mode: "browser-session", + createdAt: new Date(Date.now() - 7200_000).toISOString(), + updatedAt: new Date(Date.now() - 7200_000).toISOString(), + expiresAt: new Date(Date.now() - 3600_000).toISOString(), + sessionRef: "session-2", + }, + ], + }, null, 2), + "utf-8", + ); + const result = await run( + ["auth", "test", "--profile", "expired", "--domain", "customer.example.com", "--format", "json"], + { cwd, env: { ...forceFileStorageEnv, INTERFACECTL_AUTH_PROFILES_PATH: profilePath } }, + ); + assert.equal(result.exitCode, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, false); + assert.match(payload.error, /expired/i); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +});