From 8724e230241b053f0c11a802a5d84aeb1c3851f3 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 13:55:57 -0700 Subject: [PATCH 01/13] feat(cli): add offline project inspection command - Reuse framework and monorepo detection for JSON output - Bypass auth, update checks, and local CLI delegation --- packages/cli/package.json | 3 +- packages/cli/scripts/test-inspect.ts | 85 +++++++++++++++++++++ packages/cli/src/cmd/index.ts | 1 + packages/cli/src/cmd/inspect.ts | 108 +++++++++++++++++++++++++++ packages/cli/src/local-delegate.ts | 41 ++++++++-- packages/cli/src/main.ts | 16 ++-- 6 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 packages/cli/scripts/test-inspect.ts create mode 100644 packages/cli/src/cmd/inspect.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cf12e388..36b128296 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,7 +27,7 @@ "build": "bunx tsc --build --force && bun run build:assets", "build:assets": "bun ./scripts/copy-assets.ts", "typecheck": "bunx tsc --noEmit", - "test": "bun ../../tests/create/basic-flow.ts && bun ../../tests/create/non-empty-dir.ts && bun scripts/test-exit-codes.ts && bun scripts/test-response-schema.ts && bun scripts/test-batch-reporting.ts && bun scripts/test-response-envelope.ts && bun scripts/test-concurrent-sessions.ts", + "test": "bun ../../tests/create/basic-flow.ts && bun ../../tests/create/non-empty-dir.ts && bun scripts/test-exit-codes.ts && bun scripts/test-response-schema.ts && bun scripts/test-batch-reporting.ts && bun scripts/test-response-envelope.ts && bun scripts/test-concurrent-sessions.ts && bun scripts/test-inspect.ts", "test:create": "bun ../../tests/create/basic-flow.ts", "test:create:non-empty": "bun ../../tests/create/non-empty-dir.ts", "test:exit-codes": "bun scripts/test-exit-codes.ts", @@ -36,6 +36,7 @@ "test:envelope": "bun scripts/test-response-envelope.ts", "test:bundled-create": "bun scripts/test-bundled-create.ts", "test:concurrent-sessions": "bun scripts/test-concurrent-sessions.ts", + "test:inspect": "bun scripts/test-inspect.ts", "prepublishOnly": "bun run clean && bun run build" }, "dependencies": { diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts new file mode 100644 index 000000000..03f7395cd --- /dev/null +++ b/packages/cli/scripts/test-inspect.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env bun + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { command } from '../src/cmd/inspect.ts'; +import { isInspectInvocation } from '../src/local-delegate.ts'; + +const testDir = join(tmpdir(), `agentuity-inspect-${process.pid}-${Date.now()}`); +mkdirSync(testDir, { recursive: true }); + +try { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + name: 'unlinked-vite-app', + scripts: { dev: 'vite', build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }) + ); + + if (command.requires || command.optional) { + throw new Error('inspect must not declare auth or project context'); + } + if (!command.skipUpgradeCheck || !command.skipInternalLogging) { + throw new Error('inspect must skip network update checks and auth-backed internal logging'); + } + if (!isInspectInvocation(['--json', 'inspect', '--dir', testDir])) { + throw new Error('inspect must bypass local CLI installation and delegation'); + } + if (isInspectInvocation(['build', '--dir', 'inspect'])) { + throw new Error('an inspect directory value must not bypass delegation for another command'); + } + + const cli = Bun.spawn( + ['bun', join(import.meta.dir, '..', 'src', 'main.ts'), '--json', 'inspect', '--dir', testDir], + { + cwd: testDir, + env: { + ...process.env, + AGENTUITY_AGENT_MODE: 'none', + AGENTUITY_API_KEY: '', + AGENTUITY_USER_ID: '', + HTTPS_PROXY: 'http://127.0.0.1:1', + HTTP_PROXY: 'http://127.0.0.1:1', + }, + stdout: 'pipe', + stderr: 'pipe', + } + ); + + const [exitCode, stdout, stderr] = await Promise.all([ + cli.exited, + new Response(cli.stdout).text(), + new Response(cli.stderr).text(), + ]); + + if (exitCode !== 0) { + throw new Error(`inspect exited ${exitCode}: ${stderr}`); + } + if (stderr.trim()) { + throw new Error(`inspect wrote to stderr: ${stderr}`); + } + + const result = JSON.parse(stdout) as { + framework: string; + runtime: string; + packageManager: string; + entrypoints: string[]; + commands: { dev: string | null; build: string; start: string | null }; + monorepo: unknown; + }; + + if (result.framework !== 'vite') throw new Error(`expected vite, got ${result.framework}`); + if (result.commands.dev !== 'vite') + throw new Error(`unexpected dev command: ${result.commands.dev}`); + if (result.commands.build !== 'vite build') { + throw new Error(`unexpected build command: ${result.commands.build}`); + } + if (result.monorepo !== null) throw new Error('standalone project must not report a monorepo'); + + console.log('inspect command passed offline, unlinked Vite project test'); +} finally { + rmSync(testDir, { recursive: true, force: true }); +} diff --git a/packages/cli/src/cmd/index.ts b/packages/cli/src/cmd/index.ts index 5e8f59bbf..43242f551 100644 --- a/packages/cli/src/cmd/index.ts +++ b/packages/cli/src/cmd/index.ts @@ -12,6 +12,7 @@ export async function discoverCommands(): Promise { import('./dev/index.ts').then((m) => m.command), import('./git/index.ts').then((m) => m.gitCommand), import('./help/index.ts').then((m) => m.command), + import('./inspect.ts').then((m) => m.command), import('./profile/index.ts').then((m) => m.command), import('./project/index.ts').then((m) => m.command), import('./repl/index.ts').then((m) => m.command), diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts new file mode 100644 index 000000000..166c118ce --- /dev/null +++ b/packages/cli/src/cmd/inspect.ts @@ -0,0 +1,108 @@ +import { resolve } from 'node:path'; +import { z } from 'zod'; +import { getCommand } from '../command-prefix.ts'; +import { detectFrameworkWithPackageJson } from './build/detect/index.ts'; +import { detectMonorepoContext } from './build/detect/monorepo.ts'; +import { ErrorCode } from '../errors.ts'; +import * as tui from '../tui.ts'; +import { createCommand } from '../types.ts'; + +const InspectOptionsSchema = z.object({ + dir: z.string().optional().describe('Project directory to inspect (default: current directory)'), +}); + +const InspectResponseSchema = z.object({ + directory: z.string().describe('Absolute path to the inspected project directory'), + framework: z.string().describe('Detected framework slug'), + runtime: z.enum(['node', 'bun']).describe('Runtime used to start the built application'), + packageManager: z.enum(['bun', 'npm', 'pnpm', 'yarn']).describe('Detected package manager'), + entrypoints: z.array(z.string()).describe('Detected server entrypoints'), + commands: z.object({ + dev: z.string().nullable().describe('Development command from package.json'), + build: z.string().describe('Detected build command'), + start: z.string().nullable().describe('Detected start command'), + }), + buildOutput: z.string().describe('Build output path relative to the project directory'), + monorepo: z + .object({ + root: z.string().describe('Absolute path to the workspace root'), + workingDirectory: z.string().describe('Project path relative to the workspace root'), + packageManager: z + .enum(['bun', 'npm', 'pnpm', 'yarn']) + .describe('Package manager used by the workspace'), + }) + .nullable() + .describe('Enclosing workspace details, when the project is a workspace member'), +}); + +export const command = createCommand({ + name: 'inspect', + description: 'Inspect a local project without authentication or a project link', + skipUpgradeCheck: true, + skipInternalLogging: true, + tags: ['read-only', 'fast', 'offline'], + idempotent: true, + examples: [ + { + command: getCommand('--json inspect'), + description: 'Inspect the current directory as JSON', + }, + { + command: getCommand('--json inspect --dir ./apps/web'), + description: 'Inspect a project in another directory', + }, + ], + schema: { + options: InspectOptionsSchema, + response: InspectResponseSchema, + }, + + async handler(ctx) { + const directory = resolve(ctx.opts.dir ?? process.cwd()); + const [{ framework, packageJson }, monorepo] = await Promise.all([ + detectFrameworkWithPackageJson(directory), + detectMonorepoContext(directory), + ]); + + if (!framework) { + tui.fatal( + `Could not detect a deployable project in ${directory}`, + ErrorCode.PROJECT_NOT_FOUND + ); + } + + const result = { + directory, + framework: framework.name, + runtime: framework.runtime, + packageManager: framework.packageManager, + entrypoints: framework.serverEntry ? [framework.serverEntry] : [], + commands: { + dev: packageJson?.scripts?.dev ?? null, + build: framework.buildCommand, + start: framework.startCommand ?? null, + }, + buildOutput: framework.buildOutput, + monorepo: monorepo + ? { + root: monorepo.root, + workingDirectory: monorepo.subpath, + packageManager: monorepo.packageManager, + } + : null, + }; + + if (!ctx.options.json) { + tui.output(`Framework: ${result.framework}`); + tui.output(`Runtime: ${result.runtime}`); + tui.output(`Package manager: ${result.packageManager}`); + tui.output(`Build command: ${result.commands.build}`); + if (result.commands.dev) tui.output(`Dev command: ${result.commands.dev}`); + if (result.monorepo) { + tui.output(`Working directory: ${result.monorepo.workingDirectory}`); + } + } + + return result; + }, +}); diff --git a/packages/cli/src/local-delegate.ts b/packages/cli/src/local-delegate.ts index 654473452..70ed49124 100644 --- a/packages/cli/src/local-delegate.ts +++ b/packages/cli/src/local-delegate.ts @@ -16,11 +16,9 @@ * - Only runs when the invoked binary is a global install. * - Only delegates when the local version differs from the global version * (same version → run in-process, no extra spawn). - * - Delegates EVERYTHING with no command/flag exclusions: once we've decided - * the project-local CLI owns this project, it should own every command, - * including `--version` / `--help` / `--ai-help` (so introspection reflects - * the CLI actually handling the project) and `upgrade` (so it upgrades the - * CLI being used here). + * - Delegates every command except `inspect`. Inspection must use the current + * CLI's detector and stay offline, even for legacy projects whose local CLI + * would otherwise be installed or invoked before command registration. * - A loop guard env var (`AGENTUITY_DELEGATED`) prevents the local CLI * from delegating again. * @@ -45,6 +43,35 @@ export const LOCAL_DELEGATION_GUARD_ENV = 'AGENTUITY_DELEGATED'; const DELEGATED_ENV = LOCAL_DELEGATION_GUARD_ENV; const PACKAGE_NAME = '@agentuity/cli'; +const GLOBAL_OPTIONS_WITH_VALUES = new Set([ + '--config', + '--env', + '--log-level', + '--org-id', + '--project-id', + '--color-scheme', + '--color', + '--error-format', + '--input', + '--fields', +]); + +/** Return whether the first command operand is `inspect`. */ +export function isInspectInvocation(argv: string[]): boolean { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; + if (arg === '--') return argv[i + 1] === 'inspect'; + if (GLOBAL_OPTIONS_WITH_VALUES.has(arg)) { + i++; + continue; + } + if (arg.startsWith('-')) continue; + return arg === 'inspect'; + } + return false; +} + export interface LocalCli { /** Absolute path to the local CLI's executable (the `bin.agentuity` entry). */ binPath: string; @@ -212,6 +239,10 @@ async function ensureLocalCliForV2(projectDir: string): Promise { * `process.argv.slice(2)`. */ export async function maybeDelegateToLocal(argv: string[]): Promise { + // `inspect` must use this CLI's current detector without installing or + // invoking a project-local CLI first. + if (isInspectInvocation(argv)) return; + // Loop guard: a delegated child must never delegate again. if (process.env[DELEGATED_ENV]) return; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index b26d29518..39d2dd89e 100755 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -298,14 +298,16 @@ async function main() { config = await loadConfig(earlyOpts.config, false, earlyOpts.profile); - // Update internal logger with userId if available from auth (keychain or config) - try { - const auth = await getAuth(); - if (auth?.userId) { - internalLogger.setUserId(auth.userId); + // Commands that disable internal logging should not touch local auth state. + if (!shouldSkipInternalLogging) { + try { + const auth = await getAuth(); + if (auth?.userId) { + internalLogger.setUserId(auth.userId); + } + } catch { + // Ignore auth errors - user might not be logged in } - } catch { - // Ignore auth errors - user might not be logged in } const ctx = { From 258b7759a2cb8206f7a46358126f46de70186546 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 15:09:59 -0700 Subject: [PATCH 02/13] fix(cli): stabilize project inspection contract --- packages/cli/scripts/test-inspect.ts | 8 ++++++++ packages/cli/src/cmd/inspect.ts | 11 +++++++++-- packages/cli/src/local-delegate.ts | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts index 03f7395cd..aaef5df63 100644 --- a/packages/cli/scripts/test-inspect.ts +++ b/packages/cli/scripts/test-inspect.ts @@ -28,6 +28,9 @@ try { if (!isInspectInvocation(['--json', 'inspect', '--dir', testDir])) { throw new Error('inspect must bypass local CLI installation and delegation'); } + if (!isInspectInvocation(['--profile', 'work', '--json', 'inspect'])) { + throw new Error('inspect must bypass delegation when a profile is selected'); + } if (isInspectInvocation(['build', '--dir', 'inspect'])) { throw new Error('an inspect directory value must not bypass delegation for another command'); } @@ -63,6 +66,7 @@ try { } const result = JSON.parse(stdout) as { + schemaVersion: number; framework: string; runtime: string; packageManager: string; @@ -71,7 +75,11 @@ try { monorepo: unknown; }; + if (result.schemaVersion !== 1) { + throw new Error(`expected schema version 1, got ${result.schemaVersion}`); + } if (result.framework !== 'vite') throw new Error(`expected vite, got ${result.framework}`); + if (result.runtime !== 'node') throw new Error(`expected node, got ${result.runtime}`); if (result.commands.dev !== 'vite') throw new Error(`unexpected dev command: ${result.commands.dev}`); if (result.commands.build !== 'vite build') { diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index 166c118ce..149e95a84 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -4,14 +4,20 @@ import { getCommand } from '../command-prefix.ts'; import { detectFrameworkWithPackageJson } from './build/detect/index.ts'; import { detectMonorepoContext } from './build/detect/monorepo.ts'; import { ErrorCode } from '../errors.ts'; +import { isJSONMode } from '../output.ts'; import * as tui from '../tui.ts'; import { createCommand } from '../types.ts'; +const INSPECT_SCHEMA_VERSION = 1; + const InspectOptionsSchema = z.object({ dir: z.string().optional().describe('Project directory to inspect (default: current directory)'), }); const InspectResponseSchema = z.object({ + schemaVersion: z + .literal(INSPECT_SCHEMA_VERSION) + .describe('Version of the inspect response contract'), directory: z.string().describe('Absolute path to the inspected project directory'), framework: z.string().describe('Detected framework slug'), runtime: z.enum(['node', 'bun']).describe('Runtime used to start the built application'), @@ -71,7 +77,8 @@ export const command = createCommand({ ); } - const result = { + const result: z.infer = { + schemaVersion: INSPECT_SCHEMA_VERSION, directory, framework: framework.name, runtime: framework.runtime, @@ -92,7 +99,7 @@ export const command = createCommand({ : null, }; - if (!ctx.options.json) { + if (!isJSONMode(ctx.options)) { tui.output(`Framework: ${result.framework}`); tui.output(`Runtime: ${result.runtime}`); tui.output(`Package manager: ${result.packageManager}`); diff --git a/packages/cli/src/local-delegate.ts b/packages/cli/src/local-delegate.ts index 70ed49124..42264ceea 100644 --- a/packages/cli/src/local-delegate.ts +++ b/packages/cli/src/local-delegate.ts @@ -54,6 +54,7 @@ const GLOBAL_OPTIONS_WITH_VALUES = new Set([ '--error-format', '--input', '--fields', + '--profile', ]); /** Return whether the first command operand is `inspect`. */ From 98f8d2785550c5090917d4914f31c7646f267d6e Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 15:13:27 -0700 Subject: [PATCH 03/13] fix(cli): return structured inspect errors --- packages/cli/scripts/test-inspect.ts | 76 ++++++++++++++++++++-------- packages/cli/src/cmd/inspect.ts | 12 +++-- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts index aaef5df63..bd5e32e03 100644 --- a/packages/cli/scripts/test-inspect.ts +++ b/packages/cli/scripts/test-inspect.ts @@ -9,6 +9,35 @@ import { isInspectInvocation } from '../src/local-delegate.ts'; const testDir = join(tmpdir(), `agentuity-inspect-${process.pid}-${Date.now()}`); mkdirSync(testDir, { recursive: true }); +const cliPath = join(import.meta.dir, '..', 'src', 'main.ts'); +const cliEnv = { + ...process.env, + AGENTUITY_AGENT_MODE: 'none', + AGENTUITY_API_KEY: '', + AGENTUITY_USER_ID: '', + HTTPS_PROXY: 'http://127.0.0.1:1', + HTTP_PROXY: 'http://127.0.0.1:1', +}; + +async function runInspect(directory: string): Promise<{ + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}> { + const cli = Bun.spawn(['bun', cliPath, '--json', 'inspect', '--dir', directory], { + cwd: directory, + env: cliEnv, + stdout: 'pipe', + stderr: 'pipe', + }); + const [exitCode, stdout, stderr] = await Promise.all([ + cli.exited, + new Response(cli.stdout).text(), + new Response(cli.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + try { writeFileSync( join(testDir, 'package.json'), @@ -35,28 +64,7 @@ try { throw new Error('an inspect directory value must not bypass delegation for another command'); } - const cli = Bun.spawn( - ['bun', join(import.meta.dir, '..', 'src', 'main.ts'), '--json', 'inspect', '--dir', testDir], - { - cwd: testDir, - env: { - ...process.env, - AGENTUITY_AGENT_MODE: 'none', - AGENTUITY_API_KEY: '', - AGENTUITY_USER_ID: '', - HTTPS_PROXY: 'http://127.0.0.1:1', - HTTP_PROXY: 'http://127.0.0.1:1', - }, - stdout: 'pipe', - stderr: 'pipe', - } - ); - - const [exitCode, stdout, stderr] = await Promise.all([ - cli.exited, - new Response(cli.stdout).text(), - new Response(cli.stderr).text(), - ]); + const { exitCode, stdout, stderr } = await runInspect(testDir); if (exitCode !== 0) { throw new Error(`inspect exited ${exitCode}: ${stderr}`); @@ -87,6 +95,30 @@ try { } if (result.monorepo !== null) throw new Error('standalone project must not report a monorepo'); + const emptyDir = join(testDir, 'empty'); + mkdirSync(emptyDir); + const { + exitCode: invalidExitCode, + stdout: invalidStdout, + stderr: invalidStderr, + } = await runInspect(emptyDir); + if (invalidExitCode !== 12) { + throw new Error(`empty directory inspect exited ${invalidExitCode}: ${invalidStderr}`); + } + if (invalidStdout.trim()) { + throw new Error(`empty directory inspect wrote to stdout: ${invalidStdout}`); + } + const invalidResult = JSON.parse(invalidStderr) as { + error?: { code?: string; message?: string; exitCode?: number }; + }; + if ( + invalidResult.error?.code !== 'PROJECT_NOT_FOUND' || + invalidResult.error.exitCode !== 12 || + !invalidResult.error.message?.includes(emptyDir) + ) { + throw new Error(`unexpected empty directory error: ${invalidStderr}`); + } + console.log('inspect command passed offline, unlinked Vite project test'); } finally { rmSync(testDir, { recursive: true, force: true }); diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index 149e95a84..bd13df976 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getCommand } from '../command-prefix.ts'; import { detectFrameworkWithPackageJson } from './build/detect/index.ts'; import { detectMonorepoContext } from './build/detect/monorepo.ts'; -import { ErrorCode } from '../errors.ts'; +import { createError, ErrorCode, exitWithError } from '../errors.ts'; import { isJSONMode } from '../output.ts'; import * as tui from '../tui.ts'; import { createCommand } from '../types.ts'; @@ -71,9 +71,13 @@ export const command = createCommand({ ]); if (!framework) { - tui.fatal( - `Could not detect a deployable project in ${directory}`, - ErrorCode.PROJECT_NOT_FOUND + exitWithError( + createError( + ErrorCode.PROJECT_NOT_FOUND, + `Could not detect a deployable project in ${directory}` + ), + ctx.logger, + ctx.options.errorFormat ); } From 9e00ce59e198d651b2ab82cf79741c71de33b39b Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 15:20:19 -0700 Subject: [PATCH 04/13] docs(cli): clarify inspect guarantees --- packages/cli/scripts/test-inspect.ts | 2 +- packages/cli/src/cmd/inspect.ts | 9 ++++----- packages/cli/src/local-delegate.ts | 7 ++++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts index bd5e32e03..6db16f356 100644 --- a/packages/cli/scripts/test-inspect.ts +++ b/packages/cli/scripts/test-inspect.ts @@ -119,7 +119,7 @@ try { throw new Error(`unexpected empty directory error: ${invalidStderr}`); } - console.log('inspect command passed offline, unlinked Vite project test'); + console.log('inspect passed without auth, agentuity.json, or a linked cloud project'); } finally { rmSync(testDir, { recursive: true, force: true }); } diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index bd13df976..f25b19dda 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -15,9 +15,7 @@ const InspectOptionsSchema = z.object({ }); const InspectResponseSchema = z.object({ - schemaVersion: z - .literal(INSPECT_SCHEMA_VERSION) - .describe('Version of the inspect response contract'), + schemaVersion: z.literal(INSPECT_SCHEMA_VERSION).describe('Version of this response shape'), directory: z.string().describe('Absolute path to the inspected project directory'), framework: z.string().describe('Detected framework slug'), runtime: z.enum(['node', 'bun']).describe('Runtime used to start the built application'), @@ -43,10 +41,11 @@ const InspectResponseSchema = z.object({ export const command = createCommand({ name: 'inspect', - description: 'Inspect a local project without authentication or a project link', + description: + 'Inspect a Genesis import before the user authenticates, adds agentuity.json, or links a cloud project', skipUpgradeCheck: true, skipInternalLogging: true, - tags: ['read-only', 'fast', 'offline'], + tags: ['read-only', 'fast'], idempotent: true, examples: [ { diff --git a/packages/cli/src/local-delegate.ts b/packages/cli/src/local-delegate.ts index 42264ceea..9e856269a 100644 --- a/packages/cli/src/local-delegate.ts +++ b/packages/cli/src/local-delegate.ts @@ -16,9 +16,10 @@ * - Only runs when the invoked binary is a global install. * - Only delegates when the local version differs from the global version * (same version → run in-process, no extra spawn). - * - Delegates every command except `inspect`. Inspection must use the current - * CLI's detector and stay offline, even for legacy projects whose local CLI - * would otherwise be installed or invoked before command registration. + * - Delegates every command except `inspect`. Genesis inspects imported + * repositories before the user authenticates, adds `agentuity.json`, or + * links a cloud project. Inspection must use this CLI's detector without + * installing or invoking a project-local CLI first. * - A loop guard env var (`AGENTUITY_DELEGATED`) prevents the local CLI * from delegating again. * From 2c62cb42935b344474f18f8bc7050732894381e8 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 16:23:39 -0700 Subject: [PATCH 05/13] feat(cli): expose framework inspection signals --- packages/cli/scripts/test-inspect.ts | 76 ++++++++++++++++++++++++++++ packages/cli/src/cmd/inspect.ts | 10 ++++ 2 files changed, 86 insertions(+) diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts index 6db16f356..3c149abf7 100644 --- a/packages/cli/scripts/test-inspect.ts +++ b/packages/cli/scripts/test-inspect.ts @@ -80,6 +80,9 @@ try { packageManager: string; entrypoints: string[]; commands: { dev: string | null; build: string; start: string | null }; + port: number | null; + confidence: 'high' | 'medium' | 'low'; + warnings: readonly string[]; monorepo: unknown; }; @@ -94,6 +97,79 @@ try { throw new Error(`unexpected build command: ${result.commands.build}`); } if (result.monorepo !== null) throw new Error('standalone project must not report a monorepo'); + if (result.port !== null) throw new Error(`expected null port for vite, got ${result.port}`); + if (result.confidence !== 'high') { + throw new Error(`expected high confidence for vite, got ${result.confidence}`); + } + if (result.warnings.length !== 0) { + throw new Error(`expected no warnings for vite, got ${JSON.stringify(result.warnings)}`); + } + + const tanstackDir = join(testDir, 'tanstack-start'); + mkdirSync(tanstackDir); + writeFileSync( + join(tanstackDir, 'package.json'), + JSON.stringify({ + name: 'tanstack-start-app', + dependencies: { '@tanstack/react-start': '^1.0.0' }, + scripts: { build: 'vite build' }, + }) + ); + const { exitCode: tanstackExitCode, stdout: tanstackStdout } = await runInspect(tanstackDir); + if (tanstackExitCode !== 0) { + throw new Error(`tanstack inspect exited ${tanstackExitCode}`); + } + const tanstackResult = JSON.parse(tanstackStdout) as { + framework: string; + confidence: string; + warnings: readonly string[]; + }; + if (tanstackResult.framework !== 'tanstack-start') { + throw new Error(`expected tanstack-start, got ${tanstackResult.framework}`); + } + if (tanstackResult.confidence !== 'high') { + throw new Error( + `expected high confidence for tanstack-start, got ${tanstackResult.confidence}` + ); + } + if (!tanstackResult.warnings.some((warning) => warning.includes('Nitro'))) { + throw new Error(`expected Nitro warning, got ${JSON.stringify(tanstackResult.warnings)}`); + } + + const legacyDir = join(testDir, 'agentuity-legacy'); + mkdirSync(legacyDir); + writeFileSync( + join(legacyDir, 'package.json'), + JSON.stringify({ + name: 'legacy-app', + scripts: { build: 'agentuity build', start: 'bun .agentuity/app.js' }, + dependencies: { '@agentuity/runtime': '^2.0.0' }, + }) + ); + const { exitCode: legacyExitCode, stdout: legacyStdout } = await runInspect(legacyDir); + if (legacyExitCode !== 0) { + throw new Error(`legacy inspect exited ${legacyExitCode}`); + } + const legacyResult = JSON.parse(legacyStdout) as { + framework: string; + port: number | null; + confidence: string; + warnings: readonly string[]; + }; + if (legacyResult.framework !== 'agentuity-legacy') { + throw new Error(`expected agentuity-legacy, got ${legacyResult.framework}`); + } + if (legacyResult.port !== 3000) { + throw new Error(`expected port 3000 for agentuity-legacy, got ${legacyResult.port}`); + } + if (legacyResult.confidence !== 'high') { + throw new Error( + `expected high confidence for agentuity-legacy, got ${legacyResult.confidence}` + ); + } + if (!legacyResult.warnings.some((warning) => warning.includes('@agentuity/cli'))) { + throw new Error(`expected CLI warning, got ${JSON.stringify(legacyResult.warnings)}`); + } const emptyDir = join(testDir, 'empty'); mkdirSync(emptyDir); diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index f25b19dda..0dcedbbab 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -27,6 +27,13 @@ const InspectResponseSchema = z.object({ start: z.string().nullable().describe('Detected start command'), }), buildOutput: z.string().describe('Build output path relative to the project directory'), + port: z.number().int().nullable().describe('Port the application listens on, when known'), + confidence: z + .enum(['high', 'medium', 'low']) + .describe('How confidently the framework detector matched this project'), + warnings: z + .array(z.string()) + .describe('Non-fatal advisories from framework detection (empty when none)'), monorepo: z .object({ root: z.string().describe('Absolute path to the workspace root'), @@ -93,6 +100,9 @@ export const command = createCommand({ start: framework.startCommand ?? null, }, buildOutput: framework.buildOutput, + port: framework.port ?? null, + confidence: framework.confidence, + warnings: framework.warnings ?? [], monorepo: monorepo ? { root: monorepo.root, From 7516896f2bec1e781d76849e48b5a54eef9627b3 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Mon, 27 Jul 2026 16:35:50 -0700 Subject: [PATCH 06/13] test(cli): bound and simplify inspect fixtures - Stop the inspect subprocess after 15 seconds. - Share setup and validation across framework fixtures. --- packages/cli/scripts/test-inspect.ts | 116 +++++++++++---------------- 1 file changed, 49 insertions(+), 67 deletions(-) diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts index 3c149abf7..9de7b1ac1 100644 --- a/packages/cli/scripts/test-inspect.ts +++ b/packages/cli/scripts/test-inspect.ts @@ -19,6 +19,23 @@ const cliEnv = { HTTP_PROXY: 'http://127.0.0.1:1', }; +type InspectResult = { + readonly schemaVersion: number; + readonly framework: string; + readonly runtime: string; + readonly packageManager: string; + readonly entrypoints: readonly string[]; + readonly commands: { + readonly dev: string | null; + readonly build: string; + readonly start: string | null; + }; + readonly port: number | null; + readonly confidence: 'high' | 'medium' | 'low'; + readonly warnings: readonly string[]; + readonly monorepo: unknown; +}; + async function runInspect(directory: string): Promise<{ readonly exitCode: number; readonly stdout: string; @@ -29,6 +46,7 @@ async function runInspect(directory: string): Promise<{ env: cliEnv, stdout: 'pipe', stderr: 'pipe', + timeout: 15_000, }); const [exitCode, stdout, stderr] = await Promise.all([ cli.exited, @@ -38,16 +56,23 @@ async function runInspect(directory: string): Promise<{ return { exitCode, stdout, stderr }; } -try { - writeFileSync( - join(testDir, 'package.json'), - JSON.stringify({ - name: 'unlinked-vite-app', - scripts: { dev: 'vite', build: 'vite build' }, - devDependencies: { vite: '^7.0.0' }, - }) - ); +async function inspectFixture( + directory: string, + packageJson: Readonly> +): Promise { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'package.json'), JSON.stringify(packageJson)); + const { exitCode, stdout, stderr } = await runInspect(directory); + if (exitCode !== 0) { + throw new Error(`inspect exited ${exitCode}: ${stderr}`); + } + if (stderr.trim()) { + throw new Error(`inspect wrote to stderr: ${stderr}`); + } + return JSON.parse(stdout) as InspectResult; +} +try { if (command.requires || command.optional) { throw new Error('inspect must not declare auth or project context'); } @@ -64,27 +89,11 @@ try { throw new Error('an inspect directory value must not bypass delegation for another command'); } - const { exitCode, stdout, stderr } = await runInspect(testDir); - - if (exitCode !== 0) { - throw new Error(`inspect exited ${exitCode}: ${stderr}`); - } - if (stderr.trim()) { - throw new Error(`inspect wrote to stderr: ${stderr}`); - } - - const result = JSON.parse(stdout) as { - schemaVersion: number; - framework: string; - runtime: string; - packageManager: string; - entrypoints: string[]; - commands: { dev: string | null; build: string; start: string | null }; - port: number | null; - confidence: 'high' | 'medium' | 'low'; - warnings: readonly string[]; - monorepo: unknown; - }; + const result = await inspectFixture(testDir, { + name: 'unlinked-vite-app', + scripts: { dev: 'vite', build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }); if (result.schemaVersion !== 1) { throw new Error(`expected schema version 1, got ${result.schemaVersion}`); @@ -106,24 +115,11 @@ try { } const tanstackDir = join(testDir, 'tanstack-start'); - mkdirSync(tanstackDir); - writeFileSync( - join(tanstackDir, 'package.json'), - JSON.stringify({ - name: 'tanstack-start-app', - dependencies: { '@tanstack/react-start': '^1.0.0' }, - scripts: { build: 'vite build' }, - }) - ); - const { exitCode: tanstackExitCode, stdout: tanstackStdout } = await runInspect(tanstackDir); - if (tanstackExitCode !== 0) { - throw new Error(`tanstack inspect exited ${tanstackExitCode}`); - } - const tanstackResult = JSON.parse(tanstackStdout) as { - framework: string; - confidence: string; - warnings: readonly string[]; - }; + const tanstackResult = await inspectFixture(tanstackDir, { + name: 'tanstack-start-app', + dependencies: { '@tanstack/react-start': '^1.0.0' }, + scripts: { build: 'vite build' }, + }); if (tanstackResult.framework !== 'tanstack-start') { throw new Error(`expected tanstack-start, got ${tanstackResult.framework}`); } @@ -137,25 +133,11 @@ try { } const legacyDir = join(testDir, 'agentuity-legacy'); - mkdirSync(legacyDir); - writeFileSync( - join(legacyDir, 'package.json'), - JSON.stringify({ - name: 'legacy-app', - scripts: { build: 'agentuity build', start: 'bun .agentuity/app.js' }, - dependencies: { '@agentuity/runtime': '^2.0.0' }, - }) - ); - const { exitCode: legacyExitCode, stdout: legacyStdout } = await runInspect(legacyDir); - if (legacyExitCode !== 0) { - throw new Error(`legacy inspect exited ${legacyExitCode}`); - } - const legacyResult = JSON.parse(legacyStdout) as { - framework: string; - port: number | null; - confidence: string; - warnings: readonly string[]; - }; + const legacyResult = await inspectFixture(legacyDir, { + name: 'legacy-app', + scripts: { build: 'agentuity build', start: 'bun .agentuity/app.js' }, + dependencies: { '@agentuity/runtime': '^2.0.0' }, + }); if (legacyResult.framework !== 'agentuity-legacy') { throw new Error(`expected agentuity-legacy, got ${legacyResult.framework}`); } From 7b8b041deee4896e09674a7bc862f08543eccd39 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:19:26 -0700 Subject: [PATCH 07/13] fix(cli): validate launch.json structurally at the boundary - Zod-parse launch.json; derive UserLaunchOverride from schema - throw LaunchConfigError with per-field issue paths - build maps it to CONFIG_INVALID (exit 10), not INTERNAL_ERROR - tolerate JSON null as absent; keep unknown keys passthrough Co-authored-by: Cursor --- packages/cli/src/cmd/build/index.ts | 12 +- packages/cli/src/cmd/build/package/launch.ts | 143 ++++++++++++++++-- .../cli/test/cmd/build/package/launch.test.ts | 54 +++++++ 3 files changed, 196 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cmd/build/index.ts b/packages/cli/src/cmd/build/index.ts index a06b3ad71..8580c059f 100644 --- a/packages/cli/src/cmd/build/index.ts +++ b/packages/cli/src/cmd/build/index.ts @@ -2,7 +2,7 @@ import { copyFile } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; import { z } from 'zod'; import { getCommand } from '../../command-prefix.ts'; -import { ErrorCode } from '../../errors.ts'; +import { createError, ErrorCode, exitWithError } from '../../errors.ts'; import { pathExists } from '../../node-compat/fs.ts'; import * as tui from '../../tui.ts'; import { createCommand, DeployOptionsSchema } from '../../types.ts'; @@ -11,6 +11,7 @@ import { setGlobalCollector, clearGlobalCollector, } from '../../build-report.ts'; +import { LaunchConfigError } from './package/launch.ts'; import { FrameworkDetectionError, TypecheckError, runBuildPipeline } from './run.ts'; const BuildResponseSchema = z.object({ @@ -186,6 +187,15 @@ export const command = createCommand({ clearGlobalCollector(); tui.fatal('Fix type errors before building', ErrorCode.BUILD_FAILED); } + if (error instanceof LaunchConfigError) { + if (opts.reportFile) await collector.forceWrite(); + clearGlobalCollector(); + exitWithError( + createError(ErrorCode.CONFIG_INVALID, error.message, { issues: error.issues }), + ctx.logger, + ctx.options.errorFormat + ); + } // Fall through to the original generic error handler below. // Add error to collector if (error instanceof AggregateError) { diff --git a/packages/cli/src/cmd/build/package/launch.ts b/packages/cli/src/cmd/build/package/launch.ts index 1254dee80..e9756aba5 100644 --- a/packages/cli/src/cmd/build/package/launch.ts +++ b/packages/cli/src/cmd/build/package/launch.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { z } from 'zod'; import type { BuildResult } from '../adapters/types.ts'; import type { DetectedFramework } from '../detect/types.ts'; import type { MonorepoContext } from '../detect/monorepo.ts'; @@ -18,34 +19,147 @@ import type { MonorepoContext } from '../detect/monorepo.ts'; */ export const USER_LAUNCH_FILENAME = 'launch.json'; +/** + * Structural shape of a user-supplied `launch.json`. `.passthrough()` + * everywhere so unknown extra keys — including the machine-generated + * `build` field users copy from an emitted launch.json — pass through + * unrejected. Wrong *types* on known fields still fail validation. + * + * `processes[].default` is optional here even though the internal + * `ProcessDefinition` requires it: files written before this field + * existed must keep working. Callers coerce with `default ?? false`. + * + * Every optional field is `.nullish()` + a `?? undefined` transform, not + * plain `.optional()`: the pre-Zod code read these fields with `?.`, + * which tolerated an explicit JSON `null` as well as absence. Collapsing + * `null` to `undefined` here (rather than leaving it in the parsed + * shape) keeps `UserLaunchOverride` — derived via `z.infer` — free of + * `| null`, so downstream consumers only ever handle "absent". + */ +const UserLaunchProcessSchema = z + .object({ + type: z.string(), + command: z.string(), + default: z + .boolean() + .nullish() + .transform((v) => v ?? undefined), + workingDirectory: z + .string() + .nullish() + .transform((v) => v ?? undefined), + }) + .passthrough(); + +const UserLaunchOverrideSchema = z + .object({ + processes: z + .array(UserLaunchProcessSchema) + .nullish() + .transform((v) => v ?? undefined), + framework: z + .object({ + name: z + .string() + .nullish() + .transform((v) => v ?? undefined), + version: z + .string() + .nullish() + .transform((v) => v ?? undefined), + }) + .passthrough() + .nullish() + .transform((v) => v ?? undefined), + runtime: z + .object({ + name: z + .string() + .nullish() + .transform((v) => v ?? undefined), + port: z + .number() + .nullish() + .transform((v) => v ?? undefined), + }) + .passthrough() + .nullish() + .transform((v) => v ?? undefined), + }) + .passthrough(); + /** * Partial launch metadata a user can ship at the project root to * override what the CLI infers. Every field is optional; provided * fields win over the generated ones. `build.{date,duration}` is * always machine-generated and ignored here. */ -export interface UserLaunchOverride { - processes?: ProcessDefinition[]; - framework?: { name?: string; version?: string }; - runtime?: { name?: string; port?: number }; +export type UserLaunchOverride = z.infer; + +/** One field-level validation failure, normalized for error messages. */ +export interface LaunchConfigIssue { + path: string; + message: string; +} + +/** + * Thrown by `readUserLaunchOverride` for both invalid JSON and + * schema-invalid `launch.json` files. Callers that own a `CommandContext` + * (inspect, build) catch this and translate it into a `CONFIG_INVALID` + * structured error instead of letting a raw crash reach the user. + */ +export class LaunchConfigError extends Error { + readonly filePath: string; + readonly issues: LaunchConfigIssue[]; + + constructor(filePath: string, issues: LaunchConfigIssue[], message: string) { + super(message); + this.name = 'LaunchConfigError'; + this.filePath = filePath; + this.issues = issues; + } } /** * Read a user-supplied `launch.json` from the project root, if any. * - * Returns `null` when the file is missing. Throws on invalid JSON — - * a malformed override is a user error worth surfacing rather than - * silently falling back to inference. + * Returns `null` when the file is missing. Throws `LaunchConfigError` on + * invalid JSON or a structurally invalid shape — a malformed override is + * a user error worth surfacing rather than silently falling back to + * inference (or, worse, crashing deep inside a consumer that assumed the + * shape was already validated). */ export function readUserLaunchOverride(projectDir: string): UserLaunchOverride | null { const path = join(projectDir, USER_LAUNCH_FILENAME); if (!existsSync(path)) return null; + + let parsed: unknown; try { - return JSON.parse(readFileSync(path, 'utf-8')) as UserLaunchOverride; + parsed = JSON.parse(readFileSync(path, 'utf-8')); } catch (ex) { - const _ex = ex as Error; - throw new Error(`Invalid ${USER_LAUNCH_FILENAME} at ${path}: ${_ex.message}`); + const message = (ex as Error).message; + throw new LaunchConfigError( + path, + [{ path: 'root', message }], + `Invalid ${USER_LAUNCH_FILENAME} at ${path}: ${message}` + ); + } + + const result = UserLaunchOverrideSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues.map((issue) => ({ + path: issue.path.join('.') || 'root', + message: issue.message, + })); + const summary = issues.map((issue) => `${issue.path}: ${issue.message}`).join('; '); + throw new LaunchConfigError( + path, + issues, + `Invalid ${USER_LAUNCH_FILENAME} at ${path}: ${summary}` + ); } + + return result.data; } /** @@ -132,8 +246,13 @@ export function generateLaunchMetadata( return framework.runtime; })(); - const finalProcesses = - override?.processes && override.processes.length > 0 ? override.processes : processes; + // The user schema keeps `default` optional for backward compat with + // files written before this field existed; the emitted metadata's + // `ProcessDefinition` requires it, so coerce here at the boundary. + const finalProcesses: ProcessDefinition[] = + override?.processes && override.processes.length > 0 + ? override.processes.map((p) => ({ ...p, default: p.default ?? false })) + : processes; return { processes: finalProcesses, diff --git a/packages/cli/test/cmd/build/package/launch.test.ts b/packages/cli/test/cmd/build/package/launch.test.ts index 765bc8d6e..5f7aab0b7 100644 --- a/packages/cli/test/cmd/build/package/launch.test.ts +++ b/packages/cli/test/cmd/build/package/launch.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { generateLaunchMetadata, + LaunchConfigError, readUserLaunchOverride, writeLaunchMetadata, } from '../../../../src/cmd/build/package/launch'; @@ -367,5 +368,58 @@ describe('Launch Metadata', () => { writeFileSync(join(testDir, 'launch.json'), '{ not json'); expect(() => readUserLaunchOverride(testDir)).toThrow(/Invalid launch\.json/); }); + + test('treats JSON null on optional top-level fields as absent, matching pre-Zod `?.` semantics', () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ processes: null, runtime: null }) + ); + const result = readUserLaunchOverride(testDir); + expect(result?.processes).toBeUndefined(); + expect(result?.runtime).toBeUndefined(); + }); + + test('treats JSON null on a nested optional field (runtime.port) as absent', () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ runtime: { name: 'bun', port: null } }) + ); + const result = readUserLaunchOverride(testDir); + expect(result?.runtime?.name).toBe('bun'); + expect(result?.runtime?.port).toBeUndefined(); + }); + + test('rejects a string runtime.port, reporting the path runtime.port', () => { + writeFileSync(join(testDir, 'launch.json'), JSON.stringify({ runtime: { port: '3000' } })); + let thrown: unknown; + try { + readUserLaunchOverride(testDir); + } catch (ex) { + thrown = ex; + } + expect(thrown).toBeInstanceOf(LaunchConfigError); + expect((thrown as LaunchConfigError).issues.some((i) => i.path === 'runtime.port')).toBe( + true + ); + }); + + test('rejects a string processes[].default, reporting a path that mentions default', () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ + processes: [{ type: 'web', command: 'node server.js', default: 'true' }], + }) + ); + let thrown: unknown; + try { + readUserLaunchOverride(testDir); + } catch (ex) { + thrown = ex; + } + expect(thrown).toBeInstanceOf(LaunchConfigError); + expect((thrown as LaunchConfigError).issues.some((i) => i.path.includes('default'))).toBe( + true + ); + }); }); }); From 47a9d2b4800a42e9c5a99a895db7330426619aeb Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:19:31 -0700 Subject: [PATCH 08/13] feat(cli): add skipConfigLoad flag for config-free commands - gate pre-dispatch loadConfig and getAuth in main.ts - getAuth() re-loads config internally, so both need the gate Co-authored-by: Cursor --- packages/cli/src/main.ts | 15 +++++++++++++-- packages/cli/src/types.ts | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 39d2dd89e..e2555d89c 100755 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -254,6 +254,12 @@ async function main() { earlyCommandDef?.skipInternalLogging || earlySubcommandDef?.skipInternalLogging; + // Commands that opt out of config loading (e.g. `inspect`) must produce + // identical output whether the profile config is absent, valid, or + // malformed — loadConfig's schema-invalid path calls process.exit(1), + // which would otherwise take the whole command down with it. + const shouldSkipConfigLoad = earlyCommandDef?.skipConfigLoad === true; + // Create internal logger for trace/debug logging (always at trace level) const internalLogger = createInternalLogger(version, getPackageName()); @@ -296,10 +302,15 @@ async function main() { process.env.AGENTUITY_SKIP_VERSION_CHECK = '1'; } - config = await loadConfig(earlyOpts.config, false, earlyOpts.profile); + config = shouldSkipConfigLoad + ? null + : await loadConfig(earlyOpts.config, false, earlyOpts.profile); // Commands that disable internal logging should not touch local auth state. - if (!shouldSkipInternalLogging) { + // getAuth() calls loadConfig() again internally, so config-independent + // commands must skip this too — otherwise a malformed profile config + // would still exit(1) here even though `config` above was left null. + if (!shouldSkipInternalLogging && !shouldSkipConfigLoad) { try { const auth = await getAuth(); if (auth?.userId) { diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 573226b03..de300502a 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -365,6 +365,7 @@ export function createCommand< hidden?: boolean; executable?: boolean; skipUpgradeCheck?: boolean; + skipConfigLoad?: boolean; passThroughArgs?: boolean; skipInternalLogging?: boolean; requires?: R; @@ -408,6 +409,7 @@ type CommandDefBase = banner?: boolean; executable?: boolean; skipUpgradeCheck?: boolean; + skipConfigLoad?: boolean; passThroughArgs?: boolean; skipSkill?: boolean; skipInternalLogging?: boolean; @@ -429,6 +431,7 @@ type CommandDefBase = banner?: boolean; executable?: boolean; skipUpgradeCheck?: boolean; + skipConfigLoad?: boolean; passThroughArgs?: boolean; skipSkill?: boolean; skipInternalLogging?: boolean; From 3e7ab6d1c465ac53b195350b4afccc06a65d7766 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:19:35 -0700 Subject: [PATCH 09/13] feat(cli): record build-command provenance in detection - add DetectedFramework.buildCommandKind at all producers - extract NO_BUILD_SENTINEL const; adapters unchanged Co-authored-by: Cursor --- packages/cli/src/cmd/build/adapters/generic.ts | 6 +++--- .../cli/src/cmd/build/detect/agentuity-legacy.ts | 1 + packages/cli/src/cmd/build/detect/generic.ts | 1 + packages/cli/src/cmd/build/detect/index.ts | 16 +++++++++++++--- packages/cli/src/cmd/build/detect/types.ts | 8 ++++++++ packages/cli/src/cmd/build/detect/util.ts | 7 +++++++ 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cmd/build/adapters/generic.ts b/packages/cli/src/cmd/build/adapters/generic.ts index 66ee808a2..9c7211eaf 100644 --- a/packages/cli/src/cmd/build/adapters/generic.ts +++ b/packages/cli/src/cmd/build/adapters/generic.ts @@ -13,7 +13,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, join, relative, resolve } from 'node:path'; import { run } from '../../../node-compat/proc.ts'; -import { getRunCommand, isAgentuityCliInvocation } from '../detect/util.ts'; +import { getRunCommand, isAgentuityCliInvocation, NO_BUILD_SENTINEL } from '../detect/util.ts'; import { copyMonorepoTree, formatMonorepoStageLogs } from './monorepo-stage.ts'; import type { BuildAdapter, BuildAdapterOptions, BuildResult } from './types.ts'; @@ -204,7 +204,7 @@ export async function runBuildCommand( const isScriptName = /^[a-zA-Z0-9_:-]+$/.test(buildCommand); let cmd: string[]; - if (isScriptName && buildCommand !== '__agentuity_internal__') { + if (isScriptName && buildCommand !== NO_BUILD_SENTINEL) { const runCmd = getRunCommand(packageManager as 'bun' | 'npm' | 'pnpm' | 'yarn'); cmd = runCmd.split(' ').concat(buildCommand); } else { @@ -423,7 +423,7 @@ export const genericAdapter: BuildAdapter = { preparation = await prepareFrameworkBuild(projectDir, framework, logger); // Step 2: Run the build command - if (framework.buildCommand && framework.buildCommand !== '__agentuity_internal__') { + if (framework.buildCommand && framework.buildCommand !== NO_BUILD_SENTINEL) { logger.debug(`Running build: ${framework.buildCommand}`); const buildStart = Date.now(); await runBuildCommand( diff --git a/packages/cli/src/cmd/build/detect/agentuity-legacy.ts b/packages/cli/src/cmd/build/detect/agentuity-legacy.ts index 7240e3aa9..d192efb76 100644 --- a/packages/cli/src/cmd/build/detect/agentuity-legacy.ts +++ b/packages/cli/src/cmd/build/detect/agentuity-legacy.ts @@ -105,6 +105,7 @@ export async function detectAgentuityLegacy( runtime: 'bun', packageManager: 'bun', buildCommand, + buildCommandKind: 'command', buildOutput: LEGACY_OUTPUT_DIR, // The legacy build emits its client assets under `.agentuity/client`. staticDir: join(LEGACY_OUTPUT_DIR, 'client'), diff --git a/packages/cli/src/cmd/build/detect/generic.ts b/packages/cli/src/cmd/build/detect/generic.ts index 0a97dc6d5..1cc23e183 100644 --- a/packages/cli/src/cmd/build/detect/generic.ts +++ b/packages/cli/src/cmd/build/detect/generic.ts @@ -88,6 +88,7 @@ export const genericDetector: FrameworkDetector = { runtime, packageManager: pm, buildCommand: buildCommand ?? 'echo "No build step"', + buildCommandKind: buildCommand ? 'package-script' : 'none', buildOutput: '.', // Generic — build output could be anywhere startCommand, serverEntry, diff --git a/packages/cli/src/cmd/build/detect/index.ts b/packages/cli/src/cmd/build/detect/index.ts index d5fc38b3a..6028b6f35 100644 --- a/packages/cli/src/cmd/build/detect/index.ts +++ b/packages/cli/src/cmd/build/detect/index.ts @@ -13,7 +13,12 @@ import { join } from 'node:path'; import { pathExists } from '../../../node-compat/fs.ts'; import type { DetectedFramework, PackageJsonData } from './types.ts'; -import { readPackageJson, detectPackageManager, isAgentuityCliInvocation } from './util.ts'; +import { + readPackageJson, + detectPackageManager, + isAgentuityCliInvocation, + NO_BUILD_SENTINEL, +} from './util.ts'; import { detectAgentuityLegacy } from './agentuity-legacy.ts'; import { frameworkDefinitions, type FrameworkDefinition } from './frameworks.ts'; import { detectFromDatabase } from './engine.ts'; @@ -63,7 +68,8 @@ async function detectCustomLauncher( packageManager: pm, // Sentinel that tells the generic adapter to skip the build step. // The user is on the hook for prebuilding before `agentuity deploy`. - buildCommand: '__agentuity_internal__', + buildCommand: NO_BUILD_SENTINEL, + buildCommandKind: 'none', buildOutput: '.', startCommand, port: override.runtime?.port, @@ -246,6 +252,9 @@ async function frameworkDefToDetected( runtime, packageManager: pm, buildCommand: resolvedBuildCommand, + // Resolved from either the user's script body or the framework + // definition's raw command — both are terminal-runnable as-is. + buildCommandKind: 'command', buildOutput: resolvedOutputDir, staticDir: resolvedStaticDir, staticAssetPublicPath: resolvedStaticAssetPublicPath, @@ -270,7 +279,8 @@ function bareStaticHtmlDetected(): DetectedFramework { name: 'static-html', runtime: 'node', packageManager: 'npm', - buildCommand: '__agentuity_internal__', + buildCommand: NO_BUILD_SENTINEL, + buildCommandKind: 'none', buildOutput: '.', staticDir: '.', startCommand: 'npx serve', diff --git a/packages/cli/src/cmd/build/detect/types.ts b/packages/cli/src/cmd/build/detect/types.ts index 4aff9ab73..604515157 100644 --- a/packages/cli/src/cmd/build/detect/types.ts +++ b/packages/cli/src/cmd/build/detect/types.ts @@ -47,6 +47,14 @@ export interface DetectedFramework { /** The build command to execute (e.g., "next build", "vite build") */ buildCommand: string; + /** + * How `buildCommand` should be interpreted by public surfaces (inspect). + * Internal provenance, not inferred from the string: script names and + * raw commands can collide, and the generic detector's "no build step" + * fallback is a real string too, not a marker on its own. + */ + buildCommandKind?: 'package-script' | 'command' | 'none'; + /** Directory where build output is written (relative to project root) */ buildOutput: string; diff --git a/packages/cli/src/cmd/build/detect/util.ts b/packages/cli/src/cmd/build/detect/util.ts index 0821e2376..4603c2a2c 100644 --- a/packages/cli/src/cmd/build/detect/util.ts +++ b/packages/cli/src/cmd/build/detect/util.ts @@ -7,6 +7,13 @@ import { join } from 'node:path'; import { pathExists } from '../../../node-compat/fs.ts'; import type { PackageJsonData, PackageManager } from './types.ts'; +/** + * Marks a `buildCommand` that adapters must skip entirely — the project + * is either a bare static-HTML deploy or ships its own prebuilt output + * via a custom `launch.json`. Not a real command to run. + */ +export const NO_BUILD_SENTINEL = '__agentuity_internal__'; + /** * Check if a file exists (any of the given names) in a directory. * Returns the first matching filename, or null. From e02c2320ddb7e2512cf55f79581357c08d1c819b Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:19:39 -0700 Subject: [PATCH 10/13] feat(cli): make inspect offline-safe with an honest contract - skipConfigLoad: identical output for any profile config state - commands.build: discriminated union; sentinel never public - entrypoints -> detectedServerEntry (singular detector hint) - validate launch.json even when a framework matches - move tests to test/cmd/inspect.test.ts; run in CI test chain - add packed-tarball inspect smoke to install matrix Co-authored-by: Cursor --- packages/cli/package.json | 4 +- packages/cli/scripts/test-inspect.ts | 183 ---------- packages/cli/src/cmd/inspect.ts | 81 ++++- packages/cli/test/cmd/inspect.test.ts | 474 ++++++++++++++++++++++++++ scripts/test-package-install.sh | 44 +++ 5 files changed, 590 insertions(+), 196 deletions(-) delete mode 100644 packages/cli/scripts/test-inspect.ts create mode 100644 packages/cli/test/cmd/inspect.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 36b128296..750e4cd0f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,7 +27,7 @@ "build": "bunx tsc --build --force && bun run build:assets", "build:assets": "bun ./scripts/copy-assets.ts", "typecheck": "bunx tsc --noEmit", - "test": "bun ../../tests/create/basic-flow.ts && bun ../../tests/create/non-empty-dir.ts && bun scripts/test-exit-codes.ts && bun scripts/test-response-schema.ts && bun scripts/test-batch-reporting.ts && bun scripts/test-response-envelope.ts && bun scripts/test-concurrent-sessions.ts && bun scripts/test-inspect.ts", + "test": "bun ../../tests/create/basic-flow.ts && bun ../../tests/create/non-empty-dir.ts && bun scripts/test-exit-codes.ts && bun scripts/test-response-schema.ts && bun scripts/test-batch-reporting.ts && bun scripts/test-response-envelope.ts && bun scripts/test-concurrent-sessions.ts && bun run test:inspect", "test:create": "bun ../../tests/create/basic-flow.ts", "test:create:non-empty": "bun ../../tests/create/non-empty-dir.ts", "test:exit-codes": "bun scripts/test-exit-codes.ts", @@ -36,7 +36,7 @@ "test:envelope": "bun scripts/test-response-envelope.ts", "test:bundled-create": "bun scripts/test-bundled-create.ts", "test:concurrent-sessions": "bun scripts/test-concurrent-sessions.ts", - "test:inspect": "bun scripts/test-inspect.ts", + "test:inspect": "bun test test/cmd/inspect.test.ts", "prepublishOnly": "bun run clean && bun run build" }, "dependencies": { diff --git a/packages/cli/scripts/test-inspect.ts b/packages/cli/scripts/test-inspect.ts deleted file mode 100644 index 9de7b1ac1..000000000 --- a/packages/cli/scripts/test-inspect.ts +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bun - -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { command } from '../src/cmd/inspect.ts'; -import { isInspectInvocation } from '../src/local-delegate.ts'; - -const testDir = join(tmpdir(), `agentuity-inspect-${process.pid}-${Date.now()}`); -mkdirSync(testDir, { recursive: true }); - -const cliPath = join(import.meta.dir, '..', 'src', 'main.ts'); -const cliEnv = { - ...process.env, - AGENTUITY_AGENT_MODE: 'none', - AGENTUITY_API_KEY: '', - AGENTUITY_USER_ID: '', - HTTPS_PROXY: 'http://127.0.0.1:1', - HTTP_PROXY: 'http://127.0.0.1:1', -}; - -type InspectResult = { - readonly schemaVersion: number; - readonly framework: string; - readonly runtime: string; - readonly packageManager: string; - readonly entrypoints: readonly string[]; - readonly commands: { - readonly dev: string | null; - readonly build: string; - readonly start: string | null; - }; - readonly port: number | null; - readonly confidence: 'high' | 'medium' | 'low'; - readonly warnings: readonly string[]; - readonly monorepo: unknown; -}; - -async function runInspect(directory: string): Promise<{ - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -}> { - const cli = Bun.spawn(['bun', cliPath, '--json', 'inspect', '--dir', directory], { - cwd: directory, - env: cliEnv, - stdout: 'pipe', - stderr: 'pipe', - timeout: 15_000, - }); - const [exitCode, stdout, stderr] = await Promise.all([ - cli.exited, - new Response(cli.stdout).text(), - new Response(cli.stderr).text(), - ]); - return { exitCode, stdout, stderr }; -} - -async function inspectFixture( - directory: string, - packageJson: Readonly> -): Promise { - mkdirSync(directory, { recursive: true }); - writeFileSync(join(directory, 'package.json'), JSON.stringify(packageJson)); - const { exitCode, stdout, stderr } = await runInspect(directory); - if (exitCode !== 0) { - throw new Error(`inspect exited ${exitCode}: ${stderr}`); - } - if (stderr.trim()) { - throw new Error(`inspect wrote to stderr: ${stderr}`); - } - return JSON.parse(stdout) as InspectResult; -} - -try { - if (command.requires || command.optional) { - throw new Error('inspect must not declare auth or project context'); - } - if (!command.skipUpgradeCheck || !command.skipInternalLogging) { - throw new Error('inspect must skip network update checks and auth-backed internal logging'); - } - if (!isInspectInvocation(['--json', 'inspect', '--dir', testDir])) { - throw new Error('inspect must bypass local CLI installation and delegation'); - } - if (!isInspectInvocation(['--profile', 'work', '--json', 'inspect'])) { - throw new Error('inspect must bypass delegation when a profile is selected'); - } - if (isInspectInvocation(['build', '--dir', 'inspect'])) { - throw new Error('an inspect directory value must not bypass delegation for another command'); - } - - const result = await inspectFixture(testDir, { - name: 'unlinked-vite-app', - scripts: { dev: 'vite', build: 'vite build' }, - devDependencies: { vite: '^7.0.0' }, - }); - - if (result.schemaVersion !== 1) { - throw new Error(`expected schema version 1, got ${result.schemaVersion}`); - } - if (result.framework !== 'vite') throw new Error(`expected vite, got ${result.framework}`); - if (result.runtime !== 'node') throw new Error(`expected node, got ${result.runtime}`); - if (result.commands.dev !== 'vite') - throw new Error(`unexpected dev command: ${result.commands.dev}`); - if (result.commands.build !== 'vite build') { - throw new Error(`unexpected build command: ${result.commands.build}`); - } - if (result.monorepo !== null) throw new Error('standalone project must not report a monorepo'); - if (result.port !== null) throw new Error(`expected null port for vite, got ${result.port}`); - if (result.confidence !== 'high') { - throw new Error(`expected high confidence for vite, got ${result.confidence}`); - } - if (result.warnings.length !== 0) { - throw new Error(`expected no warnings for vite, got ${JSON.stringify(result.warnings)}`); - } - - const tanstackDir = join(testDir, 'tanstack-start'); - const tanstackResult = await inspectFixture(tanstackDir, { - name: 'tanstack-start-app', - dependencies: { '@tanstack/react-start': '^1.0.0' }, - scripts: { build: 'vite build' }, - }); - if (tanstackResult.framework !== 'tanstack-start') { - throw new Error(`expected tanstack-start, got ${tanstackResult.framework}`); - } - if (tanstackResult.confidence !== 'high') { - throw new Error( - `expected high confidence for tanstack-start, got ${tanstackResult.confidence}` - ); - } - if (!tanstackResult.warnings.some((warning) => warning.includes('Nitro'))) { - throw new Error(`expected Nitro warning, got ${JSON.stringify(tanstackResult.warnings)}`); - } - - const legacyDir = join(testDir, 'agentuity-legacy'); - const legacyResult = await inspectFixture(legacyDir, { - name: 'legacy-app', - scripts: { build: 'agentuity build', start: 'bun .agentuity/app.js' }, - dependencies: { '@agentuity/runtime': '^2.0.0' }, - }); - if (legacyResult.framework !== 'agentuity-legacy') { - throw new Error(`expected agentuity-legacy, got ${legacyResult.framework}`); - } - if (legacyResult.port !== 3000) { - throw new Error(`expected port 3000 for agentuity-legacy, got ${legacyResult.port}`); - } - if (legacyResult.confidence !== 'high') { - throw new Error( - `expected high confidence for agentuity-legacy, got ${legacyResult.confidence}` - ); - } - if (!legacyResult.warnings.some((warning) => warning.includes('@agentuity/cli'))) { - throw new Error(`expected CLI warning, got ${JSON.stringify(legacyResult.warnings)}`); - } - - const emptyDir = join(testDir, 'empty'); - mkdirSync(emptyDir); - const { - exitCode: invalidExitCode, - stdout: invalidStdout, - stderr: invalidStderr, - } = await runInspect(emptyDir); - if (invalidExitCode !== 12) { - throw new Error(`empty directory inspect exited ${invalidExitCode}: ${invalidStderr}`); - } - if (invalidStdout.trim()) { - throw new Error(`empty directory inspect wrote to stdout: ${invalidStdout}`); - } - const invalidResult = JSON.parse(invalidStderr) as { - error?: { code?: string; message?: string; exitCode?: number }; - }; - if ( - invalidResult.error?.code !== 'PROJECT_NOT_FOUND' || - invalidResult.error.exitCode !== 12 || - !invalidResult.error.message?.includes(emptyDir) - ) { - throw new Error(`unexpected empty directory error: ${invalidStderr}`); - } - - console.log('inspect passed without auth, agentuity.json, or a linked cloud project'); -} finally { - rmSync(testDir, { recursive: true, force: true }); -} diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index 0dcedbbab..9ac7ceefb 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getCommand } from '../command-prefix.ts'; import { detectFrameworkWithPackageJson } from './build/detect/index.ts'; import { detectMonorepoContext } from './build/detect/monorepo.ts'; +import { LaunchConfigError, readUserLaunchOverride } from './build/package/launch.ts'; import { createError, ErrorCode, exitWithError } from '../errors.ts'; import { isJSONMode } from '../output.ts'; import * as tui from '../tui.ts'; @@ -14,16 +15,39 @@ const InspectOptionsSchema = z.object({ dir: z.string().optional().describe('Project directory to inspect (default: current directory)'), }); +const InspectBuildCommandSchema = z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('package-script'), + name: z + .string() + .describe('package.json script name to run via the detected package manager'), + }), + z.object({ + kind: z.literal('command'), + command: z.string().describe('Raw shell command'), + }), + ]) + .nullable() + .describe( + 'Detector-level classification of the build step, not the final build/launch instruction — adapters may resolve the real command at build time. Null when no build step is required.' + ); + const InspectResponseSchema = z.object({ schemaVersion: z.literal(INSPECT_SCHEMA_VERSION).describe('Version of this response shape'), directory: z.string().describe('Absolute path to the inspected project directory'), framework: z.string().describe('Detected framework slug'), runtime: z.enum(['node', 'bun']).describe('Runtime used to start the built application'), packageManager: z.enum(['bun', 'npm', 'pnpm', 'yarn']).describe('Detected package manager'), - entrypoints: z.array(z.string()).describe('Detected server entrypoints'), + detectedServerEntry: z + .string() + .nullable() + .describe( + 'Server entry the detector inferred, relative to buildOutput; not the final launch entrypoint — adapters may resolve the real entry at build time' + ), commands: z.object({ dev: z.string().nullable().describe('Development command from package.json'), - build: z.string().describe('Detected build command'), + build: InspectBuildCommandSchema, start: z.string().nullable().describe('Detected start command'), }), buildOutput: z.string().describe('Build output path relative to the project directory'), @@ -48,10 +72,10 @@ const InspectResponseSchema = z.object({ export const command = createCommand({ name: 'inspect', - description: - 'Inspect a Genesis import before the user authenticates, adds agentuity.json, or links a cloud project', + description: 'Inspect a local project without authentication or cloud linking.', skipUpgradeCheck: true, skipInternalLogging: true, + skipConfigLoad: true, tags: ['read-only', 'fast'], idempotent: true, examples: [ @@ -71,10 +95,30 @@ export const command = createCommand({ async handler(ctx) { const directory = resolve(ctx.opts.dir ?? process.cwd()); - const [{ framework, packageJson }, monorepo] = await Promise.all([ - detectFrameworkWithPackageJson(directory), - detectMonorepoContext(directory), - ]); + + let framework: Awaited>['framework']; + let packageJson: Awaited>['packageJson']; + let monorepo: Awaited>; + try { + // Validate launch.json structurally even when detection never reaches + // the custom-launcher fallback (e.g. a Vite project) — otherwise a + // malformed override passes inspect but still fails build later. + // Return value unused; the call's only job here is validation. + readUserLaunchOverride(directory); + [{ framework, packageJson }, monorepo] = await Promise.all([ + detectFrameworkWithPackageJson(directory), + detectMonorepoContext(directory), + ]); + } catch (error) { + if (error instanceof LaunchConfigError) { + exitWithError( + createError(ErrorCode.CONFIG_INVALID, error.message, { issues: error.issues }), + ctx.logger, + ctx.options.errorFormat + ); + } + throw error; + } if (!framework) { exitWithError( @@ -87,16 +131,26 @@ export const command = createCommand({ ); } + const build: z.infer = (() => { + if (framework.buildCommandKind === 'none') return null; + if (framework.buildCommandKind === 'package-script') { + return { kind: 'package-script' as const, name: framework.buildCommand }; + } + // 'command', or undefined for detectors that predate this field — + // buildCommand is a terminal-runnable string either way. + return { kind: 'command' as const, command: framework.buildCommand }; + })(); + const result: z.infer = { schemaVersion: INSPECT_SCHEMA_VERSION, directory, framework: framework.name, runtime: framework.runtime, packageManager: framework.packageManager, - entrypoints: framework.serverEntry ? [framework.serverEntry] : [], + detectedServerEntry: framework.serverEntry ?? null, commands: { dev: packageJson?.scripts?.dev ?? null, - build: framework.buildCommand, + build, start: framework.startCommand ?? null, }, buildOutput: framework.buildOutput, @@ -116,7 +170,12 @@ export const command = createCommand({ tui.output(`Framework: ${result.framework}`); tui.output(`Runtime: ${result.runtime}`); tui.output(`Package manager: ${result.packageManager}`); - tui.output(`Build command: ${result.commands.build}`); + const buildLabel = result.commands.build + ? result.commands.build.kind === 'package-script' + ? `${result.packageManager} run ${result.commands.build.name}` + : result.commands.build.command + : 'none'; + tui.output(`Build command: ${buildLabel}`); if (result.commands.dev) tui.output(`Dev command: ${result.commands.dev}`); if (result.monorepo) { tui.output(`Working directory: ${result.monorepo.workingDirectory}`); diff --git a/packages/cli/test/cmd/inspect.test.ts b/packages/cli/test/cmd/inspect.test.ts new file mode 100644 index 000000000..886ed6e22 --- /dev/null +++ b/packages/cli/test/cmd/inspect.test.ts @@ -0,0 +1,474 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { z } from 'zod'; +import { command as inspectCommand } from '../../src/cmd/inspect.ts'; +import { isInspectInvocation } from '../../src/local-delegate.ts'; + +const CLI_ROOT = resolve(import.meta.dir, '..', '..'); +const SRC_ENTRY = join(CLI_ROOT, 'src', 'main.ts'); +const BIN_ENTRY = join(CLI_ROOT, 'bin', 'cli.js'); +const DIST_ENTRY = join(CLI_ROOT, 'dist', 'main.js'); + +type Runtime = 'bun' | 'node'; +const RUNTIMES: Runtime[] = ['bun', 'node']; + +interface CliRunResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +/** + * Minimal, explicit env for CLI subprocesses. Deliberately does NOT spread + * `process.env` wholesale — a developer's real ~/.config/agentuity profile, + * auth, or proxy settings must never leak into these fixtures. Every case + * pins its own `AGENTUITY_CONFIG_DIR` to prove config independence (D1). + */ +function cliEnv(configDir: string): Record { + const env: Record = { + AGENTUITY_CONFIG_DIR: configDir, + AGENTUITY_API_KEY: '', + AGENTUITY_USER_ID: '', + HTTP_PROXY: 'http://127.0.0.1:1', + HTTPS_PROXY: 'http://127.0.0.1:1', + // Otherwise the CLI's coding-agent detection fires when this test + // itself runs under an agent (Claude Code, etc.) and prints an + // unrelated "[agent] ..." hint to stderr, breaking the empty-stderr + // assertions below. + AGENTUITY_AGENT_MODE: 'none', + }; + if (process.env.PATH) env.PATH = process.env.PATH; + if (process.env.HOME) env.HOME = process.env.HOME; + if (process.env.TMPDIR) env.TMPDIR = process.env.TMPDIR; + return env; +} + +async function runInspect( + runtime: Runtime, + directory: string, + configDir: string +): Promise { + const cmd = + runtime === 'bun' + ? ['bun', SRC_ENTRY, '--json', 'inspect', '--dir', directory] + : ['node', BIN_ENTRY, '--json', 'inspect', '--dir', directory]; + const proc = Bun.spawn(cmd, { + cwd: directory, + env: cliEnv(configDir), + stdout: 'pipe', + stderr: 'pipe', + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +function write(path: string, content: string): void { + writeFileSync(path, content); +} + +let root: string; +let emptyConfigDir: string; +let validConfigDir: string; +let malformedConfigDir: string; +let viteDir: string; +let genericDir: string; +let bareHtmlDir: string; +let customLaunchDir: string; +let nullTolerantLaunchDir: string; +let malformedLaunchDir: string; +let viteMalformedLaunchDir: string; +let monorepoRoot: string; +let monorepoMemberDir: string; +let tanstackDir: string; +let legacyDir: string; +let emptyProjectDir: string; + +beforeAll(async () => { + // Local-dev fallback: CI builds before running `bun run test`, but on a + // clean checkout a developer running this file directly needs a full + // build (dist/ JS *and* the copied templates) for the `node bin/cli.js` + // runtime leg. Assets only need copying once, hence gated on dist being + // absent rather than run unconditionally. + if (!existsSync(DIST_ENTRY)) { + const build = Bun.spawn(['bun', 'run', 'build'], { + cwd: CLI_ROOT, + stdout: 'inherit', + stderr: 'inherit', + }); + const code = await build.exited; + if (code !== 0) throw new Error(`bun run build failed with exit code ${code}`); + } + + // Always recompile incrementally, even when dist/ already exists: the + // node leg must exercise current src/, never a dist left stale by + // uncommitted edits made after the last full build. `tsc --build` + // (no `--force`) is a ~1s no-op when nothing changed. + const tscBuild = Bun.spawn(['bunx', 'tsc', '--build'], { + cwd: CLI_ROOT, + stdout: 'inherit', + stderr: 'inherit', + }); + const tscCode = await tscBuild.exited; + if (tscCode !== 0) throw new Error(`tsc --build failed with exit code ${tscCode}`); + + root = mkdtempSync(join(tmpdir(), 'agentuity-inspect-test-')); + + emptyConfigDir = join(root, 'config-empty'); + mkdirSync(emptyConfigDir, { recursive: true }); + + validConfigDir = join(root, 'config-valid'); + mkdirSync(validConfigDir, { recursive: true }); + write(join(validConfigDir, 'production.yaml'), 'name: default\n'); + + // Same shape as the malformed profile finding this test guards against: + // `overrides` must be an object, not a string. + malformedConfigDir = join(root, 'config-malformed'); + mkdirSync(malformedConfigDir, { recursive: true }); + write( + join(malformedConfigDir, 'production.yaml'), + 'name: default\noverrides: "this-should-be-an-object"\n' + ); + + viteDir = join(root, 'vite-app'); + mkdirSync(viteDir, { recursive: true }); + write( + join(viteDir, 'package.json'), + JSON.stringify({ + name: 'unlinked-vite-app', + scripts: { dev: 'vite', build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }) + ); + + genericDir = join(root, 'generic-app'); + mkdirSync(genericDir, { recursive: true }); + write( + join(genericDir, 'package.json'), + JSON.stringify({ name: 'generic-app', scripts: { build: 'tsc' } }) + ); + + bareHtmlDir = join(root, 'bare-html'); + mkdirSync(bareHtmlDir, { recursive: true }); + write(join(bareHtmlDir, 'index.html'), 'hi\n'); + + customLaunchDir = join(root, 'custom-launch'); + mkdirSync(customLaunchDir, { recursive: true }); + write( + join(customLaunchDir, 'launch.json'), + JSON.stringify({ processes: [{ type: 'web', command: 'node server.js', default: true }] }) + ); + + // JSON `null` on optional fields must behave as absent (F5), matching + // the pre-Zod `?.` semantics these files relied on. + nullTolerantLaunchDir = join(root, 'null-tolerant-launch'); + mkdirSync(nullTolerantLaunchDir, { recursive: true }); + write( + join(nullTolerantLaunchDir, 'launch.json'), + JSON.stringify({ processes: null, runtime: { port: null } }) + ); + + // Structurally invalid but valid JSON: `processes` must be an array. + malformedLaunchDir = join(root, 'malformed-launch'); + mkdirSync(malformedLaunchDir, { recursive: true }); + write(join(malformedLaunchDir, 'launch.json'), JSON.stringify({ processes: 'not-an-array' })); + + // Vite is matched by the framework database, so detection never reaches + // the custom-launcher fallback that would otherwise be the only path + // reading launch.json — this fixture guards the F2 preflight validation. + viteMalformedLaunchDir = join(root, 'vite-malformed-launch'); + mkdirSync(viteMalformedLaunchDir, { recursive: true }); + write( + join(viteMalformedLaunchDir, 'package.json'), + JSON.stringify({ + name: 'vite-malformed-launch-app', + scripts: { dev: 'vite', build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }) + ); + write( + join(viteMalformedLaunchDir, 'launch.json'), + JSON.stringify({ processes: 'not-an-array' }) + ); + + monorepoRoot = join(root, 'monorepo'); + mkdirSync(monorepoRoot, { recursive: true }); + write( + join(monorepoRoot, 'package.json'), + JSON.stringify({ name: 'mono-root', private: true, workspaces: ['packages/*'] }) + ); + monorepoMemberDir = join(monorepoRoot, 'packages', 'app'); + mkdirSync(monorepoMemberDir, { recursive: true }); + write( + join(monorepoMemberDir, 'package.json'), + JSON.stringify({ + name: 'app', + scripts: { dev: 'vite', build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }) + ); + + tanstackDir = join(root, 'tanstack-start'); + mkdirSync(tanstackDir, { recursive: true }); + write( + join(tanstackDir, 'package.json'), + JSON.stringify({ + name: 'tanstack-start-app', + dependencies: { '@tanstack/react-start': '^1.0.0' }, + scripts: { build: 'vite build' }, + }) + ); + + legacyDir = join(root, 'agentuity-legacy'); + mkdirSync(legacyDir, { recursive: true }); + write( + join(legacyDir, 'package.json'), + JSON.stringify({ + name: 'legacy-app', + scripts: { build: 'agentuity build', start: 'bun .agentuity/app.js' }, + dependencies: { '@agentuity/runtime': '^2.0.0' }, + }) + ); + + emptyProjectDir = join(root, 'empty-project'); + mkdirSync(emptyProjectDir, { recursive: true }); +}, 120_000); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('isInspectInvocation', () => { + test('bypasses local CLI delegation for inspect with --dir', () => { + expect(isInspectInvocation(['--json', 'inspect', '--dir', '/tmp/whatever'])).toBe(true); + }); + + test('bypasses delegation when a profile is selected', () => { + expect(isInspectInvocation(['--profile', 'work', '--json', 'inspect'])).toBe(true); + }); + + test('does not bypass delegation for another command using "inspect" as a value', () => { + expect(isInspectInvocation(['build', '--dir', 'inspect'])).toBe(false); + }); +}); + +describe('inspect command definition', () => { + test('declares no auth or project context', () => { + expect(inspectCommand.requires).toBeUndefined(); + expect(inspectCommand.optional).toBeUndefined(); + }); + + test('skips network update checks, auth-backed internal logging, and config load', () => { + expect(inspectCommand.skipUpgradeCheck).toBe(true); + expect(inspectCommand.skipInternalLogging).toBe(true); + expect(inspectCommand.skipConfigLoad).toBe(true); + }); + + test('describes itself without mentioning Genesis', () => { + expect(inspectCommand.description.toLowerCase()).not.toContain('genesis'); + }); + + test('response schema marks build/detectedServerEntry as detector-level facts', () => { + const responseSchema = inspectCommand.schema?.response; + expect(responseSchema).toBeDefined(); + const jsonSchema = z.toJSONSchema(responseSchema as z.ZodType) as { + properties?: { + commands?: { properties?: { build?: { description?: string } } }; + detectedServerEntry?: { description?: string }; + }; + }; + const buildDescription = + jsonSchema.properties?.commands?.properties?.build?.description ?? ''; + const entryDescription = jsonSchema.properties?.detectedServerEntry?.description ?? ''; + expect(buildDescription).toContain('Detector-level'); + expect(buildDescription).toContain('not the final'); + expect(entryDescription).toContain('not the final launch entrypoint'); + }); +}); + +for (const runtime of RUNTIMES) { + describe(`agentuity --json inspect (${runtime})`, () => { + test('vite fixture: happy path with a terminal-runnable build command', async () => { + const { exitCode, stdout, stderr } = await runInspect(runtime, viteDir, emptyConfigDir); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.schemaVersion).toBe(1); + expect(result.framework).toBe('vite'); + expect(result.runtime).toBe('node'); + expect(result.detectedServerEntry).toBeNull(); + expect(result.commands.build).toEqual({ kind: 'command', command: 'vite build' }); + expect(result.monorepo).toBeNull(); + expect(result.confidence).toBe('high'); + expect(result.warnings).toEqual([]); + }, 20_000); + + test('generic fixture: build classified as a package script', async () => { + const { exitCode, stdout, stderr } = await runInspect(runtime, genericDir, emptyConfigDir); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.framework).toBe('generic'); + expect(result.commands.build).toEqual({ kind: 'package-script', name: 'build' }); + }, 20_000); + + test('bare index.html: no build step, sentinel never leaks', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + bareHtmlDir, + emptyConfigDir + ); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + expect(stdout).not.toContain('__agentuity_internal__'); + const result = JSON.parse(stdout); + expect(result.commands.build).toBeNull(); + }, 20_000); + + test('valid custom launch.json: no build step, sentinel never leaks', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + customLaunchDir, + emptyConfigDir + ); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + expect(stdout).not.toContain('__agentuity_internal__'); + const result = JSON.parse(stdout); + expect(result.commands.build).toBeNull(); + expect(result.commands.start).toBe('node server.js'); + }, 20_000); + + test('launch.json with JSON null on optional fields: still accepted, no build step leaks', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + nullTolerantLaunchDir, + emptyConfigDir + ); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + expect(stdout).not.toContain('__agentuity_internal__'); + const result = JSON.parse(stdout); + expect(result.commands.build).toBeNull(); + }, 20_000); + + test('malformed launch.json: CONFIG_INVALID, never INTERNAL_ERROR', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + malformedLaunchDir, + emptyConfigDir + ); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(10); + expect(stderr).not.toContain('INTERNAL_ERROR'); + // A clean structured-error payload parses as a single JSON object; + // a leaked stack trace or extra console output would break this. + const error = JSON.parse(stderr); + expect(error.error.code).toBe('CONFIG_INVALID'); + expect(error.error.exitCode).toBe(10); + expect(error.error.message).toContain('processes'); + }, 20_000); + + test('vite fixture with malformed launch.json: still CONFIG_INVALID even though detection never reaches the custom-launcher fallback', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + viteMalformedLaunchDir, + emptyConfigDir + ); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(10); + expect(stderr).not.toContain('INTERNAL_ERROR'); + const error = JSON.parse(stderr); + expect(error.error.code).toBe('CONFIG_INVALID'); + expect(error.error.exitCode).toBe(10); + expect(error.error.message).toContain('processes'); + }, 20_000); + + test('custom launch.json without a `default` field is still accepted', async () => { + const dir = join(root, `launch-no-default-${runtime}`); + mkdirSync(dir, { recursive: true }); + write( + join(dir, 'launch.json'), + JSON.stringify({ processes: [{ type: 'web', command: 'node server.js' }] }) + ); + const { exitCode, stdout, stderr } = await runInspect(runtime, dir, emptyConfigDir); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.commands.start).toBe('node server.js'); + }, 20_000); + + test('monorepo member: monorepo block populated', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + monorepoMemberDir, + emptyConfigDir + ); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.monorepo).not.toBeNull(); + expect(result.monorepo.root).toBe(monorepoRoot); + expect(result.monorepo.workingDirectory).toBe('packages/app'); + expect(result.monorepo.packageManager).toBe('npm'); + }, 20_000); + + test('tanstack-start fixture: framework detected with a Nitro warning', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + tanstackDir, + emptyConfigDir + ); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.framework).toBe('tanstack-start'); + expect(result.confidence).toBe('high'); + expect(result.warnings.some((w: string) => w.includes('Nitro'))).toBe(true); + }, 20_000); + + test('agentuity-legacy fixture', async () => { + const { exitCode, stdout, stderr } = await runInspect(runtime, legacyDir, emptyConfigDir); + expect(stderr.trim()).toBe(''); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.framework).toBe('agentuity-legacy'); + expect(result.port).toBe(3000); + expect(result.confidence).toBe('high'); + expect(result.warnings.some((w: string) => w.includes('@agentuity/cli'))).toBe(true); + }, 20_000); + + test('empty directory: PROJECT_NOT_FOUND on stderr, exit 12', async () => { + const { exitCode, stdout, stderr } = await runInspect( + runtime, + emptyProjectDir, + emptyConfigDir + ); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(12); + const error = JSON.parse(stderr); + expect(error.error.code).toBe('PROJECT_NOT_FOUND'); + expect(error.error.exitCode).toBe(12); + expect(error.error.message).toContain(emptyProjectDir); + }, 20_000); + + test('config independence: identical JSON whether the profile config is absent, valid, or malformed', async () => { + const [empty, valid, malformed] = await Promise.all([ + runInspect(runtime, viteDir, emptyConfigDir), + runInspect(runtime, viteDir, validConfigDir), + runInspect(runtime, viteDir, malformedConfigDir), + ]); + for (const run of [empty, valid, malformed]) { + expect(run.stderr.trim()).toBe(''); + expect(run.exitCode).toBe(0); + } + expect(valid.stdout).toBe(empty.stdout); + expect(malformed.stdout).toBe(empty.stdout); + }, 30_000); + }); +} diff --git a/scripts/test-package-install.sh b/scripts/test-package-install.sh index 00e4f2945..4b4e2e712 100755 --- a/scripts/test-package-install.sh +++ b/scripts/test-package-install.sh @@ -124,6 +124,50 @@ else cat cli-output.log fi +# Step 3b: Validate `inspect` from the packed tarball, isolated from any +# real profile config or auth, across the runtime matrix (Node/Bun via +# CLI_RUNTIME). Guards against the offline inspect surface regressing when +# installed as a real package rather than run from the repo checkout. +echo "" +log_info "Step 3b: Validating inspect from packed tarball (runtime: ${CLI_RUNTIME:-node})..." + +INSPECT_FIXTURE_DIR="$CLI_TEST_DIR/inspect-fixture" +INSPECT_CONFIG_DIR="$CLI_TEST_DIR/inspect-config" +mkdir -p "$INSPECT_FIXTURE_DIR" "$INSPECT_CONFIG_DIR" +cat >"$INSPECT_FIXTURE_DIR/package.json" <<'EOF' +{"name":"inspect-smoke-app","scripts":{"dev":"vite","build":"vite build"},"devDependencies":{"vite":"^7.0.0"}} +EOF + +AGENTUITY_CONFIG_DIR="$INSPECT_CONFIG_DIR" \ + AGENTUITY_API_KEY='' \ + AGENTUITY_USER_ID='' \ + AGENTUITY_AGENT_MODE='none' \ + HTTP_PROXY='http://127.0.0.1:1' \ + HTTPS_PROXY='http://127.0.0.1:1' \ + "${CLI_RUNTIME:-node}" node_modules/.bin/agentuity --json inspect --dir "$INSPECT_FIXTURE_DIR" \ + >inspect-output.log 2>&1 +inspect_exit=$? + +if [ "$inspect_exit" -ne 0 ]; then + log_error "inspect exited $inspect_exit" + cat inspect-output.log || true + exit 1 +fi + +if ! grep -q '"framework": *"vite"' inspect-output.log; then + log_error "inspect did not report framework \"vite\"" + cat inspect-output.log || true + exit 1 +fi + +if grep -q '__agentuity_internal__' inspect-output.log; then + log_error "inspect leaked the internal build-command sentinel" + cat inspect-output.log || true + exit 1 +fi + +log_success "inspect runs from packed tarball with an isolated profile and reports vite" + cd "$SDK_ROOT" # Step 4: Create a test project From b9320093a11b65f7898c2ecb0c3c39485c5a62d0 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:44:11 -0700 Subject: [PATCH 11/13] fix(cli): address inspect review feedback - Read launch overrides with async filesystem helpers - Await packaging across build and test callers - Preserve inspect logs when packed smoke checks fail --- packages/cli/src/cmd/build/detect/index.ts | 2 +- packages/cli/src/cmd/build/package/index.ts | 6 +-- packages/cli/src/cmd/build/package/launch.ts | 12 +++-- packages/cli/src/cmd/build/run.ts | 2 +- packages/cli/src/cmd/inspect.ts | 2 +- .../test/cmd/build/buildpack-contract.test.ts | 14 +++--- .../cli/test/cmd/build/package/launch.test.ts | 44 ++++++++--------- .../cli/test/cmd/build/static-assets.test.ts | 48 +++++++++++++++---- .../test/cmd/build/wsl-smoke-layout.test.ts | 2 +- .../cmd/build/wsl-smoke-local-validate.ts | 2 +- scripts/test-package-install.sh | 4 +- 11 files changed, 87 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/cmd/build/detect/index.ts b/packages/cli/src/cmd/build/detect/index.ts index 6028b6f35..c86008cd2 100644 --- a/packages/cli/src/cmd/build/detect/index.ts +++ b/packages/cli/src/cmd/build/detect/index.ts @@ -38,7 +38,7 @@ async function detectCustomLauncher( projectDir: string, pkg: PackageJsonData | null ): Promise { - const override = readUserLaunchOverride(projectDir); + const override = await readUserLaunchOverride(projectDir); if (!override) return null; const webProcess = diff --git a/packages/cli/src/cmd/build/package/index.ts b/packages/cli/src/cmd/build/package/index.ts index 90dc67d03..b574a5fde 100644 --- a/packages/cli/src/cmd/build/package/index.ts +++ b/packages/cli/src/cmd/build/package/index.ts @@ -40,14 +40,14 @@ export interface PackageResult { * its fields override the generated launch metadata. See * `readUserLaunchOverride` for the merge semantics. */ -export function packageBuildOutput( +export async function packageBuildOutput( framework: DetectedFramework, buildResult: BuildResult, outputDir: string, projectDir?: string, monorepo?: MonorepoContext -): PackageResult { - const override = projectDir ? readUserLaunchOverride(projectDir) : null; +): Promise { + const override = projectDir ? await readUserLaunchOverride(projectDir) : null; // Generate launch metadata (with optional user override applied). // In monorepo mode, every process inherits the subpackage as its diff --git a/packages/cli/src/cmd/build/package/launch.ts b/packages/cli/src/cmd/build/package/launch.ts index e9756aba5..448b758ee 100644 --- a/packages/cli/src/cmd/build/package/launch.ts +++ b/packages/cli/src/cmd/build/package/launch.ts @@ -6,8 +6,10 @@ */ import { join } from 'node:path'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { z } from 'zod'; +import { pathExists } from '../../../node-compat/fs.ts'; import type { BuildResult } from '../adapters/types.ts'; import type { DetectedFramework } from '../detect/types.ts'; import type { MonorepoContext } from '../detect/monorepo.ts'; @@ -129,13 +131,15 @@ export class LaunchConfigError extends Error { * inference (or, worse, crashing deep inside a consumer that assumed the * shape was already validated). */ -export function readUserLaunchOverride(projectDir: string): UserLaunchOverride | null { +export async function readUserLaunchOverride( + projectDir: string +): Promise { const path = join(projectDir, USER_LAUNCH_FILENAME); - if (!existsSync(path)) return null; + if (!(await pathExists(path))) return null; let parsed: unknown; try { - parsed = JSON.parse(readFileSync(path, 'utf-8')); + parsed = JSON.parse(await readFile(path, 'utf-8')); } catch (ex) { const message = (ex as Error).message; throw new LaunchConfigError( diff --git a/packages/cli/src/cmd/build/run.ts b/packages/cli/src/cmd/build/run.ts index f11dc29f4..34a7ce800 100644 --- a/packages/cli/src/cmd/build/run.ts +++ b/packages/cli/src/cmd/build/run.ts @@ -211,7 +211,7 @@ export async function runBuildPipeline(input: BuildPipelineInput): Promise { }); // Package - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); // Validate contract const violations = validateBuildpackContract(buildResult.outputDir); @@ -178,7 +178,7 @@ describe('Buildpack Contract — End-to-End', () => { expect(buildResult.startCommand).toBeDefined(); // Package - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); // Validate contract — should still produce a valid buildpack output const violations = validateBuildpackContract(buildResult.outputDir); @@ -205,7 +205,7 @@ describe('Buildpack Contract — End-to-End', () => { logger, }); - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); expect(existsSync(join(buildResult.outputDir, 'index.html'))).toBe(true); expect(existsSync(join(buildResult.outputDir, 'node_modules'))).toBe(false); @@ -244,7 +244,7 @@ describe('Buildpack Contract — End-to-End', () => { logger, }); - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); // The build output should contain the dist/server.js file // The generic adapter copies build output to outputDir and the project root is '.' @@ -283,7 +283,7 @@ describe('Buildpack Contract — End-to-End', () => { logger, }); - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); // Verify build artifacts exist in the project (generic adapter with buildOutput '.') expect(existsSync(join(testDir, 'dist', 'index.html'))).toBe(true); @@ -314,7 +314,7 @@ describe('Buildpack Contract — End-to-End', () => { logger, }); - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); const launch: LaunchMetadata = JSON.parse( readFileSync(join(buildResult.outputDir, 'launch.json'), 'utf-8') @@ -366,7 +366,7 @@ describe('Buildpack Contract — End-to-End', () => { logger, }); - packageBuildOutput(framework!, buildResult, buildResult.outputDir); + await packageBuildOutput(framework!, buildResult, buildResult.outputDir); const launch: LaunchMetadata = JSON.parse( readFileSync(join(buildResult.outputDir, 'launch.json'), 'utf-8') diff --git a/packages/cli/test/cmd/build/package/launch.test.ts b/packages/cli/test/cmd/build/package/launch.test.ts index 5f7aab0b7..512c1dac4 100644 --- a/packages/cli/test/cmd/build/package/launch.test.ts +++ b/packages/cli/test/cmd/build/package/launch.test.ts @@ -237,7 +237,7 @@ describe('Launch Metadata', () => { // ── packageBuildOutput ── describe('packageBuildOutput', () => { - test('returns hasStaticAssets when staticDir exists', () => { + test('returns hasStaticAssets when staticDir exists', async () => { const staticDir = join(testDir, 'static'); mkdirSync(staticDir, { recursive: true }); @@ -259,12 +259,12 @@ describe('Launch Metadata', () => { logs: [], }; - const result = packageBuildOutput(framework, buildResult, testDir); + const result = await packageBuildOutput(framework, buildResult, testDir); expect(result.hasStaticAssets).toBe(true); expect(result.staticDir).toBe(staticDir); }); - test('returns hasStaticAssets false when no static dir', () => { + test('returns hasStaticAssets false when no static dir', async () => { const framework: DetectedFramework = { name: 'generic', runtime: 'node', @@ -282,11 +282,11 @@ describe('Launch Metadata', () => { logs: [], }; - const result = packageBuildOutput(framework, buildResult, testDir); + const result = await packageBuildOutput(framework, buildResult, testDir); expect(result.hasStaticAssets).toBe(false); }); - test('user override at projectDir replaces processes and runtime fields', () => { + test('user override at projectDir replaces processes and runtime fields', async () => { const framework: DetectedFramework = { name: 'nextjs', runtime: 'node', @@ -314,7 +314,7 @@ describe('Launch Metadata', () => { }) ); - packageBuildOutput(framework, buildResult, testDir, testDir); + await packageBuildOutput(framework, buildResult, testDir, testDir); const parsed = JSON.parse(readFileSync(join(testDir, 'launch.json'), 'utf-8')); expect(parsed.processes).toHaveLength(2); @@ -327,7 +327,7 @@ describe('Launch Metadata', () => { expect(parsed.build.duration).toBe(2000); }); - test('writes launch metadata output files', () => { + test('writes launch metadata output files', async () => { const framework: DetectedFramework = { name: 'sveltekit', runtime: 'node', @@ -345,7 +345,7 @@ describe('Launch Metadata', () => { logs: [], }; - packageBuildOutput(framework, buildResult, testDir); + await packageBuildOutput(framework, buildResult, testDir); expect(existsSync(join(testDir, 'launch.json'))).toBe(true); }); @@ -354,46 +354,46 @@ describe('Launch Metadata', () => { // ── readUserLaunchOverride ── describe('readUserLaunchOverride', () => { - test('returns null when no launch.json present', () => { - expect(readUserLaunchOverride(testDir)).toBeNull(); + test('returns null when no launch.json present', async () => { + expect(await readUserLaunchOverride(testDir)).toBeNull(); }); - test('parses a partial override', () => { + test('parses a partial override', async () => { writeFileSync(join(testDir, 'launch.json'), JSON.stringify({ runtime: { name: 'bun' } })); - const result = readUserLaunchOverride(testDir); + const result = await readUserLaunchOverride(testDir); expect(result?.runtime?.name).toBe('bun'); }); - test('throws on invalid JSON', () => { + test('throws on invalid JSON', async () => { writeFileSync(join(testDir, 'launch.json'), '{ not json'); - expect(() => readUserLaunchOverride(testDir)).toThrow(/Invalid launch\.json/); + await expect(readUserLaunchOverride(testDir)).rejects.toThrow(/Invalid launch\.json/); }); - test('treats JSON null on optional top-level fields as absent, matching pre-Zod `?.` semantics', () => { + test('treats JSON null on optional top-level fields as absent, matching pre-Zod `?.` semantics', async () => { writeFileSync( join(testDir, 'launch.json'), JSON.stringify({ processes: null, runtime: null }) ); - const result = readUserLaunchOverride(testDir); + const result = await readUserLaunchOverride(testDir); expect(result?.processes).toBeUndefined(); expect(result?.runtime).toBeUndefined(); }); - test('treats JSON null on a nested optional field (runtime.port) as absent', () => { + test('treats JSON null on a nested optional field (runtime.port) as absent', async () => { writeFileSync( join(testDir, 'launch.json'), JSON.stringify({ runtime: { name: 'bun', port: null } }) ); - const result = readUserLaunchOverride(testDir); + const result = await readUserLaunchOverride(testDir); expect(result?.runtime?.name).toBe('bun'); expect(result?.runtime?.port).toBeUndefined(); }); - test('rejects a string runtime.port, reporting the path runtime.port', () => { + test('rejects a string runtime.port, reporting the path runtime.port', async () => { writeFileSync(join(testDir, 'launch.json'), JSON.stringify({ runtime: { port: '3000' } })); let thrown: unknown; try { - readUserLaunchOverride(testDir); + await readUserLaunchOverride(testDir); } catch (ex) { thrown = ex; } @@ -403,7 +403,7 @@ describe('Launch Metadata', () => { ); }); - test('rejects a string processes[].default, reporting a path that mentions default', () => { + test('rejects a string processes[].default, reporting a path that mentions default', async () => { writeFileSync( join(testDir, 'launch.json'), JSON.stringify({ @@ -412,7 +412,7 @@ describe('Launch Metadata', () => { ); let thrown: unknown; try { - readUserLaunchOverride(testDir); + await readUserLaunchOverride(testDir); } catch (ex) { thrown = ex; } diff --git a/packages/cli/test/cmd/build/static-assets.test.ts b/packages/cli/test/cmd/build/static-assets.test.ts index 4fb914f00..3540e276c 100644 --- a/packages/cli/test/cmd/build/static-assets.test.ts +++ b/packages/cli/test/cmd/build/static-assets.test.ts @@ -102,7 +102,11 @@ describe('Static Asset CDN Upload', () => { expect(existsSync(buildResult.staticDir!)).toBe(true); // Package - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); // Generate deploy metadata (non-Agentuity path) const metadata = await generateDeployMetadata({ @@ -184,7 +188,11 @@ describe('Static Asset CDN Upload', () => { expect(buildResult.staticDir).toBeDefined(); // Package - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); // Generate deploy metadata const metadata = await generateDeployMetadata({ @@ -254,7 +262,11 @@ describe('Static Asset CDN Upload', () => { expect(buildResult.staticDir!.startsWith(resolve(outputDir))).toBe(true); // Package - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); // Generate deploy metadata const metadata = await generateDeployMetadata({ @@ -314,7 +326,11 @@ describe('Static Asset CDN Upload', () => { expect(buildResult.staticDir).toBeDefined(); expect(existsSync(buildResult.staticDir!)).toBe(true); - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); const metadata = await generateDeployMetadata({ buildResult, packageResult, @@ -364,7 +380,11 @@ describe('Static Asset CDN Upload', () => { logger, }); - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); const metadata = await generateDeployMetadata({ buildResult, packageResult, @@ -413,7 +433,11 @@ describe('Static Asset CDN Upload', () => { // No static dir in result expect(buildResult.staticDir).toBeUndefined(); - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); const metadata = await generateDeployMetadata({ buildResult, @@ -464,7 +488,11 @@ describe('Static Asset CDN Upload', () => { logger, }); - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); const metadata = await generateDeployMetadata({ buildResult, @@ -576,7 +604,11 @@ describe('Static Asset CDN Upload', () => { logger, }); - const packageResult = packageBuildOutput(framework!, buildResult, buildResult.outputDir); + const packageResult = await packageBuildOutput( + framework!, + buildResult, + buildResult.outputDir + ); const metadata = await generateDeployMetadata({ buildResult, diff --git a/packages/cli/test/cmd/build/wsl-smoke-layout.test.ts b/packages/cli/test/cmd/build/wsl-smoke-layout.test.ts index 4d71a1699..b6bc2b02f 100644 --- a/packages/cli/test/cmd/build/wsl-smoke-layout.test.ts +++ b/packages/cli/test/cmd/build/wsl-smoke-layout.test.ts @@ -158,7 +158,7 @@ describe('WSL smoke layout (monorepo non-member)', () => { // is not set to the smoke subpath of a monorepo root. expect(monorepo).toBeNull(); - const packageResult = packageBuildOutput( + const packageResult = await packageBuildOutput( framework!, { outputDir: join(smokeDir, '.agentuity'), diff --git a/packages/cli/test/cmd/build/wsl-smoke-local-validate.ts b/packages/cli/test/cmd/build/wsl-smoke-local-validate.ts index b49e208dd..d41d5eecf 100644 --- a/packages/cli/test/cmd/build/wsl-smoke-local-validate.ts +++ b/packages/cli/test/cmd/build/wsl-smoke-local-validate.ts @@ -133,7 +133,7 @@ function main(): void { // launch.json must not get monorepo workingDirectory const outDir = join(smokeDir, '.agentuity-validate'); - const packageResult = packageBuildOutput( + const packageResult = await packageBuildOutput( framework, { outputDir: outDir, diff --git a/scripts/test-package-install.sh b/scripts/test-package-install.sh index 4b4e2e712..407e91560 100755 --- a/scripts/test-package-install.sh +++ b/scripts/test-package-install.sh @@ -145,8 +145,8 @@ AGENTUITY_CONFIG_DIR="$INSPECT_CONFIG_DIR" \ HTTP_PROXY='http://127.0.0.1:1' \ HTTPS_PROXY='http://127.0.0.1:1' \ "${CLI_RUNTIME:-node}" node_modules/.bin/agentuity --json inspect --dir "$INSPECT_FIXTURE_DIR" \ - >inspect-output.log 2>&1 -inspect_exit=$? + >inspect-output.log 2>&1 || inspect_exit=$? +inspect_exit=${inspect_exit:-0} if [ "$inspect_exit" -ne 0 ]; then log_error "inspect exited $inspect_exit" From ef26085f9743f40dddb435286c2d3c764c33aa85 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 11:48:43 -0700 Subject: [PATCH 12/13] fix(cli): align inspect schema with its default Mark --dir optional in generated command metadata.\n\nCover the built schema contract with a regression test. --- packages/cli/src/cmd/inspect.ts | 2 +- packages/cli/test/cmd/inspect.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmd/inspect.ts b/packages/cli/src/cmd/inspect.ts index be9fa233c..19047270f 100644 --- a/packages/cli/src/cmd/inspect.ts +++ b/packages/cli/src/cmd/inspect.ts @@ -12,7 +12,7 @@ import { createCommand } from '../types.ts'; const INSPECT_SCHEMA_VERSION = 1; const InspectOptionsSchema = z.object({ - dir: z.string().optional().describe('Project directory to inspect (default: current directory)'), + dir: z.string().default('.').describe('Project directory to inspect'), }); const InspectBuildCommandSchema = z diff --git a/packages/cli/test/cmd/inspect.test.ts b/packages/cli/test/cmd/inspect.test.ts index 886ed6e22..1efcc7082 100644 --- a/packages/cli/test/cmd/inspect.test.ts +++ b/packages/cli/test/cmd/inspect.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'; import { z } from 'zod'; import { command as inspectCommand } from '../../src/cmd/inspect.ts'; import { isInspectInvocation } from '../../src/local-delegate.ts'; +import { extractCommandSchema } from '../../src/schema-generator.ts'; const CLI_ROOT = resolve(import.meta.dir, '..', '..'); const SRC_ENTRY = join(CLI_ROOT, 'src', 'main.ts'); @@ -273,6 +274,13 @@ describe('inspect command definition', () => { expect(inspectCommand.description.toLowerCase()).not.toContain('genesis'); }); + test('describes --dir as optional with the current directory default', () => { + const schema = extractCommandSchema(inspectCommand); + const dir = schema.options?.find((option) => option.name === 'dir'); + expect(dir?.required).toBe(false); + expect(dir?.default).toBe('.'); + }); + test('response schema marks build/detectedServerEntry as detector-level facts', () => { const responseSchema = inspectCommand.schema?.response; expect(responseSchema).toBeDefined(); From 668e52c4ba89f9718c6bd7f4233e2c4772b4767e Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 29 Jul 2026 12:04:46 -0700 Subject: [PATCH 13/13] chore: retry docs preview deployment