Skip to content
Draft
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
64 changes: 64 additions & 0 deletions packages/cli/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ describe('@codor/cli', () => {
[dry-run] write /home/setup-test/.config/codor/env mode 600
CODOR_TOKEN=<redacted generated-or-existing token>
PATH=/home/setup-test/.local/bin:/home/setup-test/.nvm/versions/node/v22.8.0/bin:/home/setup-test/.opencode/bin:/usr/local/bin:/usr/bin
NODE_PATH=<repo>/node_modules/.pnpm/node_modules
[dry-run] systemctl --user daemon-reload
[dry-run] systemctl --user enable --now codor.service
[dry-run] access localhost; skip Tailscale Serve
Expand Down Expand Up @@ -477,13 +478,62 @@ describe('@codor/cli', () => {
// The service ExecStart references the durable ~/.codor/runtime copy, never the npx cache.
expect(unit).toContain(`\"${durableCli}\"`);
expect(unit).not.toContain('_npx');
// The stubbed installIo reports no pnpm hoist dir at the source root, so no
// NODE_PATH is derived — the negative case for the pnpm-linked detection.
expect(readFileSync(join(home, '.config', 'codor', 'env'), 'utf8')).not.toContain('NODE_PATH');
// The durable install stages the npx module tree beside ~/.codor/runtime.
expect(copies).toEqual([[
join(dir, 'npx cache', '.npm', '_npx', 'deadbeef', 'node_modules'),
join(`${join(home, '.codor', 'runtime')}.staging`, 'node_modules'),
]]);
});

it('derives NODE_PATH from the durable copy, not the ephemeral source, when the invoking runtime is ephemeral', async () => {
const sourceRoot = fileURLToPath(new URL('../../../', import.meta.url));
const home = join(dir, 'npx home nodepath');
const npxCacheRoot = join(dir, 'npx cache nodepath', '.npm', '_npx', 'deadbeef');
const npxCli = join(npxCacheRoot, 'node_modules', '@richhardry', 'codor', 'node_modules', '@codor', 'cli');
const sourceHoistDir = join(npxCacheRoot, 'node_modules', '.pnpm', 'node_modules');
await runCli(['node', 'codor', 'setup', '--yes', '--access', 'localhost'], {
env: { HOME: home, USER: 'setup-test', PATH: '/usr/bin' },
stdout: (line) => output.push(line),
setup: {
exec: () => '',
home,
nodePath: process.execPath,
platform: 'linux',
probe: async () => true,
randomToken: () => 'a'.repeat(64),
sleep: async () => undefined,
which: () => undefined,
runtime: {
root: npxCli,
layout: 'installed-package',
cliEntrypoint: join(npxCli, 'dist', 'index.js'),
staticRoot: join(npxCli, 'runtime', 'web'),
serviceTemplate: join(sourceRoot, 'packaging', 'systemd', 'codor.service'),
},
installIo: {
// The ephemeral source is pnpm-linked (unlike the sibling npx case
// above), so the positive derivation actually runs here.
exists: (path) => path.includes('.staging') || path === sourceHoistDir,
copyTree: () => undefined,
move: () => undefined,
remove: () => undefined,
readVersion: () => undefined,
},
},
});

const durableHoistDir = join(home, '.codor', 'runtime', 'node_modules', '.pnpm', 'node_modules');
const env = readFileSync(join(home, '.config', 'codor', 'env'), 'utf8');
// This is the case that fails if the probe is pointed at the destination
// instead of the source: NODE_PATH must be rooted at the durable copy the
// service will actually run from, never at the ephemeral npx cache path.
expect(env).toContain(`NODE_PATH=${durableHoistDir}`);
expect(env).not.toContain(npxCacheRoot);
});

// harn:assume wsl-setup-keeps-private-windows-loopback ref=wsl-bind-regression
it.each([
{
Expand Down Expand Up @@ -658,6 +708,12 @@ describe('@codor/cli', () => {
expect(readFileSync(envPath, 'utf8')).toContain(
`PATH=${join(home, '.local', 'bin')}:/opt/node/bin:/usr/bin`,
);
// repoRoot is this real checkout, which is pnpm-linked, so the hoisted
// dependency dir the durable copy will carry over is present and durable
// (repoRoot === serviceLocation for a source checkout).
expect(readFileSync(envPath, 'utf8')).toContain(
`NODE_PATH=${join(repoRoot, 'node_modules', '.pnpm', 'node_modules')}`,
);
// Tailscale is resolved to an absolute path, capability-probed, and Serve is
// published during Choose access — before the daemon is started.
expect(commands).toEqual([
Expand Down Expand Up @@ -717,6 +773,11 @@ describe('@codor/cli', () => {
expect(dryRunPlist).toContain('<string>&lt;redacted generated-or-existing token&gt;</string>');
expect(dryRunPlist).toContain('<key>ProcessType</key>\n <string>Background</string>');
expect(dryRunPlist).toContain('<key>Umask</key>\n <integer>63</integer>');
// repoRoot is this real checkout, which is pnpm-linked (source checkout,
// so repoRoot === serviceLocation), so NODE_PATH is emitted alongside PATH.
expect(dryRunPlist).toContain(
`<key>NODE_PATH</key>\n <string>${join(repoRoot, 'node_modules', '.pnpm', 'node_modules')}</string>`,
);

output = [];
await runCli(['node', 'codor', 'setup', '--yes', '--access', 'localhost'], {
Expand Down Expand Up @@ -755,6 +816,9 @@ describe('@codor/cli', () => {
expect(installedPlist).toContain(
`<string>${join(home, '.local', 'bin').replace('&', '&amp;')}:/opt/homebrew/bin:/Applications/Claude Code/bin:/opt/codor tools/bin:/usr/bin</string>`,
);
expect(installedPlist).toContain(
`<key>NODE_PATH</key>\n <string>${join(repoRoot, 'node_modules', '.pnpm', 'node_modules')}</string>`,
);
expect(commands).toEqual([
`plutil -lint ${launchAgentPath}`,
'launchctl bootout gui/501/app.codor.switchboard',
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/setup-win32.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('codor setup on Windows', () => {
expect(rendered).toContain('<Hidden>true</Hidden>');
expect(rendered).toContain('schtasks /Create');
expect(rendered).not.toContain('a'.repeat(64));
expect(rendered).not.toContain('NODE_PATH');
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down Expand Up @@ -89,6 +90,32 @@ describe('codor setup on Windows', () => {
expect(commands).toContain(`schtasks /Create /TN Codor Switchboard /XML ${taskPath} /F`);
expect(commands).toContain('schtasks /Run /TN Codor Switchboard');
expect(output.join('\n')).not.toContain('a'.repeat(64));
expect(readFileSync(scriptPath, 'utf8')).not.toContain('NODE_PATH');
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('emits NODE_PATH pointing at the pnpm hoist dir when the invoking runtime is pnpm-linked', async () => {
const root = mkdtempSync(join(tmpdir(), 'codor-win32-nodepath-'));
const commands: string[] = [];
const output: string[] = [];
const home = join(root, 'home');
try {
const overrides = winOptions(root, commands, output);
const hoistDir = join(overrides.repoRoot, 'node_modules', '.pnpm', 'node_modules');
mkdirSync(hoistDir, { recursive: true });
await runSetup({
access: 'localhost',
dryRun: false,
env: { USERNAME: 'test-user', PATH: 'C:\\Windows\\System32' },
out: (line) => output.push(line),
overrides,
yes: true,
});
const scriptPath = join(home, '.config', 'codor', 'codor-service.ps1');
const script = readFileSync(scriptPath, 'utf8');
expect(script).toContain(`$env:NODE_PATH = '${hoistDir}'`);
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
26 changes: 21 additions & 5 deletions packages/cli/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function xml(value: string): string {
interface LaunchAgentOptions {
dataDir: string;
logDir: string;
nodeModulePath?: string;
nodePath: string;
runtime: RuntimePaths;
servicePath: string;
Expand All @@ -196,6 +197,9 @@ function renderLaunchAgent(options: LaunchAgentOptions): string {
staticRoot: xml(options.runtime.staticRoot),
token: xml(options.token),
};
const nodeModulePathEntry = options.nodeModulePath === undefined
? ''
: `\n <key>NODE_PATH</key>\n <string>${xml(options.nodeModulePath)}</string>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Expand Down Expand Up @@ -223,7 +227,7 @@ function renderLaunchAgent(options: LaunchAgentOptions): string {
<key>CODOR_TOKEN</key>
<string>${values.token}</string>
<key>PATH</key>
<string>${values.servicePath}</string>
<string>${values.servicePath}</string>${nodeModulePathEntry}
</dict>
<key>RunAtLoad</key>
<true/>
Expand Down Expand Up @@ -557,6 +561,7 @@ export async function runRemoteAccess(deps: RemoteAccessDeps): Promise<RemoteAcc
export function renderWindowsServiceScript(options: {
dataDir: string;
logDir: string;
nodeModulePath?: string;
nodePath: string;
runtime: RuntimePaths;
servicePath: string;
Expand All @@ -568,6 +573,7 @@ export function renderWindowsServiceScript(options: {
return [
`$env:CODOR_TOKEN = (Get-Content -Raw -Path '${quote(options.tokenPath)}').Trim()`,
`$env:PATH = '${quote(options.servicePath)}'`,
...(options.nodeModulePath === undefined ? [] : [`$env:NODE_PATH = '${quote(options.nodeModulePath)}'`]),
`Set-Location -Path '${quote(options.runtime.root)}'`,
`& '${quote(options.nodePath)}' '${quote(entrypoint)}' --data-dir '${quote(options.dataDir)}' up --static-root '${quote(staticRoot)}' --channel desk --channel-name Desk >> '${quote(join(options.logDir, 'codor.out.log'))}' 2>> '${quote(join(options.logDir, 'codor.err.log'))}'`,
'exit $LASTEXITCODE',
Expand Down Expand Up @@ -666,6 +672,14 @@ export async function runSetup(options: SetupOptions): Promise<void> {
const installSource = resolveInstallSource(runtime);
const serviceLocation = installSource.durable ? installSource.installRoot : durableRuntimeLocation(dataDir);
const serviceRuntime = installSource.durable ? runtime : packageRuntimePaths(installedCliRoot(serviceLocation));
// pnpm's hidden hoist directory sits at a fixed offset inside a pnpm-linked
// tree and survives installDurableRuntime's wholesale node_modules copy.
// Probe the source root, which exists now, and emit the destination path,
// which is where the service will actually run once installed.
const hoistDirRelative = join('node_modules', '.pnpm', 'node_modules');
const nodeModulePath = installIo.exists(join(installSource.installRoot, hoistDirRelative))
? join(serviceLocation, hoistDirRelative)
: undefined;
const windowsScriptPath = join(configDir, 'codor-service.ps1');
const windowsTaskPath = join(configDir, 'codor-task.xml');
const windowsUser = options.env.USERNAME ?? options.env.USER;
Expand Down Expand Up @@ -710,7 +724,7 @@ export async function runSetup(options: SetupOptions): Promise<void> {
const launchDomain = launchUid === undefined ? undefined : `gui/${String(launchUid)}`;
const launchTarget = launchDomain === undefined ? undefined : `${launchDomain}/${LAUNCH_AGENT_LABEL}`;
const windowsScript = platform === 'win32'
? renderWindowsServiceScript({ dataDir, logDir, nodePath, runtime: serviceRuntime, servicePath, tokenPath })
? renderWindowsServiceScript({ dataDir, logDir, nodeModulePath, nodePath, runtime: serviceRuntime, servicePath, tokenPath })
: undefined;
const windowsTask = platform === 'win32'
? renderWindowsScheduledTask({ scriptPath: windowsScriptPath, user: windowsUser! })
Expand Down Expand Up @@ -750,11 +764,12 @@ export async function runSetup(options: SetupOptions): Promise<void> {
options.out(`[dry-run] write ${envPath} mode 600`);
options.out('CODOR_TOKEN=<redacted generated-or-existing token>');
options.out(`PATH=${servicePath}`);
if (nodeModulePath !== undefined) options.out(`NODE_PATH=${nodeModulePath}`);
options.out('[dry-run] systemctl --user daemon-reload');
options.out('[dry-run] systemctl --user enable --now codor.service');
} else if (platform === 'darwin') {
const launchAgent = renderLaunchAgent({
dataDir, logDir, nodePath, runtime: serviceRuntime, servicePath,
dataDir, logDir, nodeModulePath, nodePath, runtime: serviceRuntime, servicePath,
token: '<redacted generated-or-existing token>',
});
options.out(`[dry-run] create ${logDir} mode 700`);
Expand Down Expand Up @@ -891,7 +906,8 @@ export async function runSetup(options: SetupOptions): Promise<void> {
mkdirSync(userUnitDir, { recursive: true, mode: 0o700 });
writeFileSync(userUnitPath, unitContent!, { encoding: 'utf8', mode: 0o600 });
chmodSync(userUnitPath, 0o600);
writeFileSync(envPath, `CODOR_TOKEN=${token}\nPATH=${servicePath}\n`, { encoding: 'utf8', mode: 0o600 });
const nodePathEnvLine = nodeModulePath === undefined ? '' : `NODE_PATH=${nodeModulePath}\n`;
writeFileSync(envPath, `CODOR_TOKEN=${token}\nPATH=${servicePath}\n${nodePathEnvLine}`, { encoding: 'utf8', mode: 0o600 });
chmodSync(envPath, 0o600);
exec('systemctl', ['--user', 'daemon-reload']);
exec('systemctl', ['--user', 'enable', '--now', 'codor.service']);
Expand All @@ -907,7 +923,7 @@ export async function runSetup(options: SetupOptions): Promise<void> {
mkdirSync(logDir, { recursive: true, mode: 0o700 });
chmodSync(logDir, 0o700);
writeFileSync(launchAgentPath, renderLaunchAgent({
dataDir, logDir, nodePath, runtime: serviceRuntime, servicePath, token,
dataDir, logDir, nodeModulePath, nodePath, runtime: serviceRuntime, servicePath, token,
}), { encoding: 'utf8', mode: 0o600 });
chmodSync(launchAgentPath, 0o600);
await bootstrapLaunchAgent({
Expand Down