Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/cmd/build/adapters/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cmd/build/detect/agentuity-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cmd/build/detect/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/cmd/build/detect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +38,7 @@ async function detectCustomLauncher(
projectDir: string,
pkg: PackageJsonData | null
): Promise<DetectedFramework | null> {
const override = readUserLaunchOverride(projectDir);
const override = await readUserLaunchOverride(projectDir);
if (!override) return null;

const webProcess =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/cmd/build/detect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/cmd/build/detect/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/cmd/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/cmd/build/package/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PackageResult> {
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
Expand Down
153 changes: 138 additions & 15 deletions packages/cli/src/cmd/build/package/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof UserLaunchOverrideSchema>;

/** 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<UserLaunchOverride | null> {
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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cmd/build/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export async function runBuildPipeline(input: BuildPipelineInput): Promise<Build

// 5. Package the output — writes launch.json with `workingDirectory`
// set from monorepo.subpath when in monorepo mode.
const packageResult = packageBuildOutput(
const packageResult = await packageBuildOutput(
framework,
buildResult,
buildResult.outputDir,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cmd/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export async function discoverCommands(): Promise<CommandDefinition[]> {
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),
Expand Down
Loading
Loading