diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cf12e388..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", + "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,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 test test/cmd/inspect.test.ts", "prepublishOnly": "bun run clean && bun run build" }, "dependencies": { 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..c86008cd2 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'; @@ -33,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 = @@ -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. 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/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 1254dee80..448b758ee 100644 --- a/packages/cli/src/cmd/build/package/launch.ts +++ b/packages/cli/src/cmd/build/package/launch.ts @@ -6,7 +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'; @@ -18,34 +21,149 @@ 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 { +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 { - return JSON.parse(readFileSync(path, 'utf-8')) as UserLaunchOverride; + parsed = JSON.parse(await readFile(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 +250,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/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 { 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..19047270f --- /dev/null +++ b/packages/cli/src/cmd/inspect.ts @@ -0,0 +1,187 @@ +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 { 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'; +import { createCommand } from '../types.ts'; + +const INSPECT_SCHEMA_VERSION = 1; + +const InspectOptionsSchema = z.object({ + dir: z.string().default('.').describe('Project directory to inspect'), +}); + +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'), + 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: InspectBuildCommandSchema, + 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'), + 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 cloud linking.', + skipUpgradeCheck: true, + skipInternalLogging: true, + skipConfigLoad: true, + tags: ['read-only', 'fast'], + 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()); + + 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. + await 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( + createError( + ErrorCode.PROJECT_NOT_FOUND, + `Could not detect a deployable project in ${directory}` + ), + ctx.logger, + ctx.options.errorFormat + ); + } + + 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, + detectedServerEntry: framework.serverEntry ?? null, + commands: { + dev: packageJson?.scripts?.dev ?? null, + build, + start: framework.startCommand ?? null, + }, + buildOutput: framework.buildOutput, + port: framework.port ?? null, + confidence: framework.confidence, + warnings: framework.warnings ?? [], + monorepo: monorepo + ? { + root: monorepo.root, + workingDirectory: monorepo.subpath, + packageManager: monorepo.packageManager, + } + : null, + }; + + if (!isJSONMode(ctx.options)) { + tui.output(`Framework: ${result.framework}`); + tui.output(`Runtime: ${result.runtime}`); + tui.output(`Package manager: ${result.packageManager}`); + 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}`); + } + } + + return result; + }, +}); diff --git a/packages/cli/src/local-delegate.ts b/packages/cli/src/local-delegate.ts index 654473452..9e856269a 100644 --- a/packages/cli/src/local-delegate.ts +++ b/packages/cli/src/local-delegate.ts @@ -16,11 +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 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`. 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. * @@ -45,6 +44,36 @@ 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', + '--profile', +]); + +/** 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 +241,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..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,16 +302,23 @@ 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); - // 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. + // 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) { + 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 = { 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; diff --git a/packages/cli/test/cmd/build/buildpack-contract.test.ts b/packages/cli/test/cmd/build/buildpack-contract.test.ts index daf079315..420b9e769 100644 --- a/packages/cli/test/cmd/build/buildpack-contract.test.ts +++ b/packages/cli/test/cmd/build/buildpack-contract.test.ts @@ -138,7 +138,7 @@ describe('Buildpack Contract — End-to-End', () => { }); // 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 765bc8d6e..512c1dac4 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'; @@ -236,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 }); @@ -258,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', @@ -281,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', @@ -313,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); @@ -326,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', @@ -344,7 +345,7 @@ describe('Launch Metadata', () => { logs: [], }; - packageBuildOutput(framework, buildResult, testDir); + await packageBuildOutput(framework, buildResult, testDir); expect(existsSync(join(testDir, 'launch.json'))).toBe(true); }); @@ -353,19 +354,72 @@ 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', async () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ processes: null, runtime: null }) + ); + 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', async () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ runtime: { name: 'bun', port: null } }) + ); + 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', async () => { + writeFileSync(join(testDir, 'launch.json'), JSON.stringify({ runtime: { port: '3000' } })); + let thrown: unknown; + try { + await 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', async () => { + writeFileSync( + join(testDir, 'launch.json'), + JSON.stringify({ + processes: [{ type: 'web', command: 'node server.js', default: 'true' }], + }) + ); + let thrown: unknown; + try { + await readUserLaunchOverride(testDir); + } catch (ex) { + thrown = ex; + } + expect(thrown).toBeInstanceOf(LaunchConfigError); + expect((thrown as LaunchConfigError).issues.some((i) => i.path.includes('default'))).toBe( + true + ); }); }); }); 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/packages/cli/test/cmd/inspect.test.ts b/packages/cli/test/cmd/inspect.test.ts new file mode 100644 index 000000000..1efcc7082 --- /dev/null +++ b/packages/cli/test/cmd/inspect.test.ts @@ -0,0 +1,482 @@ +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'; +import { extractCommandSchema } from '../../src/schema-generator.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('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(); + 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..407e91560 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=$? +inspect_exit=${inspect_exit:-0} + +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