@hcu-lab.me/mcp-device 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/config-manager.js +55 -18
- package/dist/device/device-state.d.ts +2 -0
- package/dist/device/device-state.js +3 -1
- package/dist/device/device.js +65 -0
- package/dist/device/execution-engine.d.ts +1 -0
- package/dist/device/execution-engine.js +7 -1
- package/dist/device/gateway-channel.d.ts +14 -0
- package/dist/device/gateway-channel.js +120 -3
- package/dist/device/gateway-identity.d.ts +1 -0
- package/dist/device/gateway-identity.js +4 -0
- package/dist/device/linux-service.d.ts +2 -0
- package/dist/device/linux-service.js +2 -2
- package/dist/device/runtime-owner.d.ts +1 -0
- package/dist/device/runtime-owner.js +4 -4
- package/dist/device/self-update.d.ts +115 -0
- package/dist/device/self-update.js +685 -0
- package/dist/device/update-helper.cjs +343 -0
- package/dist/handlers/filesystem-handlers.js +3 -0
- package/dist/index.js +7 -3
- package/dist/npm-scripts/remote.d.ts +5 -0
- package/dist/npm-scripts/remote.js +50 -1
- package/dist/server.js +76 -70
- package/dist/terminal-manager.js +40 -0
- package/dist/utils/capture.js +1 -1
- package/dist/utils/files/factory.js +20 -0
- package/dist/utils/files/text.d.ts +1 -0
- package/dist/utils/files/text.js +78 -34
- package/dist/utils/trackTools.js +23 -2
- package/dist/utils/usageTracker.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn as spawnCallback } from 'child_process';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { VERSION } from '../version.js';
|
|
8
|
+
import { deviceStatePaths } from './device-state.js';
|
|
9
|
+
import { PM2_NAME, Pm2DeviceService, SYSTEMD_UNIT, SystemdUserDeviceService } from './linux-service.js';
|
|
10
|
+
import { runtimeOwnerControlFileFor } from './runtime-owner.js';
|
|
11
|
+
import { buildWindowsTaskName, WindowsDeviceService } from './windows-service.js';
|
|
12
|
+
const execFileAsync = promisify(execFileCallback);
|
|
13
|
+
export const MCP_DEVICE_PACKAGE = '@hcu-lab.me/mcp-device';
|
|
14
|
+
const UPDATE_STATE_SCHEMA = 1;
|
|
15
|
+
const UPDATE_STATE_STALE_MS = 15 * 60 * 1000;
|
|
16
|
+
function updateError(code, message) {
|
|
17
|
+
return Object.assign(new Error(message), { code });
|
|
18
|
+
}
|
|
19
|
+
export function normalizeUpdateVersion(value) {
|
|
20
|
+
const version = String(value || '').trim();
|
|
21
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
22
|
+
throw updateError('DEVICE_UPDATE_INVALID_VERSION', 'Update target must be an exact stable semantic version.');
|
|
23
|
+
}
|
|
24
|
+
return version;
|
|
25
|
+
}
|
|
26
|
+
function safeToken(value) {
|
|
27
|
+
const normalized = String(value || '').replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40);
|
|
28
|
+
const digest = crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex').slice(0, 10);
|
|
29
|
+
return (normalized || 'update') + '-' + digest;
|
|
30
|
+
}
|
|
31
|
+
async function equalPath(left, right, platform = process.platform) {
|
|
32
|
+
const canonical = async (value) => {
|
|
33
|
+
const resolved = path.resolve(value);
|
|
34
|
+
return await fs.realpath(resolved).catch(() => resolved);
|
|
35
|
+
};
|
|
36
|
+
const a = await canonical(left);
|
|
37
|
+
const b = await canonical(right);
|
|
38
|
+
return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
39
|
+
}
|
|
40
|
+
async function exists(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(filePath);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function defaultExecFile(file, args) {
|
|
50
|
+
const result = await execFileAsync(file, args, {
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
maxBuffer: 16 * 1024 * 1024
|
|
54
|
+
});
|
|
55
|
+
return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
56
|
+
}
|
|
57
|
+
async function atomicWriteJson(filePath, value) {
|
|
58
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
59
|
+
const temporary = filePath + '.' + process.pid + '.' + Date.now() + '.' +
|
|
60
|
+
crypto.randomBytes(6).toString('hex') + '.tmp';
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(temporary, JSON.stringify(value, null, 2), {
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
mode: 0o600,
|
|
65
|
+
flag: 'wx'
|
|
66
|
+
});
|
|
67
|
+
await fs.rename(temporary, filePath);
|
|
68
|
+
try {
|
|
69
|
+
await fs.chmod(filePath, 0o600);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Windows ACLs inherit from the user profile.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await fs.rm(temporary, { force: true }).catch(() => { });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function updateFiles(paths = deviceStatePaths()) {
|
|
81
|
+
return {
|
|
82
|
+
root: paths.update,
|
|
83
|
+
state: path.join(paths.update, 'update-state.json'),
|
|
84
|
+
helper: path.join(paths.update, 'update-helper.cjs'),
|
|
85
|
+
helperRunner: path.join(paths.update, 'run-update.ps1'),
|
|
86
|
+
handoff: path.join(paths.update, 'handoff.ready'),
|
|
87
|
+
stage: path.join(paths.update, 'stage'),
|
|
88
|
+
verify: path.join(paths.update, 'verify'),
|
|
89
|
+
cache: path.join(paths.update, 'npm-cache'),
|
|
90
|
+
rollbackSnapshot: path.join(paths.update, 'rollback-package')
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export async function readDeviceUpdateState(paths = deviceStatePaths()) {
|
|
94
|
+
const filePath = updateFiles(paths).state;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
97
|
+
return parsed?.schema === UPDATE_STATE_SCHEMA ? parsed : null;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error?.code === 'ENOENT')
|
|
101
|
+
return null;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function updateStateAgeMs(state, now = Date.now()) {
|
|
106
|
+
const touchedAt = Number(state.updatedAt || state.startedAt || 0);
|
|
107
|
+
return Number.isFinite(touchedAt) && touchedAt > 0 ? Math.max(0, now - touchedAt) : Number.POSITIVE_INFINITY;
|
|
108
|
+
}
|
|
109
|
+
function isStaleUpdateState(state, now = Date.now()) {
|
|
110
|
+
return updateStateAgeMs(state, now) > UPDATE_STATE_STALE_MS;
|
|
111
|
+
}
|
|
112
|
+
function isHelperOwnedUpdatePhase(state) {
|
|
113
|
+
return ['helper_started', 'handoff_ready', 'quiescing', 'installing', 'rollback'].includes(state.state);
|
|
114
|
+
}
|
|
115
|
+
async function markInterruptedUpdateState(state, code, message, paths) {
|
|
116
|
+
const next = {
|
|
117
|
+
...state,
|
|
118
|
+
state: 'failed',
|
|
119
|
+
updatedAt: Date.now(),
|
|
120
|
+
completedAt: Date.now(),
|
|
121
|
+
errorCode: code,
|
|
122
|
+
message
|
|
123
|
+
};
|
|
124
|
+
await writeDeviceUpdateState(next, paths);
|
|
125
|
+
return next;
|
|
126
|
+
}
|
|
127
|
+
export async function assertDeviceStartupAllowedDuringUpdate(currentVersion = VERSION, paths = deviceStatePaths()) {
|
|
128
|
+
const state = await readDeviceUpdateState(paths);
|
|
129
|
+
if (!state || state.state === 'failed')
|
|
130
|
+
return;
|
|
131
|
+
// The target package itself is allowed to start and prove successful
|
|
132
|
+
// installation by authenticating with targetVersion.
|
|
133
|
+
if (state.targetVersion === currentVersion)
|
|
134
|
+
return;
|
|
135
|
+
if (state.state === 'installed_waiting_reconnect') {
|
|
136
|
+
await markInterruptedUpdateState(state, 'DEVICE_UPDATE_VERSION_MISMATCH', 'Update state expected a different package version after restart.', paths);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// "prepared" means the helper was never successfully launched, so there is
|
|
140
|
+
// no external updater to wait for after a process restart.
|
|
141
|
+
if (state.state === 'prepared') {
|
|
142
|
+
await markInterruptedUpdateState(state, 'DEVICE_UPDATE_INTERRUPTED', 'MCP Device restarted before the update helper was launched.', paths);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (isHelperOwnedUpdatePhase(state) && !isStaleUpdateState(state)) {
|
|
146
|
+
throw updateError('DEVICE_UPDATE_RECOVERY_IN_PROGRESS', 'An MCP Device update helper still owns the update lifecycle; retry startup after it completes.');
|
|
147
|
+
}
|
|
148
|
+
if (isStaleUpdateState(state)) {
|
|
149
|
+
await markInterruptedUpdateState(state, 'DEVICE_UPDATE_INTERRUPTED', 'Stale MCP Device update state was recovered after the helper stopped making progress.', paths);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function writeDeviceUpdateState(state, paths = deviceStatePaths()) {
|
|
153
|
+
await atomicWriteJson(updateFiles(paths).state, state);
|
|
154
|
+
}
|
|
155
|
+
async function patchDeviceUpdateState(patch, paths = deviceStatePaths()) {
|
|
156
|
+
const current = await readDeviceUpdateState(paths);
|
|
157
|
+
if (!current)
|
|
158
|
+
throw new Error('MCP Device update state is missing.');
|
|
159
|
+
const next = { ...current, ...patch, updatedAt: Date.now() };
|
|
160
|
+
await writeDeviceUpdateState(next, paths);
|
|
161
|
+
return next;
|
|
162
|
+
}
|
|
163
|
+
async function resolveExecutable(command, platform, execFile) {
|
|
164
|
+
if (path.isAbsolute(command) && await exists(command))
|
|
165
|
+
return path.resolve(command);
|
|
166
|
+
const lookup = platform === 'win32' ? 'where.exe' : 'which';
|
|
167
|
+
const result = await execFile(lookup, [command]);
|
|
168
|
+
const candidates = String(result.stdout || '')
|
|
169
|
+
.split(/\r?\n/)
|
|
170
|
+
.map(value => value.trim())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
if (await exists(candidate))
|
|
174
|
+
return path.resolve(candidate);
|
|
175
|
+
}
|
|
176
|
+
throw new Error('Unable to resolve executable: ' + command);
|
|
177
|
+
}
|
|
178
|
+
export async function resolveNpmCliPath(options = {}) {
|
|
179
|
+
const platform = options.platform || process.platform;
|
|
180
|
+
const execFile = options.execFile || defaultExecFile;
|
|
181
|
+
const nodePath = path.resolve(options.nodePath || process.execPath);
|
|
182
|
+
const directCandidates = [
|
|
183
|
+
options.npmExecPath,
|
|
184
|
+
// npm_execpath belongs to the host process. Ignore it when callers
|
|
185
|
+
// deliberately resolve for another platform (tests, cross-platform
|
|
186
|
+
// packaging) so a Windows npm-cli path cannot shadow Unix discovery.
|
|
187
|
+
platform === process.platform ? process.env.npm_execpath : undefined,
|
|
188
|
+
path.join(path.dirname(nodePath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.map(value => path.resolve(String(value)));
|
|
192
|
+
for (const candidate of directCandidates) {
|
|
193
|
+
if (await exists(candidate))
|
|
194
|
+
return candidate;
|
|
195
|
+
}
|
|
196
|
+
const lookup = platform === 'win32' ? 'where.exe' : 'which';
|
|
197
|
+
const npmCommand = platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
198
|
+
const result = await execFile(lookup, [npmCommand]);
|
|
199
|
+
const wrappers = String(result.stdout || '')
|
|
200
|
+
.split(/\r?\n/)
|
|
201
|
+
.map(value => value.trim())
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
for (const wrapper of wrappers) {
|
|
204
|
+
const realWrapper = await fs.realpath(wrapper).catch(() => wrapper);
|
|
205
|
+
const candidates = [
|
|
206
|
+
// On many Unix distributions /usr/bin/npm is a symlink whose
|
|
207
|
+
// realpath is npm-cli.js itself.
|
|
208
|
+
path.basename(realWrapper).toLowerCase() === 'npm-cli.js' ? realWrapper : '',
|
|
209
|
+
path.join(path.dirname(wrapper), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
210
|
+
path.join(path.dirname(realWrapper), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
211
|
+
path.resolve(path.dirname(realWrapper), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
212
|
+
].filter(Boolean);
|
|
213
|
+
for (const candidate of candidates) {
|
|
214
|
+
if (await exists(candidate))
|
|
215
|
+
return path.resolve(candidate);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw new Error('Unable to resolve an absolute npm-cli.js path for self-update.');
|
|
219
|
+
}
|
|
220
|
+
async function runNpm(nodePath, npmCliPath, args, execFile) {
|
|
221
|
+
return await execFile(nodePath, [npmCliPath, ...args]);
|
|
222
|
+
}
|
|
223
|
+
export async function findCurrentPackageRoot(entrypoint = process.argv[1]) {
|
|
224
|
+
let current = path.resolve(path.dirname(entrypoint || process.execPath));
|
|
225
|
+
for (let i = 0; i < 8; i++) {
|
|
226
|
+
const manifest = path.join(current, 'package.json');
|
|
227
|
+
if (await exists(manifest)) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(await fs.readFile(manifest, 'utf8'));
|
|
230
|
+
if (parsed?.name === MCP_DEVICE_PACKAGE)
|
|
231
|
+
return current;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Keep walking until the package root is found.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const parent = path.dirname(current);
|
|
238
|
+
if (parent === current)
|
|
239
|
+
break;
|
|
240
|
+
current = parent;
|
|
241
|
+
}
|
|
242
|
+
throw new Error('Unable to locate the installed MCP Device package root.');
|
|
243
|
+
}
|
|
244
|
+
function managerArg(argv) {
|
|
245
|
+
const value = argv.find(item => String(item).startsWith('--manager='));
|
|
246
|
+
return value ? String(value).slice('--manager='.length).trim().toLowerCase() : '';
|
|
247
|
+
}
|
|
248
|
+
async function detectManager(options) {
|
|
249
|
+
if (!options.argv.includes('--service')) {
|
|
250
|
+
throw updateError('DEVICE_UPDATE_REQUIRES_SERVICE', 'Dashboard updates require an installed background MCP Device service.');
|
|
251
|
+
}
|
|
252
|
+
const serviceExec = async (file, args) => {
|
|
253
|
+
const result = await options.execFile(file, args);
|
|
254
|
+
return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
255
|
+
};
|
|
256
|
+
if (options.platform === 'win32') {
|
|
257
|
+
const service = new WindowsDeviceService({
|
|
258
|
+
platform: options.platform,
|
|
259
|
+
execFile: serviceExec
|
|
260
|
+
});
|
|
261
|
+
const status = await service.status(options.deviceId);
|
|
262
|
+
if (!status.installed || status.registration === 'stale') {
|
|
263
|
+
throw updateError('DEVICE_UPDATE_REQUIRES_SERVICE', 'Windows MCP Device background registration is missing or stale.');
|
|
264
|
+
}
|
|
265
|
+
const managerKind = status.registration === 'task'
|
|
266
|
+
? 'windows-task'
|
|
267
|
+
: status.registration === 'run'
|
|
268
|
+
? 'windows-run'
|
|
269
|
+
: 'windows-manual';
|
|
270
|
+
const systemRoot = String(process.env.SystemRoot || 'C:\\Windows');
|
|
271
|
+
return {
|
|
272
|
+
managerKind,
|
|
273
|
+
windowsRunnerPath: path.join(options.paths.root, 'run-device.ps1'),
|
|
274
|
+
windowsTaskName: buildWindowsTaskName(options.deviceId),
|
|
275
|
+
powershellPath: path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
276
|
+
schtasksPath: path.join(systemRoot, 'System32', 'schtasks.exe')
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (options.platform === 'linux') {
|
|
280
|
+
const manager = managerArg(options.argv);
|
|
281
|
+
if (manager === 'systemd') {
|
|
282
|
+
const service = new SystemdUserDeviceService({
|
|
283
|
+
platform: options.platform,
|
|
284
|
+
execFile: serviceExec
|
|
285
|
+
});
|
|
286
|
+
const status = await service.status();
|
|
287
|
+
if (!status.installed) {
|
|
288
|
+
throw updateError('DEVICE_UPDATE_REQUIRES_SERVICE', 'systemd-user MCP Device service is not installed.');
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
managerKind: 'systemd',
|
|
292
|
+
systemctlPath: await resolveExecutable('systemctl', options.platform, options.execFile),
|
|
293
|
+
systemdRunPath: await resolveExecutable('systemd-run', options.platform, options.execFile),
|
|
294
|
+
systemdUnit: SYSTEMD_UNIT
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (manager === 'pm2') {
|
|
298
|
+
const service = new Pm2DeviceService({
|
|
299
|
+
platform: options.platform,
|
|
300
|
+
execFile: serviceExec
|
|
301
|
+
});
|
|
302
|
+
const status = await service.status();
|
|
303
|
+
if (!status.installed) {
|
|
304
|
+
throw updateError('DEVICE_UPDATE_REQUIRES_SERVICE', 'PM2 MCP Device service is not installed.');
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
managerKind: 'pm2',
|
|
308
|
+
pm2Path: await resolveExecutable('pm2', options.platform, options.execFile),
|
|
309
|
+
pm2Name: PM2_NAME
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
throw updateError('DEVICE_UPDATE_REQUIRES_SERVICE', 'Linux dashboard update requires systemd or PM2 service ownership.');
|
|
313
|
+
}
|
|
314
|
+
throw updateError('DEVICE_UPDATE_UNSUPPORTED_PLATFORM', 'Dashboard self-update is supported only on installed Windows and Linux devices.');
|
|
315
|
+
}
|
|
316
|
+
function tarballFromPackOutput(stdout, stageDir) {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(stdout);
|
|
319
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
320
|
+
const filename = rows.map(item => String(item?.filename || '').trim()).find(Boolean);
|
|
321
|
+
if (filename)
|
|
322
|
+
return path.isAbsolute(filename) ? filename : path.join(stageDir, filename);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Some npm versions print a plain tarball filename.
|
|
326
|
+
}
|
|
327
|
+
const plain = stdout
|
|
328
|
+
.split(/\r?\n/)
|
|
329
|
+
.map(value => value.trim())
|
|
330
|
+
.filter(Boolean)
|
|
331
|
+
.at(-1);
|
|
332
|
+
if (plain?.endsWith('.tgz')) {
|
|
333
|
+
return path.isAbsolute(plain) ? plain : path.join(stageDir, plain);
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
async function directoryByteSize(root) {
|
|
338
|
+
let total = 0;
|
|
339
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
const entryPath = path.join(root, entry.name);
|
|
342
|
+
if (entry.isDirectory()) {
|
|
343
|
+
total += await directoryByteSize(entryPath);
|
|
344
|
+
}
|
|
345
|
+
else if (entry.isFile()) {
|
|
346
|
+
total += (await fs.stat(entryPath)).size;
|
|
347
|
+
}
|
|
348
|
+
else if (entry.isSymbolicLink()) {
|
|
349
|
+
total += (await fs.lstat(entryPath)).size;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return total;
|
|
353
|
+
}
|
|
354
|
+
async function assertDiskBudgetAt(location, requiredBytes, purpose) {
|
|
355
|
+
// fs.promises.statfs was added in Node 18.15. The package still supports
|
|
356
|
+
// earlier Node 18 releases, so this proactive check must degrade safely;
|
|
357
|
+
// staging/copy/install operations still fail closed on ENOSPC.
|
|
358
|
+
if (typeof fs.statfs !== 'function')
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
const volume = await fs.statfs(location);
|
|
362
|
+
const availableBytes = Number(volume.bavail) * Number(volume.bsize);
|
|
363
|
+
if (!Number.isFinite(availableBytes) || availableBytes < requiredBytes) {
|
|
364
|
+
throw updateError('DEVICE_UPDATE_INSUFFICIENT_DISK', 'Insufficient free space for MCP Device update ' + purpose + '.');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error?.code === 'DEVICE_UPDATE_INSUFFICIENT_DISK')
|
|
369
|
+
throw error;
|
|
370
|
+
throw updateError('DEVICE_UPDATE_DISK_CHECK_FAILED', 'Unable to verify free disk space for MCP Device update ' + purpose + ': ' +
|
|
371
|
+
String(error?.message || error).slice(0, 160));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function verifyPackageVersion(packageRoot, expected) {
|
|
375
|
+
let actual = '';
|
|
376
|
+
try {
|
|
377
|
+
const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
378
|
+
actual = String(manifest?.version || '');
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
actual = '';
|
|
382
|
+
}
|
|
383
|
+
if (actual !== expected) {
|
|
384
|
+
throw updateError('DEVICE_UPDATE_VERIFY_FAILED', 'Package verification failed: expected ' + expected + ', got ' + (actual || 'unknown') + '.');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function helperAssetPath() {
|
|
388
|
+
return fileURLToPath(new URL('./update-helper.cjs', import.meta.url));
|
|
389
|
+
}
|
|
390
|
+
export async function prepareDevicePackageUpdate(options) {
|
|
391
|
+
const targetVersion = normalizeUpdateVersion(options.targetVersion);
|
|
392
|
+
const fromVersion = normalizeUpdateVersion(options.fromVersion || VERSION);
|
|
393
|
+
if (targetVersion === fromVersion) {
|
|
394
|
+
throw updateError('DEVICE_UPDATE_ALREADY_CURRENT', 'MCP Device is already on ' + targetVersion + '.');
|
|
395
|
+
}
|
|
396
|
+
const requestId = String(options.requestId || '').trim();
|
|
397
|
+
if (!requestId) {
|
|
398
|
+
throw updateError('DEVICE_UPDATE_INVALID_REQUEST', 'Device update request_id is required.');
|
|
399
|
+
}
|
|
400
|
+
const platform = options.platform || process.platform;
|
|
401
|
+
const execFile = options.execFile || defaultExecFile;
|
|
402
|
+
const paths = options.paths || deviceStatePaths();
|
|
403
|
+
const files = updateFiles(paths);
|
|
404
|
+
const nodePath = path.resolve(options.nodePath || process.execPath);
|
|
405
|
+
const packageRoot = path.resolve(options.packageRoot || await findCurrentPackageRoot(options.entrypoint || process.argv[1]));
|
|
406
|
+
await verifyPackageVersion(packageRoot, fromVersion);
|
|
407
|
+
const existing = await readDeviceUpdateState(paths);
|
|
408
|
+
if (existing) {
|
|
409
|
+
const priorTargetIsCurrent = existing.targetVersion === fromVersion;
|
|
410
|
+
const interruptedBeforeHelper = existing.state === 'prepared';
|
|
411
|
+
const recoverableTerminal = ['failed', 'installed_waiting_reconnect'].includes(existing.state);
|
|
412
|
+
const stale = isStaleUpdateState(existing);
|
|
413
|
+
if (priorTargetIsCurrent || interruptedBeforeHelper || recoverableTerminal || stale) {
|
|
414
|
+
// packageRoot was verified above as the currently running package,
|
|
415
|
+
// so prior external update artifacts are no longer authoritative.
|
|
416
|
+
await retireDeviceUpdateState(existing, paths);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
throw updateError('DEVICE_UPDATE_IN_PROGRESS', 'A device update is already in progress.');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const npmCliPath = path.resolve(options.npmCliPath || await resolveNpmCliPath({ platform, execFile, nodePath }));
|
|
423
|
+
const prefix = options.globalPrefix
|
|
424
|
+
? path.resolve(options.globalPrefix)
|
|
425
|
+
: path.resolve(String((await runNpm(nodePath, npmCliPath, ['config', 'get', 'prefix'], execFile)).stdout || '').trim());
|
|
426
|
+
if (!prefix || prefix === path.parse(prefix).root) {
|
|
427
|
+
throw updateError('DEVICE_UPDATE_PREFIX_INVALID', 'npm global prefix could not be resolved safely.');
|
|
428
|
+
}
|
|
429
|
+
// npm's global node_modules layout is platform/install dependent:
|
|
430
|
+
// Windows commonly uses <prefix>/node_modules, while Unix commonly uses
|
|
431
|
+
// <prefix>/lib/node_modules. Ask npm for the authoritative root instead of
|
|
432
|
+
// deriving it from prefix.
|
|
433
|
+
const globalRoot = options.globalRoot
|
|
434
|
+
? path.resolve(options.globalRoot)
|
|
435
|
+
: path.resolve(String((await runNpm(nodePath, npmCliPath, ['root', '-g'], execFile)).stdout || '').trim());
|
|
436
|
+
if (!globalRoot || globalRoot === path.parse(globalRoot).root) {
|
|
437
|
+
throw updateError('DEVICE_UPDATE_ROOT_INVALID', 'npm global package root could not be resolved safely.');
|
|
438
|
+
}
|
|
439
|
+
const expectedPackageRoot = path.join(globalRoot, '@hcu-lab.me', 'mcp-device');
|
|
440
|
+
if (!await equalPath(packageRoot, expectedPackageRoot, platform)) {
|
|
441
|
+
throw updateError('DEVICE_UPDATE_PACKAGE_ROOT_MISMATCH', 'Installed package root does not match npm global package root: ' + packageRoot);
|
|
442
|
+
}
|
|
443
|
+
const manager = options.managerOverride || await detectManager({
|
|
444
|
+
deviceId: options.deviceId,
|
|
445
|
+
argv: options.argv || process.argv,
|
|
446
|
+
platform,
|
|
447
|
+
execFile,
|
|
448
|
+
paths
|
|
449
|
+
});
|
|
450
|
+
await fs.mkdir(files.root, { recursive: true, mode: 0o700 });
|
|
451
|
+
for (const disposable of [files.stage, files.verify, files.cache, files.rollbackSnapshot]) {
|
|
452
|
+
await fs.rm(disposable, { recursive: true, force: true });
|
|
453
|
+
}
|
|
454
|
+
await fs.rm(files.handoff, { force: true });
|
|
455
|
+
await fs.rm(files.helper, { force: true });
|
|
456
|
+
await fs.rm(files.helperRunner, { force: true });
|
|
457
|
+
await fs.mkdir(files.stage, { recursive: true, mode: 0o700 });
|
|
458
|
+
await fs.mkdir(files.cache, { recursive: true, mode: 0o700 });
|
|
459
|
+
let targetTarball = '';
|
|
460
|
+
let stagedInstallBytes = 0;
|
|
461
|
+
try {
|
|
462
|
+
const packed = await runNpm(nodePath, npmCliPath, [
|
|
463
|
+
'pack',
|
|
464
|
+
MCP_DEVICE_PACKAGE + '@' + targetVersion,
|
|
465
|
+
'--pack-destination', files.stage,
|
|
466
|
+
'--json',
|
|
467
|
+
'--cache', files.cache
|
|
468
|
+
], execFile);
|
|
469
|
+
targetTarball = tarballFromPackOutput(String(packed.stdout || ''), files.stage) || '';
|
|
470
|
+
if (!targetTarball || !await exists(targetTarball)) {
|
|
471
|
+
throw new Error('npm did not materialize the requested tarball.');
|
|
472
|
+
}
|
|
473
|
+
await runNpm(nodePath, npmCliPath, [
|
|
474
|
+
'install',
|
|
475
|
+
'--prefix', files.verify,
|
|
476
|
+
targetTarball,
|
|
477
|
+
'--cache', files.cache,
|
|
478
|
+
'--no-audit',
|
|
479
|
+
'--no-fund'
|
|
480
|
+
], execFile);
|
|
481
|
+
await verifyPackageVersion(path.join(files.verify, 'node_modules', '@hcu-lab.me', 'mcp-device'), targetVersion);
|
|
482
|
+
stagedInstallBytes = await directoryByteSize(files.verify);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
throw updateError('DEVICE_UPDATE_TARGET_UNAVAILABLE', 'Target pre-stage failed before shutdown: ' + String(error?.message || error).slice(0, 180));
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await fs.rm(files.verify, { recursive: true, force: true }).catch(() => { });
|
|
489
|
+
}
|
|
490
|
+
// Budget both volumes before copying the rollback snapshot. The staging
|
|
491
|
+
// area may live on a different volume from npm's global prefix.
|
|
492
|
+
const rollbackBytes = await directoryByteSize(packageRoot);
|
|
493
|
+
const rollbackBudget = Math.ceil(rollbackBytes * 1.1) + (16 * 1024 * 1024);
|
|
494
|
+
const installBudget = Math.ceil(stagedInstallBytes * 1.2) + (16 * 1024 * 1024);
|
|
495
|
+
await assertDiskBudgetAt(files.root, rollbackBudget, 'rollback staging');
|
|
496
|
+
await assertDiskBudgetAt(path.dirname(packageRoot), installBudget, 'offline installation');
|
|
497
|
+
// Preserve a complete offline rollback before the live global package is
|
|
498
|
+
// touched. If this cannot be materialized (including ENOSPC), update aborts
|
|
499
|
+
// while the old runtime is still healthy.
|
|
500
|
+
await fs.cp(packageRoot, files.rollbackSnapshot, {
|
|
501
|
+
recursive: true,
|
|
502
|
+
force: true,
|
|
503
|
+
preserveTimestamps: true
|
|
504
|
+
});
|
|
505
|
+
await verifyPackageVersion(files.rollbackSnapshot, fromVersion);
|
|
506
|
+
const rollbackPath = path.join(path.dirname(packageRoot), '.mcp-device-rollback-' + safeToken(requestId));
|
|
507
|
+
if (await exists(rollbackPath)) {
|
|
508
|
+
throw updateError('DEVICE_UPDATE_ROLLBACK_PRESENT', 'A rollback package from an earlier update still exists.');
|
|
509
|
+
}
|
|
510
|
+
const state = {
|
|
511
|
+
schema: UPDATE_STATE_SCHEMA,
|
|
512
|
+
requestId,
|
|
513
|
+
fromVersion,
|
|
514
|
+
targetVersion,
|
|
515
|
+
state: 'prepared',
|
|
516
|
+
startedAt: Date.now(),
|
|
517
|
+
updatedAt: Date.now(),
|
|
518
|
+
completedAt: null,
|
|
519
|
+
errorCode: null,
|
|
520
|
+
message: null,
|
|
521
|
+
deviceId: String(options.deviceId),
|
|
522
|
+
parentPid: process.pid,
|
|
523
|
+
childPid: Number.isInteger(options.childPid) ? Number(options.childPid) : null,
|
|
524
|
+
packageRoot,
|
|
525
|
+
globalPrefix: prefix,
|
|
526
|
+
nodePath,
|
|
527
|
+
npmCliPath,
|
|
528
|
+
targetTarball: path.resolve(targetTarball),
|
|
529
|
+
cacheDir: files.cache,
|
|
530
|
+
rollbackSnapshot: files.rollbackSnapshot,
|
|
531
|
+
rollbackPath,
|
|
532
|
+
helperPath: files.helper,
|
|
533
|
+
handoffPath: files.handoff,
|
|
534
|
+
runtimeOwnerControlPath: runtimeOwnerControlFileFor(paths),
|
|
535
|
+
...manager
|
|
536
|
+
};
|
|
537
|
+
if (platform === 'win32') {
|
|
538
|
+
state.windowsUpdateTaskName = ('MCP-Device-Update-' + safeToken(requestId)).slice(0, 220);
|
|
539
|
+
}
|
|
540
|
+
else if (state.managerKind === 'systemd') {
|
|
541
|
+
state.systemdUpdateUnit = ('mcp-device-update-' + safeToken(requestId)).slice(0, 180);
|
|
542
|
+
}
|
|
543
|
+
const helperSource = path.resolve(options.helperSourcePath || helperAssetPath());
|
|
544
|
+
if (!await exists(helperSource)) {
|
|
545
|
+
throw updateError('DEVICE_UPDATE_HELPER_MISSING', 'Standalone update helper asset is missing.');
|
|
546
|
+
}
|
|
547
|
+
await fs.copyFile(helperSource, files.helper);
|
|
548
|
+
try {
|
|
549
|
+
await fs.chmod(files.helper, 0o600);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// Windows ACLs inherit from the profile.
|
|
553
|
+
}
|
|
554
|
+
let helperRunnerPath;
|
|
555
|
+
if (platform === 'win32') {
|
|
556
|
+
const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'";
|
|
557
|
+
const runner = [
|
|
558
|
+
"$ErrorActionPreference = 'Stop'",
|
|
559
|
+
'& ' + psQuote(nodePath) + ' ' + psQuote(files.helper) + ' ' + psQuote(files.state),
|
|
560
|
+
'exit $LASTEXITCODE',
|
|
561
|
+
''
|
|
562
|
+
].join('\r\n');
|
|
563
|
+
await fs.writeFile(files.helperRunner, runner, { encoding: 'utf8', mode: 0o600 });
|
|
564
|
+
helperRunnerPath = files.helperRunner;
|
|
565
|
+
}
|
|
566
|
+
await writeDeviceUpdateState(state, paths);
|
|
567
|
+
return { state, statePath: files.state, helperRunnerPath };
|
|
568
|
+
}
|
|
569
|
+
export async function launchDeviceUpdateHelper(prepared, options = {}) {
|
|
570
|
+
const execFile = options.execFile || defaultExecFile;
|
|
571
|
+
const spawn = options.spawn || spawnCallback;
|
|
572
|
+
const platform = options.platform || process.platform;
|
|
573
|
+
const paths = options.paths || deviceStatePaths();
|
|
574
|
+
const state = prepared.state;
|
|
575
|
+
try {
|
|
576
|
+
if (platform === 'win32') {
|
|
577
|
+
if (!prepared.helperRunnerPath || !state.powershellPath || !state.windowsUpdateTaskName) {
|
|
578
|
+
throw new Error('Windows update helper task metadata is incomplete.');
|
|
579
|
+
}
|
|
580
|
+
const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'";
|
|
581
|
+
const actionArgs = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
|
|
582
|
+
prepared.helperRunnerPath + '"';
|
|
583
|
+
const command = [
|
|
584
|
+
'$action = New-ScheduledTaskAction -Execute ' + psQuote(state.powershellPath) + ' -Argument ' + psQuote(actionArgs),
|
|
585
|
+
'$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name',
|
|
586
|
+
'$principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Limited',
|
|
587
|
+
// Demand-start only: no future trigger can replay the helper.
|
|
588
|
+
// Battery/idle settings are explicit so laptops cannot silently
|
|
589
|
+
// queue or terminate the helper after the device has shut down.
|
|
590
|
+
'$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew',
|
|
591
|
+
'Register-ScheduledTask -TaskName ' + psQuote(state.windowsUpdateTaskName) + ' -Action $action -Principal $principal -Settings $settings -Force | Out-Null',
|
|
592
|
+
'Start-ScheduledTask -TaskName ' + psQuote(state.windowsUpdateTaskName)
|
|
593
|
+
].join('; ');
|
|
594
|
+
await execFile(state.powershellPath, ['-NoProfile', '-NonInteractive', '-Command', command]);
|
|
595
|
+
}
|
|
596
|
+
else if (state.managerKind === 'systemd') {
|
|
597
|
+
if (!state.systemdRunPath || !state.systemdUpdateUnit) {
|
|
598
|
+
throw new Error('systemd update helper metadata is incomplete.');
|
|
599
|
+
}
|
|
600
|
+
await execFile(state.systemdRunPath, [
|
|
601
|
+
'--user',
|
|
602
|
+
'--collect',
|
|
603
|
+
'--unit=' + state.systemdUpdateUnit,
|
|
604
|
+
state.nodePath,
|
|
605
|
+
state.helperPath,
|
|
606
|
+
prepared.statePath
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
else if (state.managerKind === 'pm2') {
|
|
610
|
+
const child = spawn(state.nodePath, [state.helperPath, prepared.statePath], {
|
|
611
|
+
detached: true,
|
|
612
|
+
stdio: 'ignore'
|
|
613
|
+
});
|
|
614
|
+
child.unref();
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
throw new Error('Unsupported update manager: ' + state.managerKind);
|
|
618
|
+
}
|
|
619
|
+
await patchDeviceUpdateState({ state: 'helper_started' }, paths);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
await patchDeviceUpdateState({
|
|
623
|
+
state: 'failed',
|
|
624
|
+
completedAt: Date.now(),
|
|
625
|
+
errorCode: 'DEVICE_UPDATE_HELPER_LAUNCH_FAILED',
|
|
626
|
+
message: String(error?.message || error).slice(0, 240)
|
|
627
|
+
}, paths).catch(() => { });
|
|
628
|
+
throw updateError('DEVICE_UPDATE_HELPER_LAUNCH_FAILED', 'Unable to launch update helper safely: ' + String(error?.message || error).slice(0, 180));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
export async function signalDeviceUpdateHandoff(handoff = {}, paths = deviceStatePaths()) {
|
|
632
|
+
const files = updateFiles(paths);
|
|
633
|
+
const childPid = Number.isInteger(handoff.childPid) ? Number(handoff.childPid) : null;
|
|
634
|
+
// Persist the last observed child PID before exposing the handoff marker.
|
|
635
|
+
// The helper must wait on the child that actually existed immediately
|
|
636
|
+
// before shutdown, not only the PID captured during pre-stage.
|
|
637
|
+
await patchDeviceUpdateState({ state: 'handoff_ready', childPid }, paths);
|
|
638
|
+
await fs.writeFile(files.handoff, JSON.stringify({
|
|
639
|
+
timestamp: Date.now(),
|
|
640
|
+
childPid
|
|
641
|
+
}) + '\n', {
|
|
642
|
+
encoding: 'utf8',
|
|
643
|
+
mode: 0o600
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
export async function markPreparedUpdateFailed(code, message, paths = deviceStatePaths()) {
|
|
647
|
+
const current = await readDeviceUpdateState(paths);
|
|
648
|
+
if (!current)
|
|
649
|
+
return;
|
|
650
|
+
await writeDeviceUpdateState({
|
|
651
|
+
...current,
|
|
652
|
+
state: 'failed',
|
|
653
|
+
completedAt: Date.now(),
|
|
654
|
+
errorCode: String(code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
|
|
655
|
+
message: String(message || 'Device update failed.').slice(0, 240)
|
|
656
|
+
}, paths);
|
|
657
|
+
}
|
|
658
|
+
export async function reconcileDeviceUpdateAfterStart(currentVersion = VERSION, paths = deviceStatePaths()) {
|
|
659
|
+
const state = await readDeviceUpdateState(paths);
|
|
660
|
+
if (!state)
|
|
661
|
+
return { action: 'none' };
|
|
662
|
+
// A target-version reconnect is the authoritative success proof,
|
|
663
|
+
// even if the helper died after installing but before persisting its final phase.
|
|
664
|
+
if (state.targetVersion === currentVersion && state.state !== 'failed') {
|
|
665
|
+
return { action: 'success', state };
|
|
666
|
+
}
|
|
667
|
+
if (state.state === 'failed') {
|
|
668
|
+
return { action: 'report-failed', state };
|
|
669
|
+
}
|
|
670
|
+
if (state.state === 'prepared' ||
|
|
671
|
+
state.state === 'installed_waiting_reconnect' ||
|
|
672
|
+
isStaleUpdateState(state)) {
|
|
673
|
+
const failed = await markInterruptedUpdateState(state, state.state === 'installed_waiting_reconnect'
|
|
674
|
+
? 'DEVICE_UPDATE_VERSION_MISMATCH'
|
|
675
|
+
: 'DEVICE_UPDATE_INTERRUPTED', state.state === 'installed_waiting_reconnect'
|
|
676
|
+
? 'Update restart did not load the expected target package version.'
|
|
677
|
+
: 'MCP Device update did not complete and its persisted state was recovered.', paths);
|
|
678
|
+
return { action: 'report-failed', state: failed };
|
|
679
|
+
}
|
|
680
|
+
return { action: 'none' };
|
|
681
|
+
}
|
|
682
|
+
export async function retireDeviceUpdateState(state, paths = deviceStatePaths()) {
|
|
683
|
+
await fs.rm(state.rollbackPath, { recursive: true, force: true }).catch(() => { });
|
|
684
|
+
await fs.rm(paths.update, { recursive: true, force: true });
|
|
685
|
+
}
|