mcp-pacemaker 2.0.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.
- package/CHANGELOG.md +452 -0
- package/CONTRIBUTING.md +222 -0
- package/LICENSE +21 -0
- package/README.md +621 -0
- package/SECURITY.md +120 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2162 -0
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +577 -0
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +104 -0
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +242 -0
- package/bin/prewarm.mjs +233 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +42 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +178 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +238 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +597 -0
package/bin/prewarm.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
function portNumber(value) {
|
|
6
|
+
const port = Number(value);
|
|
7
|
+
if (!Number.isInteger(port) ||
|
|
8
|
+
port < 1 ||
|
|
9
|
+
port > 65535) {
|
|
10
|
+
throw new Error('Port must be an integer between 1 and 65535');
|
|
11
|
+
}
|
|
12
|
+
return port;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function jsonRequest(port, path, options = {}) {
|
|
16
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
17
|
+
...options,
|
|
18
|
+
signal: AbortSignal.timeout(10_000),
|
|
19
|
+
});
|
|
20
|
+
if (response.status === 401) {
|
|
21
|
+
throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
|
|
22
|
+
}
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await response.json();
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
|
|
31
|
+
}
|
|
32
|
+
return { status: response.status, body: result };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validatePendingReceipt(receipt, name, port) {
|
|
36
|
+
const invalid = 'Bridge returned an invalid HTTP 202 receipt. Reread settings before retrying.';
|
|
37
|
+
const nonempty = (value) => typeof value === 'string' && value.length > 0;
|
|
38
|
+
const fieldsValid = receipt?.ok === true && receipt.pending === true &&
|
|
39
|
+
receipt.name === name && nonempty(receipt.batchId) &&
|
|
40
|
+
nonempty(receipt.undoId) && nonempty(receipt.revision);
|
|
41
|
+
if (!fieldsValid) throw new Error(invalid);
|
|
42
|
+
|
|
43
|
+
const snapshot = receipt.snapshot;
|
|
44
|
+
const snapshotValid = snapshot?.service === 'mcp-pacemaker' &&
|
|
45
|
+
snapshot.port === port && nonempty(snapshot.instanceId) &&
|
|
46
|
+
Number.isSafeInteger(snapshot.snapshotVersion) && snapshot.snapshotVersion > 0 &&
|
|
47
|
+
Array.isArray(snapshot.servers) && snapshot.servers.some((server) => server?.name === name) &&
|
|
48
|
+
snapshot.prewarm?.revision === receipt.revision &&
|
|
49
|
+
Number.isSafeInteger(snapshot.prewarm.maxWarm) && snapshot.prewarm.maxWarm > 0 &&
|
|
50
|
+
Number.isFinite(snapshot.prewarm.batchDelayMs) && snapshot.prewarm.batchDelayMs > 0 &&
|
|
51
|
+
Array.isArray(snapshot.prewarm.batches);
|
|
52
|
+
if (!snapshotValid) throw new Error(invalid);
|
|
53
|
+
|
|
54
|
+
const batch = snapshot.prewarm.batches.find((candidate) => candidate?.id === receipt.batchId);
|
|
55
|
+
const timingValid = batch?.status === 'pending' ? Number.isFinite(batch.applyAt)
|
|
56
|
+
: batch?.status === 'applying' && batch.applyAt === null;
|
|
57
|
+
const changesValid = Array.isArray(batch?.changes) && batch.changes.length > 0 &&
|
|
58
|
+
batch.changes.some((change) => change?.name === name) &&
|
|
59
|
+
batch.changes.every((change) => nonempty(change?.name) &&
|
|
60
|
+
(change.mode === 'pool'
|
|
61
|
+
? change.minWarm === undefined ||
|
|
62
|
+
Number.isSafeInteger(change.minWarm) && change.minWarm >= 1 && change.minWarm <= snapshot.prewarm.maxWarm
|
|
63
|
+
: change.mode === 'isolated' && change.minWarm === undefined));
|
|
64
|
+
if (!timingValid ||
|
|
65
|
+
!changesValid ||
|
|
66
|
+
batch.revision !== receipt.revision) {
|
|
67
|
+
throw new Error(invalid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatPrewarm(snapshot, now = Date.now()) {
|
|
72
|
+
const seconds = (ms) => ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
|
|
73
|
+
const rows = [
|
|
74
|
+
['SERVER', 'MODE', 'WARM/TARGET', 'STARTS', 'COLD P50', 'P95', 'SUGGESTED', 'SHARED CHILD'],
|
|
75
|
+
...snapshot.servers.filter((server) => server.type === 'stdio').map((server) => [
|
|
76
|
+
server.name,
|
|
77
|
+
server.sharing,
|
|
78
|
+
server.sharing === 'shared' ? '-' : `${server.warm}/${server.minWarm}`,
|
|
79
|
+
String(server.spawn?.total ?? '-'),
|
|
80
|
+
seconds(server.spawn?.p50Ms),
|
|
81
|
+
seconds(server.spawn?.p95Ms),
|
|
82
|
+
server.prewarming?.eligible ? String(server.prewarming.suggestedMinWarm) : '-',
|
|
83
|
+
server.sharing === 'shared' ? server.shared
|
|
84
|
+
? `${server.shared.state}: ${server.shared.members} sessions, ${server.shared.unresolved} pending, ${server.shared.queued} queued`
|
|
85
|
+
: 'not active' : '-',
|
|
86
|
+
]),
|
|
87
|
+
];
|
|
88
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
89
|
+
const history = snapshot.prewarm?.batches ?? [];
|
|
90
|
+
const latestFinished = history.find((batch) => ['applied', 'failed', 'cancelled'].includes(batch.status));
|
|
91
|
+
const batches = history
|
|
92
|
+
.filter((batch) => ['pending', 'applying'].includes(batch.status) ||
|
|
93
|
+
batch === latestFinished && batch.status === 'failed')
|
|
94
|
+
.map((batch) => {
|
|
95
|
+
const changes = batch.changes.map((change) => {
|
|
96
|
+
const target = change.minWarm === undefined
|
|
97
|
+
? 'pooling with configured target' : `${change.minWarm} warm slot(s)`;
|
|
98
|
+
return `${change.name}: ${change.mode === 'pool' ? target : 'pre-warming off'}`;
|
|
99
|
+
}).join(', ');
|
|
100
|
+
if (batch.status === 'failed') return `Batch failed (${changes}): ${batch.error || 'Reread settings before retrying.'}`;
|
|
101
|
+
if (batch.status === 'applying') return `Applying batch: ${changes}.`;
|
|
102
|
+
const secondsLeft = Math.max(0, Math.ceil((batch.applyAt - now) / 1000));
|
|
103
|
+
return `Pending batch (reload in ${secondsLeft}s): ${changes}. Active settings above have not changed.`;
|
|
104
|
+
});
|
|
105
|
+
return [
|
|
106
|
+
`Pre-warming on :${snapshot.port} (process counters since ${snapshot.startedAt || 'bridge start'})`,
|
|
107
|
+
...rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd()),
|
|
108
|
+
'Pre-warming is opt-in. Each warm slot holds one additional resident process.',
|
|
109
|
+
'Shared mode is separately opt-in: compatible stateless tools sessions reuse one initialized child.',
|
|
110
|
+
...(typeof snapshot.prewarm?.saveWarning === 'string'
|
|
111
|
+
? [`File save notice: ${snapshot.prewarm.saveWarning}`] : []),
|
|
112
|
+
...batches,
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function shellArgument(text) {
|
|
117
|
+
if (process.platform === 'win32') return `"${text}"`;
|
|
118
|
+
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatUndoCommand(port, name, undoId, config) {
|
|
122
|
+
const executable = process.execPath;
|
|
123
|
+
const entry = fileURLToPath(new URL('./cli.mjs', import.meta.url));
|
|
124
|
+
const values = [executable, entry, name, undoId, ...(config ? [config] : [])];
|
|
125
|
+
const needsExplicitShell = process.platform === 'win32' &&
|
|
126
|
+
values.some((value) => /[%!$`"^\r\n]|\\$/.test(value));
|
|
127
|
+
if (needsExplicitShell) {
|
|
128
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
129
|
+
const configArg = config ? ` --config ${quote(config)}` : '';
|
|
130
|
+
const command = `& ${quote(executable)} ${quote(entry)} prewarm --port ${port}${configArg} --server ${quote(name)} --undo ${quote(undoId)}`;
|
|
131
|
+
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`;
|
|
132
|
+
}
|
|
133
|
+
const configArg = config ? ` --config ${shellArgument(config)}` : '';
|
|
134
|
+
const node = process.platform === 'win32' ? 'node.exe' : shellArgument(executable);
|
|
135
|
+
return `${node} ${shellArgument(entry)} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${undoId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runPrewarm(options, { configPath, ports, write = console.log } = {}) {
|
|
139
|
+
const selectedPorts = options.port ? [portNumber(options.port)] : ports.map(portNumber);
|
|
140
|
+
const actionCount = [options.enable, options.disable, options.undo].filter(Boolean).length;
|
|
141
|
+
if (actionCount > 1) throw new Error('Choose only one of --enable, --disable or --undo');
|
|
142
|
+
if (actionCount &&
|
|
143
|
+
selectedPorts.length !== 1) {
|
|
144
|
+
throw new Error('Choose --port when changing a server on a multi-bridge setup');
|
|
145
|
+
}
|
|
146
|
+
if (options.count &&
|
|
147
|
+
!options.enable) {
|
|
148
|
+
throw new Error('--count requires --enable');
|
|
149
|
+
}
|
|
150
|
+
if (options.undo &&
|
|
151
|
+
!options.server) {
|
|
152
|
+
throw new Error('--undo requires --server');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const snapshots = [];
|
|
156
|
+
for (const port of selectedPorts) {
|
|
157
|
+
const { body: snapshot } = await jsonRequest(port, '/api/status');
|
|
158
|
+
if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
|
|
159
|
+
snapshots.push(snapshot);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!actionCount) {
|
|
163
|
+
if (options.json) {
|
|
164
|
+
write(JSON.stringify(snapshots.length === 1 ? snapshots[0] : snapshots));
|
|
165
|
+
} else {
|
|
166
|
+
for (const snapshot of snapshots) write(formatPrewarm(snapshot));
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const snapshot = snapshots[0];
|
|
172
|
+
if (!snapshot.prewarm) throw new Error('The running bridge does not support pooling changes; update and restart it first');
|
|
173
|
+
const name = options.enable || options.disable || options.server;
|
|
174
|
+
const server = snapshot.servers.find((candidate) => candidate.name === name);
|
|
175
|
+
if (!server) throw new Error(`Unknown server: ${name}`);
|
|
176
|
+
if (server.type !== 'stdio') throw new Error('HTTP proxies have no local child to pre-warm');
|
|
177
|
+
const body = { revision: snapshot.prewarm.revision };
|
|
178
|
+
if (options.undo) {
|
|
179
|
+
body.undoId = options.undo;
|
|
180
|
+
} else {
|
|
181
|
+
body.mode = options.enable ? 'pool' : 'isolated';
|
|
182
|
+
if (options.enable) {
|
|
183
|
+
if (!server.prewarming?.eligible) throw new Error(server.prewarming?.reason || 'Server is not eligible for pre-warming');
|
|
184
|
+
body.minWarm = options.count === undefined ? server.prewarming.suggestedMinWarm : Number(options.count);
|
|
185
|
+
if (!Number.isSafeInteger(body.minWarm) ||
|
|
186
|
+
body.minWarm < 1 ||
|
|
187
|
+
body.minWarm > snapshot.prewarm.maxWarm) {
|
|
188
|
+
throw new Error(`--count must be an integer between 1 and ${snapshot.prewarm.maxWarm}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const nonce = (await readFile(join(dirname(options.config || configPath), 'admin.nonce'), 'utf8')).trim();
|
|
193
|
+
if (!options.json &&
|
|
194
|
+
typeof snapshot.prewarm.saveWarning === 'string') {
|
|
195
|
+
write(`File save notice: ${snapshot.prewarm.saveWarning}`);
|
|
196
|
+
}
|
|
197
|
+
const response = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
|
|
200
|
+
body: JSON.stringify(body),
|
|
201
|
+
});
|
|
202
|
+
const result = response.body;
|
|
203
|
+
if (response.status === 202) {
|
|
204
|
+
validatePendingReceipt(result, name, snapshot.port);
|
|
205
|
+
} else if (response.status !== 200 ||
|
|
206
|
+
result?.pending === true) {
|
|
207
|
+
throw new Error('Bridge returned an unexpected pooling response status. Reread settings before retrying.');
|
|
208
|
+
}
|
|
209
|
+
if (result?.ok !== true) throw new Error(result?.error || 'Bridge did not confirm the pooling change');
|
|
210
|
+
if (options.json) {
|
|
211
|
+
write(JSON.stringify(result));
|
|
212
|
+
} else if (result.pending) {
|
|
213
|
+
const batch = result.snapshot?.prewarm?.batches?.find((candidate) => candidate.id === result.batchId);
|
|
214
|
+
const scope = batch?.changes.map((change) => change.name).join(', ') || name;
|
|
215
|
+
const secondsLeft = batch?.applyAt == null ? null : Math.max(0, Math.ceil((batch.applyAt - Date.now()) / 1000));
|
|
216
|
+
const timing = secondsLeft === null ? '' : ` in ${secondsLeft}s`;
|
|
217
|
+
let action;
|
|
218
|
+
if (options.undo) action = 'previous batch configuration staged';
|
|
219
|
+
else if (body.mode === 'pool') action = `${body.minWarm} warm slot(s) staged`;
|
|
220
|
+
else action = 'pre-warming disable staged';
|
|
221
|
+
write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
|
|
222
|
+
if (result.undoId) {
|
|
223
|
+
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
|
|
224
|
+
}
|
|
225
|
+
} else if (result.cancelled) {
|
|
226
|
+
write(`${name}: pending batch cancelled. Active settings were not changed.`);
|
|
227
|
+
} else {
|
|
228
|
+
write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
|
|
229
|
+
if (result.undoId) {
|
|
230
|
+
write(`Undo: ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class ServerMetrics {
|
|
2
|
+
constructor(sampleLimit = 50) {
|
|
3
|
+
this.sampleLimit = sampleLimit;
|
|
4
|
+
this.servers = new Map();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
get(name) {
|
|
8
|
+
let metrics = this.servers.get(name);
|
|
9
|
+
if (!metrics) {
|
|
10
|
+
metrics = {
|
|
11
|
+
attempts: 0,
|
|
12
|
+
total: 0,
|
|
13
|
+
failures: 0,
|
|
14
|
+
warmAdoptions: 0,
|
|
15
|
+
sessionStarts: 0,
|
|
16
|
+
sessionResumes: 0,
|
|
17
|
+
latencies: [],
|
|
18
|
+
};
|
|
19
|
+
this.servers.set(name, metrics);
|
|
20
|
+
}
|
|
21
|
+
return metrics;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
attemptingSpawn(name) {
|
|
25
|
+
this.get(name).attempts++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
spawned(name) {
|
|
29
|
+
this.get(name).total++;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
failedSpawn(name) {
|
|
33
|
+
this.get(name).failures++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
adoptedWarmChild(name) {
|
|
37
|
+
this.get(name).warmAdoptions++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
openedSession(name, resumed = false) {
|
|
41
|
+
const metrics = this.get(name);
|
|
42
|
+
if (resumed) {
|
|
43
|
+
metrics.sessionResumes++;
|
|
44
|
+
} else {
|
|
45
|
+
metrics.sessionStarts++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
initialized(name, elapsedMs) {
|
|
50
|
+
const samples = this.get(name).latencies;
|
|
51
|
+
samples.push(elapsedMs);
|
|
52
|
+
if (samples.length > this.sampleLimit) samples.shift();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
snapshot(name) {
|
|
56
|
+
const { latencies, ...counts } = this.get(name);
|
|
57
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
58
|
+
const percentile = (fraction) => sorted.length
|
|
59
|
+
? sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))]
|
|
60
|
+
: null;
|
|
61
|
+
return {
|
|
62
|
+
...counts,
|
|
63
|
+
samples: sorted.length,
|
|
64
|
+
p50Ms: percentile(0.5),
|
|
65
|
+
p95Ms: percentile(0.95),
|
|
66
|
+
maxMs: sorted.length ? sorted[sorted.length - 1] : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function servicePaths(config, port) {
|
|
7
|
+
if (!Number.isInteger(port) ||
|
|
8
|
+
port < 1 ||
|
|
9
|
+
port > 65535) {
|
|
10
|
+
throw new Error('Service port must be an integer from 1 to 65535.');
|
|
11
|
+
}
|
|
12
|
+
const directory = dirname(resolve(config));
|
|
13
|
+
const key = createHash('sha256').update(`${directory}\n${port}`).digest('hex').slice(0, 32);
|
|
14
|
+
const socket = process.platform === 'win32'
|
|
15
|
+
? `\\\\.\\pipe\\mcp-pacemaker-${key}`
|
|
16
|
+
: join(directory, `service-${port}.sock`);
|
|
17
|
+
if (process.platform !== 'win32' &&
|
|
18
|
+
Buffer.byteLength(socket) > 103) {
|
|
19
|
+
throw new Error('Service socket path exceeds the POSIX limit of 103 UTF-8 bytes. Use a shorter config directory; no alternate endpoint was selected.');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
record: join(directory, `service-${port}.json`),
|
|
23
|
+
hold: join(directory, `service-${port}.stopped`),
|
|
24
|
+
socket,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertDurableRoot(root) {
|
|
29
|
+
if (/(^|[/\\])_npx([/\\]|$)/i.test(root)) {
|
|
30
|
+
throw new Error('Cache-backed npx roots cannot host an unattended service. Install in a durable root, then register autostart there. Use --no-autostart --no-start for wiring only.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function backendDescription(cliVersion, backend, action) {
|
|
35
|
+
const version = typeof backend?.version === 'string' ? backend.version : 'unknown';
|
|
36
|
+
const instance = typeof backend?.instanceId === 'string' ? backend.instanceId : 'unknown';
|
|
37
|
+
return `installed CLI ${cliVersion}; running backend ${version}; instance ${instance} — ${action}` +
|
|
38
|
+
(version !== cliVersion ? ' (version mismatch; adoption does not upgrade the backend)' : '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resumeService(config, port) {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(servicePaths(config, port).hold);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT') throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateServiceRecord(record, { root, config, port }) {
|
|
50
|
+
const matches = record?.protocol === 1 &&
|
|
51
|
+
record.root === realpathSync(root) &&
|
|
52
|
+
record.config === resolve(config) &&
|
|
53
|
+
record.port === port &&
|
|
54
|
+
record.socket === servicePaths(config, port).socket &&
|
|
55
|
+
typeof record.id === 'string' &&
|
|
56
|
+
/^[a-f0-9-]{36}$/.test(record.id) &&
|
|
57
|
+
typeof record.token === 'string' &&
|
|
58
|
+
/^[a-f0-9]{64}$/.test(record.token);
|
|
59
|
+
if (!matches) {
|
|
60
|
+
throw new Error('Managed service identity does not match this installation root, config, port and endpoint. Use the matching installation CLI; no process was stopped.');
|
|
61
|
+
}
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requestService(socket, request) {
|
|
66
|
+
return new Promise((resolveRequest, reject) => {
|
|
67
|
+
const client = connect(socket);
|
|
68
|
+
let data = '';
|
|
69
|
+
const finish = (error, response) => {
|
|
70
|
+
client.destroy();
|
|
71
|
+
if (error) reject(error);
|
|
72
|
+
else resolveRequest(response);
|
|
73
|
+
};
|
|
74
|
+
client.setTimeout(12000, () => finish(new Error('Supervisor response timed out; shutdown is not verified.')));
|
|
75
|
+
client.on('error', () => finish(new Error('Managed supervisor is unavailable; shutdown is not verified. Older or unmanaged services require verified OS-service shutdown.')));
|
|
76
|
+
client.on('connect', () => client.write(JSON.stringify(request) + '\n'));
|
|
77
|
+
client.on('data', (chunk) => {
|
|
78
|
+
data += chunk;
|
|
79
|
+
if (data.length > 16384) {
|
|
80
|
+
finish(new Error('Invalid supervisor response.'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!data.includes('\n')) return;
|
|
84
|
+
let response;
|
|
85
|
+
try { response = JSON.parse(data.slice(0, data.indexOf('\n'))); }
|
|
86
|
+
catch { finish(new Error('Invalid supervisor response.')); return; }
|
|
87
|
+
finish(null, response);
|
|
88
|
+
});
|
|
89
|
+
client.on('end', () => finish(new Error('Supervisor closed before acknowledging shutdown.')));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function stopManagedService({ root, config, port, request = requestService }) {
|
|
94
|
+
const paths = servicePaths(config, port);
|
|
95
|
+
let record;
|
|
96
|
+
try {
|
|
97
|
+
record = JSON.parse(readFileSync(paths.record, 'utf8'));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code !== 'ENOENT') throw error;
|
|
100
|
+
throw new Error(`No managed supervisor record for :${port}. Older or direct bridges require verified OS-service shutdown and disabled autostart; no process was stopped.`);
|
|
101
|
+
}
|
|
102
|
+
validateServiceRecord(record, { root, config, port });
|
|
103
|
+
let receipt;
|
|
104
|
+
try {
|
|
105
|
+
receipt = JSON.parse(readFileSync(paths.hold, 'utf8'));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT') throw error;
|
|
108
|
+
}
|
|
109
|
+
const completed = receipt?.stopped === true &&
|
|
110
|
+
receipt.id === record.id &&
|
|
111
|
+
receipt.root === record.root &&
|
|
112
|
+
receipt.config === record.config &&
|
|
113
|
+
receipt.port === record.port;
|
|
114
|
+
if (completed) {
|
|
115
|
+
return { root: record.root, config: record.config, port, supervisorId: record.id, alreadyStopped: true };
|
|
116
|
+
}
|
|
117
|
+
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token });
|
|
118
|
+
if (response?.id !== record.id ||
|
|
119
|
+
response.stopped !== true ||
|
|
120
|
+
response.autostartHeld !== true) {
|
|
121
|
+
throw new Error('Supervisor did not verify shutdown and its autostart hold. Do not replace the installation root.');
|
|
122
|
+
}
|
|
123
|
+
return { root: record.root, config: record.config, port, supervisorId: record.id };
|
|
124
|
+
}
|