@mechasm/cli 0.0.0-stage → 0.1.0

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/src/service.js ADDED
@@ -0,0 +1,460 @@
1
+ import { createServer, createConnection, connect } from 'node:net';
2
+ import { chmod, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { timingSafeEqual, randomBytes } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { cliConfigDirectory, createCliClient, CliError, apiUrl as validateApiUrl } from './api-client.js';
9
+ import { approvedLocalPorts } from './local-approvals.js';
10
+ import { startFrpClient } from './frp.js';
11
+ import { acquireUpdateGuard, verifyUpdateGuard } from './update-guard.js';
12
+
13
+ const socketPath = () => process.platform === 'win32'
14
+ ? `\\\\.\\pipe\\mechasm-${process.env.USERNAME || 'user'}`
15
+ : join(cliConfigDirectory(), 'service.sock');
16
+ const lockPath = () => join(cliConfigDirectory(), 'service.lock');
17
+ const keyPath = () => join(cliConfigDirectory(), 'service.key');
18
+
19
+ async function ipcKey() {
20
+ try {
21
+ const key = await readFile(keyPath());
22
+ if (key.length !== 32) throw new CliError('Local IPC key is invalid.');
23
+ return key;
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT') throw error;
27
+ const key = randomBytes(32);
28
+ let file;
29
+ try {
30
+ file = await open(keyPath(), 'wx', 0o600);
31
+ await file.writeFile(key);
32
+ await file.sync();
33
+ return key;
34
+ } catch (writeError) {
35
+ if (writeError.code !== 'EEXIST') throw writeError;
36
+ for (let attempt = 0; attempt < 20; attempt++) {
37
+ await new Promise((resolve) => setTimeout(resolve, 25));
38
+ try {
39
+ const saved = await readFile(keyPath());
40
+ if (saved.length === 32) return saved;
41
+ } catch (readError) { if (readError.code !== 'ENOENT') throw readError; }
42
+ }
43
+ throw new CliError('Could not read local IPC key.');
44
+ } finally { await file?.close(); }
45
+ }
46
+ }
47
+
48
+ export async function serviceStart(rawApiUrl) {
49
+ const apiUrl = validateApiUrl(rawApiUrl).origin;
50
+ const deadline = Date.now() + 30_000;
51
+ let startupGuard;
52
+ while (!startupGuard) {
53
+ try {
54
+ startupGuard = await acquireUpdateGuard();
55
+ } catch (error) {
56
+ if (!error.message.includes('startup or update is already in progress')) throw error;
57
+ const state = await serviceStatus();
58
+ if (state.running && state.apiUrl) {
59
+ if (state.apiUrl !== apiUrl) throw new CliError(`Local service already uses ${state.apiUrl}.`);
60
+ return state;
61
+ }
62
+ if (Date.now() >= deadline) throw error;
63
+ await delay(100);
64
+ }
65
+ }
66
+ try {
67
+ const current = await serviceStatus();
68
+ if (current.running) {
69
+ if (current.apiUrl !== apiUrl) throw new CliError(`Local service already uses ${current.apiUrl}.`);
70
+ return current;
71
+ }
72
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
73
+ if (process.platform !== 'win32') await chmod(cliConfigDirectory(), 0o700);
74
+ await ipcKey();
75
+ const script = fileURLToPath(new URL('../bin/mechasm.js', import.meta.url));
76
+ const child = spawn(process.execPath, [script, 'service', 'foreground', '--api-url', apiUrl, '--startup-guard-token', startupGuard.token], {
77
+ detached: true,
78
+ stdio: 'ignore',
79
+ env: { ...process.env, MECHASM_STARTUP_GUARD_TOKEN: startupGuard.token },
80
+ });
81
+ child.unref();
82
+ const startDeadline = Date.now() + 30_000;
83
+ while (Date.now() < startDeadline) {
84
+ const state = await serviceCall('status').catch(() => null);
85
+ if (state?.lastError) throw new CliError(state.lastError);
86
+ if (state?.running && state.lastHeartbeatAt) {
87
+ if (state.apiUrl !== apiUrl) throw new CliError(`Local service already uses ${state.apiUrl}.`);
88
+ return state;
89
+ }
90
+ await new Promise((resolve) => setTimeout(resolve, 100));
91
+ }
92
+ throw new CliError('Local service did not start. Run `mechasm service foreground` to see its error.');
93
+ } finally {
94
+ await startupGuard.release();
95
+ }
96
+ }
97
+
98
+ export async function serviceStatus() {
99
+ try {
100
+ return await serviceCall('status');
101
+ } catch {
102
+ try {
103
+ const lock = JSON.parse(await readFile(lockPath(), 'utf8'));
104
+ return await lockIsAlive(lock)
105
+ ? { running: true, lastError: 'Local service is running but not responding.' }
106
+ : { running: false };
107
+ } catch {
108
+ return { running: false };
109
+ }
110
+ }
111
+ }
112
+
113
+ export async function serviceStop() {
114
+ return await serviceCall('stop').catch(() => ({ stopped: true }));
115
+ }
116
+
117
+ function serviceCall(action) {
118
+ return new Promise(async (resolve, reject) => {
119
+ let key;
120
+ try { key = await ipcKey(); } catch (error) { reject(error); return; }
121
+ const connection = createConnection(socketPath());
122
+ let response = '';
123
+ const timeout = setTimeout(() => connection.destroy(new Error('Local service request timed out.')), 3000);
124
+ connection.once('connect', () => connection.write(`${JSON.stringify({ action, key: key.toString('base64url') })}\n`));
125
+ connection.on('data', (chunk) => {
126
+ response += chunk;
127
+ if (Buffer.byteLength(response) > 4096) connection.destroy(new Error('Local service response too large.'));
128
+ });
129
+ connection.once('error', (error) => { clearTimeout(timeout); reject(error); });
130
+ connection.once('end', () => {
131
+ clearTimeout(timeout);
132
+ try { resolve(JSON.parse(response)); } catch { reject(new Error('Invalid local service response.')); }
133
+ });
134
+ });
135
+ }
136
+
137
+ function pidIsAlive(pid) {
138
+ if (!Number.isInteger(pid) || pid < 1) return false;
139
+ try { process.kill(pid, 0); return true; }
140
+ catch (error) { return error.code === 'EPERM'; }
141
+ }
142
+
143
+ async function processStartTime(pid) {
144
+ if (process.platform !== 'linux') return null;
145
+ try {
146
+ const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
147
+ return stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/)[19] || null;
148
+ } catch { return null; }
149
+ }
150
+
151
+ async function lockIsAlive(lock) {
152
+ if (!pidIsAlive(lock?.pid)) return false;
153
+ if (typeof lock.processStartTime !== 'string') return true;
154
+ const currentStartTime = await processStartTime(lock.pid);
155
+ return !currentStartTime || currentStartTime === lock.processStartTime;
156
+ }
157
+
158
+ async function acquireLock() {
159
+ for (let attempt = 0; attempt < 2; attempt++) {
160
+ try {
161
+ const file = await open(lockPath(), 'wx', 0o600);
162
+ await file.writeFile(JSON.stringify({ pid: process.pid, processStartTime: await processStartTime(process.pid) }));
163
+ await file.sync();
164
+ await file.close();
165
+ return;
166
+ } catch (error) {
167
+ if (error.code !== 'EEXIST') throw error;
168
+ let lock;
169
+ try { lock = JSON.parse(await readFile(lockPath(), 'utf8')); }
170
+ catch {
171
+ await new Promise((resolve) => setTimeout(resolve, 250));
172
+ try { lock = JSON.parse(await readFile(lockPath(), 'utf8')); } catch { /* stale or incomplete lock */ }
173
+ }
174
+ if (await lockIsAlive(lock)) throw new CliError('Local service is already running.');
175
+ if (!Number.isInteger(lock?.pid) || lock.pid < 1) {
176
+ const info = await stat(lockPath()).catch(() => null);
177
+ if (info && Date.now() - info.mtimeMs < 5000) throw new CliError('Local service is starting.');
178
+ }
179
+ await rm(lockPath(), { force: true });
180
+ }
181
+ }
182
+ throw new CliError('Could not acquire local service lock.');
183
+ }
184
+
185
+ export async function runServiceForeground(rawApiUrl, startupGuardToken) {
186
+ let startupGuard;
187
+ if (startupGuardToken) await verifyUpdateGuard(startupGuardToken);
188
+ else startupGuard = await acquireUpdateGuard();
189
+ let released = false;
190
+ const releaseStartupGuard = async () => {
191
+ if (startupGuard && !released) {
192
+ released = true;
193
+ await startupGuard.release();
194
+ }
195
+ };
196
+ try {
197
+ return await runServiceForegroundInner(rawApiUrl, releaseStartupGuard);
198
+ } finally {
199
+ await releaseStartupGuard();
200
+ }
201
+ }
202
+
203
+ async function runServiceForegroundInner(rawApiUrl, onReady) {
204
+ const apiUrl = validateApiUrl(rawApiUrl).origin;
205
+ const client = createCliClient(apiUrl);
206
+ const identity = (await client.whoami()).data;
207
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
208
+ if (process.platform !== 'win32') await chmod(cliConfigDirectory(), 0o700);
209
+ const key = await ipcKey();
210
+ await acquireLock();
211
+ const path = socketPath();
212
+ if (process.platform !== 'win32') await rm(path, { force: true });
213
+ const state = {
214
+ running: true,
215
+ apiUrl,
216
+ deviceId: identity.deviceId,
217
+ startedAt: new Date().toISOString(),
218
+ lastHeartbeatAt: null,
219
+ lastError: null,
220
+ };
221
+ const active = new Map();
222
+ let stopped = false;
223
+ let serverClosed = false;
224
+ const pollController = new AbortController();
225
+ let retryDelay = 1000;
226
+ let lastErrorSource = null;
227
+ const closeActive = async () => {
228
+ await Promise.allSettled([...active.values()].map((item) => item.process.stop()));
229
+ active.clear();
230
+ };
231
+ const server = createServer((connection) => {
232
+ let buffer = '';
233
+ const timer = setTimeout(() => connection.destroy(), 3000);
234
+ connection.on('data', async (chunk) => {
235
+ buffer += chunk;
236
+ if (Buffer.byteLength(buffer) > 4096) { connection.destroy(); return; }
237
+ const end = buffer.indexOf('\n');
238
+ if (end < 0) return;
239
+ clearTimeout(timer);
240
+ let request;
241
+ try { request = JSON.parse(buffer.slice(0, end)); }
242
+ catch { connection.end(`${JSON.stringify({ error: 'Invalid request' })}\n`); return; }
243
+ const supplied = Buffer.from(String(request.key || ''), 'base64url');
244
+ if (supplied.length !== key.length || !timingSafeEqual(supplied, key)) {
245
+ connection.end(`${JSON.stringify({ error: 'Unauthorized' })}\n`);
246
+ return;
247
+ }
248
+ if (request.action === 'stop') {
249
+ stopped = true;
250
+ state.running = false;
251
+ pollController.abort();
252
+ void closeActive();
253
+ if (!serverClosed) { serverClosed = true; server.close(); }
254
+ }
255
+ connection.end(`${JSON.stringify({ ...state, stopped: request.action === 'stop' })}\n`);
256
+ });
257
+ connection.once('close', () => clearTimeout(timer));
258
+ });
259
+ await new Promise((resolve, reject) => {
260
+ server.once('error', reject);
261
+ server.listen(path, resolve);
262
+ }).catch(async (error) => { await rm(lockPath(), { force: true }); throw error; });
263
+ if (process.platform !== 'win32') await chmod(path, 0o600);
264
+ await onReady();
265
+ const stop = () => {
266
+ stopped = true;
267
+ state.running = false;
268
+ pollController.abort();
269
+ if (!serverClosed) { serverClosed = true; server.close(); }
270
+ };
271
+ process.once('SIGINT', stop);
272
+ process.once('SIGTERM', stop);
273
+ try {
274
+ while (!stopped) {
275
+ let operation = 'heartbeat';
276
+ try {
277
+ await client.localHeartbeat(undefined, pollController.signal);
278
+ state.lastHeartbeatAt = new Date().toISOString();
279
+ if (lastErrorSource?.type === 'heartbeat') {
280
+ state.lastError = null;
281
+ lastErrorSource = null;
282
+ }
283
+ operation = 'lease';
284
+ for (const [leaseId, item] of active) {
285
+ if (stopped) break;
286
+ try {
287
+ await client.heartbeatLocalLease(leaseId, undefined, pollController.signal);
288
+ const remote = await client.getLocalLease(leaseId, undefined, pollController.signal);
289
+ if (remote?.status !== 'active' || item.process.child.exitCode !== null || item.process.child.signalCode !== null) {
290
+ await item.process.stop();
291
+ active.delete(leaseId);
292
+ }
293
+ } catch (error) {
294
+ if (error.name === 'AbortError' && stopped) break;
295
+ if (error.exitCode === 3 || error.apiCode === 'user_unavailable') throw error;
296
+ if (error.apiCode === 'lease_closed') {
297
+ if (lastErrorSource?.type === 'lease' && lastErrorSource.leaseId === leaseId) {
298
+ state.lastError = null;
299
+ lastErrorSource = null;
300
+ }
301
+ await item.process.stop();
302
+ active.delete(leaseId);
303
+ continue;
304
+ }
305
+ state.lastError = `Lease ${leaseId}: ${error.message}`;
306
+ lastErrorSource = { type: 'lease', leaseId };
307
+ await item.process.stop();
308
+ active.delete(leaseId);
309
+ }
310
+ }
311
+ operation = 'poll';
312
+ const response = await client.nextLocalLease(undefined, pollController.signal);
313
+ const lease = response.data;
314
+ if (response.status === 200 && lease?.leaseId && !active.has(lease.leaseId)) {
315
+ try {
316
+ await startLocalLease(client, apiUrl, state.deviceId, lease, active, state, pollController.signal);
317
+ } catch (error) {
318
+ if (error.name === 'AbortError' && stopped) break;
319
+ if (error.exitCode === 3 || error.apiCode === 'user_unavailable') throw error;
320
+ state.lastError = `Lease ${lease.leaseId}: ${error.message}`;
321
+ lastErrorSource = { type: 'lease', leaseId: lease.leaseId };
322
+ try {
323
+ await client.closeLocalLease(lease.leaseId);
324
+ } catch (closeError) {
325
+ if (closeError.exitCode === 3 || closeError.apiCode === 'user_unavailable') throw closeError;
326
+ }
327
+ }
328
+ retryDelay = 1000;
329
+ } else if (response.status === 204) retryDelay = 1000;
330
+ } catch (error) {
331
+ if (error.name === 'AbortError' && stopped) break;
332
+ state.lastError = error.message;
333
+ lastErrorSource = { type: operation };
334
+ await closeActive();
335
+ if (error.exitCode === 3 || error.exitCode === 4) {
336
+ stopped = true;
337
+ state.running = false;
338
+ if (!serverClosed) { serverClosed = true; server.close(); }
339
+ } else if (!stopped) {
340
+ try { await delay(retryDelay, undefined, { signal: pollController.signal }); }
341
+ catch { if (stopped) break; }
342
+ retryDelay = Math.min(30_000, retryDelay * 2);
343
+ }
344
+ }
345
+ }
346
+ } finally {
347
+ process.off('SIGINT', stop);
348
+ process.off('SIGTERM', stop);
349
+ state.running = false;
350
+ pollController.abort();
351
+ await closeActive();
352
+ if (!serverClosed) server.close();
353
+ if (process.platform !== 'win32') await rm(path, { force: true });
354
+ await rm(lockPath(), { force: true });
355
+ }
356
+ }
357
+
358
+ async function startLocalLease(client, origin, deviceId, lease, active, state, signal) {
359
+ const leaseId = lease.leaseId;
360
+ if (typeof lease.projectId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(lease.projectId) ||
361
+ typeof leaseId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(leaseId)) {
362
+ throw new CliError('Local run lease has invalid project or lease ID.');
363
+ }
364
+ if (lease.deviceId !== deviceId) throw new CliError('Local run lease targets another device.');
365
+ if (!Array.isArray(lease.ports) || !lease.ports.length || lease.ports.length > 16 ||
366
+ lease.ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(lease.ports).size !== lease.ports.length) {
367
+ throw new CliError('Local run lease has invalid ports.');
368
+ }
369
+ const approved = await approvedLocalPorts(origin, lease.projectId, deviceId);
370
+ if (!approved || lease.ports.some((port) => !approved.ports.includes(port))) {
371
+ throw new CliError(`Run lease requests a port without local approval for project ${lease.projectId}.`);
372
+ }
373
+ const localIPs = await Promise.all(lease.ports.map((port) => chooseLoopbackAddress(port, approved.preferredLocalIP, signal)));
374
+ const relay = lease.relay;
375
+ const invalidRelayFields = [];
376
+ if (!relay || relay.role !== 'client') invalidRelayFields.push('role');
377
+ if (relay?.user !== leaseId) invalidRelayFields.push('user');
378
+ if (typeof relay?.serverAddr !== 'string' || !/^[A-Za-z0-9.:[\]-]+$/.test(relay.serverAddr)) invalidRelayFields.push('serverAddr');
379
+ const serverPort = relay?.serverPort;
380
+ if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65535) invalidRelayFields.push('serverPort');
381
+ if (typeof relay?.serverName !== 'string' || !/^[A-Za-z0-9.-]{1,253}$/.test(relay.serverName)) invalidRelayFields.push('serverName');
382
+ if (typeof relay?.trustedCaPem !== 'string' || relay.trustedCaPem.length > 32768 || !relay.trustedCaPem.includes('-----BEGIN CERTIFICATE-----')) invalidRelayFields.push('trustedCaPem');
383
+ if (typeof relay?.credential !== 'string' || relay.credential.length < 16 || relay.credential.length > 4096) invalidRelayFields.push('credential');
384
+ if (invalidRelayFields.length) throw new CliError(`Local run lease has invalid relay fields: ${invalidRelayFields.join(', ')}.`);
385
+ const executorId = lease.executorId;
386
+ const proxyRows = relay.proxies;
387
+ const idSuffix = leaseId.replaceAll('-', '');
388
+ if (typeof executorId !== 'string' || !executorId || !Array.isArray(proxyRows) || proxyRows.length !== lease.ports.length ||
389
+ proxyRows.some((proxy, index) => proxy?.name !== `ml_${idSuffix}_${lease.ports[index]}` || proxy.type !== 'stcp' ||
390
+ proxy.localIP !== '127.0.0.1' || proxy.localPort !== lease.ports[index] ||
391
+ !Array.isArray(proxy.allowUsers) || proxy.allowUsers.length !== 1 || proxy.allowUsers[0] !== executorId ||
392
+ typeof proxy.secretKey !== 'string' || proxy.secretKey.length < 16 || proxy.secretKey.length > 4096)) {
393
+ throw new CliError('Local run lease has invalid loopback proxies.');
394
+ }
395
+ const config = {
396
+ user: relay.user,
397
+ auth: { method: 'token', token: '' },
398
+ serverAddr: relay.serverAddr,
399
+ serverPort,
400
+ loginFailExit: true,
401
+ metadatas: { lease_id: leaseId, role: relay.role, credential: relay.credential },
402
+ transport: { tls: { serverName: relay.serverName } },
403
+ proxies: proxyRows.map((proxy, index) => ({
404
+ name: proxy.name,
405
+ type: 'stcp',
406
+ secretKey: proxy.secretKey,
407
+ localIP: localIPs[index],
408
+ localPort: proxy.localPort,
409
+ allowUsers: [executorId],
410
+ })),
411
+ };
412
+ const process = await startFrpClient(config, relay.trustedCaPem, signal);
413
+ if (signal?.aborted) { await process.stop(); return; }
414
+ active.set(leaseId, { process, projectId: lease.projectId, runId: lease.runId });
415
+ try {
416
+ await readyLocalLease(client, leaseId, signal);
417
+ if (signal?.aborted) throw Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' });
418
+ state.lastError = null;
419
+ } catch (error) {
420
+ active.delete(leaseId);
421
+ await process.stop();
422
+ throw error;
423
+ }
424
+ }
425
+
426
+ async function readyLocalLease(client, leaseId, signal) {
427
+ const deadline = Date.now() + 5000;
428
+ while (true) {
429
+ try {
430
+ return await client.readyLocalLease(leaseId, undefined, signal);
431
+ } catch (error) {
432
+ if (error.apiCode !== 'tunnel_not_ready' || Date.now() >= deadline) throw error;
433
+ await delay(100, undefined, { signal });
434
+ }
435
+ }
436
+ }
437
+
438
+ async function chooseLoopbackAddress(port, preferred, signal) {
439
+ const candidates = [preferred, preferred === '127.0.0.1' ? '::1' : '127.0.0.1'];
440
+ for (const host of candidates) {
441
+ if (signal?.aborted) throw Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' });
442
+ const reachable = await new Promise((resolve) => {
443
+ const socket = connect({ host, port });
444
+ const onAbort = () => finish(false);
445
+ const finish = (value) => {
446
+ clearTimeout(timer);
447
+ signal?.removeEventListener('abort', onAbort);
448
+ socket.destroy();
449
+ resolve(value);
450
+ };
451
+ const timer = setTimeout(() => finish(false), 500);
452
+ socket.once('connect', () => finish(true));
453
+ socket.once('error', () => finish(false));
454
+ signal?.addEventListener('abort', onAbort, { once: true });
455
+ if (signal?.aborted) onAbort();
456
+ });
457
+ if (reachable) return host;
458
+ }
459
+ throw new CliError(`Approved local port ${port} is not listening on 127.0.0.1 or ::1.`);
460
+ }
@@ -0,0 +1,74 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { cliConfigDirectory, CliError } from './api-client.js';
5
+
6
+ const guardPath = () => join(cliConfigDirectory(), 'update-start.lock');
7
+
8
+ function pidIsAlive(pid) {
9
+ if (!Number.isInteger(pid) || pid < 1) return false;
10
+ try { process.kill(pid, 0); return true; }
11
+ catch (error) { return error.code === 'EPERM'; }
12
+ }
13
+
14
+ export async function acquireUpdateGuard() {
15
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
16
+ if (process.platform !== 'win32') await chmod(cliConfigDirectory(), 0o700);
17
+ const token = randomBytes(24).toString('base64url');
18
+ let file;
19
+ let acquired = false;
20
+ for (let attempt = 0; attempt < 2; attempt++) {
21
+ try {
22
+ file = await open(guardPath(), 'wx', 0o600);
23
+ await file.writeFile(JSON.stringify({ token, pid: process.pid }));
24
+ await file.sync();
25
+ acquired = true;
26
+ break;
27
+ } catch (error) {
28
+ if (error.code !== 'EEXIST') {
29
+ await rm(guardPath(), { force: true });
30
+ throw error;
31
+ }
32
+ let existingPid = 0;
33
+ try { existingPid = JSON.parse(await readFile(guardPath(), 'utf8')).pid; } catch { /* incomplete lock */ }
34
+ const info = await stat(guardPath()).catch(() => null);
35
+ if (pidIsAlive(existingPid) || (info && Date.now() - info.mtimeMs < 5000)) {
36
+ throw new CliError('A service startup or update is already in progress.');
37
+ }
38
+ await rm(guardPath(), { force: true });
39
+ } finally {
40
+ await file?.close();
41
+ file = null;
42
+ }
43
+ }
44
+ if (!acquired) throw new CliError('Could not acquire service startup/update lock.');
45
+ return {
46
+ token,
47
+ async release() {
48
+ try {
49
+ const saved = JSON.parse(await readFile(guardPath(), 'utf8')).token;
50
+ const expected = Buffer.from(token);
51
+ const actual = Buffer.from(String(saved));
52
+ if (actual.length === expected.length && timingSafeEqual(actual, expected)) {
53
+ await rm(guardPath(), { force: true });
54
+ }
55
+ } catch (error) {
56
+ if (error.code !== 'ENOENT') throw error;
57
+ }
58
+ },
59
+ };
60
+ }
61
+
62
+ export async function verifyUpdateGuard(token) {
63
+ try {
64
+ const saved = JSON.parse(await readFile(guardPath(), 'utf8')).token;
65
+ const expected = Buffer.from(String(saved));
66
+ const actual = Buffer.from(String(token));
67
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
68
+ throw new CliError('Service startup guard is invalid.');
69
+ }
70
+ } catch (error) {
71
+ if (error.code === 'ENOENT') throw new CliError('Service startup guard expired.');
72
+ throw error;
73
+ }
74
+ }
package/src/version.js ADDED
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const packagePath = fileURLToPath(new URL('../package.json', import.meta.url));
5
+ export const CLI_VERSION = JSON.parse(readFileSync(packagePath, 'utf8')).version;