mcp-pacemaker 2.0.2 → 2.1.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/CONTRIBUTING.md +46 -13
  3. package/README.md +109 -524
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-access.ps1 +359 -0
  9. package/autostart/windows/legacy-task.ps1 +301 -0
  10. package/autostart/windows/register-task.ps1 +11 -2
  11. package/bin/account-task-adapter.mjs +47 -0
  12. package/bin/account-upgrade-controller.mjs +198 -0
  13. package/bin/account-upgrade-worker.mjs +141 -0
  14. package/bin/account-worker-session.mjs +215 -0
  15. package/bin/cli-dispatch.mjs +154 -0
  16. package/bin/cli-main.mjs +1063 -0
  17. package/bin/cli-selection.mjs +43 -0
  18. package/bin/cli.mjs +7 -876
  19. package/bin/dependency-lock.json +642 -0
  20. package/bin/legacy-1.3.json +32 -0
  21. package/bin/legacy-access.mjs +73 -0
  22. package/bin/legacy-installation.mjs +273 -0
  23. package/bin/legacy-task.mjs +76 -0
  24. package/bin/legacy-upgrade.mjs +527 -0
  25. package/bin/managed-runtime.mjs +170 -0
  26. package/bin/managed-state.mjs +257 -0
  27. package/bin/managed-upgrade.mjs +581 -0
  28. package/bin/mcp-bridge.mjs +150 -4
  29. package/bin/pooling-batches.mjs +13 -0
  30. package/bin/pooling-writer.mjs +4 -0
  31. package/bin/prewarm.mjs +59 -25
  32. package/bin/runtime-context.mjs +100 -0
  33. package/bin/service-control.mjs +32 -4
  34. package/bin/stable-launcher.mjs +54 -0
  35. package/bin/task-only-controller.mjs +226 -0
  36. package/bin/task-scope.mjs +65 -0
  37. package/bin/task-transaction-protocol.mjs +173 -0
  38. package/bin/top.mjs +22 -10
  39. package/bin/update-check.mjs +13 -2
  40. package/bin/upgrade-autostart.mjs +47 -0
  41. package/bin/upgrade-capability.json +9 -0
  42. package/bin/upgrade-dependencies.mjs +183 -0
  43. package/bin/upgrade-package.mjs +205 -0
  44. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  45. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  46. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  47. package/bin/windows-legacy/src/LegacyNative.cs +832 -0
  48. package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
  49. package/bin/windows-legacy-process.mjs +248 -0
  50. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  51. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  52. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  53. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  54. package/bin/windows-process-lifetime.mjs +218 -0
  55. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  56. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  57. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  58. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  59. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  60. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  61. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  62. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  63. package/bin/windows-task-channel.mjs +335 -0
  64. package/docs/account-upgrades.md +218 -0
  65. package/docs/compatibility.md +39 -0
  66. package/docs/configuration.md +146 -0
  67. package/docs/dashboards.md +125 -0
  68. package/docs/execution-context.md +119 -0
  69. package/docs/http-api.md +47 -0
  70. package/docs/install.md +52 -0
  71. package/docs/operations.md +99 -0
  72. package/docs/upgrades.md +324 -0
  73. package/package.json +19 -5
  74. package/supervisor/supervise.mjs +226 -40
  75. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  76. package/tools/windows-legacy/README.md +251 -0
  77. package/tools/windows-legacy/build.ps1 +122 -0
  78. package/tools/windows-legacy/inventory.mjs +92 -0
  79. package/tools/windows-process-lifetime/README.md +119 -0
  80. package/tools/windows-process-lifetime/build.ps1 +122 -0
  81. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  82. package/tools/windows-task-channel/README.md +329 -0
  83. package/tools/windows-task-channel/build.ps1 +107 -0
  84. package/tools/windows-task-channel/inventory.mjs +117 -0
  85. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  86. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  87. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  88. package/ui/dist/index.html +2 -2
  89. package/ui/dist/third-party-manifest.json +37 -25
  90. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,170 @@
1
+ import { fork } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync } from 'node:fs';
3
+ import { get } from 'node:http';
4
+ import { connect } from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { loadInstance, readManagedJson } from './managed-state.mjs';
7
+ import { controlManagedService, resumeService, servicePaths, stopManagedService, validateServiceRecord } from './service-control.mjs';
8
+ import { requireUserRuntimeContext } from './runtime-context.mjs';
9
+
10
+ const launches = new Map();
11
+
12
+ export function listenerOpen(port) {
13
+ return new Promise((resolveProbe, reject) => {
14
+ const socket = connect({ host: '127.0.0.1', port });
15
+ socket.once('connect', () => { socket.destroy(); resolveProbe(true); });
16
+ socket.once('error', error => {
17
+ if (error.code === 'ECONNREFUSED') resolveProbe(false);
18
+ else reject(error);
19
+ });
20
+ socket.setTimeout(2000, () => socket.destroy(new Error('Listener identity probe timed out.')));
21
+ });
22
+ }
23
+
24
+ export function backendStatus(port) {
25
+ return new Promise((resolveStatus, reject) => {
26
+ const request = get(`http://127.0.0.1:${port}/api/status`, response => {
27
+ let body = '';
28
+ response.on('data', bytes => {
29
+ body += bytes;
30
+ if (body.length > 4 * 1024 * 1024) request.destroy(new Error('Oversized backend status.'));
31
+ });
32
+ response.on('end', () => {
33
+ try {
34
+ if (response.statusCode !== 200) throw new Error('Backend not ready.');
35
+ resolveStatus(JSON.parse(body));
36
+ } catch (error) { reject(error); }
37
+ });
38
+ });
39
+ request.on('error', reject);
40
+ request.setTimeout(2000, () => request.destroy(new Error('Backend readiness timed out.')));
41
+ });
42
+ }
43
+
44
+ export function serviceSelection(instance) {
45
+ return { root: instance.active.root, config: instance.config, port: instance.port };
46
+ }
47
+
48
+ function assertInstanceService(instance) {
49
+ const record = validateServiceRecord(
50
+ readManagedJson(servicePaths(instance.config, instance.port).record), serviceSelection(instance));
51
+ if (record.managedInstance !== instance.directory) {
52
+ throw new Error('Supervisor belongs to a different managed instance; no service action was sent.');
53
+ }
54
+ }
55
+
56
+ export async function startInstance(directory, { pending = false, token } = {}) {
57
+ await requireUserRuntimeContext();
58
+ let instance = loadInstance(directory);
59
+ const journalPath = join(directory, 'journal.json');
60
+ if (existsSync(journalPath)) {
61
+ const journal = readManagedJson(journalPath);
62
+ const legacyStart = journal.phase === 'legacy-launching' &&
63
+ journal.legacy?.protocol === 1 &&
64
+ journal.legacy.acknowledged === 'restart-with-uncertain-http-and-partial-tree-v1';
65
+ const authorized = (['launching', 'rollback-launching'].includes(journal.phase) || legacyStart) &&
66
+ journal.launchToken === token &&
67
+ pending;
68
+ if (!['committed', 'rolled-back', 'aborted'].includes(journal.phase) &&
69
+ !authorized) throw new Error('Unfinished upgrade journal blocks start; use explicit recovery.');
70
+ }
71
+ if (instance.port !== 0 &&
72
+ await listenerOpen(instance.port)) throw new Error('Selected port is busy; no listener was adopted.');
73
+ if (instance.port) resumeService(instance.config, instance.port);
74
+ const log = openSync(join(directory, 'launcher.log'), 'a', 0o600);
75
+ const child = fork(join(directory, 'stable-launcher.mjs'), [], {
76
+ execPath: instance.node, execArgv: [], detached: true, windowsHide: true,
77
+ stdio: ['ignore', log, log, 'ipc'],
78
+ env: {
79
+ ...process.env, MCP_PACEMAKER_UPGRADE_PENDING: pending ? '1' : '0',
80
+ MCP_PACEMAKER_UPGRADE_TOKEN: token ?? '',
81
+ },
82
+ });
83
+ closeSync(log);
84
+ const exited = new Promise(resolveExit => child.once('exit', resolveExit));
85
+ const running = launches.get(directory) ?? [];
86
+ running.push({ child, exited });
87
+ launches.set(directory, running);
88
+ let timer;
89
+ let startupStopped = false;
90
+ let startupStopId;
91
+ child.on('message', message => {
92
+ if (message?.type === 'supervisor-stopped' &&
93
+ message.stopped === true) {
94
+ startupStopped = true;
95
+ startupStopId = message.id;
96
+ }
97
+ });
98
+ try {
99
+ const ready = await new Promise((resolveReady, reject) => {
100
+ child.once('error', reject);
101
+ child.once('exit', () => reject(new Error(`Managed startup failed. Inspect ${join(directory, 'launcher.log')}.`)));
102
+ child.on('message', message => {
103
+ if (message?.type === 'supervisor-ready') resolveReady(message);
104
+ });
105
+ timer = setTimeout(() => reject(new Error('Managed startup readiness timed out.')), 15000);
106
+ });
107
+ instance = loadInstance(directory, { verifyFiles: false });
108
+ const valid = ready.version === instance.active.version &&
109
+ ready.port === instance.port &&
110
+ ready.held === pending;
111
+ if (!valid) throw new Error('Started backend identity does not match the active selection.');
112
+ const status = await backendStatus(instance.port);
113
+ if (status.service !== 'mcp-pacemaker' ||
114
+ status.version !== ready.version ||
115
+ status.instanceId !== ready.instanceId) throw new Error('Actual listener did not verify the started backend.');
116
+ return { instance, ready };
117
+ } catch (error) {
118
+ if (child.connected) {
119
+ child.send('stop');
120
+ await new Promise((resolveExit, reject) => {
121
+ if (child.exitCode !== null) { resolveExit(); return; }
122
+ const deadline = setTimeout(() => reject(new Error('Failed startup shutdown is unverified; retain journal and package.')), 12000);
123
+ child.once('exit', () => { clearTimeout(deadline); resolveExit(); });
124
+ });
125
+ }
126
+ error.startupStopped = startupStopped;
127
+ if (startupStopped) error.startupProof = {
128
+ supervisorId: startupStopId, root: instance.active.root, config: instance.config,
129
+ port: instance.port, launchToken: token,
130
+ };
131
+ if (!startupStopped) error.cleanupUnverified = true;
132
+ throw error;
133
+ } finally {
134
+ clearTimeout(timer);
135
+ if (child.connected) child.disconnect();
136
+ child.unref();
137
+ }
138
+ }
139
+
140
+ export async function stopInstance(instance, quiescedInstanceId) {
141
+ assertInstanceService(instance);
142
+ const selection = serviceSelection(instance);
143
+ const stopped = await stopManagedService({ ...selection, quiescedInstanceId });
144
+ if (await listenerOpen(instance.port)) throw new Error('Stopped supervisor still has a live listener; recovery required.');
145
+ await waitForInstanceExit(instance.directory);
146
+ return stopped;
147
+ }
148
+
149
+ export async function waitForInstanceExit(directory) {
150
+ const running = launches.get(directory) ?? [];
151
+ let timeout;
152
+ try {
153
+ await Promise.race([
154
+ Promise.all(running.map(entry => entry.exited)),
155
+ new Promise((_, reject) => {
156
+ timeout = setTimeout(() => reject(new Error('Owned stable launcher did not exit; retain its files.')), 12000);
157
+ }),
158
+ ]);
159
+ launches.delete(directory);
160
+ } finally { clearTimeout(timeout); }
161
+ }
162
+
163
+ export async function inspectInstance(instance, action = 'inspect', expected = {}) {
164
+ assertInstanceService(instance);
165
+ const response = await controlManagedService(serviceSelection(instance), action, expected);
166
+ const status = await backendStatus(instance.port);
167
+ if (status.version !== response.version ||
168
+ status.instanceId !== response.instanceId) throw new Error('Listener and managed supervisor identity disagree.');
169
+ return response;
170
+ }
@@ -0,0 +1,257 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import {
3
+ closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync,
5
+ } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+
8
+ export const legacyMigrationPhases = [
9
+ 'legacy-prepared', 'legacy-holding', 'legacy-held', 'legacy-stopping',
10
+ 'legacy-stopped', 'legacy-launching', 'legacy-wiring', 'legacy-admitting',
11
+ 'legacy-rollback-stopping', 'legacy-task-restoring', 'legacy-cli-restoring', 'legacy-rollback-launching',
12
+ 'legacy-rolled-back', 'legacy-aborted',
13
+ ];
14
+
15
+ export function readManagedJson(path) {
16
+ const stat = lstatSync(path);
17
+ if (!stat.isFile() ||
18
+ stat.isSymbolicLink() ||
19
+ stat.nlink !== 1 ||
20
+ stat.size > 4 * 1024 * 1024) throw new Error('Unsafe managed state file.');
21
+ return JSON.parse(readFileSync(path, 'utf8'));
22
+ }
23
+
24
+ export function durableJson(path, value) {
25
+ const temporary = `${path}.${randomUUID()}.tmp`;
26
+ const fd = openSync(temporary, 'wx', 0o600);
27
+ try {
28
+ writeFileSync(fd, JSON.stringify(value, null, 2) + '\n');
29
+ fsyncSync(fd);
30
+ } finally {
31
+ closeSync(fd);
32
+ }
33
+ renameSync(temporary, path);
34
+ if (process.platform !== 'win32') {
35
+ const directory = openSync(dirname(path), 'r');
36
+ try { fsyncSync(directory); } finally { closeSync(directory); }
37
+ }
38
+ }
39
+
40
+ export function managedPath(config, port) {
41
+ return join(dirname(resolve(config)), 'managed', String(port));
42
+ }
43
+
44
+ export function directoryIdentity(path) {
45
+ const stat = lstatSync(path, { bigint: true });
46
+ if (!stat.isDirectory() ||
47
+ stat.isSymbolicLink() ||
48
+ realpathSync(path) !== resolve(path)) throw new Error('Managed directory is linked or replaced.');
49
+ return `${stat.dev}:${stat.ino}`;
50
+ }
51
+
52
+ export function readInstanceState(directory) {
53
+ directory = realpathSync.native(directory);
54
+ const instance = readManagedJson(join(directory, 'instance.json'));
55
+ const valid = instance.protocol === 1 &&
56
+ instance.directory === directory &&
57
+ instance.directoryIdentity === directoryIdentity(directory) &&
58
+ typeof instance.id === 'string' &&
59
+ /^[a-f0-9-]{36}$/.test(instance.id) &&
60
+ typeof instance.config === 'string' &&
61
+ resolve(instance.config) === instance.config &&
62
+ typeof instance.cwd === 'string' &&
63
+ resolve(instance.cwd) === instance.cwd &&
64
+ typeof instance.node === 'string' &&
65
+ realpathSync(instance.node) === instance.node &&
66
+ Number.isInteger(instance.port) &&
67
+ instance.port >= 0 &&
68
+ instance.port <= 65535;
69
+ if (!valid) throw new Error('Managed instance identity mismatch.');
70
+ const active = readManagedJson(join(directory, 'active.json'));
71
+ validateSelectionReference(active);
72
+ return { ...instance, active };
73
+ }
74
+
75
+ export function loadInstance(directory, { verifyFiles = true } = {}) {
76
+ const instance = readInstanceState(directory);
77
+ verifySelection(instance.active, { verifyFiles });
78
+ return instance;
79
+ }
80
+
81
+ export function validateSelectionReference(selection) {
82
+ const valid = selection &&
83
+ typeof selection.root === 'string' &&
84
+ selection.root.length <= 4096 &&
85
+ resolve(selection.root) === selection.root &&
86
+ typeof selection.directoryIdentity === 'string' &&
87
+ selection.directoryIdentity.length < 128 &&
88
+ typeof selection.version === 'string' &&
89
+ selection.version.length < 128 &&
90
+ Array.isArray(selection.files) &&
91
+ selection.files.length > 0 &&
92
+ selection.files.length <= 20000;
93
+ if (!valid) throw new Error('Invalid managed package selection reference.');
94
+ }
95
+
96
+ export function readRecoveryContext(directory) {
97
+ const instance = readInstanceState(directory);
98
+ const journal = readManagedJson(join(instance.directory, 'journal.json'));
99
+ const binding = { config: instance.config, cwd: instance.cwd, node: instance.node, port: instance.port };
100
+ if (journal.sxs &&
101
+ journal.binding?.port === 0) binding.port = 0;
102
+ const phases = [
103
+ 'prepared', 'quiescing', 'stopping', 'stopped', 'selecting', 'selected', 'launching',
104
+ 'rollback-selecting', 'rollback-selected', 'rollback-launching',
105
+ 'admitting', 'admitting-rollback', 'committed', 'rolled-back', 'aborted', 'starting',
106
+ ];
107
+ if (journal.legacy?.protocol === 1) {
108
+ if (journal.previous !== null ||
109
+ journal.legacy.acknowledged !== 'restart-with-uncertain-http-and-partial-tree-v1') {
110
+ throw new Error('Invalid legacy migration journal contract.');
111
+ }
112
+ phases.push(...legacyMigrationPhases);
113
+ validateSelectionReference(journal.recovery);
114
+ }
115
+ if (journal.protocol !== 1 ||
116
+ journal.instanceId !== instance.id ||
117
+ JSON.stringify(journal.binding) !== JSON.stringify(binding) ||
118
+ !phases.includes(journal.phase)) throw new Error('Recovery journal identity or phase is invalid.');
119
+ validateSelectionReference(journal.target);
120
+ if (journal.previous) validateSelectionReference(journal.previous);
121
+ const allowed = [journal.previous, journal.target].filter(Boolean);
122
+ if (!allowed.some(selection => JSON.stringify(selection) === JSON.stringify(instance.active))) {
123
+ throw new Error('Active selection is not an authorized journal selection.');
124
+ }
125
+ if (journal.rollbackStopProof &&
126
+ !allowed.some(selection => JSON.stringify(selection) === JSON.stringify(journal.rollbackStopProof.selection))) {
127
+ throw new Error('Rollback proof names a package outside the activation journal.');
128
+ }
129
+ return { instance, journal };
130
+ }
131
+
132
+ export function verifySelection(selection, { verifyFiles = true } = {}) {
133
+ validateSelectionReference(selection);
134
+ if (!selection ||
135
+ typeof selection.root !== 'string' ||
136
+ selection.directoryIdentity !== directoryIdentity(selection.root)) throw new Error('Active package directory identity changed.');
137
+ const pkg = readManagedJson(join(selection.root, 'package.json'));
138
+ if (pkg.name !== 'mcp-pacemaker' ||
139
+ pkg.version !== selection.version) throw new Error('Active package identity changed.');
140
+ const required = ['package.json', 'bin/mcp-bridge.mjs', 'supervisor/supervise.mjs', 'bin/upgrade-capability.json'];
141
+ if (!Array.isArray(selection.files) ||
142
+ required.some(path => !selection.files.some(file => file.path === path))) {
143
+ throw new Error('Immutable package inventory is missing required files.');
144
+ }
145
+ if (!verifyFiles) return;
146
+ const seen = new Set();
147
+ const directories = new Set();
148
+ for (const file of selection.files) {
149
+ if (typeof file.path !== 'string' ||
150
+ !/^[a-zA-Z0-9_./@+-]+$/.test(file.path) ||
151
+ file.path.split('/').some(part => !part || part === '.' || part === '..') ||
152
+ !/^[a-f0-9]{64}$/.test(file.sha256) ||
153
+ seen.has(file.path.toLowerCase())) throw new Error('Invalid immutable package inventory.');
154
+ seen.add(file.path.toLowerCase());
155
+ const path = resolve(selection.root, file.path);
156
+ if (!path.startsWith(selection.root + '/') &&
157
+ !path.startsWith(selection.root + '\\')) throw new Error('Unsafe package inventory path.');
158
+ const parent = dirname(path);
159
+ if (!directories.has(parent)) {
160
+ if (realpathSync.native(parent) !== parent) throw new Error('Immutable package directory was linked or replaced.');
161
+ directories.add(parent);
162
+ }
163
+ const stat = lstatSync(path);
164
+ if (!stat.isFile() ||
165
+ stat.isSymbolicLink() ||
166
+ createHash('sha256').update(readFileSync(path)).digest('hex') !== file.sha256) throw new Error('Immutable package bytes changed.');
167
+ }
168
+ }
169
+
170
+ export function createInstance({ directory, root, config, port, cwd = dirname(config), node = process.execPath,
171
+ autostart = { kind: 'none' }, selection, controllerOperation }) {
172
+ directory = resolve(directory);
173
+ root = realpathSync(root);
174
+ if (existsSync(directory)) throw new Error('Managed instance already exists; refusing to overwrite it.');
175
+ mkdirSync(dirname(directory), { recursive: true, mode: 0o700 });
176
+ mkdirSync(directory, { mode: 0o700 });
177
+ directory = realpathSync.native(directory);
178
+ const version = readManagedJson(join(root, 'package.json')).version;
179
+ const instance = {
180
+ protocol: 1, id: randomUUID(), directory, directoryIdentity: directoryIdentity(directory),
181
+ config: resolve(config), cwd: resolve(cwd), port, autostart, node: realpathSync(node),
182
+ ...(controllerOperation ? { controllerOperation: structuredClone(controllerOperation) } : {}),
183
+ };
184
+ for (const file of ['stable-launcher.mjs', 'managed-state.mjs']) {
185
+ copyFileSync(new URL(`./${file}`, import.meta.url), join(directory, file));
186
+ }
187
+ if (process.platform === 'win32') {
188
+ const launcher = join(directory, 'stable-launcher.mjs');
189
+ if (/["\r\n]/.test(instance.node + launcher)) throw new Error('Unsupported Windows launcher path.');
190
+ writeFileSync(join(directory, 'launcher.vbs'),
191
+ `Set shell = CreateObject("WScript.Shell")\r\nshell.Run """${instance.node}"" ""${launcher}""", 0, False\r\n`,
192
+ { flag: 'wx', mode: 0o600 });
193
+ }
194
+ durableJson(join(directory, 'instance.json'), instance);
195
+ if (!selection) selection = snapshotPackage(root, join(directory, 'versions', `${version}-${randomUUID()}`));
196
+ verifySelection(selection);
197
+ durableJson(join(directory, 'active.json'), selection);
198
+ return loadInstance(directory, { verifyFiles: false });
199
+ }
200
+
201
+ export function snapshotPackage(source, destination, { dependencies = true } = {}) {
202
+ const pkg = readManagedJson(join(source, 'package.json'));
203
+ if (!Array.isArray(pkg.files)) throw new Error('A packaged file inventory is required for the stable installation.');
204
+ mkdirSync(destination, { recursive: true, mode: 0o700 });
205
+ const files = [];
206
+ const seen = new Set();
207
+ const copy = relative => {
208
+ relative = relative.replace(/\/$/, '');
209
+ if (!/^[a-zA-Z0-9_./@+-]+$/.test(relative) ||
210
+ relative.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Invalid package file inventory.');
211
+ if (seen.has(relative)) return;
212
+ seen.add(relative);
213
+ const path = join(source, relative);
214
+ if (!existsSync(path)) return;
215
+ const stat = lstatSync(path);
216
+ if (stat.isSymbolicLink()) throw new Error('Linked package inputs cannot form an immutable installation.');
217
+ if (stat.isDirectory()) {
218
+ for (const entry of readdirSync(path)) copy(`${relative}/${entry}`);
219
+ return;
220
+ }
221
+ if (!stat.isFile() ||
222
+ stat.nlink !== 1) throw new Error('Unsupported package input.');
223
+ const bytes = readFileSync(path);
224
+ const output = join(destination, relative);
225
+ mkdirSync(dirname(output), { recursive: true, mode: 0o700 });
226
+ writeFileSync(output, bytes, { flag: 'wx', mode: stat.mode & 0o777 });
227
+ files.push({ path: relative, sha256: createHash('sha256').update(bytes).digest('hex') });
228
+ };
229
+ for (const path of ['package.json', 'README.md', 'CHANGELOG.md', 'LICENSE', ...pkg.files]) copy(path);
230
+ if (!dependencies) return { root: realpathSync(destination), version: pkg.version, directoryIdentity: directoryIdentity(destination), files };
231
+ if (!existsSync(join(source, 'package-lock.json')) ||
232
+ !existsSync(join(source, 'node_modules'))) throw new Error('Verified CLI dependencies are required before creating a managed installation.');
233
+ copy('package-lock.json');
234
+ if (existsSync(join(source, 'node_modules', '.package-lock.json'))) copy('node_modules/.package-lock.json');
235
+ const lock = readManagedJson(join(source, 'package-lock.json'));
236
+ for (const path of Object.keys(lock.packages ?? {})) {
237
+ if (!path) continue;
238
+ copy(path);
239
+ }
240
+ return { root: realpathSync(destination), version: pkg.version, directoryIdentity: directoryIdentity(destination), files };
241
+ }
242
+
243
+ export function acquireInstanceLock(directory) {
244
+ const identity = directoryIdentity(directory);
245
+ const path = join(directory, 'upgrade.lock');
246
+ const token = randomUUID();
247
+ const fd = openSync(path, 'wx', 0o600);
248
+ try {
249
+ writeFileSync(fd, JSON.stringify({ token, pid: process.pid }) + '\n');
250
+ fsyncSync(fd);
251
+ } finally { closeSync(fd); }
252
+ return () => {
253
+ if (directoryIdentity(directory) !== identity) throw new Error('Locked instance directory was replaced; no lock was removed.');
254
+ if (readManagedJson(path).token !== token) throw new Error('Upgrade lock identity changed; retained for recovery.');
255
+ unlinkSync(path);
256
+ };
257
+ }