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
7 changes: 7 additions & 0 deletions .changeset/output-dist-path.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
41 changes: 41 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
`<target>/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.
Comment on lines +107 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not advertise unsupported absolute CLI outputs

When a user follows this new guidance and passes an absolute path outside the project, build() includes that path in ProjectService.outputRoots, whose resolveOutputRoots rejects anything outside the project root and returns AB7002 before the artifact build runs. Therefore the CLI cannot currently serve as the documented escape hatch for absolute output locations; either allow external CLI output roots during preparation or remove this promise (which also appears in AgentBundleOutputConfig).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c2240169. The framework docs and AgentBundleOutputConfig TSDoc now state that --output only overrides the relative artifact root and remains subject to project-root containment; rejection behavior is unchanged.


## Distribution

`agent-bundle build` makes each target directory independently distributable.
Expand Down
21 changes: 16 additions & 5 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -690,6 +693,7 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
return Object.freeze({
diagnostics: prepared.diagnostics,
model,
output: Object.freeze({ distPath: prepared.artifactDistPath }),
plans,
projectContext,
...(selected === undefined ? {} : { selected }),
Expand All @@ -713,8 +717,15 @@ const assertPackageOutputSources = (

export const build = async (options: BuildOptions): Promise<BuildProjectResult> => {
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);
Expand All @@ -724,7 +735,7 @@ export const build = async (options: BuildOptions): Promise<BuildProjectResult>
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',
}]);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ export const runCli = async (

const buildCommand = configureSourceOptions(
program.command('build').description('Build a validated Agent Bundle artifact'),
).option('--output <path>', 'Artifact output path relative to --root');
).option('--output <path>', '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 });
Expand All @@ -486,7 +486,7 @@ export const runCli = async (

const prepackCommand = configureSourceOptions(
program.command('prepack').description('Build and validate the npm pack inventory'),
).option('--output <path>', 'Artifact output path relative to --root');
).option('--output <path>', '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)({
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type {
AgentBundleMcpApp,
AgentBundleMcpConfig,
AgentBundleMcpServer,
AgentBundleOutputConfig,
McpTransport,
NormalizedConfigExtension,
NormalizedMetadata,
Expand Down
47 changes: 46 additions & 1 deletion packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Record<string, unknown>> => {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject reserved output names case-insensitively

On case-insensitive filesystems, including Windows and typical macOS installations, values such as SRC, .GIT, or Node_Modules refer to the reserved directories but pass this exact-case membership check. A subsequent build publishes by replacing the resolved output directory, so output: { distPath: 'SRC' } can replace and delete the project's actual src tree. Normalize the first segment for platforms with case-insensitive path semantics, or otherwise compare it against the existing filesystem entry before accepting it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a45992e: artifactDistPathIssue now lowercases the first path segment before reserved-name lookup, with regressions for mixed/uppercase src, .git, node_modules, and .agent-bundle.

};

/** 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
Expand Down
66 changes: 65 additions & 1 deletion packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import type {
NormalizedPlugin,
} from '../core/types.ts';
import {
artifactDistPathIssue,
conventionalCliEntrySource,
conventionalIndexEntrySource,
conventionalMcpEntrySource,
isArtifactOutputConfig,
owningPayload,
reservedPayloadDestinations,
} from './normalize.ts';
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ export interface AgentBundleMcpConfig {
servers: Readonly<Record<string, AgentBundleMcpServer>>;
}

/** 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;
}
Expand Down Expand Up @@ -252,6 +263,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions {
lib?: AgentBundleLibConfig;
marketplace?: boolean;
mcp?: AgentBundleMcpConfig;
output?: AgentBundleOutputConfig;
payload?: AgentBundlePayloadConfig;
plugin: AgentBundlePluginConfig;
runtime?: AgentBundleRuntimeConfig;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/dev/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading