dsh-im-ops/plugin-src/host/update-service.mjs
oliver d53daf7d26 Rebrand as dsh-im-ops fork of xmanrui/dsh-im@4.9.1.
Keep all IM channels; rename package/cordis id for local profile install without colliding with the community plugin. Document fork intent in FORK.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:02:54 +08:00

387 lines
16 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto';
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import semver from 'semver';
import manifest from '../../package.json' with { type: 'json' };
import { withSessionBindingLock } from '../../src/channels/shared/session-binding-lock.mjs';
import { NPM_REGISTRY, PACKAGE_NAME } from './update-runtime.mjs';
const ACTIVE_STATES = new Set(['installing', 'verifying']);
const JOB_STATES = new Set([...ACTIVE_STATES, 'restart-required', 'completed', 'failed', 'interrupted']);
const MAX_METADATA_BYTES = 256 * 1024;
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
const NO_LOCK = Symbol('no update lock');
export function updateError(code) {
return Object.assign(new Error(code), { code });
}
/** Read only the fixed npm package; neither RPC callers nor registry metadata choose a command. */
export async function fetchNpmRelease(fetchImpl = globalThis.fetch, timeoutMs = 10_000) {
let response;
try {
response = await fetchImpl(`${NPM_REGISTRY}${encodeURIComponent(PACKAGE_NAME)}/latest`, {
headers: { accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw updateError('check-failed');
if (Number(response.headers.get('content-length')) > MAX_METADATA_BYTES) {
throw updateError('invalid-release');
}
const chunks = [];
let length = 0;
for await (const chunk of response.body) {
length += chunk.byteLength;
if (length > MAX_METADATA_BYTES) throw updateError('invalid-release');
chunks.push(Buffer.from(chunk));
}
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const version = value.version;
if (value.name !== PACKAGE_NAME || typeof version !== 'string'
|| semver.valid(version) !== version || semver.prerelease(version)) {
throw updateError('invalid-release');
}
const nodeRange = value.engines?.node;
if (nodeRange !== undefined && (typeof nodeRange !== 'string' || !semver.validRange(nodeRange))) {
throw updateError('invalid-release');
}
const tarball = new URL(value.dist?.tarball);
const integrity = value.dist?.integrity;
if (tarball.origin !== new URL(NPM_REGISTRY).origin || tarball.username || tarball.password
|| tarball.search || tarball.hash
|| tarball.pathname !== `/dsh-im-ops/-/dsh-im-${version}.tgz`
|| typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
throw updateError('invalid-release');
}
return { version, nodeRange: nodeRange ?? '*', integrity, tarball: tarball.href };
} catch (error) {
if (error?.code === 'invalid-release' || error instanceof SyntaxError || error instanceof TypeError && response?.ok) {
throw updateError('invalid-release');
}
throw updateError('check-failed');
}
}
function pathsFor(environment) {
if (!isAbsolute(environment.homeDir ?? '') || !isAbsolute(environment.profileDir ?? '')) return null;
const key = createHash('sha256').update(environment.profileDir).digest('hex').slice(0, 24);
const directory = join(environment.homeDir, 'updates', 'dsh-im', key);
return {
directory,
state: join(directory, 'state.json'),
lock: join(directory, 'install.lock'),
backup: join(directory, 'before.json'),
};
}
async function readJson(path, missing = null) {
try {
if ((await stat(path)).size > 10 * 1024 * 1024) throw updateError('state-unavailable');
return JSON.parse(await readFile(path, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return missing;
throw updateError('state-unavailable');
}
}
async function writeJson(path, value) {
const temporary = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
await rename(temporary, path);
} catch {
throw updateError('state-unavailable');
} finally {
await unlink(temporary).catch((error) => {
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
});
}
}
function processAlive(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function publicJob(job) {
if (!job) return null;
const { id, state, targetVersion, message } = job;
return { id, state, targetVersion, message };
}
/** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
export function createUpdateService({
runtime,
runningVersion = manifest.version,
nodeVersion = process.versions.node,
fetchImpl = globalThis.fetch,
now = Date.now,
checkTimeoutMs = 10_000,
confirmationTtlMs = 10 * 60_000,
installTimeoutMs = 15 * 60_000,
} = {}) {
const queue = {};
let checked = null;
let checking = null;
let lastCheckAt = -Infinity;
let activeJob = null;
let activeTask = null;
let abortController = null;
let unsavedJob = null;
let disposed = false;
function assertActive() {
if (disposed) throw updateError('disposed');
}
async function readJob(environment) {
const paths = pathsFor(environment);
if (!paths) return null;
// A failed final write must not make this Host display the old "installing"
// record forever. Keep the lock and expose the outcome we actually observed.
if (unsavedJob?.statePath === paths.state) return unsavedJob.job;
let job = await readJson(paths.state);
const lock = await readJson(paths.lock, NO_LOCK);
if (!job) {
if (lock !== NO_LOCK) {
return { id: 'locked', state: 'interrupted', message: 'recovery-required', targetVersion: null };
}
return null;
}
if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
throw updateError('state-unavailable');
}
if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
job = { ...job, state: 'interrupted', message: 'recovery-required' };
}
}
if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
// A later Host can retry after a verified manual repair. Never infer this
// from version equality while an old process lock is still present.
if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
&& environment.installedVersion === runningVersion && !environment.blockedReason) {
return job.targetVersion === runningVersion
? { ...job, state: 'completed', message: 'recovered' }
: { ...job, recoverable: true };
}
}
if (job.state === 'restart-required' || job.state === 'completed') {
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
return { ...job, state: 'interrupted', message: 'installation-changed' };
}
return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
}
return job;
}
function snapshot(environment, job) {
let blockedReason = environment.blockedReason ?? checked?.blockedReason ?? null;
if (job?.state === 'interrupted' && !job.recoverable
|| job?.state === 'failed' && environment.installedVersion !== runningVersion) {
blockedReason = 'recovery-required';
} else if (job?.state === 'restart-required' || environment.installedVersion && environment.installedVersion !== runningVersion) {
blockedReason = 'pending-restart';
}
const busy = ACTIVE_STATES.has(job?.state);
const canInstall = Boolean(environment.eligible && !blockedReason && !busy && checked?.checkId
&& now() < checked.expiresAt && checked.installationKey === environment.installationKey
&& semver.valid(runningVersion) && semver.gt(checked.release.version, runningVersion));
return {
runningVersion,
installedVersion: environment.installedVersion ?? null,
latestVersion: checked?.release.version ?? null,
profileName: environment.profileName ?? null,
environmentKind: environment.environmentKind ?? 'cli',
// Preserve source protection even when restart/recovery takes status priority.
sourceInstall: environment.sourceInstall === true || environment.blockedReason === 'source-install',
canInstall,
blockedReason,
checkedAt: checked?.checkedAt ?? null,
checkId: canInstall ? checked.checkId : null,
job: publicJob(job),
};
}
async function status() {
assertActive();
const environment = await runtime.inspect();
return snapshot(environment, await readJob(environment));
}
async function check() {
assertActive();
if (checking) return checking;
// Collapse double clicks without turning a failed request into a cached success.
if (checked?.checkId && now() - lastCheckAt < 2_000) return status();
lastCheckAt = now();
checking = (async () => {
try {
const environment = await runtime.inspect({ preflight: true });
const release = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
assertActive();
checked = {
release,
checkId: randomUUID(),
checkedAt: now(),
expiresAt: now() + confirmationTtlMs,
installationKey: environment.installationKey,
profileDir: environment.profileDir,
blockedReason: environment.blockedReason
?? (!semver.satisfies(nodeVersion, release.nodeRange) ? 'incompatible-node' : null),
};
return snapshot(environment, await readJob(environment));
} catch (error) {
if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
throw error;
} finally {
checking = null;
}
})();
return checking;
}
async function releaseLock(paths, id) {
const lock = await readJson(paths.lock);
if (lock?.id === id) await unlink(paths.lock);
}
async function backupProfile(environment, paths, job) {
const files = {};
for (const filename of SNAPSHOT_FILES) {
try {
const path = join(environment.profileDir, filename);
if ((await stat(path)).size > 3 * 1024 * 1024) throw updateError('state-unavailable');
files[filename] = await readFile(path, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
}
}
// Keep only the previous attempt's small manifests, never credentials or the entire home.
await writeJson(paths.backup, { jobId: job.id, previousVersion: job.previousVersion, files });
}
function rememberUnsavedJob(paths) {
activeJob = { ...activeJob, state: 'interrupted', message: 'state-unavailable' };
unsavedJob = { statePath: paths.state, job: activeJob };
}
async function execute(paths, job, originalEnvironment) {
const deadline = AbortSignal.timeout(installTimeoutMs);
try {
await runtime.install(job.targetVersion, {
signal: AbortSignal.any([abortController.signal, deadline]),
expectedInstallationKey: originalEnvironment.installationKey,
});
if (disposed) throw updateError('interrupted');
activeJob = { ...activeJob, state: 'verifying', updatedAt: now() };
await writeJson(paths.state, activeJob);
const environment = await runtime.inspect();
if (environment.homeDir !== originalEnvironment.homeDir || environment.profileDir !== originalEnvironment.profileDir
|| environment.profileName !== originalEnvironment.profileName || environment.blockedReason === 'installation-changed') {
throw updateError('installation-changed');
}
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
throw updateError('verify-failed');
}
if (environment.blockedReason && environment.blockedReason !== 'pending-restart') throw updateError('verify-failed');
activeJob = { ...activeJob, state: 'restart-required', message: null, updatedAt: now() };
await writeJson(paths.state, activeJob);
} catch (error) {
const timedOut = deadline.aborted || error.code === 'install-timeout';
const interrupted = disposed || abortController.signal.aborted
|| !timedOut && ['interrupted', 'install-interrupted'].includes(error.code);
activeJob = {
...activeJob,
state: interrupted ? 'interrupted' : 'failed',
message: interrupted ? 'interrupted' : timedOut ? 'install-timeout'
: ['verify-failed', 'state-unavailable', 'installation-changed', 'registry-conflict'].includes(error.code)
? error.code : 'install-failed',
updatedAt: now(),
};
try {
await writeJson(paths.state, activeJob);
} catch {
// Retain the lock when the final record cannot be saved; the next Host must inspect it.
rememberUnsavedJob(paths);
return;
}
}
await releaseLock(paths, job.id);
}
function install({ checkId, requestId }) {
return withSessionBindingLock(queue, 'install', async () => {
assertActive();
let environment = await runtime.inspect({ preflight: true });
const previous = await readJob(environment);
if (previous?.requestId === requestId) return snapshot(environment, previous);
if (ACTIVE_STATES.has(previous?.state) || previous?.state === 'restart-required') throw updateError('update-busy');
const confirmation = checked;
if (!confirmation || !checkId || confirmation.checkId !== checkId || now() >= confirmation.expiresAt) {
throw updateError('check-expired');
}
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
throw updateError('installation-changed');
}
if (!snapshot(environment, previous).canInstall) throw updateError(environment.blockedReason ?? 'update-busy');
const paths = pathsFor(environment);
if (!paths) throw updateError('state-unavailable');
const job = {
id: randomUUID(), requestId, state: 'installing', message: null,
targetVersion: confirmation.release.version, previousVersion: environment.installedVersion,
startedAt: now(), updatedAt: now(),
};
let locked = false;
try {
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
const lock = await open(paths.lock, 'wx', 0o600);
locked = true;
try {
await lock.writeFile(JSON.stringify({ id: job.id, pid: process.pid, startedAt: now() }));
} finally {
await lock.close();
}
const currentRelease = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
if (JSON.stringify(currentRelease) !== JSON.stringify(confirmation.release)) throw updateError('check-expired');
environment = await runtime.inspect({ preflight: true });
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
throw updateError('installation-changed');
}
if (!environment.eligible || environment.blockedReason) throw updateError(environment.blockedReason ?? 'update-busy');
assertActive();
await backupProfile(environment, paths, job);
await writeJson(paths.state, job);
assertActive();
} catch (error) {
if (locked) await releaseLock(paths, job.id);
if (error.code === 'EEXIST') throw updateError('update-busy');
if (error.code?.startsWith('E')) throw updateError('state-unavailable');
throw error;
}
activeJob = job;
abortController = new AbortController();
activeTask = execute(paths, job, environment).catch(() => {
// Keep unknown cleanup failures local and keep the on-disk lock for manual recovery.
rememberUnsavedJob(paths);
});
return snapshot(environment, job);
});
}
async function close() {
disposed = true;
abortController?.abort();
if (activeTask) await activeTask;
}
return Object.freeze({ status, check, install, close });
}