mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 02:09:42 +02:00
fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable
installEnv() only redirected NPM_CONFIG_PREFIX inside the Docker container (CODEMAN_IN_CONTAINER=1). On a native install with a root-owned system node (prefix /usr) `npm install -g @deepseek-ai/dsh` run as the server user died with EACCES (exit 243), so DeepSeek, pi and other npm-based CLIs could not be installed from Settings. Ask npm for its global prefix and, when the server user cannot write it, redirect to $HOME/.local like the container path does. An operator-set NPM_CONFIG_PREFIX and a writable prefix are left alone.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES on a native install whose system node keeps its global prefix under `/usr`. When the npm global prefix is not writable by the Codeman user, the install now goes to `~/.local` (where Codeman already looks for CLIs), as the Docker deployment already did. A prefix you set yourself, or one that is writable, is left alone.
|
||||
@@ -29,7 +29,9 @@
|
||||
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { accessSync, constants as fsConstants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
@@ -207,14 +209,54 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
|
||||
*/
|
||||
const installsInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
|
||||
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
|
||||
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
|
||||
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
|
||||
*/
|
||||
export function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): boolean {
|
||||
try {
|
||||
const prefix = execFileSync('npm', ['config', 'get', 'prefix'], {
|
||||
env: source,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
if (!prefix) return true;
|
||||
// npm creates lib/node_modules under the prefix; check that directory when it exists, else the prefix.
|
||||
for (const dir of [join(prefix, 'lib', 'node_modules'), prefix]) {
|
||||
try {
|
||||
accessSync(dir, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
|
||||
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
|
||||
* so an `npm install -g` lands on the persistent home mount instead of the image.
|
||||
* data dir, the tmux socket), none of which an installer needs.
|
||||
*
|
||||
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
|
||||
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
|
||||
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
|
||||
* update (the image's own prefix is image content);
|
||||
* - on a native install whose npm global prefix is not writable by the server user (a system node
|
||||
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
|
||||
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
|
||||
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
|
||||
*/
|
||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
export function installEnv(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = npmGlobalPrefixWritable
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
@@ -225,6 +267,8 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
|
||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
||||
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
|
||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import { EventEmitter } from 'node:events';
|
||||
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
|
||||
import {
|
||||
installEnv,
|
||||
npmGlobalPrefixWritable,
|
||||
registerCliRegistryRoutes,
|
||||
type CliListItem,
|
||||
} from '../../src/web/routes/cli-registry-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { CreateSessionSchema } from '../../src/web/schemas.js';
|
||||
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
|
||||
@@ -711,7 +716,7 @@ describe('registry writes are serialized and never clobber a file the reader wou
|
||||
} finally {
|
||||
delete process.env.CODEMAN_TEST_SECRET;
|
||||
}
|
||||
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
|
||||
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' }, () => true)).toEqual({ HOME: '/h' });
|
||||
});
|
||||
|
||||
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
|
||||
@@ -719,6 +724,32 @@ describe('registry writes are serialized and never clobber a file the reader wou
|
||||
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
|
||||
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
|
||||
});
|
||||
|
||||
it('redirects npm installs to ~/.local on a native install whose global prefix is not writable', () => {
|
||||
// A system node under /usr: `npm install -g` as the server user dies with EACCES (exit 243).
|
||||
expect(installEnv({ HOME: '/home/dev', CODEMAN_PASSWORD: 'x' }, () => false)).toEqual({
|
||||
HOME: '/home/dev',
|
||||
NPM_CONFIG_PREFIX: '/home/dev/.local',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a writable, explicit or undeterminable npm prefix alone on a native install', () => {
|
||||
expect(installEnv({ HOME: '/home/dev' }, () => true)).toEqual({ HOME: '/home/dev' });
|
||||
// An operator-set prefix wins even if it is not writable: it is theirs to fix.
|
||||
expect(installEnv({ HOME: '/home/dev', NPM_CONFIG_PREFIX: '/opt/npm' }, () => false)).toEqual({
|
||||
HOME: '/home/dev',
|
||||
NPM_CONFIG_PREFIX: '/opt/npm',
|
||||
});
|
||||
// No HOME means nowhere to redirect to.
|
||||
expect(installEnv({ PATH: '/usr/bin' }, () => false)).toEqual({ PATH: '/usr/bin' });
|
||||
});
|
||||
|
||||
it('npmGlobalPrefixWritable follows npm config and treats a probe failure as writable', () => {
|
||||
// Real probe against this machine: must return a boolean and never throw.
|
||||
expect(typeof npmGlobalPrefixWritable({ PATH: process.env.PATH })).toBe('boolean');
|
||||
// npm not found on PATH: cannot tell, so do not redirect.
|
||||
expect(npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user