@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.
- package/dist/cli.js +5 -2
- package/dist/commands/auth/index.d.ts +2 -1
- package/dist/commands/auth/index.js +18 -1
- 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
|
@@ -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
|
+
}
|
package/dist/daemon/ensure.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { getDaemonInfo, isDaemonFresh, waitForFreshDaemon } from './client.js';
|
|
2
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/daemon/index.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/daemon/launch.d.ts
CHANGED
|
@@ -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
|
|
41
|
+
export declare function launchDaemon(port: number, options?: {
|
|
42
|
+
takeover?: boolean;
|
|
43
|
+
}): Promise<DaemonLaunch>;
|