@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.
Files changed (58) hide show
  1. package/dist/cli.js +5 -2
  2. package/dist/commands/auth/index.d.ts +2 -7
  3. package/dist/commands/auth/index.js +26 -51
  4. package/dist/commands/doctor.d.ts +4 -1
  5. package/dist/commands/doctor.js +20 -1
  6. package/dist/commands/graph/index.js +2 -1
  7. package/dist/commands/notes/contentPatch.js +2 -1
  8. package/dist/commands/notes/export.js +3 -2
  9. package/dist/commands/runtime.d.ts +8 -0
  10. package/dist/commands/runtime.js +49 -1
  11. package/dist/commands/schema/globalOptions.d.ts +1 -0
  12. package/dist/commands/schema/globalOptions.js +1 -0
  13. package/dist/commands/schema/renderSkills.js +1 -0
  14. package/dist/commands/schema/spec.js +9 -1
  15. package/dist/commands/storage/repo/operations.js +3 -1
  16. package/dist/commands/sync.d.ts +2 -1
  17. package/dist/commands/sync.js +24 -5
  18. package/dist/daemon/client.js +32 -2
  19. package/dist/daemon/connectivity.d.ts +20 -0
  20. package/dist/daemon/connectivity.js +42 -0
  21. package/dist/daemon/ensure.js +48 -12
  22. package/dist/daemon/forward.d.ts +34 -0
  23. package/dist/daemon/forward.js +146 -0
  24. package/dist/daemon/fresh.d.ts +4 -0
  25. package/dist/daemon/fresh.js +10 -0
  26. package/dist/daemon/index.d.ts +11 -6
  27. package/dist/daemon/index.js +90 -25
  28. package/dist/daemon/launch.d.ts +13 -1
  29. package/dist/daemon/launch.js +31 -2
  30. package/dist/daemon/lifecycle.d.ts +3 -1
  31. package/dist/daemon/lifecycle.js +27 -3
  32. package/dist/daemon/pid.d.ts +8 -0
  33. package/dist/daemon/pid.js +20 -0
  34. package/dist/daemon/route.js +2 -1
  35. package/dist/daemon/server.d.ts +44 -0
  36. package/dist/daemon/server.js +175 -3
  37. package/dist/daemon/supervise.d.ts +6 -6
  38. package/dist/daemon/supervise.js +12 -10
  39. package/dist/daemon/supervisor.d.ts +21 -5
  40. package/dist/daemon/supervisor.js +125 -6
  41. package/dist/daemon/types.d.ts +16 -0
  42. package/dist/dinox.js +41 -64
  43. package/dist/gateway/mcpCommandTools.d.ts +9 -0
  44. package/dist/gateway/mcpCommandTools.js +40 -10
  45. package/dist/powersync/runtime.d.ts +2 -0
  46. package/dist/powersync/runtime.js +3 -1
  47. package/dist/requests/store.d.ts +10 -0
  48. package/dist/requests/store.js +190 -0
  49. package/dist/runCli.d.ts +2 -0
  50. package/dist/runCli.js +64 -0
  51. package/dist/utils/argValue.js +2 -1
  52. package/dist/utils/errorCodes.d.ts +20 -0
  53. package/dist/utils/errorCodes.js +5 -0
  54. package/dist/utils/output.d.ts +2 -0
  55. package/dist/utils/output.js +31 -7
  56. package/dist/utils/requestContext.d.ts +24 -0
  57. package/dist/utils/requestContext.js +13 -0
  58. package/package.json +2 -1
@@ -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 this.stop().catch(() => undefined);
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, the way launchd's KeepAlive.SuccessfulExit=false
21
- * and systemd's Restart=on-failure do: restarts it after a crash with an
22
- * exponential backoff, and returns once it exits cleanly (exit code 0 from
23
- * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
24
- * or when asked to stop. Used where the service manager cannot restart the
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. */
@@ -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, the way launchd's KeepAlive.SuccessfulExit=false
24
- * and systemd's Restart=on-failure do: restarts it after a crash with an
25
- * exponential backoff, and returns once it exits cleanly (exit code 0 from
26
- * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
27
- * or when asked to stop. Used where the service manager cannot restart the
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 (exit.code === 0) {
50
- deps.log('daemon exited cleanly; supervisor stopping');
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 crashed; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
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 "on failure" only: a clean exit (`dino daemon stop`, a
9
- * version/account restart, exclusive DB access for `doctor`) leaves it
10
- * stopped until the CLI starts it again through the manager. launchd and
11
- * systemd restart the daemon themselves; on Windows, Task Scheduler only
12
- * starts `dino daemon supervise`, which restarts it (see supervise.ts).
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, '&gt;')
55
57
  .replace(/"/g, '&quot;');
56
58
  }
59
+ function xmlUnescape(value) {
60
+ return value
61
+ .replace(/&lt;/g, '<')
62
+ .replace(/&gt;/g, '>')
63
+ .replace(/&quot;/g, '"')
64
+ .replace(/&apos;/g, "'")
65
+ .replace(/&amp;/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<dict>
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=on-failure
189
- RestartSec=30
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()) {
@@ -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
+ };