From dd6b466933a369eda04846057c64f311151bb357 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 07:02:09 +0000 Subject: [PATCH] feat(config): support output.distPath for the artifact build root The artifact output was hardcoded to /dist with only the per-invocation --output flag as an override. defineConfig now accepts output.distPath (Rsbuild/Rslib naming, string shorthand only): CLI --output still wins, the default stays dist, and the configured value flows from one PreparedProject source of truth into build/prepack, inspect, source-snapshot exclusion, the dev watcher ignore set, and the Workbench host-discovery doctor drift source that previously pinned /dist. Config values must be project-root-contained relative POSIX paths; malformed shapes, root escapes, and reserved namespaces report AB4707-AB4709. Per-asset distPath subdirs, filename templates, assetPrefix, and cleanDistPath are deliberately deferred: host-pack internals are framework-owned and content-addressed, so only WHERE the artifact root lives is configurable. --- .changeset/output-dist-path.md | 7 ++ docs/diagnostics.md | 2 +- docs/framework-mode.md | 41 ++++++++++++ packages/agent-bundle/src/api.ts | 21 ++++-- packages/agent-bundle/src/cli.ts | 4 +- packages/agent-bundle/src/config/index.ts | 1 + packages/agent-bundle/src/config/normalize.ts | 47 ++++++++++++- packages/agent-bundle/src/config/validate.ts | 66 ++++++++++++++++++- packages/agent-bundle/src/core/types.ts | 12 ++++ packages/agent-bundle/src/dev/coordinator.ts | 2 +- .../agent-bundle/src/dev/project-service.ts | 18 +++++ .../agent-bundle/src/dev/workbench-server.ts | 9 ++- packages/agent-bundle/tests/api.test.ts | 39 +++++++++++ .../tests/dev-coordinator.test.ts | 15 ++++- .../tests/dev-package-build-service.test.ts | 1 + .../agent-bundle/tests/dev-services.test.ts | 60 +++++++++++++++++ .../tests/host-discovery-dev-server.test.ts | 17 ++++- .../tests/package-conventions.test.ts | 40 +++++++++++ .../templates/cli-tool/agent-bundle.config.ts | 3 + .../templates/minimal/agent-bundle.config.ts | 3 + 20 files changed, 390 insertions(+), 18 deletions(-) create mode 100644 .changeset/output-dist-path.md diff --git a/.changeset/output-dist-path.md b/.changeset/output-dist-path.md new file mode 100644 index 000000000..2e1facaad --- /dev/null +++ b/.changeset/output-dist-path.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +--- + +Agent Bundle config now supports `output.distPath` to relocate the build +artifact root (the default `dist` is unchanged); CLI `--output` still takes +precedence. Invalid values report `AB4707`–`AB4709`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 69e4a0f0a..935fb7403 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -18,7 +18,7 @@ gate a build, a validation, or a dev rebuild. | `AB44xx` | Script configuration. | | `AB4500` | Registered config extensions (strict finite JSON). | | `AB46xx` | Assets and the generated-runtime floor. | -| `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`). | +| `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`; `AB4707`–`AB4709`: `output.distPath` shape, root escape, reserved namespace). | | `AB471x` | Package build `lib` configuration (`AB4710`–`AB4715`) and declaration generation (`AB4716`; see below). | | `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | | `AB473x` | Migration nudges (informational; see below). | diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 0c513aeb7..fb995957c 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -67,6 +67,47 @@ results and never renders JSX. Routed `src/cli/**` commands and Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` is plain. +## Config reference + +### `output` + +`output` controls where the host artifact root lives; it never changes the +framework-owned layout inside each target: + +```ts +export default defineConfig({ + plugin: { ... }, + output: { + distPath: 'artifact', + }, +}); +``` + +`output.distPath` defaults to `dist`. A CLI `--output ` overrides the +configured path, so precedence is CLI `--output`, then `output.distPath`, then +`dist`; existing projects are unchanged. The configured directory is excluded +from project source snapshots (as `dist` always was), ignored by the dev +watcher, and used by Workbench host discovery and doctor drift checks. + +Config values must be non-empty, project-root-contained relative POSIX paths. +A malformed `output` block or non-string/empty `distPath` reports `AB4707`; +absolute paths, backslashes, `.`, empty segments, and `..` traversal report +`AB4708`; reserved first segments (`.agent-bundle`, +`.git`, `node_modules`, and `src`) report `AB4709`. Projects with package +`bin` or `lib` entries must keep host artifacts separate from the npm package +build at `dist/` (`AB4706`); `output: { distPath: 'artifact' }` provides that +separation without a CLI flag. + +The name follows Rsbuild/Rslib's `output.distPath`, but Agent Bundle accepts +only the string shorthand, not Rsbuild 2.x's per-asset `DistPathConfig` for +such paths as JavaScript, CSS, and SVG subdirectories. +`output.filename` templates, `output.assetPrefix`, and `output.cleanDistPath` +are also deliberately deferred: host packs have a framework-owned +`/skills|mcp|scripts|assets/...` layout content-addressed by the +artifact manifest. Unlike machine-local Rsbuild config, the hashed, portable +release-identity config rejects absolute paths; use the per-invocation CLI +flag when an absolute path is required. + ## Distribution `agent-bundle build` makes each target directory independently distributable. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 33ef97bc7..806c9fd4c 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -299,6 +299,9 @@ export type StateInspection = export interface ReadyInspectResult { readonly diagnostics: readonly Diagnostic[]; readonly model: NormalizedPlugin; + readonly output: { + readonly distPath: string; + }; readonly plans: readonly InspectionPlan[]; readonly projectContext: ProjectContext; readonly selected?: { @@ -433,8 +436,8 @@ const invalidInspection = (diagnostics: readonly Diagnostic[]): InvalidInspectRe state: 'invalid', }); -const resolveOutput = (root: string, output: string | undefined): string => - resolve(root, output ?? 'dist'); +const resolveOutput = (root: string, output: string): string => + resolve(root, output); const registryFor = (options: ProjectOptions): TargetRegistry => options.registry ?? createDefaultRegistry(); @@ -690,6 +693,7 @@ export const inspect = async (options: InspectOptions): Promise = return Object.freeze({ diagnostics: prepared.diagnostics, model, + output: Object.freeze({ distPath: prepared.artifactDistPath }), plans, projectContext, ...(selected === undefined ? {} : { selected }), @@ -713,8 +717,15 @@ const assertPackageOutputSources = ( export const build = async (options: BuildOptions): Promise => { const root = resolve(options.root); - const output = resolveOutput(root, options.output); - const prepared = await new ProjectService({ ...options, outputRoots: [output], root }).prepare('build'); + const outputRoots = options.output === undefined + ? undefined + : [resolveOutput(root, options.output)]; + const prepared = await new ProjectService({ + ...options, + ...(outputRoots === undefined ? {} : { outputRoots }), + root, + }).prepare('build'); + const output = resolve(root, options.output ?? prepared.artifactDistPath); const model = requirePreparedModel(prepared); const projectContext = prepared.projectContext; if (projectContext === undefined) throw new DiagnosticError(prepared.diagnostics); @@ -724,7 +735,7 @@ export const build = async (options: BuildOptions): Promise if (packageOutputRoot !== undefined && (isInsideOrEqual(packageOutputRoot, output) || isInsideOrEqual(output, packageOutputRoot))) { throw new DiagnosticError([{ code: 'AB4706', - message: `Artifact output ${JSON.stringify(output)} overlaps the package build output ${JSON.stringify(packageOutputRoot)}; pass a different --output.`, + message: `Artifact output ${JSON.stringify(output)} overlaps the package build output ${JSON.stringify(packageOutputRoot)}; configure a different output.distPath or pass a different --output.`, severity: 'error', }]); } diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 79e696a1d..e85d030f4 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -476,7 +476,7 @@ export const runCli = async ( const buildCommand = configureSourceOptions( program.command('build').description('Build a validated Agent Bundle artifact'), - ).option('--output ', 'Artifact output path relative to --root'); + ).option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default dist)'); buildCommand.action(async (options: BuildCommandOptions) => { const { build } = await import('./api.ts'); const result = await build({ ...projectOptions(options), output: options.output, packageOutputs: true }); @@ -486,7 +486,7 @@ export const runCli = async ( const prepackCommand = configureSourceOptions( program.command('prepack').description('Build and validate the npm pack inventory'), - ).option('--output ', 'Artifact output path relative to --root'); + ).option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default dist)'); prepackCommand.action(async (options: BuildCommandOptions) => { const { prepack } = await import('./api.ts'); const result = await (dependencies.prepack ?? prepack)({ diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index d689a25b0..0fc8848f2 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -54,6 +54,7 @@ export type { AgentBundleMcpApp, AgentBundleMcpConfig, AgentBundleMcpServer, + AgentBundleOutputConfig, McpTransport, NormalizedConfigExtension, NormalizedMetadata, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index e2bacd7d3..d65ad635e 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; import { readFile, readdir, stat } from 'node:fs/promises'; -import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'; +import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from 'node:path'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -177,6 +177,51 @@ const safePackageOutputName = (name: string): boolean => */ export const packageBuildOutputDir = 'dist'; +/** The default artifact output directory of `agent-bundle build`, relative to the project root. */ +export const defaultArtifactDistPath = 'dist'; + +export type ArtifactDistPathIssue = 'path' | 'reserved' | 'shape'; + +const reservedArtifactDistPathSegments = new Set([ + '.agent-bundle', + '.git', + 'node_modules', + 'src', +]); + +export const isArtifactOutputConfig = ( + value: unknown, +): value is Readonly> => { + if (!isRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +}; + +/** Classifies one configured artifact output path without consulting the filesystem. */ +export const artifactDistPathIssue = (value: unknown): ArtifactDistPathIssue | undefined => { + if (typeof value !== 'string' || value.length === 0) return 'shape'; + if (posix.isAbsolute(value) || win32.isAbsolute(value) || value.includes('\\')) return 'path'; + const segments = value.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + return 'path'; + } + return reservedArtifactDistPathSegments.has(segments[0]!) ? 'reserved' : undefined; +}; + +/** Returns the validated config path, falling back so downstream path resolution cannot throw on malformed input. */ +export const configuredArtifactDistPath = (config: AgentBundleConfig): string => { + try { + const output = config.output as unknown; + if (!isArtifactOutputConfig(output)) return defaultArtifactDistPath; + const distPath = output.distPath; + return artifactDistPathIssue(distPath) === undefined + ? distPath as string + : defaultArtifactDistPath; + } catch { + return defaultArtifactDistPath; + } +}; + /** * The framework-generated routed-CLI bin (#102 stage 2): a generated-mode * `src/cli/**` surface with at least one compiled command becomes one diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 6d19926e0..0693d03aa 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -30,9 +30,11 @@ import type { NormalizedPlugin, } from '../core/types.ts'; import { + artifactDistPathIssue, conventionalCliEntrySource, conventionalIndexEntrySource, conventionalMcpEntrySource, + isArtifactOutputConfig, owningPayload, reservedPayloadDestinations, } from './normalize.ts'; @@ -48,7 +50,14 @@ const sourceDiagnostic = ( code: string, message: string, sourcePath: string, -): Diagnostic => ({ code, message, severity: 'error', sourcePath }); + recovery?: string, +): Diagnostic => ({ + code, + message, + ...(recovery === undefined ? {} : { recovery }), + severity: 'error', + sourcePath, +}); /** * Informational migration nudges (AB473x): they surface pre-convention @@ -1118,6 +1127,60 @@ const validateBin = (loaded: LoadedConfig): Diagnostic[] => { return diagnostics; }; +const outputShapeRecovery = + 'Declare output.distPath as a non-empty project-root-relative path string or remove the output block.'; +const outputPathRecovery = + 'Use a project-root-contained relative POSIX path; pass the CLI --output flag for per-invocation absolute locations.'; +const outputReservedRecovery = + 'Choose a directory outside the framework, VCS, dependency, and source namespaces.'; + +const validateOutput = (loaded: LoadedConfig): Diagnostic[] => { + if (!Object.hasOwn(loaded.config, 'output')) return []; + const output = loaded.config.output as unknown; + if (!isArtifactOutputConfig(output)) { + return [sourceDiagnostic( + 'AB4707', + 'Output configuration must be an object with an optional distPath string.', + loaded.configPath, + outputShapeRecovery, + )]; + } + if (!Object.hasOwn(output, 'distPath')) return []; + const distPath = output.distPath; + const issue = artifactDistPathIssue(distPath); + switch (issue) { + case undefined: + return []; + case 'shape': + return [sourceDiagnostic( + 'AB4707', + 'Output distPath must be a non-empty string when declared.', + loaded.configPath, + outputShapeRecovery, + )]; + case 'path': + return [sourceDiagnostic( + 'AB4708', + `Output distPath ${JSON.stringify(distPath)} must be a project-root-contained relative POSIX path without backslashes, ".." traversal, or empty segments, and cannot resolve to the project root.`, + loaded.configPath, + outputPathRecovery, + )]; + case 'reserved': { + const firstSegment = (distPath as string).split('/')[0]!; + return [sourceDiagnostic( + 'AB4709', + `Output distPath ${JSON.stringify(distPath)} uses reserved first path segment ${JSON.stringify(firstSegment)}.`, + loaded.configPath, + outputReservedRecovery, + )]; + } + default: { + const exhaustive: never = issue; + return exhaustive; + } + } +}; + const validateEventRoutes = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1826,6 +1889,7 @@ export const validateSource = ( diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); diagnostics.push(...validateMcp(loaded, registry, payloads)); + diagnostics.push(...validateOutput(loaded)); diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); diagnostics.push(...validateCommands(loaded, discovered, registry)); diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index f0e7eb8f1..b81028bbd 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -115,6 +115,17 @@ export interface AgentBundleMcpConfig { servers: Readonly>; } +/** Optional artifact output location config, inspired by Rsbuild's `output.distPath`. */ +export interface AgentBundleOutputConfig { + /** + * The artifact output directory of `agent-bundle build`, relative to the + * project root. Defaults to `dist`. The per-invocation CLI `--output` flag + * still wins, and remains the only way to target an absolute path outside + * the project. + */ + distPath?: string; +} + export interface AgentBundleHostConfig { nativeHooks?: string; } @@ -252,6 +263,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { lib?: AgentBundleLibConfig; marketplace?: boolean; mcp?: AgentBundleMcpConfig; + output?: AgentBundleOutputConfig; payload?: AgentBundlePayloadConfig; plugin: AgentBundlePluginConfig; runtime?: AgentBundleRuntimeConfig; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index eda1b9ef7..f1cd68a49 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -487,7 +487,7 @@ export class DevCoordinator { return this.#completeFailure(this.#beginBuild(invalidation, source), source, source.diagnostics); } - this.#watcher?.addOutputPaths?.(prepared.outputRoots); + this.#watcher?.addOutputPaths?.([prepared.artifactDistPath, ...prepared.outputRoots]); const running = this.#beginBuild(invalidation, prepared.source); try { await this.#onPreparedProject?.(prepared); diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 84f7d4646..1f27ddd1c 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -8,6 +8,8 @@ import { isProjectPathIgnored, readProjectIgnoreRules } from '../config/ignore.t import { loadConfig } from '../config/load.ts'; import { normalizeEvalConfig } from '../eval/config.ts'; import { + configuredArtifactDistPath, + defaultArtifactDistPath, normalizeProject, } from '../config/normalize.ts'; import { validateModel, validateSource } from '../config/validate.ts'; @@ -54,6 +56,8 @@ export interface ProjectServiceOptions { } export interface PreparedProject { + /** The project-relative artifact output directory: config `output.distPath` or the `dist` default. */ + readonly artifactDistPath: string; readonly configPath: string; /** The validated development-only Agent API flag from the prepared configuration. */ readonly devAgentApiEnabled?: boolean; @@ -545,6 +549,7 @@ const configWithRuntimeMetadataRemoved = (config: Record): Read }; const preparedProject = ( + artifactDistPath: string, configPath: string, snapshot: ProjectSourceSnapshot, diagnostics: readonly Diagnostic[], @@ -561,6 +566,7 @@ const preparedProject = ( tools?: AgentBundleToolsConfig, routeGraph?: CompiledRouteGraph, ): PreparedProject => Object.freeze({ + artifactDistPath, configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), diagnostics, @@ -611,6 +617,7 @@ const invalidPreparedProject = (options: { }): PreparedProject => { const snapshot = options.snapshot ?? emptySnapshot(); return preparedProject( + defaultArtifactDistPath, options.configPath, snapshot, options.diagnostics, @@ -660,6 +667,7 @@ export class ProjectService { const requestedRoot = resolve(this.#options.root); const registry = this.#registry; const requestedConfigPath = resolve(requestedRoot, this.#options.configPath ?? 'agent-bundle.config.ts'); + let artifactDistPath: string; let root = requestedRoot; let outputRoots: readonly string[] = Object.freeze([]); const failedPreparation = ( @@ -713,6 +721,14 @@ export class ProjectService { targets: this.#options.targets, }); devAgentApiEnabled = agentApiEnabled(loaded.config); + artifactDistPath = configuredArtifactDistPath(loaded.config); + const artifactOutputRoots = await resolveOutputRoots( + requestedRoot, + root, + [artifactDistPath], + ); + outputRoots = Object.freeze([...new Set([...outputRoots, ...artifactOutputRoots])] + .sort((left, right) => left.localeCompare(right))); // Eval runs are generated records, even when a project deliberately // stores them outside the conventional .agent-bundle directory. Keep // the resolved configuration as the single source of that ownership. @@ -801,6 +817,7 @@ export class ProjectService { const source = sourceStatus(sourceDiagnostics, snapshot.revision, root); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); return preparedProject( + artifactDistPath, loaded.configPath, snapshot, sourceDiagnostics, @@ -918,6 +935,7 @@ export class ProjectService { ? toolsValue as AgentBundleToolsConfig : undefined; return preparedProject( + artifactDistPath, loaded.configPath, snapshot, frozenDiagnostics, diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 00c3b6209..fcb85db1c 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -675,7 +675,12 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { + const root = await createProject(); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " output: { distPath: 'artifact-out' },", + " plugin: { name: 'output-path-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + + const inspection = await readyInspection({ root }); + const configured = await build({ root }); + const overridden = await build({ output: 'cli-artifact', root }); + + expect(inspection.output).toEqual({ distPath: 'artifact-out' }); + expect(Object.isFrozen(inspection.output)).toBe(true); + expect(configured.build.outputRoot).toBe(join(root, 'artifact-out')); + expect((await stat(join(root, 'artifact-out'))).isDirectory()).toBe(true); + await expect(validate({ artifact: join(root, 'artifact-out'), root })).resolves.toEqual({ diagnostics: [] }); + expect(overridden.build.outputRoot).toBe(join(root, 'cli-artifact')); + + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'output-path-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + const defaults = await build({ root }); + + expect(defaults.build.outputRoot).toBe(join(root, 'dist')); + expect((await stat(join(root, 'dist'))).isDirectory()).toBe(true); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}, 30_000); + it('deduplicates identical adapter diagnostics without collapsing distinct stable identities', async () => { const root = await createProject(); const diagnostic = (code: string, overrides: Partial = {}): Diagnostic => ({ diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 0dedfeaa1..e8df6156c 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -545,9 +545,17 @@ it('does not build until its watcher is ready and forwards project watcher exclu } }); -it('forwards a prepared generated eval root to its watcher', async () => { +it('forwards prepared artifact and eval output roots to its watcher', async () => { const root = await createProject(); let watcherOptions: import('../src/dev/watcher.ts').ProjectWatcherOptions | undefined; + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " output: { distPath: 'artifact-out' },", + " plugin: { name: 'dev-coordinator-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); const prepared = await new ProjectService({ root }).prepare('dev'); const evalRuns = join(root, 'recorded-evals'); const withEvalRuns = Object.freeze({ ...prepared, outputRoots: Object.freeze([...prepared.outputRoots, evalRuns]) }); @@ -567,6 +575,7 @@ it('forwards a prepared generated eval root to its watcher', async () => { }); await coordinator.start(); + expect(watcherOptions?.outputPaths).toContain(join(root, 'artifact-out')); expect(watcherOptions?.outputPaths).toContain(evalRuns); await coordinator.close(); } finally { @@ -574,7 +583,7 @@ it('forwards a prepared generated eval root to its watcher', async () => { } }); -it('adds a recovered eval runs root to the live watcher before generated run writes arrive', async () => { +it('adds recovered artifact and eval roots to the live watcher before generated writes arrive', async () => { const root = await createProject(); const sourceWatcher = new EventSourceWatcher(); const evalRuns = join(root, 'recorded-evals'); @@ -608,6 +617,7 @@ it('adds a recovered eval runs root to the live watcher before generated run wri await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " evals: { runsDir: 'recorded-evals' },", + " output: { distPath: 'artifact-out' },", " plugin: { name: 'dev-coordinator-fixture', version: '1.0.0' },", " targets: ['portable'],", '};', @@ -618,6 +628,7 @@ it('adds a recovered eval runs root to the live watcher before generated run wri expect(builds).toBe(2); sourceWatcher.emit('change', join(evalRuns, 'run.json')); + sourceWatcher.emit('change', join(root, 'artifact-out', 'portable', 'plugin.json')); await projectWatcher?.flush(); expect(builds).toBe(2); await coordinator.close(); diff --git a/packages/agent-bundle/tests/dev-package-build-service.test.ts b/packages/agent-bundle/tests/dev-package-build-service.test.ts index 4a4b18e28..c40d313d0 100644 --- a/packages/agent-bundle/tests/dev-package-build-service.test.ts +++ b/packages/agent-bundle/tests/dev-package-build-service.test.ts @@ -33,6 +33,7 @@ const prepared = (options: { readonly root?: string; readonly tools?: PreparedProject['tools']; } = {}): PreparedProject => ({ + artifactDistPath: 'dist', configPath: `${options.root ?? '/project'}/agent-bundle.config.ts`, diagnostics: [], model: { diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index fc4db907b..37adc8d58 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -648,6 +648,66 @@ it('excludes configured output trees from project identity and reports unsafe ou } }); +it('resolves, excludes, and falls back from configured artifact output paths', async () => { + const skill = [ + '---', + 'name: review', + 'description: Reviews output paths', + '---', + 'Review output paths.', + '', + ].join('\n'); + const configuredRoot = await createProject(skill); + const defaultRoot = await createProject(skill); + const malformedRoot = await createProject(skill); + const configuredOutput = join(configuredRoot, 'build', 'artifact'); + try { + await Promise.all([ + writeFile(join(configuredRoot, 'agent-bundle.config.ts'), [ + 'export default {', + " output: { distPath: 'build/artifact' },", + " plugin: { name: 'configured-output', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')), + writeFile(join(malformedRoot, 'agent-bundle.config.ts'), [ + 'export default {', + ' output: { distPath: 7 },', + " plugin: { name: 'malformed-output', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')), + ]); + await mkdir(configuredOutput, { recursive: true }); + await writeFile(join(configuredOutput, 'generated.js'), 'generated\n'); + + const [configured, defaults, malformed] = await Promise.all([ + new ProjectService({ root: configuredRoot }).prepare('inspect'), + new ProjectService({ root: defaultRoot }).prepare('inspect'), + new ProjectService({ root: malformedRoot }).prepare('inspect'), + ]); + + expect(configured.artifactDistPath).toBe('build/artifact'); + expect(configured.outputRoots).toContain(configuredOutput); + expect(configured.projectContext?.sourceInputs.map((input) => input.path)) + .not.toContain('build/artifact/generated.js'); + expect(defaults.artifactDistPath).toBe('dist'); + expect(defaults.outputRoots).toContain(join(defaultRoot, 'dist')); + expect(malformed.artifactDistPath).toBe('dist'); + expect(malformed.diagnostics).toEqual([ + expect.objectContaining({ code: 'AB4707', severity: 'error' }), + ]); + } finally { + await Promise.all([ + rm(configuredRoot, { force: true, recursive: true }), + rm(defaultRoot, { force: true, recursive: true }), + rm(malformedRoot, { force: true, recursive: true }), + ]); + } +}); + it('treats a configured eval run directory as generated output, not project source', async () => { const root = await createProject([ '---', diff --git a/packages/agent-bundle/tests/host-discovery-dev-server.test.ts b/packages/agent-bundle/tests/host-discovery-dev-server.test.ts index 93427e458..38a373b83 100644 --- a/packages/agent-bundle/tests/host-discovery-dev-server.test.ts +++ b/packages/agent-bundle/tests/host-discovery-dev-server.test.ts @@ -26,10 +26,21 @@ const unavailableCommand = (executable: string): NodeJS.ErrnoException => { return error; }; -it('serves authenticated host discovery from the real dev server', { timeout: 30_000 }, async () => { +it.each([ + { artifactDistPath: 'dist', configOutput: '', label: 'default output' }, + { + artifactDistPath: 'artifact-out', + configOutput: " output: { distPath: 'artifact-out' },\n", + label: 'configured output', + }, +])('serves authenticated host discovery from the real dev server with $label', { timeout: 30_000 }, async ({ + artifactDistPath, + configOutput, +}) => { const project = await createProjectFixture({ config: [ 'export default {', + configOutput.trimEnd(), " plugin: { name: 'host-discovery-dev-server', version: '1.0.0' },", " targets: ['claude'],", '};', @@ -103,7 +114,7 @@ it('serves authenticated host discovery from the real dev server', { timeout: 30 const report = await response.json() as HostDiscoveryReport; expect(report).toEqual({ - bundleSource: expect.stringMatching(/\/dist$/u), + bundleSource: join(project.root, artifactDistPath), diagnostics: expect.any(Array), endpoints: { diagnostics: [], @@ -121,7 +132,7 @@ it('serves authenticated host discovery from the real dev server', { timeout: 30 warnings: expect.any(Number), }, }); - expect(report.bundleSource).toBe(join(project.root, 'dist')); + expect(report.bundleSource).toBe(join(project.root, artifactDistPath)); expect(report.manifestDigest).not.toBe(''); expect(report.hosts).toHaveLength(3); for (const host of report.hosts) { diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 1cf0bea02..1ef6cb868 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -265,6 +265,46 @@ describe('bin, lib, and tools validation', () => { }); }); +describe('artifact output validation', () => { + const validated = async (output: unknown) => { + const root = await projectRoot({}); + return validateSource(loadedProject({ + output: output as never, + plugin: { name: 'review-tools', version: '1.0.0' }, + }, root), { skills: [] }, registry); + }; + + it.each([ + { code: 'AB4707', label: 'an undefined block', output: undefined }, + { code: 'AB4707', label: 'an array block', output: [] }, + { code: 'AB4707', label: 'a string block', output: 'artifact' }, + { code: 'AB4707', label: 'an undefined path', output: { distPath: undefined } }, + { code: 'AB4707', label: 'a non-string path', output: { distPath: 7 } }, + { code: 'AB4707', label: 'an empty path', output: { distPath: '' } }, + { code: 'AB4708', label: 'an absolute path', output: { distPath: '/abs/path' } }, + { code: 'AB4708', label: 'a parent path', output: { distPath: '../out' } }, + { code: 'AB4708', label: 'nested parent traversal', output: { distPath: 'a/../../b' } }, + { code: 'AB4708', label: 'the project root', output: { distPath: '.' } }, + { code: 'AB4708', label: 'a backslash path', output: { distPath: 'a\\b' } }, + { code: 'AB4708', label: 'an empty segment', output: { distPath: 'a//b' } }, + { code: 'AB4709', label: 'the framework namespace', output: { distPath: '.agent-bundle' } }, + { code: 'AB4709', label: 'the source namespace', output: { distPath: 'src' } }, + { code: 'AB4709', label: 'the dependency namespace', output: { distPath: 'node_modules/x' } }, + { code: 'AB4709', label: 'the VCS namespace', output: { distPath: '.git' } }, + ])('rejects $label with $code', async ({ code, output }) => { + const diagnostics = await validated(output); + expect(diagnostics).toEqual([expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + })]); + }); + + it.each(['artifact', 'build/artifact', 'dist'])('accepts %s', async (distPath) => { + await expect(validated({ distPath })).resolves.toEqual([]); + }); +}); + describe('migration nudges (AB473x)', () => { const factoryEntry = 'export default () => ({ close() {}, async connect() {} });\n'; const selfConnectingEntry = [ diff --git a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts index 8d97aa0fc..8b53932d8 100644 --- a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ name: 'my-agent-plugin', version: '0.1.0', }, + // Optional: move the build artifact root (default `dist`); the CLI + // `--output` flag still wins. + // output: { distPath: 'artifact' }, // One CLI bundle, two destinations: `src/cli.ts` is the package bin by // convention, and declaring it as a script also ships it inside every // host artifact. `src/index.ts` becomes the library export with diff --git a/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts index 12d4d0ea9..ac0171b6c 100644 --- a/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts @@ -8,5 +8,8 @@ export default defineConfig({ name: 'my-agent-plugin', version: '0.1.0', }, + // Optional: move the build artifact root (default `dist`); the CLI + // `--output` flag still wins. + // output: { distPath: 'artifact' }, targets: ['portable', 'codex', 'claude'], });