@dinoxx/dinox-cli 2.13.1 → 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 -1
  3. package/dist/commands/auth/index.js +18 -1
  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
@@ -0,0 +1,20 @@
1
+ /** How often the daemon checks for a wake from sleep or a network change. */
2
+ export declare const CONNECTIVITY_CHECK_INTERVAL_MS = 10000;
3
+ /** A tick this late means the machine was asleep (timers do not run during sleep). */
4
+ export declare const WAKE_DRIFT_THRESHOLD_MS = 30000;
5
+ export type ConnectivityChange = 'wake' | 'network-change';
6
+ /** Addresses of the non-internal interfaces; changes on Wi-Fi switch, VPN, or link loss. */
7
+ export declare function networkSignature(): string;
8
+ /**
9
+ * Detects the events after which a long-lived sync stream may be a dead
10
+ * connection that still reports "connected": a wake from sleep and a change
11
+ * of network. Call `tick()` every CONNECTIVITY_CHECK_INTERVAL_MS.
12
+ */
13
+ export declare function createConnectivityWatcher(options: {
14
+ onChange: (change: ConnectivityChange) => void;
15
+ now?: () => number;
16
+ signature?: () => string;
17
+ intervalMs?: number;
18
+ }): {
19
+ tick: () => void;
20
+ };
@@ -0,0 +1,42 @@
1
+ import os from 'node:os';
2
+ /** How often the daemon checks for a wake from sleep or a network change. */
3
+ export const CONNECTIVITY_CHECK_INTERVAL_MS = 10_000;
4
+ /** A tick this late means the machine was asleep (timers do not run during sleep). */
5
+ export const WAKE_DRIFT_THRESHOLD_MS = 30_000;
6
+ /** Addresses of the non-internal interfaces; changes on Wi-Fi switch, VPN, or link loss. */
7
+ export function networkSignature() {
8
+ const addresses = [];
9
+ for (const [name, entries] of Object.entries(os.networkInterfaces())) {
10
+ for (const entry of entries ?? []) {
11
+ if (!entry.internal)
12
+ addresses.push(`${name}:${entry.address}`);
13
+ }
14
+ }
15
+ return addresses.sort().join(',');
16
+ }
17
+ /**
18
+ * Detects the events after which a long-lived sync stream may be a dead
19
+ * connection that still reports "connected": a wake from sleep and a change
20
+ * of network. Call `tick()` every CONNECTIVITY_CHECK_INTERVAL_MS.
21
+ */
22
+ export function createConnectivityWatcher(options) {
23
+ const now = options.now ?? Date.now;
24
+ const signature = options.signature ?? networkSignature;
25
+ const intervalMs = options.intervalMs ?? CONNECTIVITY_CHECK_INTERVAL_MS;
26
+ let lastTickAt = now();
27
+ let lastSignature = signature();
28
+ return {
29
+ tick() {
30
+ const tickAt = now();
31
+ const drift = tickAt - lastTickAt - intervalMs;
32
+ lastTickAt = tickAt;
33
+ const currentSignature = signature();
34
+ const networkChanged = currentSignature !== lastSignature;
35
+ lastSignature = currentSignature;
36
+ if (drift >= WAKE_DRIFT_THRESHOLD_MS)
37
+ options.onChange('wake');
38
+ else if (networkChanged)
39
+ options.onChange('network-change');
40
+ },
41
+ };
42
+ }
@@ -1,9 +1,12 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { getDaemonInfo, isDaemonFresh, waitForFreshDaemon } from './client.js';
2
- import { stopDaemonLocked } from './index.js';
3
+ import { matchesRuntimeTokenBinding } from './fresh.js';
4
+ import { restartDaemonLocked, stopDaemonLocked } from './index.js';
3
5
  import { DEFAULT_DAEMON_PORT } from './types.js';
4
- import { launchDaemon } from './launch.js';
6
+ import { daemonServiceSpec, foreignServiceOwner, launchDaemon, usableDaemonService } from './launch.js';
5
7
  import { getDaemonLogPath } from './log.js';
6
- import { acquireDaemonStartLock, } from './pid.js';
8
+ import { acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, } from './pid.js';
9
+ import { getDaemonService } from './supervisor.js';
7
10
  import { loadConfig } from '../config/store.js';
8
11
  import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
9
12
  import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
@@ -20,13 +23,22 @@ async function hydrateDaemonIdentity(baseConfig) {
20
23
  runtimeConfigFingerprint: fingerprintDaemonRuntimeConfig(config),
21
24
  };
22
25
  }
23
- function matchesRuntimeTokenBinding(info, version, config) {
24
- return Boolean(config.auth.authorization
25
- && info.authenticated
26
- && info.version === version
27
- && info.tokenFingerprint === (config.auth.tokenFingerprint ?? null)
28
- && info.runtimeConfigFingerprint === fingerprintDaemonRuntimeConfig(config)
29
- && (!config.auth.userId || info.userId === config.auth.userId));
26
+ /**
27
+ * Refuses to restart or rewrite a service another dino install owns. The data
28
+ * command then runs in-process; `dino daemon install` (or start/restart)
29
+ * switches the service to this CLI.
30
+ */
31
+ async function assertServiceNotForeign() {
32
+ const service = await usableDaemonService();
33
+ const owner = service ? foreignServiceOwner(service) : null;
34
+ if (!service || !owner)
35
+ return;
36
+ throw new DinoxError(`The ${service.kind} daemon service belongs to another dino install (${owner}).`, {
37
+ code: 'DAEMON_SERVICE_FOREIGN',
38
+ recoverable: true,
39
+ details: { service: service.kind, owner, definitionPath: service.definitionPath },
40
+ suggestion: 'Run `dino daemon install` with the dino you want the service to use.',
41
+ });
30
42
  }
31
43
  async function ensureDaemonRunningUnlocked() {
32
44
  const expectedVersion = getPackageVersion();
@@ -43,15 +55,22 @@ async function ensureDaemonRunningUnlocked() {
43
55
  return;
44
56
  }
45
57
  const expectedIdentity = await hydrateDaemonIdentity(baseConfig);
58
+ if (info && isDaemonFresh(info, expectedVersion, expectedIdentity.userId, expectedIdentity.tokenFingerprint, expectedIdentity.runtimeConfigFingerprint)) {
59
+ return;
60
+ }
61
+ await assertServiceNotForeign();
46
62
  if (info) {
47
63
  // A daemon is reachable — make sure it is the *right* one. It holds a long-lived
48
64
  // PowerSync connection bound to whatever account/version it started with, so after
49
65
  // `auth login` (new userId) or `dino update` (new version) the running daemon would
50
66
  // otherwise keep serving stale or wrong-account data. Restart it when it drifts.
51
- if (isDaemonFresh(info, expectedVersion, expectedIdentity.userId, expectedIdentity.tokenFingerprint, expectedIdentity.runtimeConfigFingerprint)) {
67
+ printHumanReadableWarning('Restarting daemon (version or account changed)...');
68
+ const service = getDaemonService();
69
+ if (service?.isInstalled() && info.supervisor === service.kind
70
+ && service.isCurrent(daemonServiceSpec(service, DEFAULT_DAEMON_PORT))) {
71
+ await restartDaemonLocked(DEFAULT_DAEMON_PORT, { quiet: true });
52
72
  return;
53
73
  }
54
- printHumanReadableWarning('Restarting daemon (version or account changed)...');
55
74
  await stopDaemonLocked().catch((error) => {
56
75
  const message = error instanceof Error ? error.message : String(error);
57
76
  printHumanReadableWarning(`warning: failed to stop stale daemon: ${message}`);
@@ -65,6 +84,23 @@ async function ensureDaemonRunningUnlocked() {
65
84
  }
66
85
  }
67
86
  else {
87
+ // A live process that owns the socket but does not answer is hung; only
88
+ // launchd / systemd can replace it safely (they know the exact process).
89
+ const pid = readPid();
90
+ const service = getDaemonService();
91
+ if (pid && isProcessRunning(pid.pid) && existsSync(getDaemonSocketPath())
92
+ && service?.isInstalled() && service.kind !== 'schtasks') {
93
+ printHumanReadableWarning('warning: daemon is not responding; restarting the service');
94
+ await service.restart();
95
+ if (await waitForFreshDaemon(expectedVersion, expectedIdentity.userId, expectedIdentity.tokenFingerprint, expectedIdentity.runtimeConfigFingerprint, DAEMON_READY_TIMEOUT_MS)) {
96
+ return;
97
+ }
98
+ throw new DinoxError('The daemon service is still not responding after a restart.', {
99
+ code: 'DAEMON_START_FAILED',
100
+ details: { service: service.kind, logPath: getDaemonLogPath() },
101
+ suggestion: `Inspect ${getDaemonLogPath()} for the daemon's startup error.`,
102
+ });
103
+ }
68
104
  printHumanReadableWarning('Starting daemon...');
69
105
  }
70
106
  const launch = await launchDaemon(DEFAULT_DAEMON_PORT);
@@ -0,0 +1,34 @@
1
+ import { loadConfig } from '../config/store.js';
2
+ import { resolveConfig } from '../config/resolve.js';
3
+ import { printHumanReadableWarning } from '../utils/output.js';
4
+ import { getPackageVersion } from '../utils/version.js';
5
+ import { sendDaemonRequest } from './client.js';
6
+ import { envTokenOverridesLogin } from './launch.js';
7
+ import type { CommandExecuteResult } from './types.js';
8
+ /**
9
+ * Where a CLI invocation runs. Help, version, unknown words and every command
10
+ * that manages the local install (auth, config, daemon, gateway, doctor, ...)
11
+ * stay local; `meta schema` is introspection and needs no database.
12
+ */
13
+ export declare function commandExecutionTarget(input: string[]): 'daemon' | 'local';
14
+ declare const forwardDependencies: {
15
+ loadConfig: typeof loadConfig;
16
+ resolveConfig: typeof resolveConfig;
17
+ isDaemonFresh: (config: ReturnType<typeof resolveConfig>, version: string) => Promise<boolean>;
18
+ ensureDaemonRunning: () => Promise<void>;
19
+ sendDaemonRequest: typeof sendDaemonRequest;
20
+ envTokenOverridesLogin: typeof envTokenOverridesLogin;
21
+ version: typeof getPackageVersion;
22
+ cwd: () => string;
23
+ env: NodeJS.ProcessEnv;
24
+ note: typeof printHumanReadableWarning;
25
+ };
26
+ export type ForwardDependencies = typeof forwardDependencies;
27
+ /**
28
+ * Runs a data command inside the daemon. Returns null when it should run
29
+ * in-process instead: forwarding is disabled or impossible, or failed before
30
+ * the daemon could have received the request. A failure after that point
31
+ * throws DAEMON_UNAVAILABLE, never falls back, so a write is not applied twice.
32
+ */
33
+ export declare function tryForwardToDaemon(argv: string[], overrides?: Partial<ForwardDependencies>): Promise<CommandExecuteResult | null>;
34
+ export {};
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { loadConfig } from '../config/store.js';
3
+ import { resolveConfig } from '../config/resolve.js';
4
+ import { DinoxError } from '../utils/errors.js';
5
+ import { printHumanReadableWarning } from '../utils/output.js';
6
+ import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
7
+ import { getPackageVersion } from '../utils/version.js';
8
+ import { AUTO_REQUEST_ID_PREFIX, requestIdFromArgv } from '../requests/store.js';
9
+ import { matchesRuntimeTokenBinding } from './fresh.js';
10
+ import { getDaemonInfo, sendDaemonRequest } from './client.js';
11
+ import { envTokenOverridesLogin } from './launch.js';
12
+ /** Top-level commands that only need the database; they run inside the daemon. */
13
+ const DAEMON_COMMAND_GROUPS = new Set([
14
+ 'note', 'tag', 'box', 'todo', 'view', 'prop', 'template', 'prompt', 'graph', 'storage', 'meta', 'sync',
15
+ ]);
16
+ /**
17
+ * Where a CLI invocation runs. Help, version, unknown words and every command
18
+ * that manages the local install (auth, config, daemon, gateway, doctor, ...)
19
+ * stay local; `meta schema` is introspection and needs no database.
20
+ */
21
+ export function commandExecutionTarget(input) {
22
+ const argv = input[0] === '--' ? input.slice(1) : input;
23
+ if (argv.some(arg => ['--help', '-h', '--version', '-V'].includes(arg)))
24
+ return 'local';
25
+ const words = [];
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ if (arg === '--') {
29
+ words.push(...argv.slice(i + 1));
30
+ break;
31
+ }
32
+ if (arg === '--format' || arg === '--sync-timeout' || arg === '--request-id') {
33
+ i++;
34
+ continue;
35
+ }
36
+ if (arg.startsWith('-'))
37
+ continue;
38
+ words.push(arg);
39
+ }
40
+ const group = words[0] ?? '';
41
+ if (!DAEMON_COMMAND_GROUPS.has(group) || (group === 'meta' && words[1] === 'schema'))
42
+ return 'local';
43
+ return 'daemon';
44
+ }
45
+ const forwardDependencies = {
46
+ loadConfig,
47
+ resolveConfig,
48
+ isDaemonFresh: async (config, version) => {
49
+ const info = await getDaemonInfo();
50
+ return Boolean(info && matchesRuntimeTokenBinding(info, version, config));
51
+ },
52
+ ensureDaemonRunning: async () => {
53
+ await (await import('./ensure.js')).ensureDaemonRunning();
54
+ },
55
+ sendDaemonRequest,
56
+ envTokenOverridesLogin,
57
+ version: getPackageVersion,
58
+ cwd: () => process.cwd(),
59
+ env: process.env,
60
+ note: printHumanReadableWarning,
61
+ };
62
+ /**
63
+ * Runs a data command inside the daemon. Returns null when it should run
64
+ * in-process instead: forwarding is disabled or impossible, or failed before
65
+ * the daemon could have received the request. A failure after that point
66
+ * throws DAEMON_UNAVAILABLE, never falls back, so a write is not applied twice.
67
+ */
68
+ export async function tryForwardToDaemon(argv, overrides = {}) {
69
+ const deps = { ...forwardDependencies, ...overrides };
70
+ if (commandExecutionTarget(argv) !== 'daemon' || /^(0|false|off|no)$/i.test(deps.env.DINOX_DAEMON ?? ''))
71
+ return null;
72
+ const fallback = (error) => {
73
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, ' ');
74
+ deps.note(`note: daemon unavailable, running in-process (${reason})`);
75
+ return null;
76
+ };
77
+ const suppliedRequestId = requestIdFromArgv(argv);
78
+ const requestId = suppliedRequestId ?? `${AUTO_REQUEST_ID_PREFIX}${randomUUID()}`;
79
+ let timeoutMs;
80
+ let config;
81
+ const ensureReady = async () => {
82
+ if (!await deps.isDaemonFresh(config, deps.version()))
83
+ await deps.ensureDaemonRunning();
84
+ };
85
+ try {
86
+ const raw = await deps.loadConfig();
87
+ if (deps.envTokenOverridesLogin(raw, deps.env) || !raw.auth?.authorization)
88
+ return null;
89
+ let timeout;
90
+ for (let i = 0; i < argv.length; i++) {
91
+ if (argv[i] === '--')
92
+ break;
93
+ if (argv[i] === '--sync-timeout')
94
+ timeout = argv[++i];
95
+ else if (argv[i]?.startsWith('--sync-timeout='))
96
+ timeout = argv[i].slice(15);
97
+ }
98
+ config = deps.resolveConfig(raw);
99
+ timeoutMs = (parseSyncTimeoutMs(timeout) ?? config.sync.timeoutMs) + 60_000;
100
+ await ensureReady();
101
+ }
102
+ catch (error) {
103
+ if (error instanceof DinoxError && error.code === 'AUTH_REQUIRED')
104
+ return null;
105
+ return fallback(error);
106
+ }
107
+ const execute = async () => {
108
+ const result = await deps.sendDaemonRequest('command.execute', {
109
+ argv, cwd: deps.cwd(), clientVersion: deps.version(), requestId,
110
+ }, { timeoutMs });
111
+ if (!result || typeof result.stdout !== 'string' || typeof result.stderr !== 'string' || !Number.isInteger(result.exitCode)) {
112
+ throw new Error('Invalid command.execute response');
113
+ }
114
+ return result;
115
+ };
116
+ const requestError = (error) => error instanceof DinoxError
117
+ && ['REQUEST_ID_CONFLICT', 'REQUEST_IN_PROGRESS', 'REQUEST_INTERRUPTED', 'INVALID_ARGUMENT'].includes(error.code);
118
+ try {
119
+ return await execute();
120
+ }
121
+ catch (error) {
122
+ if (requestError(error))
123
+ throw error;
124
+ if ((error instanceof Error && /^Unknown method: command.execute$/i.test(error.message))
125
+ || error?.requestWritten === false) {
126
+ return fallback(error);
127
+ }
128
+ // Once delivery is uncertain, neither retry failures nor startup failures
129
+ // may fall back to in-process execution.
130
+ try {
131
+ await ensureReady();
132
+ return await execute();
133
+ }
134
+ catch (retryError) {
135
+ if (requestError(retryError))
136
+ throw retryError;
137
+ throw new DinoxError('Daemon command response unavailable; the command may have executed.', {
138
+ code: 'DAEMON_UNAVAILABLE', cause: retryError, recoverable: true,
139
+ details: { requestId },
140
+ suggestion: suppliedRequestId
141
+ ? `Rerun the exact same command with --request-id ${requestId} to fetch the outcome.`
142
+ : 'Verify the result before retrying the command.',
143
+ });
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,4 @@
1
+ import type { ResolvedDinoxConfig } from '../config/types.js';
2
+ import type { getDaemonInfo } from './client.js';
3
+ /** Checks the inexpensive, already-resolved runtime binding. */
4
+ export declare function matchesRuntimeTokenBinding(info: NonNullable<Awaited<ReturnType<typeof getDaemonInfo>>>, version: string, config: ResolvedDinoxConfig): boolean;
@@ -0,0 +1,10 @@
1
+ import { fingerprintDaemonRuntimeConfig } from '../config/resolve.js';
2
+ /** Checks the inexpensive, already-resolved runtime binding. */
3
+ export function matchesRuntimeTokenBinding(info, version, config) {
4
+ return Boolean(config.auth.authorization
5
+ && info.authenticated
6
+ && info.version === version
7
+ && info.tokenFingerprint === (config.auth.tokenFingerprint ?? null)
8
+ && info.runtimeConfigFingerprint === fingerprintDaemonRuntimeConfig(config)
9
+ && (!config.auth.userId || info.userId === config.auth.userId));
10
+ }
@@ -14,6 +14,8 @@ type DaemonStartResult = {
14
14
  type DaemonStartOptions = {
15
15
  quiet?: boolean;
16
16
  timeoutMs?: number;
17
+ /** Rewrite a service definition another dino install owns (explicit start/restart only). */
18
+ takeover?: boolean;
17
19
  };
18
20
  type DaemonStopResult = {
19
21
  stopped: boolean;
@@ -38,9 +40,7 @@ export declare function startDaemonLocked(port: number, detached: boolean, optio
38
40
  /** Caller must hold the daemon start lock. */
39
41
  export declare function stopDaemonLocked(): Promise<DaemonStopResult>;
40
42
  export declare function stopDaemon(): Promise<DaemonStopResult>;
41
- export declare function restartDaemonLocked(port?: number, options?: {
42
- quiet?: boolean;
43
- }): Promise<{
43
+ export declare function restartDaemonLocked(port?: number, options?: DaemonStartOptions): Promise<{
44
44
  stopped: boolean;
45
45
  running: boolean;
46
46
  port: number;
@@ -50,9 +50,7 @@ export declare function restartDaemonLocked(port?: number, options?: {
50
50
  alreadyRunning: boolean;
51
51
  service: DaemonServiceKind | null;
52
52
  }>;
53
- export declare function restartDaemon(port?: number, options?: {
54
- quiet?: boolean;
55
- }): Promise<{
53
+ export declare function restartDaemon(port?: number, options?: DaemonStartOptions): Promise<{
56
54
  stopped: boolean;
57
55
  running: boolean;
58
56
  port: number;
@@ -70,6 +68,13 @@ export declare function restartDaemon(port?: number, options?: {
70
68
  export declare function installDaemonService(port?: number): Promise<DaemonInstallResult>;
71
69
  /** Stops the managed daemon and removes its service definition. */
72
70
  export declare function uninstallDaemonService(): Promise<DaemonUninstallResult>;
71
+ export declare function waitForSavedDaemonLogin(overrides?: Partial<{
72
+ hasAuth: () => Promise<boolean>;
73
+ watchConfig: (check: () => void) => () => void;
74
+ exit: (code: number) => void;
75
+ onSignal: (handler: () => void) => () => void;
76
+ pollMs: number;
77
+ }>): Promise<void>;
73
78
  export declare const daemonCommandGroup: CommandGroupDescriptor;
74
79
  export declare function registerDaemonCommands(program: Command): void;
75
80
  export {};
@@ -1,4 +1,6 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, watch, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigFilePath } from '../config/paths.js';
2
4
  import { DaemonServer } from './server.js';
3
5
  import { DEFAULT_DAEMON_PORT } from './types.js';
4
6
  import { acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, removePid, } from './pid.js';
@@ -13,6 +15,7 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
13
15
  import { withAsyncRelease } from '../utils/asyncRelease.js';
14
16
  import { remainingDeadlineMs } from '../utils/deadline.js';
15
17
  import { getPackageVersion } from '../utils/version.js';
18
+ import { matchesRuntimeTokenBinding } from './fresh.js';
16
19
  import { DAEMON_CHILD_ENV, daemonServiceSpec, envTokenOverridesLogin, launchDaemon } from './launch.js';
17
20
  import { getDaemonLogPath, rotateDaemonLog, writeDaemonLogLine } from './log.js';
18
21
  import { DAEMON_SERVICE_ENV, getDaemonService } from './supervisor.js';
@@ -99,14 +102,6 @@ async function expectedDaemonIdentity(baseConfig, timeoutMs) {
99
102
  runtimeConfigFingerprint: fingerprintDaemonRuntimeConfig(config),
100
103
  };
101
104
  }
102
- function matchesRuntimeTokenBinding(info, version, config) {
103
- return Boolean(config.auth.authorization
104
- && info.authenticated
105
- && info.version === version
106
- && info.tokenFingerprint === (config.auth.tokenFingerprint ?? null)
107
- && info.runtimeConfigFingerprint === fingerprintDaemonRuntimeConfig(config)
108
- && (!config.auth.userId || info.userId === config.auth.userId));
109
- }
110
105
  export async function startDaemonLocked(port, detached, options = {}) {
111
106
  const maximumTimeoutMs = options.timeoutMs === undefined
112
107
  ? undefined
@@ -142,7 +137,7 @@ export async function startDaemonLocked(port, detached, options = {}) {
142
137
  }
143
138
  const expected = await expectedDaemonIdentity(baseConfig, remainingStartTimeoutMs('auth-hydration'));
144
139
  if (detached) {
145
- const launch = await launchDaemon(port);
140
+ const launch = await launchDaemon(port, { takeover: options.takeover });
146
141
  const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, remainingStartTimeoutMs('ready-poll'));
147
142
  if (!ready) {
148
143
  launch.abort();
@@ -172,7 +167,16 @@ async function serveDaemonInForeground(port, options) {
172
167
  if (!options.quiet) {
173
168
  printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
174
169
  }
175
- const server = new DaemonServer();
170
+ const stopAndExit = async (reason, exitCode, details) => {
171
+ writeDaemonLogLine('dinox-daemon', 'stopping', { reason, ...details });
172
+ const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
173
+ forceExit.unref();
174
+ await server.stop().catch(() => undefined);
175
+ process.exit(exitCode);
176
+ };
177
+ const server = new DaemonServer({
178
+ onStopRequested: (reason, details) => stopAndExit(reason, typeof details?.exitCode === 'number' ? details.exitCode : 0, details),
179
+ });
176
180
  try {
177
181
  await server.initialize();
178
182
  await server.start(port);
@@ -189,13 +193,6 @@ async function serveDaemonInForeground(port, options) {
189
193
  version: getPackageVersion(),
190
194
  supervisor: process.env[DAEMON_SERVICE_ENV] ?? null,
191
195
  });
192
- const stopAndExit = async (reason, exitCode) => {
193
- writeDaemonLogLine('dinox-daemon', 'stopping', { reason });
194
- const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
195
- forceExit.unref();
196
- await server.stop().catch(() => undefined);
197
- process.exit(exitCode);
198
- };
199
196
  process.on('SIGINT', () => {
200
197
  if (!options.quiet) {
201
198
  printHumanReadableLine('');
@@ -247,11 +244,17 @@ export async function stopDaemonLocked() {
247
244
  await shutdownLegacyDaemon(info, DAEMON_STOP_TIMEOUT_MS);
248
245
  return { stopped: true, wasRunning: true, stalePidFile: false };
249
246
  }
247
+ const service = getDaemonService();
248
+ const supervised = Boolean(service?.isInstalled() && info.supervisor === service.kind);
250
249
  try {
251
- await requestDaemonShutdown(info.instanceId, 2_000);
250
+ // Through the manager: with KeepAlive a plain socket shutdown is respawned.
251
+ if (supervised)
252
+ await service.stop();
253
+ if (!supervised || service.kind === 'schtasks')
254
+ await requestDaemonShutdown(info.instanceId, 2_000);
252
255
  }
253
256
  catch (error) {
254
- throw new DinoxError(`Failed to stop daemon through its private socket: ${error.message}`, {
257
+ throw new DinoxError(`Failed to stop daemon ${supervised ? `through ${service.kind}` : 'through its private socket'}: ${error.message}`, {
255
258
  code: 'DAEMON_STOP_FAILED',
256
259
  suggestion: 'The daemon PID was not signaled because its process identity could not be verified safely.',
257
260
  });
@@ -317,6 +320,20 @@ async function daemonStatus() {
317
320
  }
318
321
  }
319
322
  export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options = {}) {
323
+ const info = await getDaemonInfo();
324
+ const service = getDaemonService();
325
+ if (info && service?.isInstalled() && info.supervisor === service.kind
326
+ && service.isCurrent(daemonServiceSpec(service, port))) {
327
+ const expected = await expectedDaemonIdentity();
328
+ await service.restart();
329
+ if (service.kind === 'schtasks')
330
+ await requestDaemonShutdown(info.instanceId, 2_000);
331
+ const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, DAEMON_SERVICE_READY_TIMEOUT_MS);
332
+ if (!ready)
333
+ throw new DinoxError('Restarted service did not become ready', { code: 'DAEMON_START_FAILED' });
334
+ return { stopped: true, running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket',
335
+ detached: true, alreadyRunning: false, service: service.kind };
336
+ }
320
337
  const stopResult = await stopDaemonLocked();
321
338
  await new Promise((resolve) => setTimeout(resolve, 1000));
322
339
  const startResult = await startDaemonLocked(port, true, options);
@@ -388,6 +405,8 @@ export async function uninstallDaemonService() {
388
405
  const info = await getDaemonInfo();
389
406
  if (info?.supervisor === service.kind)
390
407
  await stopDaemonLocked();
408
+ else
409
+ await service.stop();
391
410
  }
392
411
  return {
393
412
  uninstalled: await service.uninstall(),
@@ -456,17 +475,63 @@ function printDaemonUninstallResult(result) {
456
475
  ? `Removed ${result.service} service; the daemon is stopped.`
457
476
  : 'The daemon service is not installed.');
458
477
  }
478
+ export async function waitForSavedDaemonLogin(overrides = {}) {
479
+ const hasAuth = overrides.hasAuth ?? (async () => Boolean(normalizeAuthToken((await loadConfig()).auth?.authorization)));
480
+ const exit = overrides.exit ?? ((code) => process.exit(code));
481
+ const onSignal = overrides.onSignal ?? ((handler) => {
482
+ process.on('SIGTERM', handler);
483
+ process.on('SIGINT', handler);
484
+ return () => { process.off('SIGTERM', handler); process.off('SIGINT', handler); };
485
+ });
486
+ const watchConfig = overrides.watchConfig ?? ((check) => {
487
+ const dir = path.dirname(getConfigFilePath());
488
+ mkdirSync(dir, { recursive: true });
489
+ try {
490
+ const watcher = watch(dir, check);
491
+ return () => watcher.close();
492
+ }
493
+ catch {
494
+ return () => undefined;
495
+ }
496
+ });
497
+ writeDaemonLogLine('dinox-daemon', 'not signed in; waiting for saved login');
498
+ await new Promise((resolve) => {
499
+ let done = false;
500
+ let closeWatch = () => undefined;
501
+ let closeSignals = () => undefined;
502
+ const finish = (code) => {
503
+ if (done)
504
+ return;
505
+ done = true;
506
+ clearInterval(timer);
507
+ closeWatch();
508
+ closeSignals();
509
+ exit(code);
510
+ resolve();
511
+ };
512
+ const check = () => {
513
+ void hasAuth().then((authorized) => {
514
+ if (authorized && !done) {
515
+ writeDaemonLogLine('dinox-daemon', 'saved login found; restarting');
516
+ finish(75);
517
+ }
518
+ }).catch(() => undefined);
519
+ };
520
+ const timer = setInterval(check, overrides.pollMs ?? 60_000);
521
+ closeWatch = watchConfig(check);
522
+ closeSignals = onSignal(() => finish(0));
523
+ check();
524
+ });
525
+ }
459
526
  async function runDaemonStart(context) {
460
527
  const port = parsePort(context.options.port);
461
528
  const structured = Boolean(getRequestedStructuredOutputFormat());
462
529
  if (context.options.detach === false && process.env[DAEMON_SERVICE_ENV]
463
530
  && !resolveConfig(await loadConfig()).auth.authorization) {
464
- // Started at login by the service while signed out: exit cleanly so the
465
- // service does not retry; the next command after `dino auth login` starts it.
466
- writeDaemonLogLine('dinox-daemon', 'not signed in; staying stopped until `dino auth login`');
531
+ await waitForSavedDaemonLogin();
467
532
  return;
468
533
  }
469
- const result = await startDaemon(port, context.options.detach !== false, { quiet: structured });
534
+ const result = await startDaemon(port, context.options.detach !== false, { quiet: structured, takeover: true });
470
535
  if (!result.running) {
471
536
  throw new DinoxError('Failed to start daemon', { code: 'DAEMON_START_FAILED' });
472
537
  }
@@ -513,7 +578,7 @@ async function runDaemonUninstall() {
513
578
  }
514
579
  async function runDaemonRestart(context) {
515
580
  const structured = Boolean(getRequestedStructuredOutputFormat());
516
- const result = await restartDaemon(parsePort(context.options.port), { quiet: structured });
581
+ const result = await restartDaemon(parsePort(context.options.port), { quiet: structured, takeover: true });
517
582
  if (structured) {
518
583
  printSuccess(result);
519
584
  return;
@@ -22,10 +22,22 @@ export declare function envTokenOverridesLogin(raw: DinoxConfig, env?: NodeJS.Pr
22
22
  export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
23
23
  /** The installed service when this caller can use it, else null. */
24
24
  export declare function usableDaemonService(): Promise<DaemonService | null>;
25
+ /**
26
+ * The entrypoint of another dino install (e.g. a global install next to a
27
+ * source checkout) that the service definition runs, or null when it runs
28
+ * this CLI or an install that no longer exists. Automatic paths must not
29
+ * rewrite or restart a service another install owns: each install would
30
+ * otherwise keep switching it to its own version.
31
+ */
32
+ export declare function foreignServiceOwner(service: DaemonService, entrypoint?: string | undefined): string | null;
25
33
  /**
26
34
  * Starts a daemon. Caller must hold the daemon start lock and have stopped any
27
35
  * stale daemon. Uses the installed service when there is one (refreshing its
28
36
  * definition if this CLI's program, arguments or environment changed, e.g.
29
37
  * after `dino update`), otherwise a detached child with a clean environment.
38
+ * A service another install owns is only started, never rewritten, unless
39
+ * `takeover` is set by an explicit `dino daemon start/restart`.
30
40
  */
31
- export declare function launchDaemon(port: number): Promise<DaemonLaunch>;
41
+ export declare function launchDaemon(port: number, options?: {
42
+ takeover?: boolean;
43
+ }): Promise<DaemonLaunch>;