@dinoxx/dinox-cli 2.5.2 → 2.6.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/commands/gateway/index.js +26 -4
- package/dist/commands/schema/renderSkills.js +1 -1
- package/dist/daemon/ensure.js +10 -19
- package/dist/daemon/env.d.ts +8 -0
- package/dist/daemon/env.js +81 -0
- package/dist/daemon/index.d.ts +27 -0
- package/dist/daemon/index.js +240 -57
- package/dist/daemon/launch.d.ts +27 -0
- package/dist/daemon/launch.js +87 -0
- package/dist/daemon/log.d.ts +12 -0
- package/dist/daemon/log.js +44 -0
- package/dist/daemon/server.js +8 -1
- package/dist/daemon/supervisor.d.ts +59 -0
- package/dist/daemon/supervisor.js +253 -0
- package/dist/daemon/types.d.ts +2 -0
- package/dist/gateway/service.js +2 -3
- package/dist/utils/errorCodes.d.ts +8 -0
- package/dist/utils/errorCodes.js +2 -0
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { isDaemonRunning, sendDaemonRequest } from '../../daemon/client.js';
|
|
1
|
+
import { getDaemonInfo, isDaemonRunning, sendDaemonRequest } from '../../daemon/client.js';
|
|
2
|
+
import { getDaemonService } from '../../daemon/supervisor.js';
|
|
2
3
|
import { ensureDaemonRunning } from '../../daemon/ensure.js';
|
|
3
4
|
import { loadConfig } from '../../config/store.js';
|
|
4
5
|
import { addGateway, adoptDetectedAgent, getGatewaysFilePath, loadGateways, removeGateway, } from '../../gateway/config.js';
|
|
5
6
|
import { detectAcpAgents, inferAgentId } from '../../gateway/detect.js';
|
|
6
7
|
import { serveDinoxMcpOverStdio } from '../../gateway/mcpServer.js';
|
|
7
8
|
import { DinoxError } from '../../utils/errors.js';
|
|
8
|
-
import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess } from '../../utils/output.js';
|
|
9
|
+
import { getRequestedStructuredOutputFormat, printHumanReadableLine, printHumanReadableWarning, printSuccess, } from '../../utils/output.js';
|
|
9
10
|
import { registerCommandGroup } from '../descriptor/register.js';
|
|
10
11
|
import { FORMAT_OPTIONS } from '../schema/globalOptions.js';
|
|
11
12
|
const GATEWAY_KINDS = ['acp', 'echo'];
|
|
@@ -42,13 +43,32 @@ function describeGateway(gateway) {
|
|
|
42
43
|
enabled: gateway.enabled,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Gateways live exactly as long as the daemon. One not owned by a service
|
|
48
|
+
* manager belongs to the shell or agent session that started it and goes
|
|
49
|
+
* offline with it, so say so where it matters (stderr, so JSON stays clean).
|
|
50
|
+
*/
|
|
51
|
+
async function warnIfDaemonUnmanaged() {
|
|
52
|
+
if (!getDaemonService())
|
|
53
|
+
return;
|
|
54
|
+
const info = await getDaemonInfo();
|
|
55
|
+
if (info && !info.supervisor) {
|
|
56
|
+
printHumanReadableWarning('warning: gateways run in a daemon owned by the shell or agent session that started it, and go offline when that session ends. Run `dino daemon install` to keep them online.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
45
59
|
/** Applies the account's gateways to the running daemon, starting it when needed. */
|
|
46
60
|
async function applyToDaemon() {
|
|
61
|
+
let gateways;
|
|
47
62
|
if (!(await isDaemonRunning())) {
|
|
48
63
|
await ensureDaemonRunning();
|
|
49
|
-
|
|
64
|
+
gateways = (await sendDaemonRequest('gateway.status')).gateways;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
gateways = (await sendDaemonRequest('gateway.reload', undefined, { timeoutMs: 30_000 })).gateways;
|
|
50
68
|
}
|
|
51
|
-
|
|
69
|
+
if (gateways.length > 0)
|
|
70
|
+
await warnIfDaemonUnmanaged();
|
|
71
|
+
return gateways;
|
|
52
72
|
}
|
|
53
73
|
function printStatuses(statuses) {
|
|
54
74
|
if (statuses.length === 0) {
|
|
@@ -162,6 +182,8 @@ async function runRemove(context) {
|
|
|
162
182
|
async function runStatus() {
|
|
163
183
|
await ensureDaemonRunning();
|
|
164
184
|
const { gateways } = (await sendDaemonRequest('gateway.status'));
|
|
185
|
+
if (gateways.length > 0)
|
|
186
|
+
await warnIfDaemonUnmanaged();
|
|
165
187
|
if (getRequestedStructuredOutputFormat()) {
|
|
166
188
|
printSuccess({ gateways });
|
|
167
189
|
return;
|
|
@@ -4,7 +4,7 @@ import { listErrorCodes } from '../../utils/errorCodes.js';
|
|
|
4
4
|
import { listCommandSchemas } from './spec.js';
|
|
5
5
|
const SECTION_ORDER = [
|
|
6
6
|
{ title: 'Auth', paths: ['auth.login', 'auth.logout', 'auth.status'] },
|
|
7
|
-
{ title: 'Daemon', paths: ['daemon.start', 'daemon.status', 'daemon.restart', 'daemon.stop'] },
|
|
7
|
+
{ title: 'Daemon', paths: ['daemon.start', 'daemon.status', 'daemon.restart', 'daemon.stop', 'daemon.install', 'daemon.uninstall'] },
|
|
8
8
|
{
|
|
9
9
|
title: 'Agent Gateways',
|
|
10
10
|
paths: ['gateway.detect', 'gateway.add', 'gateway.list', 'gateway.remove', 'gateway.status', 'gateway.reload', 'gateway.mcp'],
|
package/dist/daemon/ensure.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { getDaemonInfo, isDaemonFresh, waitForFreshDaemon } from './client.js';
|
|
3
2
|
import { stopDaemonLocked } from './index.js';
|
|
4
3
|
import { DEFAULT_DAEMON_PORT } from './types.js';
|
|
4
|
+
import { launchDaemon } from './launch.js';
|
|
5
|
+
import { getDaemonLogPath } from './log.js';
|
|
5
6
|
import { acquireDaemonStartLock, } from './pid.js';
|
|
6
7
|
import { loadConfig } from '../config/store.js';
|
|
7
8
|
import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
|
|
@@ -9,8 +10,8 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
|
|
|
9
10
|
import { getPackageVersion } from '../utils/version.js';
|
|
10
11
|
import { printHumanReadableWarning } from '../utils/output.js';
|
|
11
12
|
import { withAsyncRelease } from '../utils/asyncRelease.js';
|
|
13
|
+
import { DinoxError } from '../utils/errors.js';
|
|
12
14
|
const DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
13
|
-
const DAEMON_CHILD_ENV = 'DINOX_INTERNAL_DAEMON_CHILD';
|
|
14
15
|
async function hydrateDaemonIdentity(baseConfig) {
|
|
15
16
|
const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs: baseConfig.sync.timeoutMs });
|
|
16
17
|
return {
|
|
@@ -19,20 +20,6 @@ async function hydrateDaemonIdentity(baseConfig) {
|
|
|
19
20
|
runtimeConfigFingerprint: fingerprintDaemonRuntimeConfig(config),
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
function spawnDetachedDaemon() {
|
|
23
|
-
const args = ['daemon', 'start', '--port', String(DEFAULT_DAEMON_PORT), '--no-detach'];
|
|
24
|
-
const entrypoint = process.argv[1];
|
|
25
|
-
if (!entrypoint) {
|
|
26
|
-
throw new Error('Cannot determine the CLI entrypoint for daemon startup');
|
|
27
|
-
}
|
|
28
|
-
const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...args], {
|
|
29
|
-
detached: true,
|
|
30
|
-
stdio: 'ignore',
|
|
31
|
-
env: { ...process.env, [DAEMON_CHILD_ENV]: '1' },
|
|
32
|
-
});
|
|
33
|
-
child.unref();
|
|
34
|
-
return child;
|
|
35
|
-
}
|
|
36
23
|
function matchesRuntimeTokenBinding(info, version, config) {
|
|
37
24
|
return Boolean(config.auth.authorization
|
|
38
25
|
&& info.authenticated
|
|
@@ -73,13 +60,17 @@ async function ensureDaemonRunningUnlocked() {
|
|
|
73
60
|
else {
|
|
74
61
|
printHumanReadableWarning('Starting daemon...');
|
|
75
62
|
}
|
|
76
|
-
const
|
|
63
|
+
const launch = await launchDaemon(DEFAULT_DAEMON_PORT);
|
|
77
64
|
if (await waitForFreshDaemon(expectedVersion, expectedIdentity.userId, expectedIdentity.tokenFingerprint, expectedIdentity.runtimeConfigFingerprint, DAEMON_READY_TIMEOUT_MS)) {
|
|
78
65
|
printHumanReadableWarning('Daemon started');
|
|
79
66
|
return;
|
|
80
67
|
}
|
|
81
|
-
|
|
82
|
-
throw new
|
|
68
|
+
launch.abort();
|
|
69
|
+
throw new DinoxError('Failed to start daemon', {
|
|
70
|
+
code: 'DAEMON_START_FAILED',
|
|
71
|
+
details: { service: launch.service, logPath: getDaemonLogPath() },
|
|
72
|
+
suggestion: `Inspect ${getDaemonLogPath()} for the daemon's startup error.`,
|
|
73
|
+
});
|
|
83
74
|
}
|
|
84
75
|
export async function ensureDaemonRunning() {
|
|
85
76
|
await withAsyncRelease(() => acquireDaemonStartLock(), () => ensureDaemonRunningUnlocked());
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes sure the Node runtime running the CLI is on PATH, so agents installed
|
|
3
|
+
* as `#!/usr/bin/env node` scripts still start under a service manager's
|
|
4
|
+
* minimal PATH.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withRuntimeOnPath(pathValue: string | undefined, execPath?: string): string;
|
|
7
|
+
/** The environment a daemon process starts with: an allow-list of the caller's. */
|
|
8
|
+
export declare function buildDaemonEnv(source?: NodeJS.ProcessEnv, execPath?: string): Record<string, string>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Variables the daemon may inherit from whoever starts it. Everything else is
|
|
4
|
+
* dropped: the daemon outlives its caller, so it must not keep a coding agent's
|
|
5
|
+
* session markers (CLAUDECODE, CLAUDE_CODE_SESSION_ID, ...), credentials
|
|
6
|
+
* (ANTHROPIC_AUTH_TOKEN, ...) or app identity (XPC_SERVICE_NAME,
|
|
7
|
+
* __CFBundleIdentifier) and hand them on to the agents its gateways spawn.
|
|
8
|
+
* Agent credentials belong in `dino gateway add --env KEY=VALUE`.
|
|
9
|
+
*/
|
|
10
|
+
const INHERITED_NAMES = new Set([
|
|
11
|
+
'HOME',
|
|
12
|
+
'USER',
|
|
13
|
+
'LOGNAME',
|
|
14
|
+
'SHELL',
|
|
15
|
+
'PATH',
|
|
16
|
+
'TMPDIR',
|
|
17
|
+
'LANG',
|
|
18
|
+
'TZ',
|
|
19
|
+
'XDG_CONFIG_HOME',
|
|
20
|
+
'XDG_DATA_HOME',
|
|
21
|
+
'XDG_STATE_HOME',
|
|
22
|
+
'XDG_CACHE_HOME',
|
|
23
|
+
'XDG_RUNTIME_DIR',
|
|
24
|
+
'HTTP_PROXY',
|
|
25
|
+
'HTTPS_PROXY',
|
|
26
|
+
'ALL_PROXY',
|
|
27
|
+
'NO_PROXY',
|
|
28
|
+
'NODE_EXTRA_CA_CERTS',
|
|
29
|
+
'SSL_CERT_FILE',
|
|
30
|
+
'SSL_CERT_DIR',
|
|
31
|
+
'POWERSYNC_TOKEN_ENDPOINT',
|
|
32
|
+
// Windows process essentials.
|
|
33
|
+
'APPDATA',
|
|
34
|
+
'LOCALAPPDATA',
|
|
35
|
+
'USERPROFILE',
|
|
36
|
+
'HOMEDRIVE',
|
|
37
|
+
'HOMEPATH',
|
|
38
|
+
'SystemRoot',
|
|
39
|
+
'windir',
|
|
40
|
+
'ComSpec',
|
|
41
|
+
'PATHEXT',
|
|
42
|
+
'TEMP',
|
|
43
|
+
'TMP',
|
|
44
|
+
'ProgramData',
|
|
45
|
+
'ProgramFiles',
|
|
46
|
+
'ProgramFiles(x86)',
|
|
47
|
+
]);
|
|
48
|
+
const INHERITED_PREFIXES = ['DINOX_', 'LC_'];
|
|
49
|
+
/** Internal markers the parent sets for the child; never inherited from outside. */
|
|
50
|
+
const INTERNAL_NAMES = new Set(['DINOX_INTERNAL_DAEMON_CHILD', 'DINOX_DAEMON_SUPERVISOR']);
|
|
51
|
+
const INHERITED_UPPER = new Set([...INHERITED_NAMES].map((name) => name.toUpperCase()));
|
|
52
|
+
// Compared upper-cased: Windows names are case-insensitive (`Path`, `SystemRoot`).
|
|
53
|
+
function isInherited(name) {
|
|
54
|
+
const upper = name.toUpperCase();
|
|
55
|
+
if (INTERNAL_NAMES.has(upper))
|
|
56
|
+
return false;
|
|
57
|
+
return INHERITED_UPPER.has(upper) || INHERITED_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Makes sure the Node runtime running the CLI is on PATH, so agents installed
|
|
61
|
+
* as `#!/usr/bin/env node` scripts still start under a service manager's
|
|
62
|
+
* minimal PATH.
|
|
63
|
+
*/
|
|
64
|
+
export function withRuntimeOnPath(pathValue, execPath = process.execPath) {
|
|
65
|
+
const runtimeDir = path.dirname(execPath);
|
|
66
|
+
const entries = (pathValue ?? '').split(path.delimiter).filter(Boolean);
|
|
67
|
+
if (!entries.includes(runtimeDir))
|
|
68
|
+
entries.unshift(runtimeDir);
|
|
69
|
+
return entries.join(path.delimiter);
|
|
70
|
+
}
|
|
71
|
+
/** The environment a daemon process starts with: an allow-list of the caller's. */
|
|
72
|
+
export function buildDaemonEnv(source = process.env, execPath = process.execPath) {
|
|
73
|
+
const env = {};
|
|
74
|
+
for (const [name, value] of Object.entries(source)) {
|
|
75
|
+
if (typeof value === 'string' && isInherited(name))
|
|
76
|
+
env[name] = value;
|
|
77
|
+
}
|
|
78
|
+
const pathKey = Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'PATH';
|
|
79
|
+
env[pathKey] = withRuntimeOnPath(env[pathKey], execPath);
|
|
80
|
+
return env;
|
|
81
|
+
}
|
package/dist/daemon/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import type { CommandGroupDescriptor } from '../commands/descriptor/types.js';
|
|
3
|
+
import { type DaemonServiceKind } from './supervisor.js';
|
|
3
4
|
type DaemonStartResult = {
|
|
4
5
|
running: boolean;
|
|
5
6
|
port: number;
|
|
@@ -7,6 +8,8 @@ type DaemonStartResult = {
|
|
|
7
8
|
transport: 'socket';
|
|
8
9
|
detached: boolean;
|
|
9
10
|
alreadyRunning: boolean;
|
|
11
|
+
/** Service manager that started it; null for a detached child or foreground run. */
|
|
12
|
+
service: DaemonServiceKind | null;
|
|
10
13
|
};
|
|
11
14
|
type DaemonStartOptions = {
|
|
12
15
|
quiet?: boolean;
|
|
@@ -17,6 +20,20 @@ type DaemonStopResult = {
|
|
|
17
20
|
wasRunning: boolean;
|
|
18
21
|
stalePidFile: boolean;
|
|
19
22
|
};
|
|
23
|
+
type DaemonInstallResult = {
|
|
24
|
+
installed: boolean;
|
|
25
|
+
service: DaemonServiceKind;
|
|
26
|
+
label: string;
|
|
27
|
+
definitionPath: string;
|
|
28
|
+
logPath: string;
|
|
29
|
+
running: boolean;
|
|
30
|
+
replacedRunning: boolean;
|
|
31
|
+
};
|
|
32
|
+
type DaemonUninstallResult = {
|
|
33
|
+
uninstalled: boolean;
|
|
34
|
+
service: DaemonServiceKind | null;
|
|
35
|
+
definitionPath: string | null;
|
|
36
|
+
};
|
|
20
37
|
export declare function startDaemonLocked(port: number, detached: boolean, options?: DaemonStartOptions): Promise<DaemonStartResult>;
|
|
21
38
|
/** Caller must hold the daemon start lock. */
|
|
22
39
|
export declare function stopDaemonLocked(): Promise<DaemonStopResult>;
|
|
@@ -31,6 +48,7 @@ export declare function restartDaemonLocked(port?: number, options?: {
|
|
|
31
48
|
transport: 'socket';
|
|
32
49
|
detached: boolean;
|
|
33
50
|
alreadyRunning: boolean;
|
|
51
|
+
service: DaemonServiceKind | null;
|
|
34
52
|
}>;
|
|
35
53
|
export declare function restartDaemon(port?: number, options?: {
|
|
36
54
|
quiet?: boolean;
|
|
@@ -42,7 +60,16 @@ export declare function restartDaemon(port?: number, options?: {
|
|
|
42
60
|
transport: 'socket';
|
|
43
61
|
detached: boolean;
|
|
44
62
|
alreadyRunning: boolean;
|
|
63
|
+
service: DaemonServiceKind | null;
|
|
45
64
|
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Hands the daemon to the platform's service manager so it no longer belongs
|
|
67
|
+
* to whichever process first started it, then waits until the managed
|
|
68
|
+
* instance answers with the current identity.
|
|
69
|
+
*/
|
|
70
|
+
export declare function installDaemonService(port?: number): Promise<DaemonInstallResult>;
|
|
71
|
+
/** Stops the managed daemon and removes its service definition. */
|
|
72
|
+
export declare function uninstallDaemonService(): Promise<DaemonUninstallResult>;
|
|
46
73
|
export declare const daemonCommandGroup: CommandGroupDescriptor;
|
|
47
74
|
export declare function registerDaemonCommands(program: Command): void;
|
|
48
75
|
export {};
|
package/dist/daemon/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { DaemonServer } from './server.js';
|
|
4
3
|
import { DEFAULT_DAEMON_PORT } from './types.js';
|
|
@@ -14,12 +13,32 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
|
|
|
14
13
|
import { withAsyncRelease } from '../utils/asyncRelease.js';
|
|
15
14
|
import { remainingDeadlineMs } from '../utils/deadline.js';
|
|
16
15
|
import { getPackageVersion } from '../utils/version.js';
|
|
16
|
+
import { DAEMON_CHILD_ENV, daemonServiceSpec, envTokenOverridesLogin, launchDaemon } from './launch.js';
|
|
17
|
+
import { getDaemonLogPath, rotateDaemonLog, writeDaemonLogLine } from './log.js';
|
|
18
|
+
import { DAEMON_SERVICE_ENV, getDaemonService } from './supervisor.js';
|
|
19
|
+
import { normalizeAuthToken } from '../utils/authToken.js';
|
|
17
20
|
const DAEMON_STOP_POLL_INTERVAL_MS = 100;
|
|
18
21
|
const DAEMON_STOP_TIMEOUT_MS = 5_000;
|
|
19
|
-
const
|
|
22
|
+
const DAEMON_SERVICE_READY_TIMEOUT_MS = 20_000;
|
|
23
|
+
const DAEMON_CRASH_STOP_TIMEOUT_MS = 5_000;
|
|
20
24
|
async function withDaemonStartLock(run) {
|
|
21
25
|
return withAsyncRelease(() => acquireDaemonStartLock(), run);
|
|
22
26
|
}
|
|
27
|
+
function installedServiceKind() {
|
|
28
|
+
const service = getDaemonService();
|
|
29
|
+
return service?.isInstalled() ? service.kind : null;
|
|
30
|
+
}
|
|
31
|
+
function notRunningStatus(fields) {
|
|
32
|
+
return {
|
|
33
|
+
running: false,
|
|
34
|
+
transport: 'socket',
|
|
35
|
+
version: null,
|
|
36
|
+
service: installedServiceKind(),
|
|
37
|
+
supervised: null,
|
|
38
|
+
logPath: getDaemonLogPath(),
|
|
39
|
+
...fields,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
23
42
|
function runningDaemonStatus(info, pidInfo) {
|
|
24
43
|
const pidFileMatches = Boolean(pidInfo
|
|
25
44
|
&& pidInfo.pid === info.pid
|
|
@@ -33,6 +52,9 @@ function runningDaemonStatus(info, pidInfo) {
|
|
|
33
52
|
transport: 'socket',
|
|
34
53
|
version: info.version,
|
|
35
54
|
stalePidFile: Boolean(pidInfo && !pidFileMatches),
|
|
55
|
+
service: installedServiceKind(),
|
|
56
|
+
supervised: Boolean(info.supervisor),
|
|
57
|
+
logPath: getDaemonLogPath(),
|
|
36
58
|
};
|
|
37
59
|
}
|
|
38
60
|
function parsePort(value) {
|
|
@@ -110,7 +132,7 @@ export async function startDaemonLocked(port, detached, options = {}) {
|
|
|
110
132
|
const runningInfo = await getDaemonInfo(remainingStartTimeoutMs('initial-ping'));
|
|
111
133
|
if (runningInfo) {
|
|
112
134
|
if (matchesRuntimeTokenBinding(runningInfo, version, baseConfig)) {
|
|
113
|
-
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true };
|
|
135
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true, service: runningInfo.supervisor ?? null };
|
|
114
136
|
}
|
|
115
137
|
throw new DinoxError('A stale daemon is already responding. Run `dino daemon restart` and retry.', {
|
|
116
138
|
code: 'DAEMON_STALE',
|
|
@@ -119,54 +141,78 @@ export async function startDaemonLocked(port, detached, options = {}) {
|
|
|
119
141
|
}
|
|
120
142
|
const expected = await expectedDaemonIdentity(baseConfig, remainingStartTimeoutMs('auth-hydration'));
|
|
121
143
|
if (detached) {
|
|
122
|
-
const
|
|
123
|
-
const entrypoint = process.argv[1];
|
|
124
|
-
if (!entrypoint) {
|
|
125
|
-
throw new DinoxError('Cannot determine the CLI entrypoint for daemon startup');
|
|
126
|
-
}
|
|
127
|
-
const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...args], {
|
|
128
|
-
detached: true,
|
|
129
|
-
stdio: 'ignore',
|
|
130
|
-
env: { ...process.env, [DAEMON_CHILD_ENV]: '1' },
|
|
131
|
-
});
|
|
132
|
-
child.unref();
|
|
144
|
+
const launch = await launchDaemon(port);
|
|
133
145
|
const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, remainingStartTimeoutMs('ready-poll'));
|
|
134
146
|
if (!ready) {
|
|
135
|
-
|
|
136
|
-
throw new DinoxError('Failed to start daemon'
|
|
147
|
+
launch.abort();
|
|
148
|
+
throw new DinoxError('Failed to start daemon', {
|
|
149
|
+
code: 'DAEMON_START_FAILED',
|
|
150
|
+
details: { service: launch.service, logPath: getDaemonLogPath() },
|
|
151
|
+
suggestion: `Inspect ${getDaemonLogPath()} for the daemon's startup error.`,
|
|
152
|
+
});
|
|
137
153
|
}
|
|
138
|
-
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false };
|
|
154
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: launch.service };
|
|
139
155
|
}
|
|
140
156
|
else {
|
|
157
|
+
await serveDaemonInForeground(port, options);
|
|
158
|
+
}
|
|
159
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: null };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Runs the daemon in this process until a signal or shutdown request. A crash
|
|
163
|
+
* is logged and exits non-zero, so a service manager restarts it; a clean
|
|
164
|
+
* stop exits 0 and stays stopped.
|
|
165
|
+
*/
|
|
166
|
+
async function serveDaemonInForeground(port, options) {
|
|
167
|
+
if (process.env[DAEMON_SERVICE_ENV]) {
|
|
168
|
+
// A service manager holds the log open in append mode; keep it bounded.
|
|
169
|
+
rotateDaemonLog();
|
|
170
|
+
}
|
|
171
|
+
if (!options.quiet) {
|
|
172
|
+
printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
|
|
173
|
+
}
|
|
174
|
+
const server = new DaemonServer();
|
|
175
|
+
try {
|
|
176
|
+
await server.initialize();
|
|
177
|
+
await server.start(port);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
await server.stop().catch(() => undefined);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (!options.quiet) {
|
|
184
|
+
printHumanReadableLine(`Daemon running on ${getDaemonSocketPath()} (PID: ${process.pid})`);
|
|
185
|
+
}
|
|
186
|
+
writeDaemonLogLine('dinox-daemon', 'started', {
|
|
187
|
+
pid: process.pid,
|
|
188
|
+
version: getPackageVersion(),
|
|
189
|
+
supervisor: process.env[DAEMON_SERVICE_ENV] ?? null,
|
|
190
|
+
});
|
|
191
|
+
const stopAndExit = async (reason, exitCode) => {
|
|
192
|
+
writeDaemonLogLine('dinox-daemon', 'stopping', { reason });
|
|
193
|
+
const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
|
|
194
|
+
forceExit.unref();
|
|
195
|
+
await server.stop().catch(() => undefined);
|
|
196
|
+
process.exit(exitCode);
|
|
197
|
+
};
|
|
198
|
+
process.on('SIGINT', () => {
|
|
141
199
|
if (!options.quiet) {
|
|
142
|
-
printHumanReadableLine(
|
|
143
|
-
|
|
144
|
-
const server = new DaemonServer();
|
|
145
|
-
try {
|
|
146
|
-
await server.initialize();
|
|
147
|
-
await server.start(port);
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
await server.stop().catch(() => undefined);
|
|
151
|
-
throw error;
|
|
152
|
-
}
|
|
153
|
-
if (!options.quiet) {
|
|
154
|
-
printHumanReadableLine(`Daemon running on ${getDaemonSocketPath()} (PID: ${process.pid})`);
|
|
200
|
+
printHumanReadableLine('');
|
|
201
|
+
printHumanReadableLine('Stopping daemon...');
|
|
155
202
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
process.on('SIGTERM', async () => {
|
|
165
|
-
await server.stop();
|
|
166
|
-
process.exit(0);
|
|
203
|
+
void stopAndExit('SIGINT', 0);
|
|
204
|
+
});
|
|
205
|
+
process.on('SIGTERM', () => void stopAndExit('SIGTERM', 0));
|
|
206
|
+
// No controlling terminal to lose; never let a hangup end the daemon.
|
|
207
|
+
process.on('SIGHUP', () => undefined);
|
|
208
|
+
const crash = (kind) => (error) => {
|
|
209
|
+
writeDaemonLogLine('dinox-daemon', kind, {
|
|
210
|
+
error: error instanceof Error ? error.stack ?? error.message : String(error),
|
|
167
211
|
});
|
|
168
|
-
|
|
169
|
-
|
|
212
|
+
void stopAndExit(kind, 1);
|
|
213
|
+
};
|
|
214
|
+
process.on('uncaughtException', crash('uncaught exception'));
|
|
215
|
+
process.on('unhandledRejection', crash('unhandled rejection'));
|
|
170
216
|
}
|
|
171
217
|
async function startDaemon(port, detached = true, options = {}) {
|
|
172
218
|
const internalChild = process.env[DAEMON_CHILD_ENV] === '1';
|
|
@@ -241,7 +287,7 @@ async function daemonStatus() {
|
|
|
241
287
|
? removePid(pidInfo.instanceId)
|
|
242
288
|
: removePid(undefined, { expectedPid: pidInfo.pid });
|
|
243
289
|
if (stalePidFile) {
|
|
244
|
-
return {
|
|
290
|
+
return notRunningStatus({ unreachable: false, pid: null, port: null, socketPath: null, stalePidFile: true });
|
|
245
291
|
}
|
|
246
292
|
// A replacement rewrote the PID metadata after the initial snapshot.
|
|
247
293
|
// Re-read once and leave the replacement instance untouched.
|
|
@@ -251,28 +297,22 @@ async function daemonStatus() {
|
|
|
251
297
|
return runningDaemonStatus(replacementInfo, replacementPidInfo);
|
|
252
298
|
}
|
|
253
299
|
const replacementSocketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
|
|
254
|
-
return {
|
|
255
|
-
running: false,
|
|
300
|
+
return notRunningStatus({
|
|
256
301
|
unreachable: Boolean(replacementPidInfo || replacementSocketExists),
|
|
257
302
|
pid: replacementPidInfo?.pid ?? null,
|
|
258
303
|
port: replacementPidInfo?.port ?? null,
|
|
259
304
|
socketPath: replacementSocketExists ? getDaemonSocketPath() : null,
|
|
260
|
-
transport: 'socket',
|
|
261
|
-
version: null,
|
|
262
305
|
stalePidFile: false,
|
|
263
|
-
};
|
|
306
|
+
});
|
|
264
307
|
}
|
|
265
308
|
const socketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
|
|
266
|
-
return {
|
|
267
|
-
running: false,
|
|
309
|
+
return notRunningStatus({
|
|
268
310
|
unreachable: Boolean(pidInfo || socketExists),
|
|
269
311
|
pid: pidInfo?.pid ?? null,
|
|
270
312
|
port: pidInfo?.port ?? null,
|
|
271
313
|
socketPath: socketExists ? getDaemonSocketPath() : null,
|
|
272
|
-
transport: 'socket',
|
|
273
|
-
version: null,
|
|
274
314
|
stalePidFile: false,
|
|
275
|
-
};
|
|
315
|
+
});
|
|
276
316
|
}
|
|
277
317
|
}
|
|
278
318
|
export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options = {}) {
|
|
@@ -284,6 +324,68 @@ export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options =
|
|
|
284
324
|
export async function restartDaemon(port = DEFAULT_DAEMON_PORT, options = {}) {
|
|
285
325
|
return withDaemonStartLock(() => restartDaemonLocked(port, options));
|
|
286
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Hands the daemon to the platform's service manager so it no longer belongs
|
|
329
|
+
* to whichever process first started it, then waits until the managed
|
|
330
|
+
* instance answers with the current identity.
|
|
331
|
+
*/
|
|
332
|
+
export async function installDaemonService(port = DEFAULT_DAEMON_PORT) {
|
|
333
|
+
const service = getDaemonService();
|
|
334
|
+
if (!service) {
|
|
335
|
+
throw new DinoxError(`No supported service manager on ${process.platform}; keep using \`dino daemon start\`.`, {
|
|
336
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
337
|
+
details: { platform: process.platform },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const raw = await loadConfig();
|
|
341
|
+
if (!normalizeAuthToken(raw.auth?.authorization)) {
|
|
342
|
+
throw new DinoxError('The daemon service runs with the saved login, and none is saved.', {
|
|
343
|
+
code: 'AUTH_REQUIRED',
|
|
344
|
+
suggestion: 'Run `dino auth login`, then retry `dino daemon install`.',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (envTokenOverridesLogin(raw)) {
|
|
348
|
+
throw new DinoxError('DINOX_TOKEN selects a different account than the saved login, and the daemon service only uses the saved login.', {
|
|
349
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
350
|
+
details: { reason: 'env-token' },
|
|
351
|
+
suggestion: 'Unset DINOX_TOKEN, or save that token with `dino auth login`, then retry.',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return withDaemonStartLock(async () => {
|
|
355
|
+
const expected = await expectedDaemonIdentity(resolveConfig(raw));
|
|
356
|
+
const stopResult = await stopDaemonLocked();
|
|
357
|
+
await service.install(daemonServiceSpec(service, port));
|
|
358
|
+
const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, DAEMON_SERVICE_READY_TIMEOUT_MS);
|
|
359
|
+
if (!ready) {
|
|
360
|
+
throw new DinoxError(`The ${service.kind} service was installed, but the daemon did not become ready.`, {
|
|
361
|
+
code: 'DAEMON_START_FAILED',
|
|
362
|
+
details: { service: service.kind, definitionPath: service.definitionPath, logPath: getDaemonLogPath() },
|
|
363
|
+
suggestion: `Inspect ${getDaemonLogPath()}, then run \`dino daemon status\`.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
installed: true,
|
|
368
|
+
service: service.kind,
|
|
369
|
+
label: service.label,
|
|
370
|
+
definitionPath: service.definitionPath,
|
|
371
|
+
logPath: getDaemonLogPath(),
|
|
372
|
+
running: true,
|
|
373
|
+
replacedRunning: stopResult.stopped,
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** Stops the managed daemon and removes its service definition. */
|
|
378
|
+
export async function uninstallDaemonService() {
|
|
379
|
+
const service = getDaemonService();
|
|
380
|
+
if (!service) {
|
|
381
|
+
return { uninstalled: false, service: null, definitionPath: null };
|
|
382
|
+
}
|
|
383
|
+
return withDaemonStartLock(async () => ({
|
|
384
|
+
uninstalled: await service.uninstall(),
|
|
385
|
+
service: service.kind,
|
|
386
|
+
definitionPath: service.definitionPath,
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
287
389
|
function printDaemonStartResult(result) {
|
|
288
390
|
if (result.alreadyRunning) {
|
|
289
391
|
printHumanReadableLine('Daemon is already running');
|
|
@@ -320,10 +422,40 @@ function printDaemonStatusResult(result) {
|
|
|
320
422
|
printHumanReadableLine(`Transport: ${result.transport}`);
|
|
321
423
|
printHumanReadableLine(`Socket: ${result.socketPath}`);
|
|
322
424
|
printHumanReadableLine(`Version: ${result.version}`);
|
|
425
|
+
printHumanReadableLine(`Service: ${result.service ?? 'not installed'}${result.service && !result.supervised ? ' (running daemon is not managed by it)' : ''}`);
|
|
426
|
+
printHumanReadableLine(`Log: ${result.logPath}`);
|
|
427
|
+
if (!result.supervised) {
|
|
428
|
+
printHumanReadableLine('Note: this daemon belongs to the process that started it. Run `dino daemon install` to keep it (and agent gateways) running independently.');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function printDaemonInstallResult(result) {
|
|
432
|
+
printHumanReadableLine(`Installed ${result.service} service ${result.label}`);
|
|
433
|
+
printHumanReadableLine(`Definition: ${result.definitionPath}`);
|
|
434
|
+
printHumanReadableLine(`Log: ${result.logPath}`);
|
|
435
|
+
printHumanReadableLine('The daemon now starts at login and restarts after a crash.');
|
|
436
|
+
if (result.service === 'systemd') {
|
|
437
|
+
printHumanReadableLine('To keep it running after you log out, run `loginctl enable-linger`.');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function printDaemonUninstallResult(result) {
|
|
441
|
+
if (!result.service) {
|
|
442
|
+
printHumanReadableLine(`No supported service manager on ${process.platform}; nothing to uninstall.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
printHumanReadableLine(result.uninstalled
|
|
446
|
+
? `Removed ${result.service} service; the daemon is stopped.`
|
|
447
|
+
: 'The daemon service is not installed.');
|
|
323
448
|
}
|
|
324
449
|
async function runDaemonStart(context) {
|
|
325
450
|
const port = parsePort(context.options.port);
|
|
326
451
|
const structured = Boolean(getRequestedStructuredOutputFormat());
|
|
452
|
+
if (context.options.detach === false && process.env[DAEMON_SERVICE_ENV]
|
|
453
|
+
&& !resolveConfig(await loadConfig()).auth.authorization) {
|
|
454
|
+
// Started at login by the service while signed out: exit cleanly so the
|
|
455
|
+
// service does not retry; the next command after `dino auth login` starts it.
|
|
456
|
+
writeDaemonLogLine('dinox-daemon', 'not signed in; staying stopped until `dino auth login`');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
327
459
|
const result = await startDaemon(port, context.options.detach !== false, { quiet: structured });
|
|
328
460
|
if (!result.running) {
|
|
329
461
|
throw new DinoxError('Failed to start daemon', { code: 'DAEMON_START_FAILED' });
|
|
@@ -350,6 +482,22 @@ async function runDaemonStatus() {
|
|
|
350
482
|
}
|
|
351
483
|
printDaemonStatusResult(result);
|
|
352
484
|
}
|
|
485
|
+
async function runDaemonInstall(context) {
|
|
486
|
+
const result = await installDaemonService(parsePort(context.options.port));
|
|
487
|
+
if (getRequestedStructuredOutputFormat()) {
|
|
488
|
+
printSuccess(result);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
printDaemonInstallResult(result);
|
|
492
|
+
}
|
|
493
|
+
async function runDaemonUninstall() {
|
|
494
|
+
const result = await uninstallDaemonService();
|
|
495
|
+
if (getRequestedStructuredOutputFormat()) {
|
|
496
|
+
printSuccess(result);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
printDaemonUninstallResult(result);
|
|
500
|
+
}
|
|
353
501
|
async function runDaemonRestart(context) {
|
|
354
502
|
const structured = Boolean(getRequestedStructuredOutputFormat());
|
|
355
503
|
const result = await restartDaemon(parsePort(context.options.port), { quiet: structured });
|
|
@@ -379,7 +527,7 @@ export const daemonCommandGroup = {
|
|
|
379
527
|
output: {
|
|
380
528
|
type: 'structured',
|
|
381
529
|
summary: 'Daemon start acknowledgement',
|
|
382
|
-
fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
|
|
530
|
+
fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
|
|
383
531
|
},
|
|
384
532
|
examples: ['dino daemon start', 'dino daemon start --port 17321', 'dino daemon start --no-detach'],
|
|
385
533
|
},
|
|
@@ -413,7 +561,7 @@ export const daemonCommandGroup = {
|
|
|
413
561
|
output: {
|
|
414
562
|
type: 'structured',
|
|
415
563
|
summary: 'Daemon process status',
|
|
416
|
-
fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile'],
|
|
564
|
+
fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile', 'service', 'supervised', 'logPath'],
|
|
417
565
|
},
|
|
418
566
|
examples: ['dino daemon status'],
|
|
419
567
|
},
|
|
@@ -431,12 +579,47 @@ export const daemonCommandGroup = {
|
|
|
431
579
|
output: {
|
|
432
580
|
type: 'structured',
|
|
433
581
|
summary: 'Daemon restart acknowledgement',
|
|
434
|
-
fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
|
|
582
|
+
fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
|
|
435
583
|
},
|
|
436
584
|
examples: ['dino daemon restart', 'dino daemon restart --port 17321'],
|
|
437
585
|
},
|
|
438
586
|
action: runDaemonRestart,
|
|
439
587
|
},
|
|
588
|
+
{
|
|
589
|
+
name: 'install',
|
|
590
|
+
description: 'Run the daemon as a login service (launchd / systemd) so it outlives the shell or agent that started it',
|
|
591
|
+
options: [{ flags: '--port <number>', description: 'Legacy compatibility field; daemon listens on a private local socket', type: 'number', defaultValue: DEFAULT_DAEMON_PORT }],
|
|
592
|
+
schema: {
|
|
593
|
+
path: 'daemon.install',
|
|
594
|
+
risk: 'write',
|
|
595
|
+
auth: 'required',
|
|
596
|
+
globalOptions: FORMAT_OPTIONS,
|
|
597
|
+
output: {
|
|
598
|
+
type: 'structured',
|
|
599
|
+
summary: 'Installed daemon service',
|
|
600
|
+
fields: ['installed', 'service', 'label', 'definitionPath', 'logPath', 'running', 'replacedRunning'],
|
|
601
|
+
},
|
|
602
|
+
examples: ['dino daemon install'],
|
|
603
|
+
},
|
|
604
|
+
action: runDaemonInstall,
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
name: 'uninstall',
|
|
608
|
+
description: 'Stop the daemon service and remove it from the login session',
|
|
609
|
+
schema: {
|
|
610
|
+
path: 'daemon.uninstall',
|
|
611
|
+
risk: 'write',
|
|
612
|
+
auth: 'none',
|
|
613
|
+
globalOptions: FORMAT_OPTIONS,
|
|
614
|
+
output: {
|
|
615
|
+
type: 'structured',
|
|
616
|
+
summary: 'Daemon service removal acknowledgement',
|
|
617
|
+
fields: ['uninstalled', 'service', 'definitionPath'],
|
|
618
|
+
},
|
|
619
|
+
examples: ['dino daemon uninstall'],
|
|
620
|
+
},
|
|
621
|
+
action: runDaemonUninstall,
|
|
622
|
+
},
|
|
440
623
|
],
|
|
441
624
|
};
|
|
442
625
|
export function registerDaemonCommands(program) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DinoxConfig } from '../config/types.js';
|
|
2
|
+
import { type DaemonService, type DaemonServiceKind, type DaemonServiceSpec } from './supervisor.js';
|
|
3
|
+
/** Tells a daemon child its parent holds the start lock (or that it runs under a service). */
|
|
4
|
+
export declare const DAEMON_CHILD_ENV = "DINOX_INTERNAL_DAEMON_CHILD";
|
|
5
|
+
export type DaemonLaunch = {
|
|
6
|
+
/** The service manager that started it, or null for a detached child process. */
|
|
7
|
+
service: DaemonServiceKind | null;
|
|
8
|
+
/** Gives up on a launch that never became ready. */
|
|
9
|
+
abort(): void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A service only knows the saved login. When DINOX_TOKEN selects a different
|
|
13
|
+
* account, the caller needs a daemon bound to that token, which only a child
|
|
14
|
+
* of its own environment can have.
|
|
15
|
+
*/
|
|
16
|
+
export declare function envTokenOverridesLogin(raw: DinoxConfig, env?: NodeJS.ProcessEnv): boolean;
|
|
17
|
+
/** What the service definition runs: this CLI, with no secrets written to disk. */
|
|
18
|
+
export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
|
|
19
|
+
/** The installed service when this caller can use it, else null. */
|
|
20
|
+
export declare function usableDaemonService(): Promise<DaemonService | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Starts a daemon. Caller must hold the daemon start lock and have stopped any
|
|
23
|
+
* stale daemon. Uses the installed service when there is one (refreshing its
|
|
24
|
+
* definition if this CLI's program, arguments or environment changed, e.g.
|
|
25
|
+
* after `dino update`), otherwise a detached child with a clean environment.
|
|
26
|
+
*/
|
|
27
|
+
export declare function launchDaemon(port: number): Promise<DaemonLaunch>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { loadConfig } from '../config/store.js';
|
|
4
|
+
import { DINOX_TOKEN_ENV_VAR, normalizeAuthToken, readEnvAuthToken } from '../utils/authToken.js';
|
|
5
|
+
import { DinoxError } from '../utils/errors.js';
|
|
6
|
+
import { buildDaemonEnv } from './env.js';
|
|
7
|
+
import { closeDaemonLogFd, getDaemonLogPath, openDaemonLogForChild } from './log.js';
|
|
8
|
+
import { ensureDaemonDir } from './pid.js';
|
|
9
|
+
import { DAEMON_SERVICE_ENV, getDaemonService, serviceDefinitionIsStale, } from './supervisor.js';
|
|
10
|
+
/** Tells a daemon child its parent holds the start lock (or that it runs under a service). */
|
|
11
|
+
export const DAEMON_CHILD_ENV = 'DINOX_INTERNAL_DAEMON_CHILD';
|
|
12
|
+
function daemonCommandLine(port) {
|
|
13
|
+
const entrypoint = process.argv[1];
|
|
14
|
+
if (!entrypoint) {
|
|
15
|
+
throw new DinoxError('Cannot determine the CLI entrypoint for daemon startup', { code: 'DAEMON_START_FAILED' });
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
program: process.execPath,
|
|
19
|
+
args: [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A service only knows the saved login. When DINOX_TOKEN selects a different
|
|
24
|
+
* account, the caller needs a daemon bound to that token, which only a child
|
|
25
|
+
* of its own environment can have.
|
|
26
|
+
*/
|
|
27
|
+
export function envTokenOverridesLogin(raw, env = process.env) {
|
|
28
|
+
const envToken = readEnvAuthToken(env);
|
|
29
|
+
return Boolean(envToken && envToken !== normalizeAuthToken(raw.auth?.authorization));
|
|
30
|
+
}
|
|
31
|
+
/** What the service definition runs: this CLI, with no secrets written to disk. */
|
|
32
|
+
export function daemonServiceSpec(service, port) {
|
|
33
|
+
const { program, args } = daemonCommandLine(port);
|
|
34
|
+
const env = buildDaemonEnv();
|
|
35
|
+
delete env[DINOX_TOKEN_ENV_VAR];
|
|
36
|
+
env[DAEMON_SERVICE_ENV] = service.kind;
|
|
37
|
+
env[DAEMON_CHILD_ENV] = '1';
|
|
38
|
+
return { program, args, env, workingDirectory: os.homedir(), logPath: getDaemonLogPath() };
|
|
39
|
+
}
|
|
40
|
+
/** The installed service when this caller can use it, else null. */
|
|
41
|
+
export async function usableDaemonService() {
|
|
42
|
+
const service = getDaemonService();
|
|
43
|
+
if (!service?.isInstalled())
|
|
44
|
+
return null;
|
|
45
|
+
if (envTokenOverridesLogin(await loadConfig()))
|
|
46
|
+
return null;
|
|
47
|
+
return service;
|
|
48
|
+
}
|
|
49
|
+
function spawnDetached(port) {
|
|
50
|
+
const { program, args } = daemonCommandLine(port);
|
|
51
|
+
const log = openDaemonLogForChild();
|
|
52
|
+
try {
|
|
53
|
+
const child = spawn(program, args, {
|
|
54
|
+
detached: true,
|
|
55
|
+
stdio: ['ignore', log, log],
|
|
56
|
+
env: { ...buildDaemonEnv(), [DAEMON_CHILD_ENV]: '1' },
|
|
57
|
+
cwd: os.homedir(),
|
|
58
|
+
});
|
|
59
|
+
child.unref();
|
|
60
|
+
return { service: null, abort: () => child.kill('SIGTERM') };
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
closeDaemonLogFd(log);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Starts a daemon. Caller must hold the daemon start lock and have stopped any
|
|
68
|
+
* stale daemon. Uses the installed service when there is one (refreshing its
|
|
69
|
+
* definition if this CLI's program, arguments or environment changed, e.g.
|
|
70
|
+
* after `dino update`), otherwise a detached child with a clean environment.
|
|
71
|
+
*/
|
|
72
|
+
export async function launchDaemon(port) {
|
|
73
|
+
const service = await usableDaemonService();
|
|
74
|
+
if (!service)
|
|
75
|
+
return spawnDetached(port);
|
|
76
|
+
ensureDaemonDir();
|
|
77
|
+
const spec = daemonServiceSpec(service, port);
|
|
78
|
+
if (serviceDefinitionIsStale(service, spec)) {
|
|
79
|
+
await service.install(spec);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
await service.start();
|
|
83
|
+
}
|
|
84
|
+
// The service manager owns the process; a launch that never becomes ready
|
|
85
|
+
// is reported with the log path rather than killed here.
|
|
86
|
+
return { service: service.kind, abort: () => undefined };
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function getDaemonLogPath(): string;
|
|
2
|
+
/**
|
|
3
|
+
* Keeps one previous generation once the log grows past the limit. Copy then
|
|
4
|
+
* truncate (not rename): a service manager holds the log open in append mode,
|
|
5
|
+
* so the file must stay the same inode.
|
|
6
|
+
*/
|
|
7
|
+
export declare function rotateDaemonLog(limitBytes?: number): void;
|
|
8
|
+
/** Opens the log for a daemon child's stdout/stderr; the caller closes it after spawning. */
|
|
9
|
+
export declare function openDaemonLogForChild(): number | 'ignore';
|
|
10
|
+
export declare function closeDaemonLogFd(fd: number | 'ignore'): void;
|
|
11
|
+
/** One timestamped line on stderr, which a daemon child has pointed at its log. */
|
|
12
|
+
export declare function writeDaemonLogLine(scope: string, message: string, details?: Record<string, unknown>): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { closeSync, copyFileSync, openSync, statSync, truncateSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureDaemonDir, getDaemonDir } from './pid.js';
|
|
4
|
+
const MAX_DAEMON_LOG_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
export function getDaemonLogPath() {
|
|
6
|
+
return join(getDaemonDir(), 'daemon.log');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Keeps one previous generation once the log grows past the limit. Copy then
|
|
10
|
+
* truncate (not rename): a service manager holds the log open in append mode,
|
|
11
|
+
* so the file must stay the same inode.
|
|
12
|
+
*/
|
|
13
|
+
export function rotateDaemonLog(limitBytes = MAX_DAEMON_LOG_BYTES) {
|
|
14
|
+
const logPath = getDaemonLogPath();
|
|
15
|
+
try {
|
|
16
|
+
if (statSync(logPath).size <= limitBytes)
|
|
17
|
+
return;
|
|
18
|
+
copyFileSync(logPath, `${logPath}.1`);
|
|
19
|
+
truncateSync(logPath, 0);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Missing log or unwritable directory: logging is best effort.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Opens the log for a daemon child's stdout/stderr; the caller closes it after spawning. */
|
|
26
|
+
export function openDaemonLogForChild() {
|
|
27
|
+
try {
|
|
28
|
+
ensureDaemonDir();
|
|
29
|
+
rotateDaemonLog();
|
|
30
|
+
return openSync(getDaemonLogPath(), 'a', 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'ignore';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function closeDaemonLogFd(fd) {
|
|
37
|
+
if (fd !== 'ignore')
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
}
|
|
40
|
+
/** One timestamped line on stderr, which a daemon child has pointed at its log. */
|
|
41
|
+
export function writeDaemonLogLine(scope, message, details) {
|
|
42
|
+
const suffix = details ? ` ${JSON.stringify(details)}` : '';
|
|
43
|
+
process.stderr.write(`${new Date().toISOString()} [${scope}] ${message}${suffix}\n`);
|
|
44
|
+
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
3
|
import { chmodSync, existsSync } from 'node:fs';
|
|
4
|
+
import { DAEMON_SERVICE_ENV } from './supervisor.js';
|
|
5
|
+
import { writeDaemonLogLine } from './log.js';
|
|
4
6
|
import { loadConfig } from '../config/store.js';
|
|
5
7
|
import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
|
|
6
8
|
import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
|
|
@@ -17,6 +19,10 @@ import { getDaemonInfo } from './client.js';
|
|
|
17
19
|
import { GatewayService } from '../gateway/service.js';
|
|
18
20
|
import { readConversation } from '../gateway/conversationContext.js';
|
|
19
21
|
const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
|
|
22
|
+
function daemonSupervisor() {
|
|
23
|
+
const value = process.env[DAEMON_SERVICE_ENV];
|
|
24
|
+
return value === 'launchd' || value === 'systemd' ? value : null;
|
|
25
|
+
}
|
|
20
26
|
const DAEMON_PROCESS_VERSION = getPackageVersion();
|
|
21
27
|
async function readJsonBody(req) {
|
|
22
28
|
return new Promise((resolve, reject) => {
|
|
@@ -97,7 +103,7 @@ export class DaemonServer {
|
|
|
97
103
|
credentialsUrl: process.env.DINOX_NATS_CREDENTIALS_URL || undefined,
|
|
98
104
|
});
|
|
99
105
|
await this.gateways.start().catch((error) => {
|
|
100
|
-
|
|
106
|
+
writeDaemonLogLine('dinox-gateway', 'start failed', { error: String(error) });
|
|
101
107
|
});
|
|
102
108
|
}
|
|
103
109
|
// PowerSync keeps the local cache in sync continuously while connected,
|
|
@@ -125,6 +131,7 @@ export class DaemonServer {
|
|
|
125
131
|
tokenFingerprint: this.tokenFingerprint,
|
|
126
132
|
runtimeConfigFingerprint: this.runtimeConfigFingerprint,
|
|
127
133
|
authenticated: Boolean(this.userId && this.tokenFingerprint),
|
|
134
|
+
supervisor: daemonSupervisor(),
|
|
128
135
|
},
|
|
129
136
|
};
|
|
130
137
|
case 'shutdown': {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service manager that owns the daemon process. Without one, the daemon is a
|
|
3
|
+
* detached child of whatever ran the first `dino` command; when that was a
|
|
4
|
+
* coding agent, the agent's host may reap it together with the session and
|
|
5
|
+
* every gateway goes offline. Under launchd / systemd the daemon belongs to
|
|
6
|
+
* the login session instead, starts at login, and is restarted after a crash.
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
export type DaemonServiceKind = 'launchd' | 'systemd';
|
|
13
|
+
export type DaemonServiceSpec = {
|
|
14
|
+
program: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
env: Record<string, string>;
|
|
17
|
+
workingDirectory: string;
|
|
18
|
+
logPath: string;
|
|
19
|
+
};
|
|
20
|
+
export type ServiceCommandResult = {
|
|
21
|
+
code: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
};
|
|
25
|
+
export type ServiceCommandRunner = (command: string, args: string[]) => Promise<ServiceCommandResult>;
|
|
26
|
+
export type DaemonService = {
|
|
27
|
+
readonly kind: DaemonServiceKind;
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly definitionPath: string;
|
|
30
|
+
render(spec: DaemonServiceSpec): string;
|
|
31
|
+
isInstalled(): boolean;
|
|
32
|
+
/** Writes the definition and (re)loads it, which starts the daemon. */
|
|
33
|
+
install(spec: DaemonServiceSpec): Promise<void>;
|
|
34
|
+
/** Starts the installed daemon when it is not running. */
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
/** Stops the daemon and removes the definition; false when nothing was installed. */
|
|
37
|
+
uninstall(): Promise<boolean>;
|
|
38
|
+
};
|
|
39
|
+
/** Set in the service definition so the daemon can report who supervises it. */
|
|
40
|
+
export declare const DAEMON_SERVICE_ENV = "DINOX_DAEMON_SUPERVISOR";
|
|
41
|
+
export declare const runServiceCommand: ServiceCommandRunner;
|
|
42
|
+
/**
|
|
43
|
+
* One daemon per data directory (it owns that directory's socket), so a
|
|
44
|
+
* non-default `DINOX_DATA_DIR` gets its own service.
|
|
45
|
+
*/
|
|
46
|
+
export declare function daemonServiceLabel(env?: NodeJS.ProcessEnv): string;
|
|
47
|
+
/** True when the installed definition differs from what this CLI would write. */
|
|
48
|
+
export declare function serviceDefinitionIsStale(service: DaemonService, spec: DaemonServiceSpec): boolean;
|
|
49
|
+
export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
|
|
50
|
+
export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
|
|
51
|
+
export type DaemonServiceOptions = {
|
|
52
|
+
platform?: NodeJS.Platform;
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
homeDir?: string;
|
|
55
|
+
uid?: number;
|
|
56
|
+
run?: ServiceCommandRunner;
|
|
57
|
+
};
|
|
58
|
+
/** The service manager for this platform, or null where none is supported (Windows). */
|
|
59
|
+
export declare function getDaemonService(options?: DaemonServiceOptions): DaemonService | null;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { getDataDir } from '../config/paths.js';
|
|
7
|
+
import { DinoxError } from '../utils/errors.js';
|
|
8
|
+
/** Set in the service definition so the daemon can report who supervises it. */
|
|
9
|
+
export const DAEMON_SERVICE_ENV = 'DINOX_DAEMON_SUPERVISOR';
|
|
10
|
+
const DEFAULT_LABEL = 'com.dinox.cli.daemon';
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 15_000;
|
|
12
|
+
const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
|
|
13
|
+
const LAUNCHD_BOOTSTRAP_RETRY_MS = 300;
|
|
14
|
+
export const runServiceCommand = (command, args) => new Promise((resolve) => {
|
|
15
|
+
execFile(command, args, { timeout: COMMAND_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
16
|
+
const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
|
|
17
|
+
resolve({ code, stdout: stdout ?? '', stderr: stderr ?? (error ? String(error.message) : '') });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* One daemon per data directory (it owns that directory's socket), so a
|
|
22
|
+
* non-default `DINOX_DATA_DIR` gets its own service.
|
|
23
|
+
*/
|
|
24
|
+
export function daemonServiceLabel(env = process.env) {
|
|
25
|
+
if (!env.DINOX_DATA_DIR)
|
|
26
|
+
return DEFAULT_LABEL;
|
|
27
|
+
const hash = crypto.createHash('sha256').update(getDataDir(), 'utf8').digest('hex').slice(0, 8);
|
|
28
|
+
return `${DEFAULT_LABEL}.${hash}`;
|
|
29
|
+
}
|
|
30
|
+
function failed(kind, step, result) {
|
|
31
|
+
const output = (result.stderr || result.stdout).trim();
|
|
32
|
+
return new DinoxError(`${kind} ${step} failed${output ? `: ${output}` : ''}`, {
|
|
33
|
+
code: 'DAEMON_SERVICE_FAILED',
|
|
34
|
+
details: { service: kind, step, exitCode: result.code },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function writeDefinition(definitionPath, content) {
|
|
38
|
+
mkdirSync(path.dirname(definitionPath), { recursive: true });
|
|
39
|
+
writeFileSync(definitionPath, content, { mode: 0o644 });
|
|
40
|
+
}
|
|
41
|
+
function readDefinition(definitionPath) {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(definitionPath, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True when the installed definition differs from what this CLI would write. */
|
|
50
|
+
export function serviceDefinitionIsStale(service, spec) {
|
|
51
|
+
return readDefinition(service.definitionPath) !== service.render(spec);
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------- launchd
|
|
54
|
+
function xmlEscape(value) {
|
|
55
|
+
return value
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"');
|
|
60
|
+
}
|
|
61
|
+
export function renderLaunchdPlist(label, spec) {
|
|
62
|
+
const string = (value) => `<string>${xmlEscape(value)}</string>`;
|
|
63
|
+
const args = [spec.program, ...spec.args].map((arg) => `\t\t${string(arg)}`).join('\n');
|
|
64
|
+
const env = Object.keys(spec.env)
|
|
65
|
+
.sort()
|
|
66
|
+
.map((name) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t${string(spec.env[name])}`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
69
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
70
|
+
<plist version="1.0">
|
|
71
|
+
<dict>
|
|
72
|
+
\t<key>Label</key>
|
|
73
|
+
\t${string(label)}
|
|
74
|
+
\t<key>ProgramArguments</key>
|
|
75
|
+
\t<array>
|
|
76
|
+
${args}
|
|
77
|
+
\t</array>
|
|
78
|
+
\t<key>EnvironmentVariables</key>
|
|
79
|
+
\t<dict>
|
|
80
|
+
${env}
|
|
81
|
+
\t</dict>
|
|
82
|
+
\t<key>WorkingDirectory</key>
|
|
83
|
+
\t${string(spec.workingDirectory)}
|
|
84
|
+
\t<key>RunAtLoad</key>
|
|
85
|
+
\t<true/>
|
|
86
|
+
\t<key>KeepAlive</key>
|
|
87
|
+
\t<dict>
|
|
88
|
+
\t\t<key>SuccessfulExit</key>
|
|
89
|
+
\t\t<false/>
|
|
90
|
+
\t</dict>
|
|
91
|
+
\t<key>ThrottleInterval</key>
|
|
92
|
+
\t<integer>30</integer>
|
|
93
|
+
\t<key>StandardOutPath</key>
|
|
94
|
+
\t${string(spec.logPath)}
|
|
95
|
+
\t<key>StandardErrorPath</key>
|
|
96
|
+
\t${string(spec.logPath)}
|
|
97
|
+
</dict>
|
|
98
|
+
</plist>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
class LaunchdService {
|
|
102
|
+
label;
|
|
103
|
+
run;
|
|
104
|
+
kind = 'launchd';
|
|
105
|
+
definitionPath;
|
|
106
|
+
domain;
|
|
107
|
+
target;
|
|
108
|
+
constructor(label, run, homeDir, uid) {
|
|
109
|
+
this.label = label;
|
|
110
|
+
this.run = run;
|
|
111
|
+
this.definitionPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
112
|
+
this.domain = `gui/${uid}`;
|
|
113
|
+
this.target = `${this.domain}/${label}`;
|
|
114
|
+
}
|
|
115
|
+
render(spec) {
|
|
116
|
+
return renderLaunchdPlist(this.label, spec);
|
|
117
|
+
}
|
|
118
|
+
isInstalled() {
|
|
119
|
+
return existsSync(this.definitionPath);
|
|
120
|
+
}
|
|
121
|
+
async isLoaded() {
|
|
122
|
+
return (await this.run('launchctl', ['print', this.target])).code === 0;
|
|
123
|
+
}
|
|
124
|
+
async bootstrap() {
|
|
125
|
+
// bootout returns before launchd has fully released the label, so a
|
|
126
|
+
// bootstrap right after it can fail transiently.
|
|
127
|
+
let result = null;
|
|
128
|
+
for (let attempt = 0; attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS; attempt += 1) {
|
|
129
|
+
result = await this.run('launchctl', ['bootstrap', this.domain, this.definitionPath]);
|
|
130
|
+
if (result.code === 0)
|
|
131
|
+
return;
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, LAUNCHD_BOOTSTRAP_RETRY_MS));
|
|
133
|
+
}
|
|
134
|
+
throw failed(this.kind, 'bootstrap', result);
|
|
135
|
+
}
|
|
136
|
+
async install(spec) {
|
|
137
|
+
if (await this.isLoaded())
|
|
138
|
+
await this.run('launchctl', ['bootout', this.target]);
|
|
139
|
+
writeDefinition(this.definitionPath, this.render(spec));
|
|
140
|
+
await this.bootstrap();
|
|
141
|
+
}
|
|
142
|
+
async start() {
|
|
143
|
+
if (!(await this.isLoaded())) {
|
|
144
|
+
await this.bootstrap();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const result = await this.run('launchctl', ['kickstart', this.target]);
|
|
148
|
+
if (result.code !== 0)
|
|
149
|
+
throw failed(this.kind, 'kickstart', result);
|
|
150
|
+
}
|
|
151
|
+
async uninstall() {
|
|
152
|
+
const installed = this.isInstalled();
|
|
153
|
+
if (await this.isLoaded()) {
|
|
154
|
+
const result = await this.run('launchctl', ['bootout', this.target]);
|
|
155
|
+
if (result.code !== 0 && (await this.isLoaded()))
|
|
156
|
+
throw failed(this.kind, 'bootout', result);
|
|
157
|
+
}
|
|
158
|
+
if (installed)
|
|
159
|
+
unlinkSync(this.definitionPath);
|
|
160
|
+
return installed;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------- systemd
|
|
164
|
+
/** Quotes one word for a systemd command line or assignment value. */
|
|
165
|
+
function systemdQuote(value) {
|
|
166
|
+
const escaped = value
|
|
167
|
+
.replace(/\\/g, '\\\\')
|
|
168
|
+
.replace(/"/g, '\\"')
|
|
169
|
+
.replace(/%/g, '%%')
|
|
170
|
+
.replace(/\$/g, '$$$$')
|
|
171
|
+
.replace(/\n/g, '\\n');
|
|
172
|
+
return `"${escaped}"`;
|
|
173
|
+
}
|
|
174
|
+
export function renderSystemdUnit(spec) {
|
|
175
|
+
const execStart = [spec.program, ...spec.args].map(systemdQuote).join(' ');
|
|
176
|
+
const env = Object.keys(spec.env)
|
|
177
|
+
.sort()
|
|
178
|
+
.map((name) => `Environment=${systemdQuote(`${name}=${spec.env[name]}`)}`)
|
|
179
|
+
.join('\n');
|
|
180
|
+
return `[Unit]
|
|
181
|
+
Description=Dinox CLI daemon
|
|
182
|
+
After=network-online.target
|
|
183
|
+
|
|
184
|
+
[Service]
|
|
185
|
+
Type=simple
|
|
186
|
+
ExecStart=${execStart}
|
|
187
|
+
WorkingDirectory=${spec.workingDirectory.replace(/%/g, '%%')}
|
|
188
|
+
${env}
|
|
189
|
+
Restart=on-failure
|
|
190
|
+
RestartSec=30
|
|
191
|
+
StandardOutput=append:${spec.logPath}
|
|
192
|
+
StandardError=append:${spec.logPath}
|
|
193
|
+
|
|
194
|
+
[Install]
|
|
195
|
+
WantedBy=default.target
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
class SystemdService {
|
|
199
|
+
label;
|
|
200
|
+
run;
|
|
201
|
+
kind = 'systemd';
|
|
202
|
+
definitionPath;
|
|
203
|
+
unit;
|
|
204
|
+
constructor(label, run, configHome) {
|
|
205
|
+
this.label = label;
|
|
206
|
+
this.run = run;
|
|
207
|
+
this.unit = `${label}.service`;
|
|
208
|
+
this.definitionPath = path.join(configHome, 'systemd', 'user', this.unit);
|
|
209
|
+
}
|
|
210
|
+
render(spec) {
|
|
211
|
+
return renderSystemdUnit(spec);
|
|
212
|
+
}
|
|
213
|
+
isInstalled() {
|
|
214
|
+
return existsSync(this.definitionPath);
|
|
215
|
+
}
|
|
216
|
+
async systemctl(step, ...args) {
|
|
217
|
+
const result = await this.run('systemctl', ['--user', ...args]);
|
|
218
|
+
if (result.code !== 0)
|
|
219
|
+
throw failed(this.kind, step, result);
|
|
220
|
+
}
|
|
221
|
+
async install(spec) {
|
|
222
|
+
writeDefinition(this.definitionPath, this.render(spec));
|
|
223
|
+
await this.systemctl('daemon-reload', 'daemon-reload');
|
|
224
|
+
await this.systemctl('enable', 'enable', this.unit);
|
|
225
|
+
await this.systemctl('restart', 'restart', this.unit);
|
|
226
|
+
}
|
|
227
|
+
async start() {
|
|
228
|
+
await this.systemctl('start', 'start', this.unit);
|
|
229
|
+
}
|
|
230
|
+
async uninstall() {
|
|
231
|
+
if (!this.isInstalled())
|
|
232
|
+
return false;
|
|
233
|
+
await this.run('systemctl', ['--user', 'disable', '--now', this.unit]);
|
|
234
|
+
unlinkSync(this.definitionPath);
|
|
235
|
+
await this.run('systemctl', ['--user', 'daemon-reload']);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** The service manager for this platform, or null where none is supported (Windows). */
|
|
240
|
+
export function getDaemonService(options = {}) {
|
|
241
|
+
const platform = options.platform ?? process.platform;
|
|
242
|
+
const env = options.env ?? process.env;
|
|
243
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
244
|
+
const run = options.run ?? runServiceCommand;
|
|
245
|
+
const label = daemonServiceLabel(env);
|
|
246
|
+
if (platform === 'darwin') {
|
|
247
|
+
return new LaunchdService(label, run, homeDir, options.uid ?? process.getuid?.() ?? 0);
|
|
248
|
+
}
|
|
249
|
+
if (platform === 'linux') {
|
|
250
|
+
return new SystemdService(label, run, env.XDG_CONFIG_HOME || path.join(homeDir, '.config'));
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
package/dist/daemon/types.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export type DaemonPingResult = {
|
|
|
42
42
|
runtimeConfigFingerprint: string | null;
|
|
43
43
|
/** True only for daemons using the current authenticated local protocol. */
|
|
44
44
|
authenticated: boolean;
|
|
45
|
+
/** Service manager that owns the process; null or absent (older daemons) when unmanaged. */
|
|
46
|
+
supervisor?: 'launchd' | 'systemd' | null;
|
|
45
47
|
};
|
|
46
48
|
/** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
|
|
47
49
|
export declare const DEFAULT_DAEMON_PORT = 45678;
|
package/dist/gateway/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { getPackageVersion } from '../utils/version.js';
|
|
3
|
+
import { writeDaemonLogLine } from '../daemon/log.js';
|
|
3
4
|
import { createGatewayAdapter } from './adapters/index.js';
|
|
4
5
|
import { getMachineId, loadGateways, updateGatewaySettings } from './config.js';
|
|
5
6
|
import { GatewayDeviceHost } from './deviceHost.js';
|
|
@@ -15,9 +16,7 @@ function applyLiveSettings(target, source) {
|
|
|
15
16
|
target[key] = source[key];
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
const defaultLog = (message, details) =>
|
|
19
|
-
process.stderr.write(`[dinox-gateway] ${message}${details ? ` ${JSON.stringify(details)}` : ''}\n`);
|
|
20
|
-
};
|
|
19
|
+
const defaultLog = (message, details) => writeDaemonLogLine('dinox-gateway', message, details);
|
|
21
20
|
/**
|
|
22
21
|
* Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
|
|
23
22
|
* database, so gateway writes share its connection and upload queue.
|
|
@@ -63,6 +63,14 @@ export declare const ERROR_CODES: {
|
|
|
63
63
|
readonly exitCode: 1;
|
|
64
64
|
readonly description: "The daemon did not release database ownership in time.";
|
|
65
65
|
};
|
|
66
|
+
readonly DAEMON_SERVICE_FAILED: {
|
|
67
|
+
readonly exitCode: 1;
|
|
68
|
+
readonly description: "The service manager (launchd / systemd) rejected a daemon service operation.";
|
|
69
|
+
};
|
|
70
|
+
readonly DAEMON_SERVICE_UNSUPPORTED: {
|
|
71
|
+
readonly exitCode: 5;
|
|
72
|
+
readonly description: "The daemon cannot run as a login service on this platform or with this auth setup.";
|
|
73
|
+
};
|
|
66
74
|
readonly DAEMON_STALE: {
|
|
67
75
|
readonly exitCode: 5;
|
|
68
76
|
readonly description: "The daemon state is stale and needs a restart.";
|
package/dist/utils/errorCodes.js
CHANGED
|
@@ -21,6 +21,8 @@ export const ERROR_CODES = {
|
|
|
21
21
|
DAEMON_LEGACY_UNVERIFIED: { exitCode: 1, description: 'A daemon from an older CLI could not be verified safely.' },
|
|
22
22
|
DAEMON_RELEASE_FAILED: { exitCode: 1, description: 'The daemon could not release database ownership.' },
|
|
23
23
|
DAEMON_RELEASE_TIMEOUT: { exitCode: 1, description: 'The daemon did not release database ownership in time.' },
|
|
24
|
+
DAEMON_SERVICE_FAILED: { exitCode: 1, description: 'The service manager (launchd / systemd) rejected a daemon service operation.' },
|
|
25
|
+
DAEMON_SERVICE_UNSUPPORTED: { exitCode: 5, description: 'The daemon cannot run as a login service on this platform or with this auth setup.' },
|
|
24
26
|
DAEMON_STALE: { exitCode: 5, description: 'The daemon state is stale and needs a restart.' },
|
|
25
27
|
DAEMON_START_FAILED: { exitCode: 5, description: 'The daemon process failed to start.' },
|
|
26
28
|
DAEMON_START_TIMEOUT: { exitCode: 1, description: 'The daemon did not become ready in time.' },
|