mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
feat: add npm update checks and manual installation
Add profile-scoped npm updates for Desktop and Web, protect source installations, and keep restarts manual. Cover stale status recovery, request races, and dialog focus with regression tests. Verified with npm run check (1626 tests) and isolated Desktop/Web npm installation flows. Refs #61
This commit is contained in:
parent
5888641297
commit
f3cefd8a84
23 changed files with 4058 additions and 271 deletions
385
plugin-src/host/update-service.mjs
Normal file
385
plugin-src/host/update-service.mjs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
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 !== `/@xmanrui/dsh-im/-/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',
|
||||
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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue