@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/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,33 @@ 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 { superviseCurrentDaemon } from './supervise.js';
|
|
20
|
+
import { normalizeAuthToken } from '../utils/authToken.js';
|
|
17
21
|
const DAEMON_STOP_POLL_INTERVAL_MS = 100;
|
|
18
22
|
const DAEMON_STOP_TIMEOUT_MS = 5_000;
|
|
19
|
-
const
|
|
23
|
+
const DAEMON_SERVICE_READY_TIMEOUT_MS = 20_000;
|
|
24
|
+
const DAEMON_CRASH_STOP_TIMEOUT_MS = 5_000;
|
|
20
25
|
async function withDaemonStartLock(run) {
|
|
21
26
|
return withAsyncRelease(() => acquireDaemonStartLock(), run);
|
|
22
27
|
}
|
|
28
|
+
function installedServiceKind() {
|
|
29
|
+
const service = getDaemonService();
|
|
30
|
+
return service?.isInstalled() ? service.kind : null;
|
|
31
|
+
}
|
|
32
|
+
function notRunningStatus(fields) {
|
|
33
|
+
return {
|
|
34
|
+
running: false,
|
|
35
|
+
transport: 'socket',
|
|
36
|
+
version: null,
|
|
37
|
+
service: installedServiceKind(),
|
|
38
|
+
supervised: null,
|
|
39
|
+
logPath: getDaemonLogPath(),
|
|
40
|
+
...fields,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
23
43
|
function runningDaemonStatus(info, pidInfo) {
|
|
24
44
|
const pidFileMatches = Boolean(pidInfo
|
|
25
45
|
&& pidInfo.pid === info.pid
|
|
@@ -33,6 +53,9 @@ function runningDaemonStatus(info, pidInfo) {
|
|
|
33
53
|
transport: 'socket',
|
|
34
54
|
version: info.version,
|
|
35
55
|
stalePidFile: Boolean(pidInfo && !pidFileMatches),
|
|
56
|
+
service: installedServiceKind(),
|
|
57
|
+
supervised: Boolean(info.supervisor),
|
|
58
|
+
logPath: getDaemonLogPath(),
|
|
36
59
|
};
|
|
37
60
|
}
|
|
38
61
|
function parsePort(value) {
|
|
@@ -110,7 +133,7 @@ export async function startDaemonLocked(port, detached, options = {}) {
|
|
|
110
133
|
const runningInfo = await getDaemonInfo(remainingStartTimeoutMs('initial-ping'));
|
|
111
134
|
if (runningInfo) {
|
|
112
135
|
if (matchesRuntimeTokenBinding(runningInfo, version, baseConfig)) {
|
|
113
|
-
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true };
|
|
136
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true, service: runningInfo.supervisor ?? null };
|
|
114
137
|
}
|
|
115
138
|
throw new DinoxError('A stale daemon is already responding. Run `dino daemon restart` and retry.', {
|
|
116
139
|
code: 'DAEMON_STALE',
|
|
@@ -119,54 +142,78 @@ export async function startDaemonLocked(port, detached, options = {}) {
|
|
|
119
142
|
}
|
|
120
143
|
const expected = await expectedDaemonIdentity(baseConfig, remainingStartTimeoutMs('auth-hydration'));
|
|
121
144
|
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();
|
|
145
|
+
const launch = await launchDaemon(port);
|
|
133
146
|
const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, remainingStartTimeoutMs('ready-poll'));
|
|
134
147
|
if (!ready) {
|
|
135
|
-
|
|
136
|
-
throw new DinoxError('Failed to start daemon'
|
|
148
|
+
launch.abort();
|
|
149
|
+
throw new DinoxError('Failed to start daemon', {
|
|
150
|
+
code: 'DAEMON_START_FAILED',
|
|
151
|
+
details: { service: launch.service, logPath: getDaemonLogPath() },
|
|
152
|
+
suggestion: `Inspect ${getDaemonLogPath()} for the daemon's startup error.`,
|
|
153
|
+
});
|
|
137
154
|
}
|
|
138
|
-
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false };
|
|
155
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: launch.service };
|
|
139
156
|
}
|
|
140
157
|
else {
|
|
158
|
+
await serveDaemonInForeground(port, options);
|
|
159
|
+
}
|
|
160
|
+
return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: null };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Runs the daemon in this process until a signal or shutdown request. A crash
|
|
164
|
+
* is logged and exits non-zero, so a service manager restarts it; a clean
|
|
165
|
+
* stop exits 0 and stays stopped.
|
|
166
|
+
*/
|
|
167
|
+
async function serveDaemonInForeground(port, options) {
|
|
168
|
+
if (process.env[DAEMON_SERVICE_ENV]) {
|
|
169
|
+
// A service manager holds the log open in append mode; keep it bounded.
|
|
170
|
+
rotateDaemonLog();
|
|
171
|
+
}
|
|
172
|
+
if (!options.quiet) {
|
|
173
|
+
printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
|
|
174
|
+
}
|
|
175
|
+
const server = new DaemonServer();
|
|
176
|
+
try {
|
|
177
|
+
await server.initialize();
|
|
178
|
+
await server.start(port);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
await server.stop().catch(() => undefined);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (!options.quiet) {
|
|
185
|
+
printHumanReadableLine(`Daemon running on ${getDaemonSocketPath()} (PID: ${process.pid})`);
|
|
186
|
+
}
|
|
187
|
+
writeDaemonLogLine('dinox-daemon', 'started', {
|
|
188
|
+
pid: process.pid,
|
|
189
|
+
version: getPackageVersion(),
|
|
190
|
+
supervisor: process.env[DAEMON_SERVICE_ENV] ?? null,
|
|
191
|
+
});
|
|
192
|
+
const stopAndExit = async (reason, exitCode) => {
|
|
193
|
+
writeDaemonLogLine('dinox-daemon', 'stopping', { reason });
|
|
194
|
+
const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
|
|
195
|
+
forceExit.unref();
|
|
196
|
+
await server.stop().catch(() => undefined);
|
|
197
|
+
process.exit(exitCode);
|
|
198
|
+
};
|
|
199
|
+
process.on('SIGINT', () => {
|
|
141
200
|
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})`);
|
|
201
|
+
printHumanReadableLine('');
|
|
202
|
+
printHumanReadableLine('Stopping daemon...');
|
|
155
203
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
process.on('SIGTERM', async () => {
|
|
165
|
-
await server.stop();
|
|
166
|
-
process.exit(0);
|
|
204
|
+
void stopAndExit('SIGINT', 0);
|
|
205
|
+
});
|
|
206
|
+
process.on('SIGTERM', () => void stopAndExit('SIGTERM', 0));
|
|
207
|
+
// No controlling terminal to lose; never let a hangup end the daemon.
|
|
208
|
+
process.on('SIGHUP', () => undefined);
|
|
209
|
+
const crash = (kind) => (error) => {
|
|
210
|
+
writeDaemonLogLine('dinox-daemon', kind, {
|
|
211
|
+
error: error instanceof Error ? error.stack ?? error.message : String(error),
|
|
167
212
|
});
|
|
168
|
-
|
|
169
|
-
|
|
213
|
+
void stopAndExit(kind, 1);
|
|
214
|
+
};
|
|
215
|
+
process.on('uncaughtException', crash('uncaught exception'));
|
|
216
|
+
process.on('unhandledRejection', crash('unhandled rejection'));
|
|
170
217
|
}
|
|
171
218
|
async function startDaemon(port, detached = true, options = {}) {
|
|
172
219
|
const internalChild = process.env[DAEMON_CHILD_ENV] === '1';
|
|
@@ -241,7 +288,7 @@ async function daemonStatus() {
|
|
|
241
288
|
? removePid(pidInfo.instanceId)
|
|
242
289
|
: removePid(undefined, { expectedPid: pidInfo.pid });
|
|
243
290
|
if (stalePidFile) {
|
|
244
|
-
return {
|
|
291
|
+
return notRunningStatus({ unreachable: false, pid: null, port: null, socketPath: null, stalePidFile: true });
|
|
245
292
|
}
|
|
246
293
|
// A replacement rewrote the PID metadata after the initial snapshot.
|
|
247
294
|
// Re-read once and leave the replacement instance untouched.
|
|
@@ -251,28 +298,22 @@ async function daemonStatus() {
|
|
|
251
298
|
return runningDaemonStatus(replacementInfo, replacementPidInfo);
|
|
252
299
|
}
|
|
253
300
|
const replacementSocketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
|
|
254
|
-
return {
|
|
255
|
-
running: false,
|
|
301
|
+
return notRunningStatus({
|
|
256
302
|
unreachable: Boolean(replacementPidInfo || replacementSocketExists),
|
|
257
303
|
pid: replacementPidInfo?.pid ?? null,
|
|
258
304
|
port: replacementPidInfo?.port ?? null,
|
|
259
305
|
socketPath: replacementSocketExists ? getDaemonSocketPath() : null,
|
|
260
|
-
transport: 'socket',
|
|
261
|
-
version: null,
|
|
262
306
|
stalePidFile: false,
|
|
263
|
-
};
|
|
307
|
+
});
|
|
264
308
|
}
|
|
265
309
|
const socketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
|
|
266
|
-
return {
|
|
267
|
-
running: false,
|
|
310
|
+
return notRunningStatus({
|
|
268
311
|
unreachable: Boolean(pidInfo || socketExists),
|
|
269
312
|
pid: pidInfo?.pid ?? null,
|
|
270
313
|
port: pidInfo?.port ?? null,
|
|
271
314
|
socketPath: socketExists ? getDaemonSocketPath() : null,
|
|
272
|
-
transport: 'socket',
|
|
273
|
-
version: null,
|
|
274
315
|
stalePidFile: false,
|
|
275
|
-
};
|
|
316
|
+
});
|
|
276
317
|
}
|
|
277
318
|
}
|
|
278
319
|
export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options = {}) {
|
|
@@ -284,6 +325,77 @@ export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options =
|
|
|
284
325
|
export async function restartDaemon(port = DEFAULT_DAEMON_PORT, options = {}) {
|
|
285
326
|
return withDaemonStartLock(() => restartDaemonLocked(port, options));
|
|
286
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Hands the daemon to the platform's service manager so it no longer belongs
|
|
330
|
+
* to whichever process first started it, then waits until the managed
|
|
331
|
+
* instance answers with the current identity.
|
|
332
|
+
*/
|
|
333
|
+
export async function installDaemonService(port = DEFAULT_DAEMON_PORT) {
|
|
334
|
+
const service = getDaemonService();
|
|
335
|
+
if (!service) {
|
|
336
|
+
throw new DinoxError(`No supported service manager on ${process.platform}; keep using \`dino daemon start\`.`, {
|
|
337
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
338
|
+
details: { platform: process.platform },
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const raw = await loadConfig();
|
|
342
|
+
if (!normalizeAuthToken(raw.auth?.authorization)) {
|
|
343
|
+
throw new DinoxError('The daemon service runs with the saved login, and none is saved.', {
|
|
344
|
+
code: 'AUTH_REQUIRED',
|
|
345
|
+
suggestion: 'Run `dino auth login`, then retry `dino daemon install`.',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (envTokenOverridesLogin(raw)) {
|
|
349
|
+
throw new DinoxError('DINOX_TOKEN selects a different account than the saved login, and the daemon service only uses the saved login.', {
|
|
350
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
351
|
+
details: { reason: 'env-token' },
|
|
352
|
+
suggestion: 'Unset DINOX_TOKEN, or save that token with `dino auth login`, then retry.',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return withDaemonStartLock(async () => {
|
|
356
|
+
const expected = await expectedDaemonIdentity(resolveConfig(raw));
|
|
357
|
+
const stopResult = await stopDaemonLocked();
|
|
358
|
+
await service.install(daemonServiceSpec(service, port));
|
|
359
|
+
const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, DAEMON_SERVICE_READY_TIMEOUT_MS);
|
|
360
|
+
if (!ready) {
|
|
361
|
+
throw new DinoxError(`The ${service.kind} service was installed, but the daemon did not become ready.`, {
|
|
362
|
+
code: 'DAEMON_START_FAILED',
|
|
363
|
+
details: { service: service.kind, definitionPath: service.definitionPath, logPath: getDaemonLogPath() },
|
|
364
|
+
suggestion: `Inspect ${getDaemonLogPath()}, then run \`dino daemon status\`.`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
installed: true,
|
|
369
|
+
service: service.kind,
|
|
370
|
+
label: service.label,
|
|
371
|
+
definitionPath: service.definitionPath,
|
|
372
|
+
logPath: getDaemonLogPath(),
|
|
373
|
+
running: true,
|
|
374
|
+
replacedRunning: stopResult.stopped,
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Stops the managed daemon and removes its service definition. */
|
|
379
|
+
export async function uninstallDaemonService() {
|
|
380
|
+
const service = getDaemonService();
|
|
381
|
+
if (!service) {
|
|
382
|
+
return { uninstalled: false, service: null, definitionPath: null };
|
|
383
|
+
}
|
|
384
|
+
return withDaemonStartLock(async () => {
|
|
385
|
+
if (service.isInstalled()) {
|
|
386
|
+
// Stop through the socket first: a clean exit is not restarted, and
|
|
387
|
+
// deleting a Windows task does not end the process it started.
|
|
388
|
+
const info = await getDaemonInfo();
|
|
389
|
+
if (info?.supervisor === service.kind)
|
|
390
|
+
await stopDaemonLocked();
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
uninstalled: await service.uninstall(),
|
|
394
|
+
service: service.kind,
|
|
395
|
+
definitionPath: service.definitionPath,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
}
|
|
287
399
|
function printDaemonStartResult(result) {
|
|
288
400
|
if (result.alreadyRunning) {
|
|
289
401
|
printHumanReadableLine('Daemon is already running');
|
|
@@ -320,10 +432,40 @@ function printDaemonStatusResult(result) {
|
|
|
320
432
|
printHumanReadableLine(`Transport: ${result.transport}`);
|
|
321
433
|
printHumanReadableLine(`Socket: ${result.socketPath}`);
|
|
322
434
|
printHumanReadableLine(`Version: ${result.version}`);
|
|
435
|
+
printHumanReadableLine(`Service: ${result.service ?? 'not installed'}${result.service && !result.supervised ? ' (running daemon is not managed by it)' : ''}`);
|
|
436
|
+
printHumanReadableLine(`Log: ${result.logPath}`);
|
|
437
|
+
if (!result.supervised) {
|
|
438
|
+
printHumanReadableLine('Note: this daemon belongs to the process that started it. Run `dino daemon install` to keep it (and agent gateways) running independently.');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function printDaemonInstallResult(result) {
|
|
442
|
+
printHumanReadableLine(`Installed ${result.service} service ${result.label}`);
|
|
443
|
+
printHumanReadableLine(`Definition: ${result.definitionPath}`);
|
|
444
|
+
printHumanReadableLine(`Log: ${result.logPath}`);
|
|
445
|
+
printHumanReadableLine(`The daemon now starts at ${result.service === 'schtasks' ? 'logon (Task Scheduler)' : 'login'} and restarts after a crash.`);
|
|
446
|
+
if (result.service === 'systemd') {
|
|
447
|
+
printHumanReadableLine('To keep it running after you log out, run `loginctl enable-linger`.');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function printDaemonUninstallResult(result) {
|
|
451
|
+
if (!result.service) {
|
|
452
|
+
printHumanReadableLine(`No supported service manager on ${process.platform}; nothing to uninstall.`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
printHumanReadableLine(result.uninstalled
|
|
456
|
+
? `Removed ${result.service} service; the daemon is stopped.`
|
|
457
|
+
: 'The daemon service is not installed.');
|
|
323
458
|
}
|
|
324
459
|
async function runDaemonStart(context) {
|
|
325
460
|
const port = parsePort(context.options.port);
|
|
326
461
|
const structured = Boolean(getRequestedStructuredOutputFormat());
|
|
462
|
+
if (context.options.detach === false && process.env[DAEMON_SERVICE_ENV]
|
|
463
|
+
&& !resolveConfig(await loadConfig()).auth.authorization) {
|
|
464
|
+
// Started at login by the service while signed out: exit cleanly so the
|
|
465
|
+
// service does not retry; the next command after `dino auth login` starts it.
|
|
466
|
+
writeDaemonLogLine('dinox-daemon', 'not signed in; staying stopped until `dino auth login`');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
327
469
|
const result = await startDaemon(port, context.options.detach !== false, { quiet: structured });
|
|
328
470
|
if (!result.running) {
|
|
329
471
|
throw new DinoxError('Failed to start daemon', { code: 'DAEMON_START_FAILED' });
|
|
@@ -350,6 +492,25 @@ async function runDaemonStatus() {
|
|
|
350
492
|
}
|
|
351
493
|
printDaemonStatusResult(result);
|
|
352
494
|
}
|
|
495
|
+
async function runDaemonSupervise(context) {
|
|
496
|
+
process.exitCode = await superviseCurrentDaemon(parsePort(context.options.port));
|
|
497
|
+
}
|
|
498
|
+
async function runDaemonInstall(context) {
|
|
499
|
+
const result = await installDaemonService(parsePort(context.options.port));
|
|
500
|
+
if (getRequestedStructuredOutputFormat()) {
|
|
501
|
+
printSuccess(result);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
printDaemonInstallResult(result);
|
|
505
|
+
}
|
|
506
|
+
async function runDaemonUninstall() {
|
|
507
|
+
const result = await uninstallDaemonService();
|
|
508
|
+
if (getRequestedStructuredOutputFormat()) {
|
|
509
|
+
printSuccess(result);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
printDaemonUninstallResult(result);
|
|
513
|
+
}
|
|
353
514
|
async function runDaemonRestart(context) {
|
|
354
515
|
const structured = Boolean(getRequestedStructuredOutputFormat());
|
|
355
516
|
const result = await restartDaemon(parsePort(context.options.port), { quiet: structured });
|
|
@@ -379,7 +540,7 @@ export const daemonCommandGroup = {
|
|
|
379
540
|
output: {
|
|
380
541
|
type: 'structured',
|
|
381
542
|
summary: 'Daemon start acknowledgement',
|
|
382
|
-
fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
|
|
543
|
+
fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
|
|
383
544
|
},
|
|
384
545
|
examples: ['dino daemon start', 'dino daemon start --port 17321', 'dino daemon start --no-detach'],
|
|
385
546
|
},
|
|
@@ -413,7 +574,7 @@ export const daemonCommandGroup = {
|
|
|
413
574
|
output: {
|
|
414
575
|
type: 'structured',
|
|
415
576
|
summary: 'Daemon process status',
|
|
416
|
-
fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile'],
|
|
577
|
+
fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile', 'service', 'supervised', 'logPath'],
|
|
417
578
|
},
|
|
418
579
|
examples: ['dino daemon status'],
|
|
419
580
|
},
|
|
@@ -431,12 +592,65 @@ export const daemonCommandGroup = {
|
|
|
431
592
|
output: {
|
|
432
593
|
type: 'structured',
|
|
433
594
|
summary: 'Daemon restart acknowledgement',
|
|
434
|
-
fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
|
|
595
|
+
fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
|
|
435
596
|
},
|
|
436
597
|
examples: ['dino daemon restart', 'dino daemon restart --port 17321'],
|
|
437
598
|
},
|
|
438
599
|
action: runDaemonRestart,
|
|
439
600
|
},
|
|
601
|
+
{
|
|
602
|
+
name: 'install',
|
|
603
|
+
description: 'Run the daemon as a login service (launchd / systemd) so it outlives the shell or agent that started it',
|
|
604
|
+
options: [{ flags: '--port <number>', description: 'Legacy compatibility field; daemon listens on a private local socket', type: 'number', defaultValue: DEFAULT_DAEMON_PORT }],
|
|
605
|
+
schema: {
|
|
606
|
+
path: 'daemon.install',
|
|
607
|
+
risk: 'write',
|
|
608
|
+
auth: 'required',
|
|
609
|
+
globalOptions: FORMAT_OPTIONS,
|
|
610
|
+
output: {
|
|
611
|
+
type: 'structured',
|
|
612
|
+
summary: 'Installed daemon service',
|
|
613
|
+
fields: ['installed', 'service', 'label', 'definitionPath', 'logPath', 'running', 'replacedRunning'],
|
|
614
|
+
},
|
|
615
|
+
examples: ['dino daemon install'],
|
|
616
|
+
},
|
|
617
|
+
action: runDaemonInstall,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: 'uninstall',
|
|
621
|
+
description: 'Stop the daemon service and remove it from the login session',
|
|
622
|
+
schema: {
|
|
623
|
+
path: 'daemon.uninstall',
|
|
624
|
+
risk: 'write',
|
|
625
|
+
auth: 'none',
|
|
626
|
+
globalOptions: FORMAT_OPTIONS,
|
|
627
|
+
output: {
|
|
628
|
+
type: 'structured',
|
|
629
|
+
summary: 'Daemon service removal acknowledgement',
|
|
630
|
+
fields: ['uninstalled', 'service', 'definitionPath'],
|
|
631
|
+
},
|
|
632
|
+
examples: ['dino daemon uninstall'],
|
|
633
|
+
},
|
|
634
|
+
action: runDaemonUninstall,
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'supervise',
|
|
638
|
+
description: 'Run the daemon in the foreground and restart it after a crash (used by the Windows logon task)',
|
|
639
|
+
options: [{ flags: '--port <number>', description: 'Legacy compatibility field; daemon listens on a private local socket', type: 'number', defaultValue: DEFAULT_DAEMON_PORT }],
|
|
640
|
+
schema: {
|
|
641
|
+
path: 'daemon.supervise',
|
|
642
|
+
risk: 'write',
|
|
643
|
+
auth: 'none',
|
|
644
|
+
globalOptions: [],
|
|
645
|
+
output: {
|
|
646
|
+
type: 'structured',
|
|
647
|
+
summary: 'Runs until the daemon exits cleanly or the supervisor is stopped; no structured payload',
|
|
648
|
+
fields: [],
|
|
649
|
+
},
|
|
650
|
+
examples: ['dino daemon supervise'],
|
|
651
|
+
},
|
|
652
|
+
action: runDaemonSupervise,
|
|
653
|
+
},
|
|
440
654
|
],
|
|
441
655
|
};
|
|
442
656
|
export function registerDaemonCommands(program) {
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* What the service definition runs: this CLI, with no secrets written to disk.
|
|
19
|
+
* Task Scheduler cannot restart a crashed process, so on Windows the service
|
|
20
|
+
* runs the supervisor, which runs the daemon.
|
|
21
|
+
*/
|
|
22
|
+
export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
|
|
23
|
+
/** The installed service when this caller can use it, else null. */
|
|
24
|
+
export declare function usableDaemonService(): Promise<DaemonService | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Starts a daemon. Caller must hold the daemon start lock and have stopped any
|
|
27
|
+
* stale daemon. Uses the installed service when there is one (refreshing its
|
|
28
|
+
* definition if this CLI's program, arguments or environment changed, e.g.
|
|
29
|
+
* after `dino update`), otherwise a detached child with a clean environment.
|
|
30
|
+
*/
|
|
31
|
+
export declare function launchDaemon(port: number): Promise<DaemonLaunch>;
|
|
@@ -0,0 +1,95 @@
|
|
|
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, } 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, mode = 'start') {
|
|
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: mode === 'supervise'
|
|
20
|
+
? [...process.execArgv, entrypoint, 'daemon', 'supervise', '--port', String(port)]
|
|
21
|
+
: [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A service only knows the saved login. When DINOX_TOKEN selects a different
|
|
26
|
+
* account, the caller needs a daemon bound to that token, which only a child
|
|
27
|
+
* of its own environment can have.
|
|
28
|
+
*/
|
|
29
|
+
export function envTokenOverridesLogin(raw, env = process.env) {
|
|
30
|
+
const envToken = readEnvAuthToken(env);
|
|
31
|
+
return Boolean(envToken && envToken !== normalizeAuthToken(raw.auth?.authorization));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What the service definition runs: this CLI, with no secrets written to disk.
|
|
35
|
+
* Task Scheduler cannot restart a crashed process, so on Windows the service
|
|
36
|
+
* runs the supervisor, which runs the daemon.
|
|
37
|
+
*/
|
|
38
|
+
export function daemonServiceSpec(service, port) {
|
|
39
|
+
const { program, args } = daemonCommandLine(port, service.kind === 'schtasks' ? 'supervise' : 'start');
|
|
40
|
+
const env = buildDaemonEnv();
|
|
41
|
+
delete env[DINOX_TOKEN_ENV_VAR];
|
|
42
|
+
env[DAEMON_SERVICE_ENV] = service.kind;
|
|
43
|
+
env[DAEMON_CHILD_ENV] = '1';
|
|
44
|
+
return { program, args, env, workingDirectory: os.homedir(), logPath: getDaemonLogPath() };
|
|
45
|
+
}
|
|
46
|
+
/** The installed service when this caller can use it, else null. */
|
|
47
|
+
export async function usableDaemonService() {
|
|
48
|
+
const service = getDaemonService();
|
|
49
|
+
if (!service?.isInstalled())
|
|
50
|
+
return null;
|
|
51
|
+
if (envTokenOverridesLogin(await loadConfig()))
|
|
52
|
+
return null;
|
|
53
|
+
return service;
|
|
54
|
+
}
|
|
55
|
+
function spawnDetached(port) {
|
|
56
|
+
const { program, args } = daemonCommandLine(port);
|
|
57
|
+
const log = openDaemonLogForChild();
|
|
58
|
+
try {
|
|
59
|
+
const child = spawn(program, args, {
|
|
60
|
+
detached: true,
|
|
61
|
+
stdio: ['ignore', log, log],
|
|
62
|
+
env: { ...buildDaemonEnv(), [DAEMON_CHILD_ENV]: '1' },
|
|
63
|
+
cwd: os.homedir(),
|
|
64
|
+
// A detached Windows child otherwise opens its own console window.
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
});
|
|
67
|
+
child.unref();
|
|
68
|
+
return { service: null, abort: () => child.kill('SIGTERM') };
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeDaemonLogFd(log);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Starts a daemon. Caller must hold the daemon start lock and have stopped any
|
|
76
|
+
* stale daemon. Uses the installed service when there is one (refreshing its
|
|
77
|
+
* definition if this CLI's program, arguments or environment changed, e.g.
|
|
78
|
+
* after `dino update`), otherwise a detached child with a clean environment.
|
|
79
|
+
*/
|
|
80
|
+
export async function launchDaemon(port) {
|
|
81
|
+
const service = await usableDaemonService();
|
|
82
|
+
if (!service)
|
|
83
|
+
return spawnDetached(port);
|
|
84
|
+
ensureDaemonDir();
|
|
85
|
+
const spec = daemonServiceSpec(service, port);
|
|
86
|
+
if (!service.isCurrent(spec)) {
|
|
87
|
+
await service.install(spec);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
await service.start();
|
|
91
|
+
}
|
|
92
|
+
// The service manager owns the process; a launch that never becomes ready
|
|
93
|
+
// is reported with the log path rather than killed here.
|
|
94
|
+
return { service: service.kind, abort: () => undefined };
|
|
95
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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;
|
|
13
|
+
/** One timestamped line appended to the log file itself, for processes with no usable stderr. */
|
|
14
|
+
export declare function appendDaemonLogLine(scope: string, message: string, details?: Record<string, unknown>): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFileSync, 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
|
+
function formatLogLine(scope, message, details) {
|
|
41
|
+
const suffix = details ? ` ${JSON.stringify(details)}` : '';
|
|
42
|
+
return `${new Date().toISOString()} [${scope}] ${message}${suffix}\n`;
|
|
43
|
+
}
|
|
44
|
+
/** One timestamped line on stderr, which a daemon child has pointed at its log. */
|
|
45
|
+
export function writeDaemonLogLine(scope, message, details) {
|
|
46
|
+
process.stderr.write(formatLogLine(scope, message, details));
|
|
47
|
+
}
|
|
48
|
+
/** One timestamped line appended to the log file itself, for processes with no usable stderr. */
|
|
49
|
+
export function appendDaemonLogLine(scope, message, details) {
|
|
50
|
+
try {
|
|
51
|
+
ensureDaemonDir();
|
|
52
|
+
appendFileSync(getDaemonLogPath(), formatLogLine(scope, message, details), { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Logging is best effort.
|
|
56
|
+
}
|
|
57
|
+
}
|