@dinoxx/dinox-cli 2.5.2 → 2.7.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 +271 -57
- package/dist/daemon/launch.d.ts +31 -0
- package/dist/daemon/launch.js +95 -0
- package/dist/daemon/log.d.ts +14 -0
- package/dist/daemon/log.js +57 -0
- package/dist/daemon/server.js +8 -1
- package/dist/daemon/supervise.d.ts +29 -0
- package/dist/daemon/supervise.js +98 -0
- package/dist/daemon/supervisor.d.ts +75 -0
- package/dist/daemon/supervisor.js +441 -0
- package/dist/daemon/types.d.ts +2 -0
- package/dist/gateway/adapters/acp.js +2 -0
- package/dist/gateway/detect.js +1 -1
- 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
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 === 'schtasks' ? 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,29 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
/** First restart delay after a crash; doubles per consecutive crash. */
|
|
3
|
+
export declare const SUPERVISE_INITIAL_BACKOFF_MS = 1000;
|
|
4
|
+
export declare const SUPERVISE_MAX_BACKOFF_MS = 60000;
|
|
5
|
+
/** A daemon that ran this long counts as healthy, so the next crash restarts quickly again. */
|
|
6
|
+
export declare const SUPERVISE_HEALTHY_RUN_MS = 60000;
|
|
7
|
+
export type SupervisedExit = {
|
|
8
|
+
code: number | null;
|
|
9
|
+
signal: NodeJS.Signals | null;
|
|
10
|
+
};
|
|
11
|
+
export type SuperviseDependencies = {
|
|
12
|
+
startChild: () => ChildProcess;
|
|
13
|
+
sleep: (ms: number) => Promise<void>;
|
|
14
|
+
now: () => number;
|
|
15
|
+
log: (message: string, details?: Record<string, unknown>) => void;
|
|
16
|
+
/** Registers a handler for a stop request (signal); the loop then stops the child and returns. */
|
|
17
|
+
onStopRequest: (handler: () => void) => void;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
|
|
21
|
+
* and systemd's Restart=on-failure do: restarts it after a crash with an
|
|
22
|
+
* exponential backoff, and returns once it exits cleanly (exit code 0 from
|
|
23
|
+
* `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
|
|
24
|
+
* or when asked to stop. Used where the service manager cannot restart the
|
|
25
|
+
* daemon itself (Windows Task Scheduler).
|
|
26
|
+
*/
|
|
27
|
+
export declare function superviseDaemon(deps: SuperviseDependencies): Promise<number>;
|
|
28
|
+
/** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
|
|
29
|
+
export declare function superviseCurrentDaemon(port: number): Promise<number>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendDaemonLogLine, closeDaemonLogFd, openDaemonLogForChild } from './log.js';
|
|
3
|
+
/** First restart delay after a crash; doubles per consecutive crash. */
|
|
4
|
+
export const SUPERVISE_INITIAL_BACKOFF_MS = 1_000;
|
|
5
|
+
export const SUPERVISE_MAX_BACKOFF_MS = 60_000;
|
|
6
|
+
/** A daemon that ran this long counts as healthy, so the next crash restarts quickly again. */
|
|
7
|
+
export const SUPERVISE_HEALTHY_RUN_MS = 60_000;
|
|
8
|
+
function waitForExit(child) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const settle = (exit) => {
|
|
12
|
+
if (settled)
|
|
13
|
+
return;
|
|
14
|
+
settled = true;
|
|
15
|
+
resolve(exit);
|
|
16
|
+
};
|
|
17
|
+
child.once('exit', (code, signal) => settle({ code, signal }));
|
|
18
|
+
// A child that cannot be spawned at all counts as a crash.
|
|
19
|
+
child.once('error', () => settle({ code: 1, signal: null }));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
|
|
24
|
+
* and systemd's Restart=on-failure do: restarts it after a crash with an
|
|
25
|
+
* exponential backoff, and returns once it exits cleanly (exit code 0 from
|
|
26
|
+
* `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
|
|
27
|
+
* or when asked to stop. Used where the service manager cannot restart the
|
|
28
|
+
* daemon itself (Windows Task Scheduler).
|
|
29
|
+
*/
|
|
30
|
+
export async function superviseDaemon(deps) {
|
|
31
|
+
let stopping = false;
|
|
32
|
+
let current = null;
|
|
33
|
+
let wakeSleep = null;
|
|
34
|
+
deps.onStopRequest(() => {
|
|
35
|
+
stopping = true;
|
|
36
|
+
current?.kill('SIGTERM');
|
|
37
|
+
wakeSleep?.();
|
|
38
|
+
});
|
|
39
|
+
let backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
40
|
+
while (!stopping) {
|
|
41
|
+
const startedAt = deps.now();
|
|
42
|
+
const child = deps.startChild();
|
|
43
|
+
current = child;
|
|
44
|
+
deps.log('daemon started', { pid: child.pid ?? null });
|
|
45
|
+
const exit = await waitForExit(child);
|
|
46
|
+
current = null;
|
|
47
|
+
if (stopping)
|
|
48
|
+
break;
|
|
49
|
+
if (exit.code === 0) {
|
|
50
|
+
deps.log('daemon exited cleanly; supervisor stopping');
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (deps.now() - startedAt >= SUPERVISE_HEALTHY_RUN_MS)
|
|
54
|
+
backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
|
|
55
|
+
deps.log('daemon crashed; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
|
|
56
|
+
await Promise.race([
|
|
57
|
+
deps.sleep(backoffMs),
|
|
58
|
+
new Promise((resolve) => {
|
|
59
|
+
wakeSleep = resolve;
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
wakeSleep = null;
|
|
63
|
+
backoffMs = Math.min(backoffMs * 2, SUPERVISE_MAX_BACKOFF_MS);
|
|
64
|
+
}
|
|
65
|
+
deps.log('supervisor stopped on request');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
/** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
|
|
69
|
+
export async function superviseCurrentDaemon(port) {
|
|
70
|
+
const entrypoint = process.argv[1];
|
|
71
|
+
if (!entrypoint)
|
|
72
|
+
throw new Error('Cannot determine the CLI entrypoint for the daemon');
|
|
73
|
+
// Launched hidden at logon there is no console, so write to the log file directly.
|
|
74
|
+
const log = (message, details) => appendDaemonLogLine('dinox-supervisor', message, details);
|
|
75
|
+
return superviseDaemon({
|
|
76
|
+
startChild: () => {
|
|
77
|
+
const logFd = openDaemonLogForChild();
|
|
78
|
+
try {
|
|
79
|
+
return spawn(process.execPath, [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
|
|
80
|
+
// Same (already clean) environment, which carries the service marker.
|
|
81
|
+
{ stdio: ['ignore', logFd, logFd], env: process.env, windowsHide: true });
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
closeDaemonLogFd(logFd);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
88
|
+
now: () => Date.now(),
|
|
89
|
+
log,
|
|
90
|
+
onStopRequest: (handler) => {
|
|
91
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
92
|
+
process.on(signal, handler);
|
|
93
|
+
}
|
|
94
|
+
// Like the daemon, never stop on a hangup.
|
|
95
|
+
process.on('SIGHUP', () => undefined);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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. launchd and
|
|
11
|
+
* systemd restart the daemon themselves; on Windows, Task Scheduler only
|
|
12
|
+
* starts `dino daemon supervise`, which restarts it (see supervise.ts).
|
|
13
|
+
*/
|
|
14
|
+
export type DaemonServiceKind = 'launchd' | 'systemd' | 'schtasks';
|
|
15
|
+
export type DaemonServiceSpec = {
|
|
16
|
+
program: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
env: Record<string, string>;
|
|
19
|
+
workingDirectory: string;
|
|
20
|
+
logPath: string;
|
|
21
|
+
};
|
|
22
|
+
export type ServiceCommandResult = {
|
|
23
|
+
code: number;
|
|
24
|
+
stdout: string;
|
|
25
|
+
stderr: string;
|
|
26
|
+
};
|
|
27
|
+
export type ServiceCommandRunner = (command: string, args: string[]) => Promise<ServiceCommandResult>;
|
|
28
|
+
export type DaemonService = {
|
|
29
|
+
readonly kind: DaemonServiceKind;
|
|
30
|
+
readonly label: string;
|
|
31
|
+
readonly definitionPath: string;
|
|
32
|
+
render(spec: DaemonServiceSpec): string;
|
|
33
|
+
isInstalled(): boolean;
|
|
34
|
+
/** True when the installed definition is exactly what this CLI would write. */
|
|
35
|
+
isCurrent(spec: DaemonServiceSpec): boolean;
|
|
36
|
+
/** Writes the definition and (re)loads it, which starts the daemon. */
|
|
37
|
+
install(spec: DaemonServiceSpec): Promise<void>;
|
|
38
|
+
/** Starts the installed daemon when it is not running. */
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
/** Stops the daemon and removes the definition; false when nothing was installed. */
|
|
41
|
+
uninstall(): Promise<boolean>;
|
|
42
|
+
};
|
|
43
|
+
/** Set in the service definition so the daemon can report who supervises it. */
|
|
44
|
+
export declare const DAEMON_SERVICE_ENV = "DINOX_DAEMON_SUPERVISOR";
|
|
45
|
+
export declare const runServiceCommand: ServiceCommandRunner;
|
|
46
|
+
/**
|
|
47
|
+
* One daemon per data directory (it owns that directory's socket), so a
|
|
48
|
+
* non-default `DINOX_DATA_DIR` gets its own service.
|
|
49
|
+
*/
|
|
50
|
+
export declare function daemonServiceLabel(env?: NodeJS.ProcessEnv): string;
|
|
51
|
+
export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
|
|
52
|
+
export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
|
|
53
|
+
/** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
|
|
54
|
+
export declare function quoteWindowsArg(value: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Task Scheduler runs console programs in a visible window, so the task runs
|
|
57
|
+
* this script with wscript, which sets the daemon environment and starts the
|
|
58
|
+
* supervisor with a hidden window (the launcher pattern pm2-windows-startup
|
|
59
|
+
* uses). The supervisor then outlives the script.
|
|
60
|
+
*/
|
|
61
|
+
export declare function renderWindowsLauncher(spec: DaemonServiceSpec): string;
|
|
62
|
+
export declare function renderWindowsTaskXml(userId: string, wscriptPath: string, launcherPath: string): string;
|
|
63
|
+
export type DaemonServiceOptions = {
|
|
64
|
+
platform?: NodeJS.Platform;
|
|
65
|
+
env?: NodeJS.ProcessEnv;
|
|
66
|
+
homeDir?: string;
|
|
67
|
+
uid?: number;
|
|
68
|
+
run?: ServiceCommandRunner;
|
|
69
|
+
/** Windows: where the launcher script lives (the daemon directory). */
|
|
70
|
+
launcherDir?: string;
|
|
71
|
+
/** Windows: `DOMAIN\\user` the logon task runs as. */
|
|
72
|
+
windowsUserId?: string;
|
|
73
|
+
};
|
|
74
|
+
/** The service manager for this platform, or null where none is supported. */
|
|
75
|
+
export declare function getDaemonService(options?: DaemonServiceOptions): DaemonService | null;
|
|
@@ -0,0 +1,441 @@
|
|
|
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, encoding = 'utf8') {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(definitionPath, encoding);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------- launchd
|
|
50
|
+
function xmlEscape(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"');
|
|
56
|
+
}
|
|
57
|
+
export function renderLaunchdPlist(label, spec) {
|
|
58
|
+
const string = (value) => `<string>${xmlEscape(value)}</string>`;
|
|
59
|
+
const args = [spec.program, ...spec.args].map((arg) => `\t\t${string(arg)}`).join('\n');
|
|
60
|
+
const env = Object.keys(spec.env)
|
|
61
|
+
.sort()
|
|
62
|
+
.map((name) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t${string(spec.env[name])}`)
|
|
63
|
+
.join('\n');
|
|
64
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
65
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
66
|
+
<plist version="1.0">
|
|
67
|
+
<dict>
|
|
68
|
+
\t<key>Label</key>
|
|
69
|
+
\t${string(label)}
|
|
70
|
+
\t<key>ProgramArguments</key>
|
|
71
|
+
\t<array>
|
|
72
|
+
${args}
|
|
73
|
+
\t</array>
|
|
74
|
+
\t<key>EnvironmentVariables</key>
|
|
75
|
+
\t<dict>
|
|
76
|
+
${env}
|
|
77
|
+
\t</dict>
|
|
78
|
+
\t<key>WorkingDirectory</key>
|
|
79
|
+
\t${string(spec.workingDirectory)}
|
|
80
|
+
\t<key>RunAtLoad</key>
|
|
81
|
+
\t<true/>
|
|
82
|
+
\t<key>KeepAlive</key>
|
|
83
|
+
\t<dict>
|
|
84
|
+
\t\t<key>SuccessfulExit</key>
|
|
85
|
+
\t\t<false/>
|
|
86
|
+
\t</dict>
|
|
87
|
+
\t<key>ThrottleInterval</key>
|
|
88
|
+
\t<integer>30</integer>
|
|
89
|
+
\t<key>StandardOutPath</key>
|
|
90
|
+
\t${string(spec.logPath)}
|
|
91
|
+
\t<key>StandardErrorPath</key>
|
|
92
|
+
\t${string(spec.logPath)}
|
|
93
|
+
</dict>
|
|
94
|
+
</plist>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
class LaunchdService {
|
|
98
|
+
label;
|
|
99
|
+
run;
|
|
100
|
+
kind = 'launchd';
|
|
101
|
+
definitionPath;
|
|
102
|
+
domain;
|
|
103
|
+
target;
|
|
104
|
+
constructor(label, run, homeDir, uid) {
|
|
105
|
+
this.label = label;
|
|
106
|
+
this.run = run;
|
|
107
|
+
this.definitionPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
108
|
+
this.domain = `gui/${uid}`;
|
|
109
|
+
this.target = `${this.domain}/${label}`;
|
|
110
|
+
}
|
|
111
|
+
render(spec) {
|
|
112
|
+
return renderLaunchdPlist(this.label, spec);
|
|
113
|
+
}
|
|
114
|
+
isInstalled() {
|
|
115
|
+
return existsSync(this.definitionPath);
|
|
116
|
+
}
|
|
117
|
+
isCurrent(spec) {
|
|
118
|
+
return readDefinition(this.definitionPath) === this.render(spec);
|
|
119
|
+
}
|
|
120
|
+
async isLoaded() {
|
|
121
|
+
return (await this.run('launchctl', ['print', this.target])).code === 0;
|
|
122
|
+
}
|
|
123
|
+
async bootstrap() {
|
|
124
|
+
// bootout returns before launchd has fully released the label, so a
|
|
125
|
+
// bootstrap right after it can fail transiently.
|
|
126
|
+
let result = null;
|
|
127
|
+
for (let attempt = 0; attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS; attempt += 1) {
|
|
128
|
+
result = await this.run('launchctl', ['bootstrap', this.domain, this.definitionPath]);
|
|
129
|
+
if (result.code === 0)
|
|
130
|
+
return;
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, LAUNCHD_BOOTSTRAP_RETRY_MS));
|
|
132
|
+
}
|
|
133
|
+
throw failed(this.kind, 'bootstrap', result);
|
|
134
|
+
}
|
|
135
|
+
async install(spec) {
|
|
136
|
+
if (await this.isLoaded())
|
|
137
|
+
await this.run('launchctl', ['bootout', this.target]);
|
|
138
|
+
writeDefinition(this.definitionPath, this.render(spec));
|
|
139
|
+
await this.bootstrap();
|
|
140
|
+
}
|
|
141
|
+
async start() {
|
|
142
|
+
if (!(await this.isLoaded())) {
|
|
143
|
+
await this.bootstrap();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const result = await this.run('launchctl', ['kickstart', this.target]);
|
|
147
|
+
if (result.code !== 0)
|
|
148
|
+
throw failed(this.kind, 'kickstart', result);
|
|
149
|
+
}
|
|
150
|
+
async uninstall() {
|
|
151
|
+
const installed = this.isInstalled();
|
|
152
|
+
if (await this.isLoaded()) {
|
|
153
|
+
const result = await this.run('launchctl', ['bootout', this.target]);
|
|
154
|
+
if (result.code !== 0 && (await this.isLoaded()))
|
|
155
|
+
throw failed(this.kind, 'bootout', result);
|
|
156
|
+
}
|
|
157
|
+
if (installed)
|
|
158
|
+
unlinkSync(this.definitionPath);
|
|
159
|
+
return installed;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------- systemd
|
|
163
|
+
/** Quotes one word for a systemd command line or assignment value. */
|
|
164
|
+
function systemdQuote(value) {
|
|
165
|
+
const escaped = value
|
|
166
|
+
.replace(/\\/g, '\\\\')
|
|
167
|
+
.replace(/"/g, '\\"')
|
|
168
|
+
.replace(/%/g, '%%')
|
|
169
|
+
.replace(/\$/g, '$$$$')
|
|
170
|
+
.replace(/\n/g, '\\n');
|
|
171
|
+
return `"${escaped}"`;
|
|
172
|
+
}
|
|
173
|
+
export function renderSystemdUnit(spec) {
|
|
174
|
+
const execStart = [spec.program, ...spec.args].map(systemdQuote).join(' ');
|
|
175
|
+
const env = Object.keys(spec.env)
|
|
176
|
+
.sort()
|
|
177
|
+
.map((name) => `Environment=${systemdQuote(`${name}=${spec.env[name]}`)}`)
|
|
178
|
+
.join('\n');
|
|
179
|
+
return `[Unit]
|
|
180
|
+
Description=Dinox CLI daemon
|
|
181
|
+
After=network-online.target
|
|
182
|
+
|
|
183
|
+
[Service]
|
|
184
|
+
Type=simple
|
|
185
|
+
ExecStart=${execStart}
|
|
186
|
+
WorkingDirectory=${spec.workingDirectory.replace(/%/g, '%%')}
|
|
187
|
+
${env}
|
|
188
|
+
Restart=on-failure
|
|
189
|
+
RestartSec=30
|
|
190
|
+
StandardOutput=append:${spec.logPath}
|
|
191
|
+
StandardError=append:${spec.logPath}
|
|
192
|
+
|
|
193
|
+
[Install]
|
|
194
|
+
WantedBy=default.target
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
class SystemdService {
|
|
198
|
+
label;
|
|
199
|
+
run;
|
|
200
|
+
kind = 'systemd';
|
|
201
|
+
definitionPath;
|
|
202
|
+
unit;
|
|
203
|
+
constructor(label, run, configHome) {
|
|
204
|
+
this.label = label;
|
|
205
|
+
this.run = run;
|
|
206
|
+
this.unit = `${label}.service`;
|
|
207
|
+
this.definitionPath = path.join(configHome, 'systemd', 'user', this.unit);
|
|
208
|
+
}
|
|
209
|
+
render(spec) {
|
|
210
|
+
return renderSystemdUnit(spec);
|
|
211
|
+
}
|
|
212
|
+
isInstalled() {
|
|
213
|
+
return existsSync(this.definitionPath);
|
|
214
|
+
}
|
|
215
|
+
isCurrent(spec) {
|
|
216
|
+
return readDefinition(this.definitionPath) === this.render(spec);
|
|
217
|
+
}
|
|
218
|
+
async systemctl(step, ...args) {
|
|
219
|
+
const result = await this.run('systemctl', ['--user', ...args]);
|
|
220
|
+
if (result.code !== 0)
|
|
221
|
+
throw failed(this.kind, step, result);
|
|
222
|
+
}
|
|
223
|
+
async install(spec) {
|
|
224
|
+
writeDefinition(this.definitionPath, this.render(spec));
|
|
225
|
+
await this.systemctl('daemon-reload', 'daemon-reload');
|
|
226
|
+
await this.systemctl('enable', 'enable', this.unit);
|
|
227
|
+
await this.systemctl('restart', 'restart', this.unit);
|
|
228
|
+
}
|
|
229
|
+
async start() {
|
|
230
|
+
await this.systemctl('start', 'start', this.unit);
|
|
231
|
+
}
|
|
232
|
+
async uninstall() {
|
|
233
|
+
if (!this.isInstalled())
|
|
234
|
+
return false;
|
|
235
|
+
await this.run('systemctl', ['--user', 'disable', '--now', this.unit]);
|
|
236
|
+
unlinkSync(this.definitionPath);
|
|
237
|
+
await this.run('systemctl', ['--user', 'daemon-reload']);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------- Windows
|
|
242
|
+
/** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
|
|
243
|
+
export function quoteWindowsArg(value) {
|
|
244
|
+
if (value !== '' && !/[\s"]/.test(value))
|
|
245
|
+
return value;
|
|
246
|
+
let quoted = '"';
|
|
247
|
+
let backslashes = 0;
|
|
248
|
+
for (const char of value) {
|
|
249
|
+
if (char === '\\') {
|
|
250
|
+
backslashes += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (char === '"') {
|
|
254
|
+
quoted += '\\'.repeat(backslashes * 2 + 1) + '"';
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
quoted += '\\'.repeat(backslashes) + char;
|
|
258
|
+
}
|
|
259
|
+
backslashes = 0;
|
|
260
|
+
}
|
|
261
|
+
return `${quoted}${'\\'.repeat(backslashes * 2)}"`;
|
|
262
|
+
}
|
|
263
|
+
function vbsString(value) {
|
|
264
|
+
return `"${value.replace(/"/g, '""').replace(/\r?\n/g, '" & vbLf & "')}"`;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Task Scheduler runs console programs in a visible window, so the task runs
|
|
268
|
+
* this script with wscript, which sets the daemon environment and starts the
|
|
269
|
+
* supervisor with a hidden window (the launcher pattern pm2-windows-startup
|
|
270
|
+
* uses). The supervisor then outlives the script.
|
|
271
|
+
*/
|
|
272
|
+
export function renderWindowsLauncher(spec) {
|
|
273
|
+
const commandLine = [spec.program, ...spec.args].map(quoteWindowsArg).join(' ');
|
|
274
|
+
const env = Object.keys(spec.env)
|
|
275
|
+
.sort()
|
|
276
|
+
.map((name) => `env(${vbsString(name)}) = ${vbsString(spec.env[name])}`)
|
|
277
|
+
.join('\r\n');
|
|
278
|
+
return [
|
|
279
|
+
"' Generated by `dino daemon install`; rewritten when the CLI changes.",
|
|
280
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
281
|
+
'Set env = shell.Environment("Process")',
|
|
282
|
+
env,
|
|
283
|
+
`shell.CurrentDirectory = ${vbsString(spec.workingDirectory)}`,
|
|
284
|
+
`shell.Run ${vbsString(commandLine)}, 0, False`,
|
|
285
|
+
'',
|
|
286
|
+
].join('\r\n');
|
|
287
|
+
}
|
|
288
|
+
export function renderWindowsTaskXml(userId, wscriptPath, launcherPath) {
|
|
289
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
290
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
291
|
+
<RegistrationInfo>
|
|
292
|
+
<Description>Dinox CLI daemon (dino daemon install)</Description>
|
|
293
|
+
</RegistrationInfo>
|
|
294
|
+
<Triggers>
|
|
295
|
+
<LogonTrigger>
|
|
296
|
+
<Enabled>true</Enabled>
|
|
297
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
298
|
+
</LogonTrigger>
|
|
299
|
+
</Triggers>
|
|
300
|
+
<Principals>
|
|
301
|
+
<Principal id="Author">
|
|
302
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
303
|
+
<LogonType>InteractiveToken</LogonType>
|
|
304
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
305
|
+
</Principal>
|
|
306
|
+
</Principals>
|
|
307
|
+
<Settings>
|
|
308
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
309
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
310
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
311
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
312
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
313
|
+
<IdleSettings>
|
|
314
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
315
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
316
|
+
</IdleSettings>
|
|
317
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
318
|
+
<Enabled>true</Enabled>
|
|
319
|
+
<Hidden>false</Hidden>
|
|
320
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
321
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
322
|
+
<Priority>7</Priority>
|
|
323
|
+
</Settings>
|
|
324
|
+
<Actions Context="Author">
|
|
325
|
+
<Exec>
|
|
326
|
+
<Command>${xmlEscape(wscriptPath)}</Command>
|
|
327
|
+
<Arguments>${xmlEscape(`//B //Nologo ${quoteWindowsArg(launcherPath)}`)}</Arguments>
|
|
328
|
+
</Exec>
|
|
329
|
+
</Actions>
|
|
330
|
+
</Task>
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
/** UTF-16LE with a BOM: what schtasks /XML expects and how wscript reads non-ASCII paths. */
|
|
334
|
+
function writeUtf16(filePath, content) {
|
|
335
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
336
|
+
writeFileSync(filePath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')]));
|
|
337
|
+
}
|
|
338
|
+
function readUtf16(filePath) {
|
|
339
|
+
const content = readDefinition(filePath, 'utf16le');
|
|
340
|
+
return content === null ? null : content.replace(/^\uFEFF/, '');
|
|
341
|
+
}
|
|
342
|
+
class WindowsTaskService {
|
|
343
|
+
label;
|
|
344
|
+
run;
|
|
345
|
+
launcherDir;
|
|
346
|
+
wscriptPath;
|
|
347
|
+
userId;
|
|
348
|
+
kind = 'schtasks';
|
|
349
|
+
definitionPath;
|
|
350
|
+
taskName;
|
|
351
|
+
constructor(label, run, launcherDir, wscriptPath, userId) {
|
|
352
|
+
this.label = label;
|
|
353
|
+
this.run = run;
|
|
354
|
+
this.launcherDir = launcherDir;
|
|
355
|
+
this.wscriptPath = wscriptPath;
|
|
356
|
+
this.userId = userId;
|
|
357
|
+
this.taskName = `Dinox\\${label}`;
|
|
358
|
+
this.definitionPath = path.join(launcherDir, `${label}.vbs`);
|
|
359
|
+
}
|
|
360
|
+
render(spec) {
|
|
361
|
+
return renderWindowsLauncher(spec);
|
|
362
|
+
}
|
|
363
|
+
isInstalled() {
|
|
364
|
+
return existsSync(this.definitionPath);
|
|
365
|
+
}
|
|
366
|
+
isCurrent(spec) {
|
|
367
|
+
return readUtf16(this.definitionPath) === this.render(spec);
|
|
368
|
+
}
|
|
369
|
+
async isRegistered() {
|
|
370
|
+
return (await this.run('schtasks', ['/Query', '/TN', this.taskName])).code === 0;
|
|
371
|
+
}
|
|
372
|
+
async register() {
|
|
373
|
+
if (!existsSync(this.wscriptPath)) {
|
|
374
|
+
throw new DinoxError(`Windows Script Host (${this.wscriptPath}) is not available, so the daemon cannot start hidden at logon.`, {
|
|
375
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
376
|
+
details: { reason: 'wscript-missing' },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const xmlPath = path.join(this.launcherDir, `${this.label}.task.xml`);
|
|
380
|
+
writeUtf16(xmlPath, renderWindowsTaskXml(this.userId, this.wscriptPath, this.definitionPath));
|
|
381
|
+
try {
|
|
382
|
+
const result = await this.run('schtasks', ['/Create', '/TN', this.taskName, '/XML', xmlPath, '/F']);
|
|
383
|
+
if (result.code !== 0)
|
|
384
|
+
throw failed(this.kind, 'create', result);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
try {
|
|
388
|
+
unlinkSync(xmlPath);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Already gone.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async runTask() {
|
|
396
|
+
const result = await this.run('schtasks', ['/Run', '/TN', this.taskName]);
|
|
397
|
+
if (result.code !== 0)
|
|
398
|
+
throw failed(this.kind, 'run', result);
|
|
399
|
+
}
|
|
400
|
+
async install(spec) {
|
|
401
|
+
writeUtf16(this.definitionPath, this.render(spec));
|
|
402
|
+
await this.register();
|
|
403
|
+
await this.runTask();
|
|
404
|
+
}
|
|
405
|
+
async start() {
|
|
406
|
+
if (!(await this.isRegistered()))
|
|
407
|
+
await this.register();
|
|
408
|
+
await this.runTask();
|
|
409
|
+
}
|
|
410
|
+
async uninstall() {
|
|
411
|
+
const installed = this.isInstalled();
|
|
412
|
+
if (await this.isRegistered()) {
|
|
413
|
+
const result = await this.run('schtasks', ['/Delete', '/TN', this.taskName, '/F']);
|
|
414
|
+
if (result.code !== 0)
|
|
415
|
+
throw failed(this.kind, 'delete', result);
|
|
416
|
+
}
|
|
417
|
+
if (installed)
|
|
418
|
+
unlinkSync(this.definitionPath);
|
|
419
|
+
return installed;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** The service manager for this platform, or null where none is supported. */
|
|
423
|
+
export function getDaemonService(options = {}) {
|
|
424
|
+
const platform = options.platform ?? process.platform;
|
|
425
|
+
const env = options.env ?? process.env;
|
|
426
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
427
|
+
const run = options.run ?? runServiceCommand;
|
|
428
|
+
const label = daemonServiceLabel(env);
|
|
429
|
+
if (platform === 'darwin') {
|
|
430
|
+
return new LaunchdService(label, run, homeDir, options.uid ?? process.getuid?.() ?? 0);
|
|
431
|
+
}
|
|
432
|
+
if (platform === 'linux') {
|
|
433
|
+
return new SystemdService(label, run, env.XDG_CONFIG_HOME || path.join(homeDir, '.config'));
|
|
434
|
+
}
|
|
435
|
+
if (platform === 'win32') {
|
|
436
|
+
const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
|
|
437
|
+
const user = env.USERNAME || os.userInfo().username;
|
|
438
|
+
return new WindowsTaskService(label, run, options.launcherDir ?? path.join(getDataDir(), 'daemon'), path.join(systemRoot, 'System32', 'wscript.exe'), options.windowsUserId ?? (env.USERDOMAIN ? `${env.USERDOMAIN}\\${user}` : user));
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|