mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
339 lines
15 KiB
JavaScript
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 });
|
|
}
|