@dinoxx/dinox-cli 2.13.0 → 2.14.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/dist/cli.js +5 -2
- package/dist/commands/auth/index.d.ts +2 -7
- package/dist/commands/auth/index.js +26 -51
- package/dist/commands/doctor.d.ts +4 -1
- package/dist/commands/doctor.js +20 -1
- package/dist/commands/graph/index.js +2 -1
- package/dist/commands/notes/contentPatch.js +2 -1
- package/dist/commands/notes/export.js +3 -2
- package/dist/commands/runtime.d.ts +8 -0
- package/dist/commands/runtime.js +49 -1
- package/dist/commands/schema/globalOptions.d.ts +1 -0
- package/dist/commands/schema/globalOptions.js +1 -0
- package/dist/commands/schema/renderSkills.js +1 -0
- package/dist/commands/schema/spec.js +9 -1
- package/dist/commands/storage/repo/operations.js +3 -1
- package/dist/commands/sync.d.ts +2 -1
- package/dist/commands/sync.js +24 -5
- package/dist/daemon/client.js +32 -2
- package/dist/daemon/connectivity.d.ts +20 -0
- package/dist/daemon/connectivity.js +42 -0
- package/dist/daemon/ensure.js +48 -12
- package/dist/daemon/forward.d.ts +34 -0
- package/dist/daemon/forward.js +146 -0
- package/dist/daemon/fresh.d.ts +4 -0
- package/dist/daemon/fresh.js +10 -0
- package/dist/daemon/index.d.ts +11 -6
- package/dist/daemon/index.js +90 -25
- package/dist/daemon/launch.d.ts +13 -1
- package/dist/daemon/launch.js +31 -2
- package/dist/daemon/lifecycle.d.ts +3 -1
- package/dist/daemon/lifecycle.js +27 -3
- package/dist/daemon/pid.d.ts +8 -0
- package/dist/daemon/pid.js +20 -0
- package/dist/daemon/route.js +2 -1
- package/dist/daemon/server.d.ts +44 -0
- package/dist/daemon/server.js +175 -3
- package/dist/daemon/supervise.d.ts +6 -6
- package/dist/daemon/supervise.js +12 -10
- package/dist/daemon/supervisor.d.ts +21 -5
- package/dist/daemon/supervisor.js +125 -6
- package/dist/daemon/types.d.ts +16 -0
- package/dist/dinox.js +41 -64
- package/dist/gateway/mcpCommandTools.d.ts +9 -0
- package/dist/gateway/mcpCommandTools.js +40 -10
- package/dist/powersync/runtime.d.ts +2 -0
- package/dist/powersync/runtime.js +3 -1
- package/dist/requests/store.d.ts +10 -0
- package/dist/requests/store.js +190 -0
- package/dist/runCli.d.ts +2 -0
- package/dist/runCli.js +64 -0
- package/dist/utils/argValue.js +2 -1
- package/dist/utils/errorCodes.d.ts +20 -0
- package/dist/utils/errorCodes.js +5 -0
- package/dist/utils/output.d.ts +2 -0
- package/dist/utils/output.js +31 -7
- package/dist/utils/requestContext.d.ts +24 -0
- package/dist/utils/requestContext.js +13 -0
- package/package.json +2 -1
package/dist/daemon/server.js
CHANGED
|
@@ -2,6 +2,8 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { createServer } from 'http';
|
|
3
3
|
import { chmodSync, existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { CONNECTIVITY_CHECK_INTERVAL_MS, createConnectivityWatcher } from './connectivity.js';
|
|
6
|
+
import { commandExecutionTarget } from './forward.js';
|
|
5
7
|
import { DAEMON_SERVICE_ENV } from './supervisor.js';
|
|
6
8
|
import { writeDaemonLogLine } from './log.js';
|
|
7
9
|
import { loadConfig } from '../config/store.js';
|
|
@@ -12,14 +14,21 @@ import { executeNoteSearch } from '../commands/notes/repo.js';
|
|
|
12
14
|
import { getNoteDetail } from '../commands/notes/repo/read.js';
|
|
13
15
|
import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
|
|
14
16
|
import { executeTodoSearch } from '../commands/todo/repo.js';
|
|
15
|
-
import { ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
|
|
17
|
+
import { acquireDaemonExclusiveIntentLock, ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
|
|
16
18
|
import { getPackageVersion } from '../utils/version.js';
|
|
17
19
|
import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
|
|
18
20
|
import { buildStructuredErrorPayload, DinoxError } from '../utils/errors.js';
|
|
21
|
+
import { requestContext } from '../utils/requestContext.js';
|
|
22
|
+
import { runWithRequestId } from '../requests/store.js';
|
|
19
23
|
import { getDaemonInfo } from './client.js';
|
|
20
24
|
import { GatewayService } from '../gateway/service.js';
|
|
21
25
|
import { readConversation } from '../gateway/conversationContext.js';
|
|
22
26
|
const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
|
|
27
|
+
const DAEMON_HEALTH_CHECK_INTERVAL_MS = 60_000;
|
|
28
|
+
const DAEMON_STUCK_SYNC_RESTART_MS = 15 * 60_000;
|
|
29
|
+
const DAEMON_INTENT_WAIT_CHUNK_MS = 30_000;
|
|
30
|
+
/** EX_TEMPFAIL: the service manager restarts the daemon. */
|
|
31
|
+
export const DAEMON_RESTART_EXIT_CODE = 75;
|
|
23
32
|
function daemonSupervisor() {
|
|
24
33
|
const value = process.env[DAEMON_SERVICE_ENV];
|
|
25
34
|
return value === 'launchd' || value === 'systemd' || value === 'schtasks' ? value : null;
|
|
@@ -66,6 +75,8 @@ function validateLocalRequest(req) {
|
|
|
66
75
|
return { ok: true };
|
|
67
76
|
}
|
|
68
77
|
export class DaemonServer {
|
|
78
|
+
options;
|
|
79
|
+
config = null;
|
|
69
80
|
db = null;
|
|
70
81
|
server = null;
|
|
71
82
|
startTime = Date.now();
|
|
@@ -78,14 +89,30 @@ export class DaemonServer {
|
|
|
78
89
|
ownsArtifacts = false;
|
|
79
90
|
releaseOwner = null;
|
|
80
91
|
stopPromise = null;
|
|
92
|
+
healthTimer = null;
|
|
93
|
+
connectivityTimer = null;
|
|
94
|
+
reconnect = null;
|
|
95
|
+
reconnecting = false;
|
|
96
|
+
syncUnconfirmedSince = null;
|
|
81
97
|
gateways = null;
|
|
98
|
+
constructor(options = {}) {
|
|
99
|
+
this.options = options;
|
|
100
|
+
if (options.runtime) {
|
|
101
|
+
this.db = options.runtime.db;
|
|
102
|
+
this.config = options.runtime.config;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
82
105
|
async initialize() {
|
|
106
|
+
// Before reading config: an exclusive command (e.g. `auth login`) may be
|
|
107
|
+
// changing the saved identity while this respawned daemon waits.
|
|
108
|
+
await waitForDaemonExclusiveIntent(this.options.acquireIntentLock);
|
|
83
109
|
const baseConfig = resolveConfig(await loadConfig());
|
|
84
110
|
const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs: baseConfig.sync.timeoutMs });
|
|
111
|
+
this.config = config;
|
|
85
112
|
this.userId = config.auth.userId ?? null;
|
|
86
113
|
this.tokenFingerprint = config.auth.tokenFingerprint ?? null;
|
|
87
114
|
this.runtimeConfigFingerprint = fingerprintDaemonRuntimeConfig(config);
|
|
88
|
-
const { db, releaseOwner } = await connectPowerSync({
|
|
115
|
+
const { db, releaseOwner, reconnect } = await (this.options.connect ?? connectPowerSync)({
|
|
89
116
|
config,
|
|
90
117
|
offline: false,
|
|
91
118
|
timeoutMs: config.sync.timeoutMs,
|
|
@@ -94,6 +121,13 @@ export class DaemonServer {
|
|
|
94
121
|
});
|
|
95
122
|
this.db = db;
|
|
96
123
|
this.releaseOwner = releaseOwner;
|
|
124
|
+
this.reconnect = reconnect ?? null;
|
|
125
|
+
if (process.env[DAEMON_SERVICE_ENV]) {
|
|
126
|
+
this.startHealthCheck(db);
|
|
127
|
+
}
|
|
128
|
+
if (this.reconnect) {
|
|
129
|
+
this.startConnectivityWatch();
|
|
130
|
+
}
|
|
97
131
|
if (config.auth.userId && config.auth.authorization) {
|
|
98
132
|
// Agent gateways share the daemon's PowerSync connection; a gateway
|
|
99
133
|
// failure must never take the daemon down.
|
|
@@ -117,6 +151,43 @@ export class DaemonServer {
|
|
|
117
151
|
}
|
|
118
152
|
const { method, params } = req;
|
|
119
153
|
switch (method) {
|
|
154
|
+
case 'command.execute': {
|
|
155
|
+
const input = params;
|
|
156
|
+
const valid = Boolean(input)
|
|
157
|
+
&& Array.isArray(input.argv)
|
|
158
|
+
&& input.argv.every((arg) => typeof arg === 'string')
|
|
159
|
+
&& typeof input.cwd === 'string'
|
|
160
|
+
&& path.isAbsolute(input.cwd)
|
|
161
|
+
&& typeof input.clientVersion === 'string'
|
|
162
|
+
&& commandExecutionTarget(input.argv) === 'daemon';
|
|
163
|
+
if (!valid) {
|
|
164
|
+
throw new DinoxError('Command cannot run inside the daemon.', { code: 'DAEMON_COMMAND_NOT_FORWARDABLE' });
|
|
165
|
+
}
|
|
166
|
+
if (!this.config)
|
|
167
|
+
throw new Error('Daemon config not initialized');
|
|
168
|
+
const context = {
|
|
169
|
+
stdout: [],
|
|
170
|
+
stderr: [],
|
|
171
|
+
format: null,
|
|
172
|
+
noticeProvider: null,
|
|
173
|
+
cwd: input.cwd,
|
|
174
|
+
daemon: {
|
|
175
|
+
db: this.db,
|
|
176
|
+
config: this.config,
|
|
177
|
+
syncUnconfirmedSince: () => this.currentSyncUnconfirmedSince(),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
const execute = async () => {
|
|
181
|
+
// Load the command tree only when the request is not a replay.
|
|
182
|
+
const { runCli } = await import('../runCli.js');
|
|
183
|
+
const exitCode = await requestContext.run(context, () => runCli(input.argv));
|
|
184
|
+
return { stdout: context.stdout.join(''), stderr: context.stderr.join(''), exitCode };
|
|
185
|
+
};
|
|
186
|
+
const result = input.requestId === undefined
|
|
187
|
+
? await execute()
|
|
188
|
+
: await runWithRequestId(input.requestId, input.argv, execute);
|
|
189
|
+
return { id: req.id, ok: true, result };
|
|
190
|
+
}
|
|
120
191
|
case 'ping':
|
|
121
192
|
return {
|
|
122
193
|
id: req.id,
|
|
@@ -149,7 +220,9 @@ export class DaemonServer {
|
|
|
149
220
|
});
|
|
150
221
|
}
|
|
151
222
|
const timer = setTimeout(() => {
|
|
152
|
-
void
|
|
223
|
+
void Promise.resolve().then(() => this.options.onStopRequested
|
|
224
|
+
? this.options.onStopRequested('shutdown-request', { requester: shutdownParams.requester })
|
|
225
|
+
: this.stop()).catch(() => undefined);
|
|
153
226
|
}, 10);
|
|
154
227
|
timer.unref?.();
|
|
155
228
|
return { id: req.id, ok: true, result: { stopping: true, instanceId: this.instanceId } };
|
|
@@ -318,7 +391,83 @@ export class DaemonServer {
|
|
|
318
391
|
}
|
|
319
392
|
}
|
|
320
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* PowerSync reconnects on its own; a connection that stays failed for this
|
|
396
|
+
* long usually needs a fresh process (for example a rotated credential), so
|
|
397
|
+
* exit for the service manager to restart it.
|
|
398
|
+
*/
|
|
399
|
+
startHealthCheck(db) {
|
|
400
|
+
let stuckSince = null;
|
|
401
|
+
this.healthTimer = setInterval(() => {
|
|
402
|
+
const status = getStatusSnapshot(db);
|
|
403
|
+
const failing = !status.connected
|
|
404
|
+
&& Boolean(status.dataFlowStatus.downloadError || status.dataFlowStatus.uploadError);
|
|
405
|
+
if (!failing) {
|
|
406
|
+
stuckSince = null;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
stuckSince ??= Date.now();
|
|
410
|
+
if (Date.now() - stuckSince < DAEMON_STUCK_SYNC_RESTART_MS)
|
|
411
|
+
return;
|
|
412
|
+
if (this.healthTimer)
|
|
413
|
+
clearInterval(this.healthTimer);
|
|
414
|
+
this.healthTimer = null;
|
|
415
|
+
void Promise.resolve(this.options.onStopRequested?.('powersync-stuck', { exitCode: DAEMON_RESTART_EXIT_CODE }))
|
|
416
|
+
.catch(() => undefined);
|
|
417
|
+
}, DAEMON_HEALTH_CHECK_INTERVAL_MS);
|
|
418
|
+
this.healthTimer.unref();
|
|
419
|
+
}
|
|
420
|
+
startConnectivityWatch() {
|
|
421
|
+
const watcher = createConnectivityWatcher({
|
|
422
|
+
onChange: (change) => void this.handleConnectivityChange(change),
|
|
423
|
+
});
|
|
424
|
+
this.connectivityTimer = setInterval(() => watcher.tick(), CONNECTIVITY_CHECK_INTERVAL_MS);
|
|
425
|
+
this.connectivityTimer.unref();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* After a wake from sleep or a network change the stream may be a dead
|
|
429
|
+
* connection that still reports "connected". Reconnect, and treat the cache
|
|
430
|
+
* as unconfirmed until a checkpoint completes on the new stream.
|
|
431
|
+
*/
|
|
432
|
+
async handleConnectivityChange(change) {
|
|
433
|
+
if (!this.reconnect || this.reconnecting)
|
|
434
|
+
return;
|
|
435
|
+
this.reconnecting = true;
|
|
436
|
+
this.syncUnconfirmedSince = new Date().toISOString();
|
|
437
|
+
writeDaemonLogLine('dinox-daemon', 'reconnecting sync stream', { reason: change });
|
|
438
|
+
try {
|
|
439
|
+
await this.reconnect();
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
writeDaemonLogLine('dinox-daemon', 'reconnect failed', {
|
|
443
|
+
reason: change,
|
|
444
|
+
error: error instanceof Error ? error.message : String(error),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
this.reconnecting = false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** The reconnect time while no checkpoint has completed since; null once confirmed. */
|
|
452
|
+
currentSyncUnconfirmedSince() {
|
|
453
|
+
const since = this.syncUnconfirmedSince;
|
|
454
|
+
if (!since || !this.db)
|
|
455
|
+
return since;
|
|
456
|
+
const status = getStatusSnapshot(this.db);
|
|
457
|
+
// PowerSync reports checkpoint times with whole-second precision.
|
|
458
|
+
const sinceMs = Math.floor(Date.parse(since) / 1_000) * 1_000;
|
|
459
|
+
if (!this.reconnecting && status.connected && status.lastSyncedAt && Date.parse(status.lastSyncedAt) >= sinceMs) {
|
|
460
|
+
this.syncUnconfirmedSince = null;
|
|
461
|
+
}
|
|
462
|
+
return this.syncUnconfirmedSince;
|
|
463
|
+
}
|
|
321
464
|
async stopInternal() {
|
|
465
|
+
if (this.healthTimer)
|
|
466
|
+
clearInterval(this.healthTimer);
|
|
467
|
+
this.healthTimer = null;
|
|
468
|
+
if (this.connectivityTimer)
|
|
469
|
+
clearInterval(this.connectivityTimer);
|
|
470
|
+
this.connectivityTimer = null;
|
|
322
471
|
const gateways = this.gateways;
|
|
323
472
|
this.gateways = null;
|
|
324
473
|
await gateways?.stop().catch(() => undefined);
|
|
@@ -353,3 +502,26 @@ export class DaemonServer {
|
|
|
353
502
|
}
|
|
354
503
|
}
|
|
355
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Blocks until no exclusive command holds the intent lock. A respawned
|
|
507
|
+
* daemon must not take the database from the command that just stopped it;
|
|
508
|
+
* the lease is released by the OS if that command dies.
|
|
509
|
+
*/
|
|
510
|
+
export async function waitForDaemonExclusiveIntent(acquire = acquireDaemonExclusiveIntentLock) {
|
|
511
|
+
let logged = false;
|
|
512
|
+
for (;;) {
|
|
513
|
+
try {
|
|
514
|
+
const release = await acquire(DAEMON_INTENT_WAIT_CHUNK_MS);
|
|
515
|
+
await release();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (error.code !== 'DAEMON_EXCLUSIVE_BUSY')
|
|
520
|
+
throw error;
|
|
521
|
+
if (!logged) {
|
|
522
|
+
writeDaemonLogLine('dinox-daemon', 'waiting for exclusive Dinox command to finish');
|
|
523
|
+
logged = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -10,6 +10,7 @@ export type SupervisedExit = {
|
|
|
10
10
|
};
|
|
11
11
|
export type SuperviseDependencies = {
|
|
12
12
|
startChild: () => ChildProcess;
|
|
13
|
+
isStopRequested: () => boolean;
|
|
13
14
|
sleep: (ms: number) => Promise<void>;
|
|
14
15
|
now: () => number;
|
|
15
16
|
log: (message: string, details?: Record<string, unknown>) => void;
|
|
@@ -17,12 +18,11 @@ export type SuperviseDependencies = {
|
|
|
17
18
|
onStopRequest: (handler: () => void) => void;
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
|
-
* Keeps one daemon running
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* daemon itself (Windows Task Scheduler).
|
|
21
|
+
* Keeps one daemon running the way launchd's KeepAlive and systemd's
|
|
22
|
+
* Restart=always do: restarts it after every exit, with an exponential
|
|
23
|
+
* backoff for repeated crashes, until `dino daemon stop` leaves the stop
|
|
24
|
+
* marker or the supervisor is signalled. Used where the service manager
|
|
25
|
+
* cannot restart the daemon itself (Windows Task Scheduler).
|
|
26
26
|
*/
|
|
27
27
|
export declare function superviseDaemon(deps: SuperviseDependencies): Promise<number>;
|
|
28
28
|
/** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
|
package/dist/daemon/supervise.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { appendDaemonLogLine, closeDaemonLogFd, openDaemonLogForChild } from './log.js';
|
|
4
|
+
import { getSupervisorStopMarkerPath } from './pid.js';
|
|
3
5
|
/** First restart delay after a crash; doubles per consecutive crash. */
|
|
4
6
|
export const SUPERVISE_INITIAL_BACKOFF_MS = 1_000;
|
|
5
7
|
export const SUPERVISE_MAX_BACKOFF_MS = 60_000;
|
|
@@ -20,12 +22,11 @@ function waitForExit(child) {
|
|
|
20
22
|
});
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
|
-
* Keeps one daemon running
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* daemon itself (Windows Task Scheduler).
|
|
25
|
+
* Keeps one daemon running the way launchd's KeepAlive and systemd's
|
|
26
|
+
* Restart=always do: restarts it after every exit, with an exponential
|
|
27
|
+
* backoff for repeated crashes, until `dino daemon stop` leaves the stop
|
|
28
|
+
* marker or the supervisor is signalled. Used where the service manager
|
|
29
|
+
* cannot restart the daemon itself (Windows Task Scheduler).
|
|
29
30
|
*/
|
|
30
31
|
export async function superviseDaemon(deps) {
|
|
31
32
|
let stopping = false;
|
|
@@ -37,7 +38,7 @@ export async function superviseDaemon(deps) {
|
|
|
37
38
|
wakeSleep?.();
|
|
38
39
|
});
|
|
39
40
|
let backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
40
|
-
while (!stopping) {
|
|
41
|
+
while (!stopping && !deps.isStopRequested()) {
|
|
41
42
|
const startedAt = deps.now();
|
|
42
43
|
const child = deps.startChild();
|
|
43
44
|
current = child;
|
|
@@ -46,13 +47,13 @@ export async function superviseDaemon(deps) {
|
|
|
46
47
|
current = null;
|
|
47
48
|
if (stopping)
|
|
48
49
|
break;
|
|
49
|
-
if (
|
|
50
|
-
deps.log('
|
|
50
|
+
if (deps.isStopRequested()) {
|
|
51
|
+
deps.log('stop marker found; supervisor stopping');
|
|
51
52
|
return 0;
|
|
52
53
|
}
|
|
53
54
|
if (deps.now() - startedAt >= SUPERVISE_HEALTHY_RUN_MS)
|
|
54
55
|
backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
55
|
-
deps.log('daemon
|
|
56
|
+
deps.log('daemon exited; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
|
|
56
57
|
await Promise.race([
|
|
57
58
|
deps.sleep(backoffMs),
|
|
58
59
|
new Promise((resolve) => {
|
|
@@ -73,6 +74,7 @@ export async function superviseCurrentDaemon(port) {
|
|
|
73
74
|
// Launched hidden at logon there is no console, so write to the log file directly.
|
|
74
75
|
const log = (message, details) => appendDaemonLogLine('dinox-supervisor', message, details);
|
|
75
76
|
return superviseDaemon({
|
|
77
|
+
isStopRequested: () => existsSync(getSupervisorStopMarkerPath()),
|
|
76
78
|
startChild: () => {
|
|
77
79
|
const logFd = openDaemonLogForChild();
|
|
78
80
|
try {
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* every gateway goes offline. Under launchd / systemd the daemon belongs to
|
|
6
6
|
* the login session instead, starts at login, and is restarted after a crash.
|
|
7
7
|
*
|
|
8
|
-
* Restart policy is "
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Restart policy is "always": launchd KeepAlive, systemd Restart=always, and
|
|
9
|
+
* on Windows `dino daemon supervise` (Task Scheduler only starts it; see
|
|
10
|
+
* supervise.ts) bring the daemon back after any exit, including a clean one
|
|
11
|
+
* for exclusive DB access or a crash. Only `dino daemon stop` / `uninstall`,
|
|
12
|
+
* which go through stop() / uninstall() here, leave it down.
|
|
13
13
|
*/
|
|
14
14
|
export type DaemonServiceKind = 'launchd' | 'systemd' | 'schtasks';
|
|
15
15
|
export type DaemonServiceSpec = {
|
|
@@ -33,25 +33,41 @@ export type DaemonService = {
|
|
|
33
33
|
isInstalled(): boolean;
|
|
34
34
|
/** True when the installed definition is exactly what this CLI would write. */
|
|
35
35
|
isCurrent(spec: DaemonServiceSpec): boolean;
|
|
36
|
+
/** The program and arguments the installed definition runs, or null when there is none. */
|
|
37
|
+
installedCommand(): string[] | null;
|
|
36
38
|
/** Writes the definition and (re)loads it, which starts the daemon. */
|
|
37
39
|
install(spec: DaemonServiceSpec): Promise<void>;
|
|
38
40
|
/** Starts the installed daemon when it is not running. */
|
|
39
41
|
start(): Promise<void>;
|
|
42
|
+
/** Stops the daemon and keeps it stopped until start() (or the next login). */
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
/** Restarts it now, skipping restart throttling. Windows: the caller then stops it through the socket. */
|
|
45
|
+
restart(): Promise<void>;
|
|
40
46
|
/** Stops the daemon and removes the definition; false when nothing was installed. */
|
|
41
47
|
uninstall(): Promise<boolean>;
|
|
42
48
|
};
|
|
43
49
|
/** Set in the service definition so the daemon can report who supervises it. */
|
|
44
50
|
export declare const DAEMON_SERVICE_ENV = "DINOX_DAEMON_SUPERVISOR";
|
|
51
|
+
/** Windows: file in the daemon directory telling the supervisor not to restart. */
|
|
52
|
+
export declare const SUPERVISOR_STOP_MARKER = "supervisor.stop";
|
|
45
53
|
export declare const runServiceCommand: ServiceCommandRunner;
|
|
46
54
|
/**
|
|
47
55
|
* One daemon per data directory (it owns that directory's socket), so a
|
|
48
56
|
* non-default `DINOX_DATA_DIR` gets its own service.
|
|
49
57
|
*/
|
|
50
58
|
export declare function daemonServiceLabel(env?: NodeJS.ProcessEnv): string;
|
|
59
|
+
/** ProgramArguments of a plist written by renderLaunchdPlist. */
|
|
60
|
+
export declare function parseLaunchdProgramArguments(plist: string): string[] | null;
|
|
51
61
|
export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
|
|
62
|
+
/** The words of an ExecStart line written by renderSystemdUnit. */
|
|
63
|
+
export declare function parseSystemdExecStart(unit: string): string[] | null;
|
|
52
64
|
export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
|
|
53
65
|
/** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
|
|
54
66
|
export declare function quoteWindowsArg(value: string): string;
|
|
67
|
+
/** Splits a command line the way the Windows C runtime does (the inverse of quoteWindowsArg). */
|
|
68
|
+
export declare function splitWindowsCommandLine(commandLine: string): string[];
|
|
69
|
+
/** The command line of a launcher written by renderWindowsLauncher. */
|
|
70
|
+
export declare function parseWindowsLauncherCommand(launcher: string): string[] | null;
|
|
55
71
|
/**
|
|
56
72
|
* Task Scheduler runs console programs in a visible window, so the task runs
|
|
57
73
|
* this script with wscript, which sets the daemon environment and starts the
|
|
@@ -8,6 +8,8 @@ import { DinoxError } from '../utils/errors.js';
|
|
|
8
8
|
/** Set in the service definition so the daemon can report who supervises it. */
|
|
9
9
|
export const DAEMON_SERVICE_ENV = 'DINOX_DAEMON_SUPERVISOR';
|
|
10
10
|
const DEFAULT_LABEL = 'com.dinox.cli.daemon';
|
|
11
|
+
/** Windows: file in the daemon directory telling the supervisor not to restart. */
|
|
12
|
+
export const SUPERVISOR_STOP_MARKER = 'supervisor.stop';
|
|
11
13
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
12
14
|
const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
|
|
13
15
|
const LAUNCHD_BOOTSTRAP_RETRY_MS = 300;
|
|
@@ -54,6 +56,21 @@ function xmlEscape(value) {
|
|
|
54
56
|
.replace(/>/g, '>')
|
|
55
57
|
.replace(/"/g, '"');
|
|
56
58
|
}
|
|
59
|
+
function xmlUnescape(value) {
|
|
60
|
+
return value
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>')
|
|
63
|
+
.replace(/"/g, '"')
|
|
64
|
+
.replace(/'/g, "'")
|
|
65
|
+
.replace(/&/g, '&');
|
|
66
|
+
}
|
|
67
|
+
/** ProgramArguments of a plist written by renderLaunchdPlist. */
|
|
68
|
+
export function parseLaunchdProgramArguments(plist) {
|
|
69
|
+
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist);
|
|
70
|
+
if (!array)
|
|
71
|
+
return null;
|
|
72
|
+
return [...array[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => xmlUnescape(match[1]));
|
|
73
|
+
}
|
|
57
74
|
export function renderLaunchdPlist(label, spec) {
|
|
58
75
|
const string = (value) => `<string>${xmlEscape(value)}</string>`;
|
|
59
76
|
const args = [spec.program, ...spec.args].map((arg) => `\t\t${string(arg)}`).join('\n');
|
|
@@ -80,10 +97,7 @@ ${env}
|
|
|
80
97
|
\t<key>RunAtLoad</key>
|
|
81
98
|
\t<true/>
|
|
82
99
|
\t<key>KeepAlive</key>
|
|
83
|
-
\t<
|
|
84
|
-
\t\t<key>SuccessfulExit</key>
|
|
85
|
-
\t\t<false/>
|
|
86
|
-
\t</dict>
|
|
100
|
+
\t<true/>
|
|
87
101
|
\t<key>ThrottleInterval</key>
|
|
88
102
|
\t<integer>30</integer>
|
|
89
103
|
\t<key>StandardOutPath</key>
|
|
@@ -117,6 +131,10 @@ class LaunchdService {
|
|
|
117
131
|
isCurrent(spec) {
|
|
118
132
|
return readDefinition(this.definitionPath) === this.render(spec);
|
|
119
133
|
}
|
|
134
|
+
installedCommand() {
|
|
135
|
+
const plist = readDefinition(this.definitionPath);
|
|
136
|
+
return plist === null ? null : parseLaunchdProgramArguments(plist);
|
|
137
|
+
}
|
|
120
138
|
async isLoaded() {
|
|
121
139
|
return (await this.run('launchctl', ['print', this.target])).code === 0;
|
|
122
140
|
}
|
|
@@ -147,6 +165,18 @@ class LaunchdService {
|
|
|
147
165
|
if (result.code !== 0)
|
|
148
166
|
throw failed(this.kind, 'kickstart', result);
|
|
149
167
|
}
|
|
168
|
+
async stop() {
|
|
169
|
+
const result = await this.run('launchctl', ['bootout', this.target]);
|
|
170
|
+
if (result.code !== 0 && await this.isLoaded())
|
|
171
|
+
throw failed(this.kind, 'bootout', result);
|
|
172
|
+
}
|
|
173
|
+
async restart() {
|
|
174
|
+
if (!(await this.isLoaded()))
|
|
175
|
+
await this.bootstrap();
|
|
176
|
+
const result = await this.run('launchctl', ['kickstart', '-k', this.target]);
|
|
177
|
+
if (result.code !== 0)
|
|
178
|
+
throw failed(this.kind, 'kickstart', result);
|
|
179
|
+
}
|
|
150
180
|
async uninstall() {
|
|
151
181
|
const installed = this.isInstalled();
|
|
152
182
|
if (await this.isLoaded()) {
|
|
@@ -170,6 +200,16 @@ function systemdQuote(value) {
|
|
|
170
200
|
.replace(/\n/g, '\\n');
|
|
171
201
|
return `"${escaped}"`;
|
|
172
202
|
}
|
|
203
|
+
/** The words of an ExecStart line written by renderSystemdUnit. */
|
|
204
|
+
export function parseSystemdExecStart(unit) {
|
|
205
|
+
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
206
|
+
if (!line)
|
|
207
|
+
return null;
|
|
208
|
+
return [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1]
|
|
209
|
+
.replace(/%%/g, '%')
|
|
210
|
+
.replace(/\$\$/g, '$')
|
|
211
|
+
.replace(/\\(.)/g, (_, char) => (char === 'n' ? '\n' : char)));
|
|
212
|
+
}
|
|
173
213
|
export function renderSystemdUnit(spec) {
|
|
174
214
|
const execStart = [spec.program, ...spec.args].map(systemdQuote).join(' ');
|
|
175
215
|
const env = Object.keys(spec.env)
|
|
@@ -185,8 +225,8 @@ Type=simple
|
|
|
185
225
|
ExecStart=${execStart}
|
|
186
226
|
WorkingDirectory=${spec.workingDirectory.replace(/%/g, '%%')}
|
|
187
227
|
${env}
|
|
188
|
-
Restart=
|
|
189
|
-
RestartSec=
|
|
228
|
+
Restart=always
|
|
229
|
+
RestartSec=5
|
|
190
230
|
StandardOutput=append:${spec.logPath}
|
|
191
231
|
StandardError=append:${spec.logPath}
|
|
192
232
|
|
|
@@ -215,6 +255,10 @@ class SystemdService {
|
|
|
215
255
|
isCurrent(spec) {
|
|
216
256
|
return readDefinition(this.definitionPath) === this.render(spec);
|
|
217
257
|
}
|
|
258
|
+
installedCommand() {
|
|
259
|
+
const unit = readDefinition(this.definitionPath);
|
|
260
|
+
return unit === null ? null : parseSystemdExecStart(unit);
|
|
261
|
+
}
|
|
218
262
|
async systemctl(step, ...args) {
|
|
219
263
|
const result = await this.run('systemctl', ['--user', ...args]);
|
|
220
264
|
if (result.code !== 0)
|
|
@@ -229,6 +273,12 @@ class SystemdService {
|
|
|
229
273
|
async start() {
|
|
230
274
|
await this.systemctl('start', 'start', this.unit);
|
|
231
275
|
}
|
|
276
|
+
async stop() {
|
|
277
|
+
await this.systemctl('stop', 'stop', this.unit);
|
|
278
|
+
}
|
|
279
|
+
async restart() {
|
|
280
|
+
await this.systemctl('restart', 'restart', this.unit);
|
|
281
|
+
}
|
|
232
282
|
async uninstall() {
|
|
233
283
|
if (!this.isInstalled())
|
|
234
284
|
return false;
|
|
@@ -260,6 +310,57 @@ export function quoteWindowsArg(value) {
|
|
|
260
310
|
}
|
|
261
311
|
return `${quoted}${'\\'.repeat(backslashes * 2)}"`;
|
|
262
312
|
}
|
|
313
|
+
/** Splits a command line the way the Windows C runtime does (the inverse of quoteWindowsArg). */
|
|
314
|
+
export function splitWindowsCommandLine(commandLine) {
|
|
315
|
+
const args = [];
|
|
316
|
+
let current = '';
|
|
317
|
+
let inArg = false;
|
|
318
|
+
let quoted = false;
|
|
319
|
+
for (let index = 0; index < commandLine.length; index += 1) {
|
|
320
|
+
const char = commandLine[index];
|
|
321
|
+
if (char === '\\') {
|
|
322
|
+
let backslashes = 0;
|
|
323
|
+
while (commandLine[index] === '\\') {
|
|
324
|
+
backslashes += 1;
|
|
325
|
+
index += 1;
|
|
326
|
+
}
|
|
327
|
+
if (commandLine[index] === '"') {
|
|
328
|
+
current += '\\'.repeat(Math.floor(backslashes / 2));
|
|
329
|
+
if (backslashes % 2 === 1)
|
|
330
|
+
current += '"';
|
|
331
|
+
else
|
|
332
|
+
quoted = !quoted;
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
current += '\\'.repeat(backslashes);
|
|
336
|
+
index -= 1;
|
|
337
|
+
}
|
|
338
|
+
inArg = true;
|
|
339
|
+
}
|
|
340
|
+
else if (char === '"') {
|
|
341
|
+
quoted = !quoted;
|
|
342
|
+
inArg = true;
|
|
343
|
+
}
|
|
344
|
+
else if (/\s/.test(char) && !quoted) {
|
|
345
|
+
if (inArg)
|
|
346
|
+
args.push(current);
|
|
347
|
+
current = '';
|
|
348
|
+
inArg = false;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
current += char;
|
|
352
|
+
inArg = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (inArg)
|
|
356
|
+
args.push(current);
|
|
357
|
+
return args;
|
|
358
|
+
}
|
|
359
|
+
/** The command line of a launcher written by renderWindowsLauncher. */
|
|
360
|
+
export function parseWindowsLauncherCommand(launcher) {
|
|
361
|
+
const line = /^shell\.Run "((?:[^"]|"")*)", 0, False\r?$/m.exec(launcher);
|
|
362
|
+
return line ? splitWindowsCommandLine(line[1].replace(/""/g, '"')) : null;
|
|
363
|
+
}
|
|
263
364
|
function vbsString(value) {
|
|
264
365
|
return `"${value.replace(/"/g, '""').replace(/\r?\n/g, '" & vbLf & "')}"`;
|
|
265
366
|
}
|
|
@@ -366,6 +467,10 @@ class WindowsTaskService {
|
|
|
366
467
|
isCurrent(spec) {
|
|
367
468
|
return readUtf16(this.definitionPath) === this.render(spec);
|
|
368
469
|
}
|
|
470
|
+
installedCommand() {
|
|
471
|
+
const launcher = readUtf16(this.definitionPath);
|
|
472
|
+
return launcher === null ? null : parseWindowsLauncherCommand(launcher);
|
|
473
|
+
}
|
|
369
474
|
async isRegistered() {
|
|
370
475
|
return (await this.run('schtasks', ['/Query', '/TN', this.taskName])).code === 0;
|
|
371
476
|
}
|
|
@@ -398,15 +503,29 @@ class WindowsTaskService {
|
|
|
398
503
|
throw failed(this.kind, 'run', result);
|
|
399
504
|
}
|
|
400
505
|
async install(spec) {
|
|
506
|
+
this.clearStopMarker();
|
|
401
507
|
writeUtf16(this.definitionPath, this.render(spec));
|
|
402
508
|
await this.register();
|
|
403
509
|
await this.runTask();
|
|
404
510
|
}
|
|
405
511
|
async start() {
|
|
512
|
+
this.clearStopMarker();
|
|
406
513
|
if (!(await this.isRegistered()))
|
|
407
514
|
await this.register();
|
|
408
515
|
await this.runTask();
|
|
409
516
|
}
|
|
517
|
+
async stop() {
|
|
518
|
+
mkdirSync(this.launcherDir, { recursive: true });
|
|
519
|
+
writeFileSync(path.join(this.launcherDir, SUPERVISOR_STOP_MARKER), '');
|
|
520
|
+
}
|
|
521
|
+
async restart() {
|
|
522
|
+
this.clearStopMarker();
|
|
523
|
+
}
|
|
524
|
+
clearStopMarker() {
|
|
525
|
+
const marker = path.join(this.launcherDir, SUPERVISOR_STOP_MARKER);
|
|
526
|
+
if (existsSync(marker))
|
|
527
|
+
unlinkSync(marker);
|
|
528
|
+
}
|
|
410
529
|
async uninstall() {
|
|
411
530
|
const installed = this.isInstalled();
|
|
412
531
|
if (await this.isRegistered()) {
|
package/dist/daemon/types.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export type DaemonResponse = {
|
|
|
12
12
|
};
|
|
13
13
|
export type DaemonShutdownParams = {
|
|
14
14
|
expectedInstanceId: string;
|
|
15
|
+
requester?: {
|
|
16
|
+
pid: number;
|
|
17
|
+
version: string;
|
|
18
|
+
command?: string;
|
|
19
|
+
};
|
|
15
20
|
};
|
|
16
21
|
export type DaemonShutdownResult = {
|
|
17
22
|
stopping: true;
|
|
@@ -47,3 +52,14 @@ export type DaemonPingResult = {
|
|
|
47
52
|
};
|
|
48
53
|
/** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
|
|
49
54
|
export declare const DEFAULT_DAEMON_PORT = 45678;
|
|
55
|
+
export type CommandExecuteParams = {
|
|
56
|
+
argv: string[];
|
|
57
|
+
cwd: string;
|
|
58
|
+
clientVersion: string;
|
|
59
|
+
requestId?: string;
|
|
60
|
+
};
|
|
61
|
+
export type CommandExecuteResult = {
|
|
62
|
+
stdout: string;
|
|
63
|
+
stderr: string;
|
|
64
|
+
exitCode: number;
|
|
65
|
+
};
|