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
5 changes: 5 additions & 0 deletions .changeset/static-packed-installer-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Keep generated package-relative installer paths statically analyzable when plugin projects build through a packed `agent-bundle` dependency.
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ export const generatedInstallBinEntrySource = (options: {
readonly hosts: readonly ('claude' | 'codex' | 'cursor')[];
readonly name: string;
}): string => [
"import { fileURLToPath } from 'node:url';",
`import { runGeneratedInstallProcess } from ${JSON.stringify(installEntryRuntimeSpecifier)};`,
'',
'process.exitCode = await runGeneratedInstallProcess(process.argv.slice(2), Object.freeze({',
` artifactRelativeUrl: ${JSON.stringify(options.artifactRelativeUrl)},`,
` artifactRoot: fileURLToPath(new URL(${JSON.stringify(options.artifactRelativeUrl)}, import.meta.url)),`,

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 Keep filesystem URL conversion inside the installer error boundary

When the project path contains a literal % (for example, /tmp/plugin%build), fileURLToPath throws URI malformed synchronously while constructing these options, before runGeneratedInstallProcess can handle --help or enter its diagnostic try block. This makes even <name>-install --help terminate with an unhandled stack trace; previously URL conversion happened after the help check and inside the try. Keep the literal new URL(...) in the generated wrapper for static analysis, but defer fileURLToPath until the runtime's guarded path (for example, by passing the URL itself or a resolver).

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 5317ce619: the generated wrapper now passes a URL, and runGeneratedInstallProcess converts it with fileURLToPath only inside the guarded execution path after help handling. Source-contract and literal-percent path regressions cover the boundary. Merged via #319.

` hosts: Object.freeze(${stableJson(options.hosts)}),`,
` name: ${JSON.stringify(options.name)},`,
'}));',
Expand Down
10 changes: 4 additions & 6 deletions packages/agent-bundle/src/install-entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { lstat } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

import { stableJson } from './core/digest.ts';
import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts';
Expand All @@ -11,7 +10,7 @@ import {
} from './install/install.ts';

export interface GeneratedInstallProcessOptions {
readonly artifactRelativeUrl: string;
readonly artifactRoot: string;
readonly hosts: readonly InstallHost[];
readonly name: string;
}
Expand Down Expand Up @@ -98,16 +97,15 @@ export const runGeneratedInstallProcess = async (
let parsed: ParsedInstallArguments | undefined;
try {
parsed = parseArguments(argv, options);
const artifactRoot = fileURLToPath(new URL(options.artifactRelativeUrl, import.meta.url));
const metadata = await lstat(artifactRoot).catch(() => undefined);
const metadata = await lstat(options.artifactRoot).catch(() => undefined);
if (metadata === undefined || !metadata.isDirectory()) {
throw new Error(
`Package artifact root is missing at ${JSON.stringify(artifactRoot)}; ` +
`Package artifact root is missing at ${JSON.stringify(options.artifactRoot)}; ` +
'the package must ship its generated artifact directory.',
);
}
const result = await installBundle({
from: artifactRoot,
from: options.artifactRoot,
host: parsed.host,
scope: parsed.scope,
});
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/tests/packed-consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ it('uses only an installed tarball after source deletion', async () => {
'export default {',
' mcp: { servers: { greeter: {} } },',
" plugin: { name: 'framework-build-fixture', version: '1.0.0' },",
" targets: ['portable'],",
" targets: ['claude', 'codex', 'portable'],",
'};',
'',
].join('\n')),
Expand Down Expand Up @@ -371,7 +371,9 @@ it('uses only an installed tarball after source deletion', async () => {
await runInstalled(frameworkCli, frameworkRoot, ['build', '--root', frameworkRoot, '--output', frameworkArtifact]);

const packedBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture.js');
const packedInstallerBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture-install.js');
expect((await stat(packedBin)).mode & 0o111).not.toBe(0);
expect((await stat(packedInstallerBin)).mode & 0o111).not.toBe(0);
expect((await readFile(packedBin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true);
await expect(execFile(packedBin, ['alpha'], { cwd: frameworkRoot, env: installedEnvironment() }))
.resolves.toMatchObject({ stdout: 'packed bin ran:alpha\n' });
Expand Down
Loading