dsh-im-ops/plugin-src/host/update-runtime.mjs

339 lines
15 KiB
JavaScript

import { createHash } from 'node:crypto';
import { readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import semver from 'semver';
export const PACKAGE_NAME = '@xmanrui/dsh-im';
export const NPM_REGISTRY = 'https://registry.npmjs.org/';
const INSTALL_TIMEOUT_MS = 15 * 60_000;
const CONFIG_TIMEOUT_MS = 10_000;
const OUTPUT_LIMIT = 16 * 1024;
function failure(code, extra = {}) {
return Object.assign(new Error(code), { code, ...extra });
}
// Cordis 4's get() is the supported optional-service lookup. Putting these
// Desktop-only services in the plugin's inject array would disable web Hosts.
function service(ctx, name) {
return typeof ctx?.get === 'function' ? ctx.get(name) : ctx?.[name];
}
function inside(directory, filename) {
const suffix = relative(directory, filename);
return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
}
async function readOptional(filename) {
try {
return await readFile(filename, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') return '';
throw error;
}
}
async function packageAt(directory) {
const contents = await readFile(join(directory, 'package.json'), 'utf8');
return { directory: await realpath(directory), manifest: JSON.parse(contents), contents };
}
async function containingPackage(filename, name) {
let directory = dirname(await realpath(filename));
while (true) {
try {
const found = await packageAt(directory);
return found.manifest?.name === name ? found : null;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const parent = dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function profileNameValid(name) {
return typeof name === 'string' && name.length > 0 && Buffer.byteLength(name) <= 255
&& !name.startsWith('-') && !['.', '..', 'node_modules'].includes(name)
&& !/[\\/\x00-\x1f\x7f<>:"|?*]/u.test(name);
}
function cliProfile(args) {
if (args[0] === 'web') return 'web';
let name;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === '--profile') name = args[++index];
else if (token.startsWith('--profile=')) name = token.slice('--profile='.length);
else if (token === '--patch') index += 1;
else if (!token.startsWith('--patch=')) break;
}
return name;
}
function dshHome(env, osHome) {
let selected = env.DSH_HOME?.trim() ? env.DSH_HOME : join(osHome, '.dsh');
if (selected === '~') selected = osHome;
else if (/^~[\\/]/u.test(selected)) selected = join(osHome, selected.slice(2));
return resolve(selected);
}
function registrySpec(spec) {
return typeof spec === 'string' && spec.trim().length > 0
&& (semver.validRange(spec) !== null || /^[A-Za-z][A-Za-z0-9._-]*$/u.test(spec));
}
async function validPackage(pkg) {
if (pkg.manifest?.name !== PACKAGE_NAME || semver.valid(pkg.manifest.version) === null) return false;
const entries = [
pkg.manifest.main,
pkg.manifest.exports?.['./client'],
pkg.manifest.dsh?.bundle?.patch,
];
for (const entry of entries) {
if (typeof entry !== 'string' || !entry || isAbsolute(entry) || entry.includes('\0')) return false;
const filename = resolve(pkg.directory, entry);
if (!inside(pkg.directory, filename) || !inside(pkg.directory, await realpath(filename))) return false;
if (!(await stat(filename)).isFile()) return false;
}
return true;
}
function officialRegistry(value) {
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
return url.href === NPM_REGISTRY;
} catch {
return false;
}
}
/** Drain bounded output; raw subprocess diagnostics never cross the RPC boundary. */
async function run(start, { signal, timeoutMs, errorCode, capture = false }) {
if (signal?.aborted) throw failure('install-interrupted');
const controller = new AbortController();
let interrupted;
let operation;
const cancel = (code) => {
interrupted ??= code;
controller.abort();
operation?.cancel?.();
};
const onAbort = () => cancel('install-interrupted');
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => cancel('install-timeout'), timeoutMs);
let stdout = '';
let stderr = '';
try {
operation = start(controller.signal);
// The Host services own process-tree termination and wait for actual exit.
// Do not race their completion: a timed-out installer may still hold files.
operation.stdout?.on('data', (chunk) => {
if (capture) stdout = (stdout + chunk.toString()).slice(-OUTPUT_LIMIT);
});
operation.stderr?.on('data', (chunk) => {
stderr = (stderr + chunk.toString()).slice(-OUTPUT_LIMIT);
});
operation.stdout?.on('error', () => cancel(errorCode));
operation.stderr?.on('error', () => cancel(errorCode));
const outcome = await operation.done;
if (interrupted) throw failure(interrupted);
if (outcome.exitCode !== 0 || outcome.signal) {
throw failure(errorCode, {
exitCode: outcome.exitCode,
diagnosticCode: stderr.match(/\bERR_PNPM_[A-Z0-9_]+\b/u)?.[0],
});
}
return { exitCode: 0, signal: null, ...(capture ? { stdout } : {}) };
} catch (error) {
if (interrupted) throw failure(interrupted);
if (error.code === errorCode) throw error;
throw failure(errorCode);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
/**
* Adapt the running Host's existing package-management capabilities. Options
* replace process facts in tests only; no runtime path comes from RPC input.
*/
export function createUpdateRuntime(options = {}) {
const ctx = options.ctx;
const env = options.env ?? process.env;
const argv = options.argv ?? process.argv;
const execArgv = options.execArgv ?? process.execArgv;
const execPath = options.execPath ?? process.execPath;
const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const osHome = options.osHome ?? homedir();
const electron = options.electron ?? process.versions.electron;
const moduleUrl = options.moduleUrl ?? import.meta.url;
const loadedPackage = containingPackage(fileURLToPath(moduleUrl), PACKAGE_NAME).catch(() => null);
let boundProfile;
async function environment() {
const profiles = service(ctx, 'desktopProfiles');
const desktop = service(ctx, 'desktopPnpm');
const bootstrap = service(ctx, 'desktopPnpmBootstrap');
const isDesktop = Boolean(electron || profiles || desktop || bootstrap);
const current = profiles?.current;
const profileName = isDesktop ? current?.name : cliProfile(argv.slice(2));
const homeDir = isDesktop && bootstrap?.homeDir ? resolve(bootstrap.homeDir) : dshHome(env, osHome);
const result = { environmentKind: isDesktop ? 'desktop' : 'cli', homeDir, profileName };
if (!profileNameValid(profileName)) return { ...result, blockedReason: 'unknown-profile' };
const profileDir = await realpath(join(homeDir, 'profiles', profileName));
const home = await realpath(homeDir);
const base = { ...result, homeDir: home, profileDir };
if (isDesktop) {
try {
if (typeof desktop?.runPlugin !== 'function' || typeof desktop?.run !== 'function' || !bootstrap
|| !current?.dir || bootstrap.activeProfileName !== profileName
|| await realpath(current.dir) !== profileDir
|| await realpath(bootstrap.activeProfileDir) !== profileDir
|| await realpath(bootstrap.appExecutable) !== await realpath(execPath)) {
return { ...base, blockedReason: 'executor-unavailable' };
}
const desktopPackage = await containingPackage(bootstrap.dshBootstrapPath, 'dsh-plugin-desktop');
if (!desktopPackage || basename(bootstrap.dshBootstrapPath) !== 'desktop-cli.js'
|| dirname(await realpath(bootstrap.dshBootstrapPath)) !== join(desktopPackage.directory, 'lib')) {
return { ...base, blockedReason: 'executor-unavailable' };
}
return { ...base, desktop, executable: execPath, cliEntry: bootstrap.dshBootstrapPath };
} catch {
return { ...base, blockedReason: 'executor-unavailable' };
}
}
const subprocess = service(ctx, 'subprocess');
if (typeof subprocess?.spawn !== 'function' || typeof argv[1] !== 'string' || !isAbsolute(argv[1]) || platform === 'win32') {
return { ...base, blockedReason: 'executor-unavailable' };
}
try {
const cli = await containingPackage(argv[1], '@deepseek-ai/dsh');
if (!cli) return { ...base, blockedReason: 'executor-unavailable' };
const cliEntry = await realpath(argv[1]);
const declared = typeof cli.manifest.bin === 'string' ? cli.manifest.bin : cli.manifest.bin?.dsh;
const publishedEntry = typeof declared === 'string' ? resolve(cli.directory, declared) : '';
if (cliEntry !== publishedEntry && cliEntry !== join(cli.directory, 'src', 'bin.ts')) {
return { ...base, blockedReason: 'executor-unavailable' };
}
return { ...base, subprocess, executable: execPath, cliEntry };
} catch {
return { ...base, blockedReason: 'executor-unavailable' };
}
}
function cliOperation(runtime, args, signal, directPnpm = false) {
const child = runtime.subprocess.spawn({
argv: directPnpm ? ['pnpm', ...args] : [execPath, ...execArgv, runtime.cliEntry, ...args],
cwd: directPnpm ? runtime.profileDir : cwd,
env: { DSH_HOME: runtime.homeDir, CI: 'true' },
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
graceMs: 3_000,
signal,
});
return {
stdout: child.stdout,
stderr: child.stderr,
cancel: () => child.terminate(),
done: (async () => {
try { return await child.done; }
finally { await child.waitForExit(); }
})(),
};
}
async function checkRegistry(runtime, signal) {
const args = ['config', 'get', '@xmanrui:registry', '--json'];
const result = await run(
(childSignal) => runtime.desktop
? runtime.desktop.run(args, childSignal)
: cliOperation(runtime, args, childSignal, true),
{ signal, timeoutMs: options.configTimeoutMs ?? CONFIG_TIMEOUT_MS, errorCode: 'registry-check-failed', capture: true },
);
const output = result.stdout.trim();
let value;
try { value = output === '' || output === 'undefined' ? undefined : JSON.parse(output); }
catch { throw failure('registry-check-failed'); }
if (!officialRegistry(value)) throw failure('registry-conflict');
}
async function inspect({ preflight = false } = {}) {
let runtime;
const result = { installedVersion: null, packageValid: false, eligible: false, installationKey: null };
try {
runtime = await environment();
for (const key of ['homeDir', 'profileDir', 'profileName', 'environmentKind', 'executable', 'cliEntry']) {
result[key] = runtime[key] ?? null;
}
if (!runtime.profileDir) return { ...result, blockedReason: runtime.blockedReason ?? 'unknown-profile' };
const profile = await packageAt(runtime.profileDir);
const installed = await packageAt(join(runtime.profileDir, 'node_modules', PACKAGE_NAME));
result.sourceInstall = !registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory);
result.installedVersion = typeof installed.manifest.version === 'string' ? installed.manifest.version : null;
result.packageValid = await validPackage(installed);
const loaded = await loadedPackage;
const identity = `${runtime.homeDir}\0${runtime.profileDir}\0${runtime.profileName}`;
const sameLoadedPackage = loaded?.directory === installed.directory
&& loaded?.manifest.version === installed.manifest.version;
if (boundProfile === undefined && sameLoadedPackage && result.packageValid) boundProfile = identity;
const stateFiles = await Promise.all(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package-lock.json']
.map((filename) => readOptional(join(runtime.profileDir, filename))));
result.installationKey = createHash('sha256').update(JSON.stringify([
identity, profile.contents, installed.directory, installed.contents,
runtime.cliEntry, runtime.executable, ...stateFiles,
])).digest('hex');
if (boundProfile !== undefined && boundProfile !== identity) result.blockedReason = 'installation-changed';
else if (!sameLoadedPackage && boundProfile === undefined) result.blockedReason = 'installation-changed';
else if (!result.packageValid) result.blockedReason = 'invalid-installation';
else if (result.sourceInstall) result.blockedReason = 'source-install';
else if (runtime.blockedReason) result.blockedReason = runtime.blockedReason;
else if (!sameLoadedPackage) result.blockedReason = 'pending-restart';
else if (preflight) await checkRegistry(runtime);
result.eligible = !result.blockedReason;
return { ...result, blockedReason: result.blockedReason ?? null };
} catch (error) {
const known = ['registry-conflict', 'registry-check-failed', 'install-timeout', 'install-interrupted'];
return { ...result, blockedReason: known.includes(error.code) ? error.code : runtime?.profileDir ? 'invalid-installation' : 'unknown-profile' };
}
}
async function install(version, { signal, expectedInstallationKey } = {}) {
if (semver.valid(version) !== version || semver.prerelease(version)) throw failure('invalid-version');
const before = await inspect();
if (expectedInstallationKey !== undefined && before.installationKey !== expectedInstallationKey) {
throw failure('installation-changed');
}
if (!before.eligible) throw failure(before.blockedReason);
const runtime = await environment();
await checkRegistry(runtime, signal);
// Config validation is asynchronous: do not replace a package another
// package manager changed while it ran.
const checked = await inspect();
if (!checked.eligible || checked.installationKey !== before.installationKey) throw failure('installation-changed');
const args = ['add', '-w', '--save-exact', `${PACKAGE_NAME}@${version}`, `--registry=${NPM_REGISTRY}`];
return run(
(childSignal) => runtime.desktop
? runtime.desktop.runPlugin(args, runtime.profileDir, childSignal)
: cliOperation(runtime, ['plugin', '--profile', runtime.profileName, ...args], childSignal),
{ signal, timeoutMs: options.installTimeoutMs ?? INSTALL_TIMEOUT_MS, errorCode: 'install-failed' },
);
}
return Object.freeze({ inspect, install });
}