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:
xmanrui 2026-08-28 06:37:40 +08:00
parent 5888641297
commit f3cefd8a84
23 changed files with 4058 additions and 271 deletions

View file

@ -9,6 +9,62 @@ const EN = Object.freeze({
'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'Connecting DeepSeek Harness',
'当前版本': 'Current version',
'DSH-IM 更新': 'DSH-IM update',
'检查更新': 'Check for updates',
'重新检查': 'Check again',
'刷新状态': 'Refresh status',
'更新至': 'Update to',
'安装更新': 'Install update',
'正在更新…': 'Updating…',
'待手动重启': 'Restart needed',
'运行版本': 'Running version',
'已安装版本': 'Installed version',
'npm 最新版本': 'Latest on npm',
'目标 profile': 'Target profile',
'目标版本': 'Target version',
'更新来源': 'Update source',
'页面版本': 'Page version',
'无法确认': 'Unknown',
'发现新版本': 'Update available',
'已是最新版本': 'Up to date',
'当前版本无需更新': 'No newer version available',
'已获取 npm 最新版本': 'Latest npm version retrieved',
'检查 npm 最新版本,不会自动安装。': 'Check the latest npm version. Nothing is installed automatically.',
'正在从 npm 检查最新版本…': 'Checking npm for the latest version…',
'正在安装,请稍候…': 'Installing, please wait…',
'正在校验安装结果…': 'Verifying the installation…',
'已安装,待手动重启': 'Installed — manual restart needed',
'更新已生效': 'Updated version is active',
'更新失败': 'Update failed',
'更新请求失败': 'Update request failed',
'仅更新 DSH-IM。安装完成后需手动重启后台;本功能不会自动重启或主动刷新页面。': 'Only DSH-IM will be updated. Restart the backend manually after installation. This feature does not automatically restart it or initiate a page refresh.',
'当前是源码或链接安装,只能检查版本;请手动更新源码,或迁移到 npm 安装。': 'This is a source or linked installation. You can check versions, but must update the source manually or migrate to an npm installation.',
'无法确认当前 profile,请在对应的 Harness 环境中手动更新。': 'The current profile could not be identified. Update manually in the corresponding Harness environment.',
'当前运行环境不支持按钮安装,请手动更新插件。': 'This runtime cannot install through this button. Update the plugin manually.',
'当前 npm 源配置与官方源不一致,请先检查 registry 配置。': 'The npm registry configuration differs from the official registry. Check your registry settings first.',
'当前 Host 的 Node.js 版本不满足新版要求,请先更新运行环境。': 'The Host Node.js version does not meet the new version’s requirements. Update the runtime first.',
'新版本已安装,请在方便时手动重启当前 Harness 或 Desktop。': 'The new version is installed. Manually restart this Harness or Desktop when convenient.',
'上次安装结果无法确认,请先检查此 profile 的插件安装状态。': 'The previous installation result is uncertain. Check this profile’s plugin installation first.',
'无法访问 npm 或请求超时,请稍后重新检查。': 'npm is unreachable or the request timed out. Check again later.',
'npm 返回的版本信息无效,暂时无法更新。': 'npm returned invalid release information. Updating is unavailable.',
'版本确认已过期,请重新检查后再安装。': 'The version confirmation expired. Check again before installing.',
'插件安装状态已发生变化,请重新检查。': 'The plugin installation changed. Check again.',
'此 profile 正在更新,请稍后查看状态。': 'This profile is already updating. Check its status shortly.',
'安装失败,请检查当前安装状态后重试。': 'Installation failed. Check the current installation before retrying.',
'安装结果校验失败,请手动检查插件版本。': 'Installation verification failed. Check the plugin version manually.',
'无法安全保存更新状态,请先检查当前安装结果。': 'Update state could not be saved safely. Check the current installation result before retrying.',
'上次更新已中断,请检查安装状态后重试。': 'The previous update was interrupted. Check the installation before retrying.',
'更新服务已关闭,请手动重新打开设置页。': 'The update service has closed. Reopen settings manually.',
'更新请求无效,请重新检查版本。': 'The update request is invalid. Check the version again.',
'当前插件安装不完整或与 profile 不符,请手动检查安装配置。': 'The plugin installation is incomplete or does not match this profile. Check the installation configuration manually.',
'无法确认当前 npm 源配置,暂时不能安装更新。': 'The npm registry configuration could not be verified. Installation is unavailable.',
'安装超时,请先确认当前安装状态,再决定是否重试。': 'Installation timed out. Check the current installation before retrying.',
'更新请求失败,请重试。': 'The update request failed. Try again.',
'更新服务返回了无法识别的响应。': 'The update service returned an unrecognized response.',
'当前 Host 不支持更新接口,请先手动更新插件并重启。': 'This Host does not support the update API. Update the plugin manually and restart first.',
'关闭窗口不会取消安装。请勿同时在其他窗口管理此 profile 的插件。': 'Closing this window does not cancel installation. Do not manage this profile’s plugins in another window at the same time.',
'页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。': 'The page and running versions differ. Refresh the page manually; if they still differ, manually restart Harness or Desktop.',
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。': 'Install while bots are idle. This changes the current profile’s dependencies and requires a manual restart afterward.',
'AI Office': 'AI Office',
'(实验功能)': '(Experimental)',
'AI Office 设置': 'AI Office settings',

View file

@ -48,6 +48,7 @@ import {
replacePageLocation,
} from './loopback-recovery.js';
import { installImStyles } from './styles.js';
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
export const name = 'im-settings';
@ -155,17 +156,22 @@ export function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker,
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation,
}) {
const [selected, setSelected] = React.useState('weixin');
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
const githubTooltipId = React.useId();
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []);
const reportUpdateStatus = React.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion);
}, []);
const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
@ -177,6 +183,7 @@ export function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
}, {
location: browserLocation,
onRecovery: reportLoopbackRecovery,
@ -190,6 +197,7 @@ export function IMSettingsTab({
reportLoopbackRecovery,
slackRpcCall,
telegramRpcCall,
updateRpcCall,
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
@ -200,8 +208,14 @@ export function IMSettingsTab({
h('div', { className: 'dim-brand' },
h('div', { className: 'dim-brandHeading' },
h('strong', { className: 'dim-brandName' }, 'DSH-IM'),
h('span', { className: 'dim-brandVersion' }, `v${IM_PLUGIN_VERSION}`)),
h('span', { className: 'dim-brandVersion' }, `v${runningVersion}`)),
h('p', null, '让 DeepSeek Harness 触手可及')),
h('div', { className: 'dim-titleActions' },
h(UpdatePanel, {
rpcCall: rpcCalls.updateRpcCall,
clientVersion: IM_PLUGIN_VERSION,
onStatus: reportUpdateStatus,
}),
h('span', { className: 'dim-githubAction' },
h('a', {
className: 'dim-githubLink',
@ -217,7 +231,7 @@ export function IMSettingsTab({
id: githubTooltipId,
className: 'dim-githubTooltip',
role: 'tooltip',
}, '帮助与反馈 · 前往 GitHub')),
}, '帮助与反馈 · 前往 GitHub'))),
),
h('div', { className: 'dim-layout' },
h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
@ -318,6 +332,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
const officeRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
const updateRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
const workspaceDirectoryPicker = Object.freeze({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory(),
@ -340,6 +356,7 @@ export function apply(ctx) {
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
updateRpcCall,
workspaceDirectoryPicker,
}),
}, IMSettingsTab));

View file

@ -17,6 +17,32 @@ const CSS = String.raw`
.dim-brandName { color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 24px; font-weight: 800; letter-spacing: .04em; }
.dim-brandVersion { color: var(--dsw-alias-label-tertiary, #8f959e); font: 500 10px/16px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; }
.dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; font-weight: 500; white-space: nowrap; }
.dim-titleActions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.dim-updateButton { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 18px; font-weight: 560; cursor: pointer; }
.dim-updateButton:hover:not(:disabled) { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dim-updateButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateButton:disabled { opacity: .55; cursor: default; }
.dim-updateTrigger { white-space: nowrap; }
.dim-updateBackdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); }
.dim-updateBackdrop, .dim-updateBackdrop * { box-sizing: border-box; }
.dim-updateDialog { width: min(480px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 16px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 20px 70px rgb(0 0 0 / 20%); text-align: left; }
.dim-updateDialog:focus { outline: none; }
.dim-updateDialog h3 { margin: 22px 24px 8px; font-size: 18px; line-height: 25px; font-weight: 680; }
.dim-updateDescription { margin: 0 24px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; }
.dim-updateBody { padding: 18px 24px 20px; }
.dim-updateVersions { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 18px; margin: 0 0 18px; font-size: 12px; line-height: 18px; }
.dim-updateVersions dt { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateVersions dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dim-updateStatus { padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #f7f8fa); font-size: 13px; line-height: 20px; }
.dim-updateStatus strong { font-weight: 600; }
.dim-updateStatus p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateStatusError { border-color: color-mix(in srgb, var(--dsw-alias-state-danger-primary, #d92d20) 25%, var(--dsw-alias-border-l2, #dfe1e5)); }
.dim-updateHint, .dim-updateError { margin: 12px 0 0; font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateHint { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateError { color: var(--dsw-alias-state-danger-primary, #d92d20); }
.dim-updateFooter { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateFooter .dim-updateButton:first-child { margin-right: auto; }
.dim-updatePrimary, .dim-updatePrimary:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-githubAction { position: relative; display: inline-flex; flex: none; }
.dim-githubLink { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 12px; line-height: normal; font-weight: 560; text-decoration: none; transition: border-color .15s ease, color .15s ease, background .15s ease; }
.dim-githubLink:hover { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
@ -284,6 +310,13 @@ const CSS = String.raw`
@media (max-width: 560px) {
.dim-title { flex-direction: column; gap: 10px; }
.dim-title p { white-space: normal; }
.dim-titleActions { justify-content: flex-start; }
.dim-updateBackdrop { padding: 12px; }
.dim-updateDialog { max-height: calc(100vh - 24px); }
.dim-updateDialog h3 { margin: 18px 18px 8px; }
.dim-updateDescription { margin: 0 18px; }
.dim-updateBody { padding: 16px 18px; }
.dim-updateFooter { padding: 12px 18px; }
.dim-githubTooltip { right: auto; left: 0; }
.dim-rail { grid-template-columns: minmax(0, 1fr); }
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }

View file

@ -0,0 +1,367 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { h } from './i18n.js';
import { createPollScheduler } from './lifecycle.js';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
const ACTIVE_STATES = new Set(['installing', 'verifying']);
const BLOCKED_REASONS = Object.freeze({
'source-install': '当前是源码或链接安装,只能检查版本;请手动更新源码,或迁移到 npm 安装。',
'unknown-profile': '无法确认当前 profile,请在对应的 Harness 环境中手动更新。',
'unsupported-runtime': '当前运行环境不支持按钮安装,请手动更新插件。',
'registry-conflict': '当前 npm 源配置与官方源不一致,请先检查 registry 配置。',
'incompatible-node': '当前 Host 的 Node.js 版本不满足新版要求,请先更新运行环境。',
'pending-restart': '新版本已安装,请在方便时手动重启当前 Harness 或 Desktop。',
'recovery-required': '上次安装结果无法确认,请先检查此 profile 的插件安装状态。',
});
const ERROR_MESSAGES = Object.freeze({
'check-failed': '无法访问 npm 或请求超时,请稍后重新检查。',
'update-failed': '更新请求失败,请重试。',
'invalid-release': 'npm 返回的版本信息无效,暂时无法更新。',
'check-expired': '版本确认已过期,请重新检查后再安装。',
'installation-changed': '插件安装状态已发生变化,请重新检查。',
'update-busy': '此 profile 正在更新,请稍后查看状态。',
'install-failed': '安装失败,请检查当前安装状态后重试。',
'verify-failed': '安装结果校验失败,请手动检查插件版本。',
'state-unavailable': '无法安全保存更新状态,请先检查当前安装结果。',
interrupted: '上次更新已中断,请检查安装状态后重试。',
disposed: '更新服务已关闭,请手动重新打开设置页。',
'bad-request': '更新请求无效,请重新检查版本。',
'invalid-installation': '当前插件安装不完整或与 profile 不符,请手动检查安装配置。',
'executor-unavailable': BLOCKED_REASONS['unsupported-runtime'],
'registry-check-failed': '无法确认当前 npm 源配置,暂时不能安装更新。',
'install-interrupted': '上次更新已中断,请检查安装状态后重试。',
'install-timeout': '安装超时,请先确认当前安装状态,再决定是否重试。',
'invalid-version': 'npm 返回的版本信息无效,暂时无法更新。',
});
function unwrapSnapshot(result) {
if (result?.ok === false) {
const error = new Error(result.error?.message || '更新请求失败,请重试。');
error.code = result.error?.code;
throw error;
}
const value = result?.value;
if (result?.ok !== true || typeof value?.runningVersion !== 'string'
|| typeof value?.canInstall !== 'boolean') {
throw new Error('更新服务返回了无法识别的响应。');
}
return value;
}
function presentError(error) {
const code = error?.rpcError?.code ?? error?.code ?? '';
const message = error?.rpcError?.message ?? error?.message ?? '';
if (code === 'update-unavailable'
|| /(?:not[-_ ]found|unimplemented|unknown[-_ ](?:endpoint|channel)|no[-_ ]handler)/i.test(code)
|| /(?:not found|unknown (?:endpoint|channel)|not registered|no .*handler|HTTP 404)/i.test(message)) {
return '当前 Host 不支持更新接口,请先手动更新插件并重启。';
}
return BLOCKED_REASONS[code] ?? ERROR_MESSAGES[code]
?? (message.slice(0, 400) || '更新请求失败,请重试。');
}
function summary(snapshot, action, error) {
if (action === 'checking') return '正在从 npm 检查最新版本…';
if (action === 'starting' || snapshot?.job?.state === 'installing') return '正在安装,请稍候…';
if (snapshot?.job?.state === 'verifying') return '正在校验安装结果…';
if (snapshot?.job?.state === 'restart-required' || snapshot?.blockedReason === 'pending-restart') {
return '已安装,待手动重启';
}
if (snapshot?.job?.state === 'completed') return '更新已生效';
if (snapshot?.job?.state === 'failed') return '更新失败';
if (snapshot?.job?.state === 'interrupted') return '上次更新已中断,请检查安装状态后重试。';
if (error) return '更新请求失败';
if (snapshot?.canInstall) return '发现新版本';
if (snapshot?.checkedAt && snapshot.latestVersion === snapshot.runningVersion) return '已是最新版本';
if (snapshot?.blockedReason === 'no-update') return '当前版本无需更新';
if (snapshot?.checkedAt) return '已获取 npm 最新版本';
return '检查 npm 最新版本,不会自动安装。';
}
function retainDialogFocus(event) {
event?.currentTarget?.closest?.('.dim-updateDialog')?.focus?.({ preventScroll: true });
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React.useRef(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => { if (previous?.isConnected) previous.focus?.(); };
}, []);
const content = h('div', {
className: 'dim-updateBackdrop',
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
},
h('section', {
ref: dialogRef,
className: 'dim-updateDialog',
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': titleId,
'aria-describedby': descriptionId,
tabIndex: -1,
onKeyDown: (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
if (event.key !== 'Tab') return;
const buttons = dialogRef.current?.querySelectorAll?.('button:not(:disabled)');
if (!buttons?.length) return;
const first = buttons[0];
const last = buttons[buttons.length - 1];
const active = globalThis.document?.activeElement;
if (event.shiftKey && (active === first || active === dialogRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || active === dialogRef.current)) {
event.preventDefault();
first.focus();
}
},
},
h('h3', { id: titleId }, 'DSH-IM 更新'),
h('p', { id: descriptionId, className: 'dim-updateDescription' },
'仅更新 DSH-IM。安装完成后需手动重启后台;本功能不会自动重启或主动刷新页面。'),
children));
return typeof document !== 'undefined' && document.body
? createPortal(content, document.body)
: content;
}
export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React.useState(null);
const [action, setAction] = React.useState('status');
const [error, setError] = React.useState(null);
const [open, setOpen] = React.useState(false);
const [uncertainInstall, setUncertainInstall] = React.useState(false);
const mounted = React.useRef(false);
const busy = React.useRef(false);
const readController = React.useRef(null);
const pollReadController = React.useRef(null);
const installRequest = React.useRef(null);
const onStatusRef = React.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
const acceptAuthoritative = (next) => {
// Abort synchronously: effect cleanup alone can lose to an already queued reply.
pollReadController.current?.abort();
setUncertainInstall(false);
accept(next);
};
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') {
const unavailable = new Error('当前 Host 不支持更新接口,请先手动更新插件并重启。');
unavailable.code = 'update-unavailable';
throw unavailable;
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
void invoke('update.status', {}, controller.signal).then((next) => {
if (!controller.signal.aborted) accept(next);
}).catch((cause) => {
if (!controller.signal.aborted) setError(presentError(cause));
}).finally(() => {
if (!controller.signal.aborted) {
busy.current = false;
setAction(null);
}
});
return () => {
mounted.current = false;
controller.abort();
readController.current?.abort();
};
}, [accept, invoke]);
const activeJob = ACTIVE_STATES.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === 'restart-required'
|| snapshot?.blockedReason === 'pending-restart';
const shouldPoll = activeJob || uncertainInstall;
React.useEffect(() => {
if (!shouldPoll) return undefined;
let controller;
const scheduler = createPollScheduler({
setTimeoutFn: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeoutFn: (timer) => globalThis.clearTimeout(timer),
});
const poll = async () => {
const pendingController = new AbortController();
controller = pendingController;
pollReadController.current = pendingController;
let keepPolling = true;
try {
const next = await invoke('update.status', {}, pendingController.signal);
if (!pendingController.signal.aborted) {
accept(next);
setError(null);
setUncertainInstall(false);
keepPolling = ACTIVE_STATES.has(next.job?.state);
}
} catch (cause) {
if (!pendingController.signal.aborted) setError(presentError(cause));
} finally {
if (pollReadController.current === pendingController) pollReadController.current = null;
}
if (keepPolling) scheduler.schedule(poll, 1_000);
};
scheduler.schedule(poll, 1_000);
return () => {
controller?.abort();
scheduler.dispose();
};
}, [accept, invoke, shouldPoll]);
const readSnapshot = async (endpoint) => {
const checkLatest = endpoint === 'update.check';
if (!mounted.current || busy.current || (checkLatest && (activeJob || restartRequired))) return;
busy.current = true;
setAction(checkLatest ? 'checking' : 'status');
setError(null);
if (checkLatest) {
installRequest.current = null;
setSnapshot((current) => current
? { ...current, canInstall: false, checkId: null, latestVersion: null, checkedAt: null }
: current);
}
const controller = new AbortController();
readController.current = controller;
try {
const next = await invoke(endpoint, {}, controller.signal);
if (!controller.signal.aborted && mounted.current) {
if (checkLatest) accept(next);
else acceptAuthoritative(next);
}
} catch (cause) {
if (!controller.signal.aborted && mounted.current) setError(presentError(cause));
} finally {
if (!controller.signal.aborted && mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const check = () => readSnapshot('update.check');
const refreshStatus = () => readSnapshot('update.status');
const install = async () => {
if (busy.current || !snapshot?.canInstall || !snapshot.checkId) return;
busy.current = true;
setAction('starting');
setError(null);
if (installRequest.current?.checkId !== snapshot.checkId) {
installRequest.current = {
checkId: snapshot.checkId,
requestId: globalThis.crypto?.randomUUID?.()
?? `update-${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
}
try {
// Closing this page only aborts reads, never an accepted Host installation.
const next = await invoke('update.install', installRequest.current);
if (mounted.current) acceptAuthoritative(next);
} catch (cause) {
if (mounted.current) {
setError(presentError(cause));
setUncertainInstall(true);
}
} finally {
if (mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const busyAction = action !== null;
const blocked = BLOCKED_REASONS[snapshot?.blockedReason] ?? ERROR_MESSAGES[snapshot?.blockedReason];
const canConfirm = snapshot?.canInstall && snapshot.checkId && !activeJob && !restartRequired;
const versionsDiffer = snapshot && clientVersion && snapshot.runningVersion !== clientVersion;
const failedJob = ['failed', 'interrupted'].includes(snapshot?.job?.state);
const jobMessage = snapshot?.job?.message;
const targetVersion = snapshot?.job?.targetVersion;
const buttonLabel = action === 'checking' ? '检查中…'
: action === 'starting' || activeJob ? '正在更新…'
: restartRequired ? '待手动重启'
: snapshot?.canInstall ? '更新至'
: '检查更新';
return h(React.Fragment, null,
h('button', {
type: 'button',
className: 'dim-updateButton dim-updateTrigger',
disabled: busyAction,
'aria-haspopup': 'dialog',
onClick: () => {
setOpen(true);
if (restartRequired) void refreshStatus();
else if (!snapshot?.canInstall && !snapshot?.job) void check();
},
}, buttonLabel, buttonLabel === '更新至' ? ` v${snapshot.latestVersion}` : null),
open ? h(UpdateDialog, { onClose: () => setOpen(false) },
h('div', { className: 'dim-updateBody' },
h('dl', { className: 'dim-updateVersions' },
h('dt', null, '运行版本'), h('dd', null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion
? h(React.Fragment, null,
h('dt', null, '已安装版本'), h('dd', null, `v${snapshot.installedVersion}`)) : null,
h('dt', null, 'npm 最新版本'), h('dd', null, snapshot?.latestVersion ? `v${snapshot.latestVersion}` : '尚未检查'),
targetVersion ? h(React.Fragment, null,
h('dt', null, '目标版本'), h('dd', null, `v${targetVersion}`)) : null,
h('dt', null, '目标 profile'), h('dd', null, snapshot?.profileName ?? '无法确认'),
h('dt', null, '更新来源'), h('dd', null, 'registry.npmjs.org'),
versionsDiffer ? h(React.Fragment, null,
h('dt', null, '页面版本'), h('dd', null, `v${clientVersion}`)) : null),
h('div', {
className: `dim-updateStatus${error || failedJob ? ' dim-updateStatusError' : ''}`,
role: 'status',
'aria-live': 'polite',
'aria-atomic': 'true',
},
h('strong', null, summary(snapshot, action, error)),
activeJob || action === 'starting'
? h('p', null, '关闭窗口不会取消安装。请勿同时在其他窗口管理此 profile 的插件。') : null,
restartRequired ? h('p', null, BLOCKED_REASONS['pending-restart']) : null,
failedJob && jobMessage ? h('p', null,
BLOCKED_REASONS[jobMessage] ?? ERROR_MESSAGES[jobMessage] ?? jobMessage) : null),
error ? h('p', { className: 'dim-updateError', role: 'alert' }, error) : null,
blocked && !restartRequired ? h('p', { className: 'dim-updateHint' }, blocked) : null,
versionsDiffer && !restartRequired ? h('p', { className: 'dim-updateHint' }, '页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。') : null,
canConfirm ? h('p', { className: 'dim-updateHint' },
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null),
h('footer', { className: 'dim-updateFooter' },
h('button', { type: 'button', className: 'dim-updateButton', onClick: () => setOpen(false) }, '关闭'),
restartRequired || !activeJob ? h('button', {
type: 'button', className: 'dim-updateButton', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void (restartRequired ? refreshStatus() : check());
},
}, restartRequired ? '刷新状态' : '重新检查') : null,
canConfirm ? h('button', {
type: 'button', className: 'dim-updateButton dim-updatePrimary', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void install();
},
}, '安装更新') : null)) : null);
}

View file

@ -10,6 +10,7 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
import { installUpdateRpc } from './update-rpc.mjs';
export const name = 'dsh-im-host';
export const inject = [
@ -27,6 +28,7 @@ function channelConfig(config, name) {
}
export function createImHostPlugin(internals = {}) {
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
const startFeishu = internals.applyFeishu ?? applyFeishu;
const startWeixin = internals.applyWeixin ?? applyWeixin;
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
@ -64,6 +66,13 @@ export function createImHostPlugin(internals = {}) {
const logger = typeof ctx?.logger === 'function'
? ctx.logger(name)
: (ctx?.logger ?? console);
if (ctx?.connection?.rpc) {
try {
startUpdate(ctx);
} catch (error) {
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
}
}
const failures = [];
for (const [channel, start] of channels) {
try {

View file

@ -0,0 +1,50 @@
import { createUpdateRuntime } from './update-runtime.mjs';
import { createUpdateService } from './update-service.mjs';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
function validPayload(endpoint, payload) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
if (endpoint !== 'update.install') return keys.length === 0;
return keys.length === 2 && keys.every((key) => key === 'checkId' || key === 'requestId')
&& ['checkId', 'requestId'].every((key) => typeof payload[key] === 'string'
&& /^[A-Za-z0-9_-]{1,128}$/.test(payload[key]));
}
const PUBLIC_ERRORS = new Set([
'check-failed', 'invalid-release', 'check-expired', 'installation-changed', 'update-busy',
'install-failed', 'verify-failed', 'state-unavailable', 'interrupted', 'disposed',
'source-install', 'unknown-profile', 'unsupported-runtime', 'registry-conflict',
'incompatible-node', 'pending-restart', 'recovery-required', 'executor-unavailable',
'invalid-installation', 'registry-check-failed', 'install-timeout', 'install-interrupted', 'invalid-version',
]);
export function createUpdateRpcHandler(service) {
return async (endpoint, payload, signal) => {
if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) {
return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } };
}
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
try {
// The submitted install belongs to the Host, not the lifetime of this browser request.
const value = endpoint === 'update.install' ? await service.install(payload)
: endpoint === 'update.check' ? await service.check() : await service.status();
return { ok: true, value };
} catch (error) {
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'update-failed';
return { ok: false, error: { code, message: code } };
}
};
}
export function installUpdateRpc(ctx, options = {}) {
const runtime = options.runtime ?? createUpdateRuntime({ ctx, moduleUrl: import.meta.url });
const service = options.service ?? createUpdateService({ runtime });
const dispose = ctx.connection.rpc.handle(UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
authority: 'loopback',
});
ctx.effect(() => () => service.close(), 'dsh-im: close update installer');
return dispose;
}

View file

@ -0,0 +1,338 @@
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.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 (!registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory)) 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 });
}

View 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 });
}