mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-21 20:18:11 +02:00
fix(code-mode): ship ACP adapters in packaged builds
Code mode spawns the Claude/Codex ACP adapters as separate `node <entry>`
processes resolved at runtime, so each must exist as a real file on disk.
esbuild can't inline them (dynamic require.resolve + spawn target), and Forge's
`ignore: /node_modules/` rule strips the workspace node_modules — so packaged
builds threw `Cannot find module '@agentclientprotocol/claude-agent-acp'` and
code mode was broken in every release. Dev worked only because the pnpm symlink
was present.
Stage the two adapters and their full production dependency closure into
.package/acp/node_modules during generateAssets, reconstructing an npm-style
nested layout: nest on version conflict (claude and codex keep their own
@agentclientprotocol/sdk; the @openai/codex launcher keeps its platform binary)
and skip platform-optional deps not installed for the build OS, so each OS ships
its own native binary. Exempt .package from the node_modules ignore rule, and
make the adapter resolver check the staged location first, falling back to
node_modules in dev.
Resolve dependency directories by walking node_modules directly rather than
require.resolve(`${pkg}/package.json`): the latter throws for packages whose
`exports` map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk),
which would silently drop them and their subtrees from the staged closure.
This commit is contained in:
parent
e2178c1488
commit
fffa34bf4e
2 changed files with 122 additions and 14 deletions
|
|
@ -5,6 +5,77 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
|
|
||||||
|
// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
|
||||||
|
// production dependency closure into the packaged app.
|
||||||
|
//
|
||||||
|
// Why this is needed: code mode spawns each adapter as a SEPARATE `node <entry>`
|
||||||
|
// process and locates it at runtime via require.resolve — so it must ship as a real
|
||||||
|
// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge
|
||||||
|
// strips the workspace node_modules (see `ignore` below). Without this, packaged
|
||||||
|
// builds throw `Cannot find module '@agentclientprotocol/...'`.
|
||||||
|
//
|
||||||
|
// Why we reconstruct a nested tree instead of copying node_modules: pnpm's store is a
|
||||||
|
// symlink farm that legitimately holds multiple versions of the same package (e.g.
|
||||||
|
// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex, and the @openai/codex
|
||||||
|
// launcher-vs-platform-binary alias). We rebuild an npm-style nested node_modules —
|
||||||
|
// dereferencing symlinks and nesting on version conflict — which resolves correctly
|
||||||
|
// regardless of pnpm layout. Platform-specific optional deps that aren't installed
|
||||||
|
// for the current OS resolve to null and are skipped, so each OS ships its own binary.
|
||||||
|
function stageAcpAdapters(mainDir, destNodeModules) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const ADAPTERS = [
|
||||||
|
'@agentclientprotocol/claude-agent-acp',
|
||||||
|
'@agentclientprotocol/codex-acp',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||||
|
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||||
|
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||||
|
// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
|
||||||
|
// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
|
||||||
|
// Returns null for deps not installed for this OS (platform-optional binaries).
|
||||||
|
const realDirOf = (key, fromDir) => {
|
||||||
|
let dir = fromDir;
|
||||||
|
for (;;) {
|
||||||
|
const cand = path.join(dir, 'node_modules', ...key.split('/'));
|
||||||
|
if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let copied = 0;
|
||||||
|
const install = (srcDir, key, destNM, chain) => {
|
||||||
|
const destDir = path.join(destNM, ...key.split('/'));
|
||||||
|
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
||||||
|
if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
|
||||||
|
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||||
|
fs.cpSync(srcDir, destDir, {
|
||||||
|
recursive: true,
|
||||||
|
dereference: true,
|
||||||
|
filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
|
||||||
|
});
|
||||||
|
copied++;
|
||||||
|
const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
|
||||||
|
const deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||||
|
const nextChain = new Set(chain).add(srcDir);
|
||||||
|
for (const depKey of Object.keys(deps)) {
|
||||||
|
const depDir = realDirOf(depKey, srcDir);
|
||||||
|
if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of ADAPTERS) {
|
||||||
|
const srcDir = realDirOf(key, mainDir);
|
||||||
|
if (!srcDir) {
|
||||||
|
throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
|
||||||
|
}
|
||||||
|
install(srcDir, key, destNodeModules, new Set());
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
executableName: 'rowboat',
|
executableName: 'rowboat',
|
||||||
|
|
@ -29,17 +100,21 @@ module.exports = {
|
||||||
appleIdPassword: process.env.APPLE_PASSWORD,
|
appleIdPassword: process.env.APPLE_PASSWORD,
|
||||||
teamId: process.env.APPLE_TEAM_ID
|
teamId: process.env.APPLE_TEAM_ID
|
||||||
},
|
},
|
||||||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
// Since we bundle the main process with esbuild, we don't need the workspace
|
||||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
// node_modules. These settings prevent Forge's dependency walker (flora-colossus)
|
||||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
// from trying to analyze/copy node_modules, which fails with pnpm's symlinked
|
||||||
|
// workspaces.
|
||||||
prune: false,
|
prune: false,
|
||||||
ignore: [
|
// Strip the workspace node_modules, BUT always keep everything under `.package/`
|
||||||
/src\//,
|
// — that's our staged output, which now also includes the ACP adapters + their
|
||||||
/node_modules\//,
|
// dependency closure (staged by the generateAssets hook). Without the `.package`
|
||||||
/.gitignore/,
|
// exemption the /node_modules/ rule would strip the staged adapters and code mode
|
||||||
/bundle\.mjs/,
|
// would break in packaged builds.
|
||||||
/tsconfig.json/,
|
ignore: (p) => {
|
||||||
],
|
if (p === '/.package' || p.startsWith('/.package/')) return false;
|
||||||
|
return [/src\//, /node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||||
|
.some((re) => re.test(p));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
makers: [
|
makers: [
|
||||||
{
|
{
|
||||||
|
|
@ -178,6 +253,15 @@ module.exports = {
|
||||||
fs.mkdirSync(rendererDest, { recursive: true });
|
fs.mkdirSync(rendererDest, { recursive: true });
|
||||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||||
|
|
||||||
|
// Stage the ACP coding-adapters (+ their dependency closure) into .package/acp.
|
||||||
|
// They are spawned as separate node processes at runtime and Forge strips the
|
||||||
|
// workspace node_modules, so they must be copied in explicitly. See
|
||||||
|
// stageAcpAdapters() above for the why.
|
||||||
|
console.log('Staging ACP adapters...');
|
||||||
|
const acpDest = path.join(packageDir, 'acp', 'node_modules');
|
||||||
|
const staged = stageAcpAdapters(__dirname, acpDest);
|
||||||
|
console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`);
|
||||||
|
|
||||||
console.log('✅ All assets staged in .package/');
|
console.log('✅ All assets staged in .package/');
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import type { CodingAgent } from './types.js';
|
import type { CodingAgent } from './types.js';
|
||||||
import { resolveClaudeExecutable } from './claude-exec.js';
|
import { resolveClaudeExecutable } from './claude-exec.js';
|
||||||
|
|
||||||
|
|
@ -20,13 +21,36 @@ export interface AgentLaunchSpec {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Locate an adapter's package.json. In packaged builds Electron Forge strips the
|
||||||
|
// workspace node_modules, so the adapters (+ their dependency closure) are staged
|
||||||
|
// next to the bundle at `.package/acp/node_modules` by the generateAssets hook (see
|
||||||
|
// apps/main/forge.config.cjs). In dev they resolve normally via the pnpm symlink.
|
||||||
|
// Try the staged location first, then fall back to ordinary resolution.
|
||||||
|
function resolveAdapterPkgJson(pkg: string): string {
|
||||||
|
// The main process is esbuild-bundled to `.package/dist/main.cjs`, so the staged
|
||||||
|
// adapters live one level up at `.package/acp`. (import.meta.url is rewritten to
|
||||||
|
// the bundle path by bundle.mjs, so this holds in both dev and packaged builds.)
|
||||||
|
const stagedRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'acp');
|
||||||
|
for (const opts of [{ paths: [stagedRoot] }, undefined]) {
|
||||||
|
try {
|
||||||
|
return require.resolve(`${pkg}/package.json`, opts);
|
||||||
|
} catch {
|
||||||
|
// not here — try the next resolution strategy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`ACP adapter '${pkg}' not found — expected it staged at ` +
|
||||||
|
`${path.join(stagedRoot, 'node_modules', pkg)} (packaged build) or resolvable ` +
|
||||||
|
`from node_modules (dev).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
|
// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an
|
||||||
// absolute path so we can spawn it directly with `node <entry>`. createRequire lets
|
// absolute path so we can spawn it directly with `node <entry>`.
|
||||||
// us resolve workspace/pnpm-installed packages from this module's location.
|
|
||||||
function resolveAdapterEntry(pkg: string): string {
|
function resolveAdapterEntry(pkg: string): string {
|
||||||
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
const pkgJsonPath = resolveAdapterPkgJson(pkg);
|
||||||
const pkgDir = path.dirname(pkgJsonPath);
|
const pkgDir = path.dirname(pkgJsonPath);
|
||||||
const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> };
|
const pkgJson = require(pkgJsonPath) as { bin?: string | Record<string, string> };
|
||||||
const bin = pkgJson.bin;
|
const bin = pkgJson.bin;
|
||||||
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined;
|
||||||
if (!rel) {
|
if (!rel) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue