@indigoai-us/hq-cli 5.345.202 → 5.345.204
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/CHANGELOG.md +18 -0
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +26 -0
- package/dist/commands/bot.js +7 -2
- package/dist/commands/capacity.js +10 -2
- package/dist/commands/cloud.d.ts +14 -2
- package/dist/commands/cloud.js +25 -16
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +225 -4
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +11 -0
- package/dist/commands/integrations-manage.js +2 -2
- package/dist/commands/integrations.js +5 -5
- package/dist/commands/sync-core.js +4 -1
- package/dist/lib/bot/claude-login.d.ts +7 -1
- package/dist/lib/bot/claude-login.js +56 -16
- package/dist/lib/bot/executive-settings.d.ts +1 -1
- package/dist/lib/bot/executive-settings.js +8 -6
- package/dist/lib/bot/jobs/meeting-detect.js +2 -2
- package/dist/lib/bot/jobs/meeting-rider.js +2 -2
- package/dist/lib/bot/run.js +3 -3
- package/dist/lib/capacity/codex-availability.d.ts +6 -0
- package/dist/lib/capacity/codex-availability.js +33 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
- package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
- package/dist/lib/capacity/ledger.d.ts +0 -11
- package/dist/lib/capacity/ledger.js +30 -3
- package/dist/lib/capacity/route.js +11 -5
- package/dist/lib/daemon/catalog.d.ts +3 -0
- package/dist/lib/daemon/catalog.js +17 -4
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +9 -2
- package/dist/lib/daemon/install.d.ts +99 -0
- package/dist/lib/daemon/install.js +316 -7
- package/dist/lib/daemon/migrate.d.ts +3 -1
- package/dist/lib/daemon/migrate.js +14 -2
- package/dist/lib/daemon/plist-refresh.d.ts +34 -0
- package/dist/lib/daemon/plist-refresh.js +62 -0
- package/dist/lib/daemon/run.js +27 -2
- package/dist/lib/daemon/selfcheck.d.ts +71 -0
- package/dist/lib/daemon/selfcheck.js +150 -0
- package/dist/lib/daemon/supervisor.d.ts +31 -0
- package/dist/lib/daemon/supervisor.js +130 -6
- package/dist/lib/daemon/unit-holds.js +2 -1
- package/dist/lib/lanes/config.js +13 -0
- package/dist/lib/lanes/throttle.d.ts +12 -1
- package/dist/lib/lanes/throttle.js +27 -8
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/service-manager/launchd.d.ts +25 -0
- package/dist/lib/service-manager/launchd.js +51 -3
- package/dist/lib/service-manager/migration.d.ts +14 -0
- package/dist/lib/service-manager/migration.js +34 -0
- package/dist/lib/service-manager/systemd.d.ts +13 -0
- package/dist/lib/service-manager/systemd.js +19 -0
- package/dist/lib/service-manager/types.d.ts +8 -0
- package/dist/lib/signals/steps/extract.d.ts +110 -4
- package/dist/lib/signals/steps/extract.js +520 -39
- package/dist/lib/sync-core/engine-home.d.ts +2 -1
- package/dist/lib/sync-core/engine-home.js +25 -8
- package/dist/lib/sync-core/reconcile.d.ts +7 -2
- package/dist/lib/sync-core/reconcile.js +32 -9
- package/dist/lib/sync-core/release.d.ts +35 -3
- package/dist/lib/sync-core/release.js +95 -12
- package/package.json +7 -7
|
@@ -163,6 +163,105 @@ export declare function classifyWindowsTask(run: RunCommand, task: string, host:
|
|
|
163
163
|
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
164
164
|
export declare function oldDaemonWarning(pid: number, reason: string): string;
|
|
165
165
|
export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
|
|
166
|
+
/** Install-level bootstrap retries after launchd left the job unloaded. */
|
|
167
|
+
export declare const DAEMON_BOOTSTRAP_RETRIES = 3;
|
|
168
|
+
export declare const DAEMON_BOOTSTRAP_RETRY_MS = 2000;
|
|
169
|
+
/**
|
|
170
|
+
* Bootstrap the daemon agent again when an install left it unloaded, up to
|
|
171
|
+
* DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
|
|
172
|
+
* says the agent is not loaded when every retry failed.
|
|
173
|
+
*/
|
|
174
|
+
export declare function retryLaunchdBootstrap(plistPath: string, run: RunCommand, uid: number, sleep?: SleepFn): {
|
|
175
|
+
ok: boolean;
|
|
176
|
+
error?: string;
|
|
177
|
+
};
|
|
178
|
+
export interface RefreshDaemonPlistResult {
|
|
179
|
+
/**
|
|
180
|
+
* - `none`: no daemon plist here (not installed; nothing to upgrade);
|
|
181
|
+
* - `current`: the plist already loads without HQ.app;
|
|
182
|
+
* - `rewritten`: the plist was rewritten; it was not loaded, so nothing was reloaded;
|
|
183
|
+
* - `reloaded`: the plist was rewritten and launchd now runs the new definition.
|
|
184
|
+
*/
|
|
185
|
+
action: "none" | "current" | "rewritten" | "reloaded";
|
|
186
|
+
plistPath: string;
|
|
187
|
+
/** The rewrite or reload did not finish; what is left and how to fix it. */
|
|
188
|
+
error?: string;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Upgrade a daemon LaunchAgent written by an older install (run through the
|
|
192
|
+
* HQ.app launcher and associated with the app's bundle, which launchd skips
|
|
193
|
+
* at login) to the app-independent form, and reload it when it is loaded.
|
|
194
|
+
*
|
|
195
|
+
* Nothing but `hq daemon install` used to rewrite the unit, so a machine that
|
|
196
|
+
* only ever self-updated kept the old plist. This runs from `hq daemon start`,
|
|
197
|
+
* and from the detached helper the managed daemon spawns at startup when it
|
|
198
|
+
* finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
|
|
199
|
+
* the daemon's own job: the bootout would end the caller before the bootstrap.
|
|
200
|
+
*/
|
|
201
|
+
export declare function refreshDaemonPlist(host: Pick<DaemonHost, "home" | "uid">, deps: {
|
|
202
|
+
run: RunCommand;
|
|
203
|
+
sleep?: SleepFn;
|
|
204
|
+
}): RefreshDaemonPlistResult;
|
|
205
|
+
/** The daemon plist on disk still runs through HQ.app or names its bundle. */
|
|
206
|
+
export declare function daemonPlistNeedsRefresh(home: string): boolean;
|
|
207
|
+
/**
|
|
208
|
+
* Who runs the daemon right now, as the service manager and the pid lock see it:
|
|
209
|
+
*
|
|
210
|
+
* - `managed`: the OS unit is loaded (and, where the manager reports one, its
|
|
211
|
+
* pid is the daemon holding the lock), so it comes back after a crash or login;
|
|
212
|
+
* - `unmanaged`: a daemon holds the lock, but the OS unit is not loaded or runs
|
|
213
|
+
* a different process (started by hand, or as the desktop app's child);
|
|
214
|
+
* - `not-loaded`: the OS unit is not loaded and no daemon runs.
|
|
215
|
+
*/
|
|
216
|
+
export type DaemonSupervision = "managed" | "unmanaged" | "not-loaded";
|
|
217
|
+
export interface DaemonServiceStatus {
|
|
218
|
+
supervision: DaemonSupervision;
|
|
219
|
+
/** Which service manager was asked. */
|
|
220
|
+
manager: "launchd" | "systemd-user" | "systemd-system" | "schtasks" | "none";
|
|
221
|
+
/** The unit file (plist, unit, task) is present. */
|
|
222
|
+
unitInstalled: boolean;
|
|
223
|
+
/** The manager has the unit loaded / enabled. */
|
|
224
|
+
loaded: boolean;
|
|
225
|
+
/** The manager reports the unit's process as running. */
|
|
226
|
+
active: boolean;
|
|
227
|
+
/** The manager's pid for the unit, when it reports one. */
|
|
228
|
+
managerPid?: number;
|
|
229
|
+
/** The pid of the live daemon holding the lock, or null. */
|
|
230
|
+
daemonPid: number | null;
|
|
231
|
+
/** One line for people: what the manager said. */
|
|
232
|
+
detail: string;
|
|
233
|
+
/**
|
|
234
|
+
* Problems with the unit that do not change who runs the daemon now, such
|
|
235
|
+
* as a plist launchd skips at login. One line each, with the fix.
|
|
236
|
+
*/
|
|
237
|
+
warnings?: string[];
|
|
238
|
+
}
|
|
239
|
+
export declare function describeSupervision(status: DaemonServiceStatus): string;
|
|
240
|
+
/**
|
|
241
|
+
* Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
|
|
242
|
+
* and combine that with the pid lock. `daemonPid` is the live daemon from the
|
|
243
|
+
* lock (null when none runs).
|
|
244
|
+
*/
|
|
245
|
+
export declare function daemonServiceStatus(host: Pick<DaemonHost, "platform" | "home" | "uid" | "systemUnitDir" | "windowsSid" | "windowsUser">, run: RunCommand, daemonPid: number | null): DaemonServiceStatus;
|
|
246
|
+
export interface StartDaemonResult {
|
|
247
|
+
/**
|
|
248
|
+
* What was done: nothing (already running), loaded the unit, started it, or
|
|
249
|
+
* reloaded it after rewriting an older install's plist (macOS).
|
|
250
|
+
*/
|
|
251
|
+
action: "already-running" | "loaded" | "started" | "reloaded";
|
|
252
|
+
status: DaemonServiceStatus;
|
|
253
|
+
/** macOS: what happened to an older install's plist before starting. */
|
|
254
|
+
refresh?: RefreshDaemonPlistResult;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* `hq daemon start`: load the daemon's OS unit when the manager does not have
|
|
258
|
+
* it, then start it. The unit file must already exist (the caller installs it
|
|
259
|
+
* first when it does not). Throws when the manager refuses.
|
|
260
|
+
*/
|
|
261
|
+
export declare function startDaemonUnit(host: DaemonHost, deps: InstallDeps & {
|
|
262
|
+
daemonPid: () => number | null;
|
|
263
|
+
waitMs?: number;
|
|
264
|
+
}): StartDaemonResult;
|
|
166
265
|
/**
|
|
167
266
|
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
168
267
|
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
-
import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
22
|
-
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
21
|
+
import { activateLaunchd, blockingSleep, bootstrapLaunchd, launchdJobInfo, launchdServiceTarget, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, launchdPlistProgramArguments, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
22
|
+
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitInfo, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
23
|
+
import { appIndependentLaunchdPlist } from "../service-manager/migration.js";
|
|
23
24
|
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
24
25
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
25
26
|
import { userToolDirs } from "./tools.js";
|
|
@@ -108,6 +109,7 @@ function daemonSpec(host) {
|
|
|
108
109
|
env: host.env ?? {},
|
|
109
110
|
restartSec: 10,
|
|
110
111
|
unitName: DAEMON_SYSTEMD_UNIT,
|
|
112
|
+
independentOfApp: true,
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
function serviceHost(host) {
|
|
@@ -454,16 +456,51 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
454
456
|
});
|
|
455
457
|
}
|
|
456
458
|
catch (err) {
|
|
459
|
+
const run = deps.run ?? defaultRunCommand;
|
|
460
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
461
|
+
const installed = fs.existsSync(plan.unitPath);
|
|
462
|
+
// The reload gave up with the job unloaded: try the bootstrap again
|
|
463
|
+
// before reporting, since an unloaded agent never starts at login.
|
|
464
|
+
if (installed && !launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
|
|
465
|
+
const retried = retryLaunchdBootstrap(plan.unitPath, run, host.uid, deps.sleep);
|
|
466
|
+
if (retried.ok)
|
|
467
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
468
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed, running: false, error: `${message}\n${retried.error}` };
|
|
469
|
+
}
|
|
470
|
+
// The reload failed but launchd still runs the previous definition: a
|
|
471
|
+
// daemon is running under launchd. Say so, and that the new plist is only
|
|
472
|
+
// read at the next load (login, or hq daemon start after a bootout).
|
|
473
|
+
if (installed && launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
|
|
474
|
+
return {
|
|
475
|
+
kind: plan.kind,
|
|
476
|
+
unitPath: plan.unitPath,
|
|
477
|
+
installed: true,
|
|
478
|
+
running: true,
|
|
479
|
+
warning: `The daemon is still running under launchd with its previous definition. ${message}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
457
482
|
return {
|
|
458
483
|
kind: plan.kind,
|
|
459
484
|
unitPath: plan.unitPath,
|
|
460
|
-
installed
|
|
461
|
-
running:
|
|
462
|
-
error:
|
|
485
|
+
installed,
|
|
486
|
+
running: false,
|
|
487
|
+
error: message,
|
|
463
488
|
};
|
|
464
489
|
}
|
|
465
|
-
if (!plist.loaded)
|
|
466
|
-
|
|
490
|
+
if (!plist.loaded) {
|
|
491
|
+
// installLaunchdPlist found the job unloaded after activating it. That is
|
|
492
|
+
// a failure, never a quiet success: retry the bootstrap, then report it.
|
|
493
|
+
const retried = retryLaunchdBootstrap(plan.unitPath, deps.run ?? defaultRunCommand, host.uid, deps.sleep);
|
|
494
|
+
if (!retried.ok) {
|
|
495
|
+
return {
|
|
496
|
+
kind: plan.kind,
|
|
497
|
+
unitPath: plan.unitPath,
|
|
498
|
+
installed: true,
|
|
499
|
+
running: false,
|
|
500
|
+
error: retried.error,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
467
504
|
}
|
|
468
505
|
else if (plan.kind === "schtasks")
|
|
469
506
|
fs.writeFileSync(plan.unitPath, encodeWindowsTaskXml(plan.rendered));
|
|
@@ -507,6 +544,278 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
507
544
|
};
|
|
508
545
|
}
|
|
509
546
|
}
|
|
547
|
+
/** Install-level bootstrap retries after launchd left the job unloaded. */
|
|
548
|
+
export const DAEMON_BOOTSTRAP_RETRIES = 3;
|
|
549
|
+
export const DAEMON_BOOTSTRAP_RETRY_MS = 2_000;
|
|
550
|
+
/**
|
|
551
|
+
* Bootstrap the daemon agent again when an install left it unloaded, up to
|
|
552
|
+
* DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
|
|
553
|
+
* says the agent is not loaded when every retry failed.
|
|
554
|
+
*/
|
|
555
|
+
export function retryLaunchdBootstrap(plistPath, run, uid, sleep = blockingSleep) {
|
|
556
|
+
let last = "";
|
|
557
|
+
for (let attempt = 1; attempt <= DAEMON_BOOTSTRAP_RETRIES; attempt++) {
|
|
558
|
+
const r = bootstrapLaunchd(plistPath, run, uid, { sleep });
|
|
559
|
+
if (r.ok && launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, uid).loaded) {
|
|
560
|
+
run("launchctl", ["kickstart", launchdServiceTarget(plistPath, uid)]);
|
|
561
|
+
return { ok: true };
|
|
562
|
+
}
|
|
563
|
+
last = r.error ?? "launchd does not list the job after bootstrap";
|
|
564
|
+
if (attempt < DAEMON_BOOTSTRAP_RETRIES)
|
|
565
|
+
sleep(DAEMON_BOOTSTRAP_RETRY_MS);
|
|
566
|
+
}
|
|
567
|
+
const target = launchdServiceTarget(plistPath, uid);
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error: `launchctl bootstrap gui/${uid} ${plistPath} failed ${DAEMON_BOOTSTRAP_RETRIES} times: ${last}. ` +
|
|
571
|
+
`${target} is NOT loaded, so the daemon will not start at login. Retry with: hq daemon start`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Upgrade a daemon LaunchAgent written by an older install (run through the
|
|
576
|
+
* HQ.app launcher and associated with the app's bundle, which launchd skips
|
|
577
|
+
* at login) to the app-independent form, and reload it when it is loaded.
|
|
578
|
+
*
|
|
579
|
+
* Nothing but `hq daemon install` used to rewrite the unit, so a machine that
|
|
580
|
+
* only ever self-updated kept the old plist. This runs from `hq daemon start`,
|
|
581
|
+
* and from the detached helper the managed daemon spawns at startup when it
|
|
582
|
+
* finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
|
|
583
|
+
* the daemon's own job: the bootout would end the caller before the bootstrap.
|
|
584
|
+
*/
|
|
585
|
+
export function refreshDaemonPlist(host, deps) {
|
|
586
|
+
const plistPath = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
587
|
+
if (!fs.existsSync(plistPath))
|
|
588
|
+
return { action: "none", plistPath };
|
|
589
|
+
const source = fs.readFileSync(plistPath, "utf8");
|
|
590
|
+
let updated;
|
|
591
|
+
try {
|
|
592
|
+
updated = appIndependentLaunchdPlist(source);
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
return { action: "current", plistPath, error: `${err instanceof Error ? err.message : String(err)}: ${plistPath}. Run: hq daemon install` };
|
|
596
|
+
}
|
|
597
|
+
if (updated === null) {
|
|
598
|
+
// The file is current, but an earlier reload may have failed and left
|
|
599
|
+
// launchd running the old definition: reload it so it matches the file.
|
|
600
|
+
if (!loadedJobRunsThroughApp(launchdJobInfo(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)))
|
|
601
|
+
return { action: "current", plistPath };
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const mode = fs.statSync(plistPath).mode & 0o777;
|
|
605
|
+
const tmp = `${plistPath}.${process.pid}.refresh.tmp`;
|
|
606
|
+
fs.writeFileSync(tmp, updated, { mode });
|
|
607
|
+
fs.renameSync(tmp, plistPath);
|
|
608
|
+
if (!launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid))
|
|
609
|
+
return { action: "rewritten", plistPath };
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
activateLaunchd(plistPath, deps.run, host.uid, { sleep: deps.sleep });
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
if (launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)) {
|
|
617
|
+
// The old definition still runs; the rewritten file is what launchd reads at login.
|
|
618
|
+
return { action: "rewritten", plistPath, error: message };
|
|
619
|
+
}
|
|
620
|
+
const retried = retryLaunchdBootstrap(plistPath, deps.run, host.uid, deps.sleep);
|
|
621
|
+
if (!retried.ok)
|
|
622
|
+
return { action: "rewritten", plistPath, error: `${message}\n${retried.error}` };
|
|
623
|
+
}
|
|
624
|
+
return { action: "reloaded", plistPath };
|
|
625
|
+
}
|
|
626
|
+
/** launchd's loaded copy of the daemon job runs through the HQ.app launcher. */
|
|
627
|
+
function loadedJobRunsThroughApp(job) {
|
|
628
|
+
return job.loaded && job.arguments !== undefined && job.arguments.includes("--hq-launchd-task");
|
|
629
|
+
}
|
|
630
|
+
/** The daemon plist on disk still runs through HQ.app or names its bundle. */
|
|
631
|
+
export function daemonPlistNeedsRefresh(home) {
|
|
632
|
+
const plistPath = launchdPlistPath(home, { label: DAEMON_LAUNCHD_LABEL });
|
|
633
|
+
try {
|
|
634
|
+
return appIndependentLaunchdPlist(fs.readFileSync(plistPath, "utf8")) !== null;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
export function describeSupervision(status) {
|
|
641
|
+
switch (status.supervision) {
|
|
642
|
+
case "managed":
|
|
643
|
+
return `running under the service manager (${status.detail})`;
|
|
644
|
+
case "unmanaged":
|
|
645
|
+
return `running outside the service manager (${status.detail}); it will not come back after a crash or restart. Run: hq daemon start`;
|
|
646
|
+
case "not-loaded":
|
|
647
|
+
return `not loaded (${status.detail}). Run: hq daemon start`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
|
|
652
|
+
* and combine that with the pid lock. `daemonPid` is the live daemon from the
|
|
653
|
+
* lock (null when none runs).
|
|
654
|
+
*/
|
|
655
|
+
export function daemonServiceStatus(host, run, daemonPid) {
|
|
656
|
+
const classify = (base, pidsComparable = false) => {
|
|
657
|
+
// Loaded but not running (launchd throttling a restart) is still managed:
|
|
658
|
+
// the manager brings it back. A live daemon the manager does not report
|
|
659
|
+
// as running was started some other way. When the unit runs node directly
|
|
660
|
+
// its pid must be the daemon's: a different pid is a second daemon that
|
|
661
|
+
// lost the lock (for example to one the desktop app hosts) and is being
|
|
662
|
+
// respawned. An older install's plist runs the HQ.app launcher, whose pid
|
|
663
|
+
// is never the daemon's, so pids are not compared there.
|
|
664
|
+
let supervision;
|
|
665
|
+
if (!base.loaded)
|
|
666
|
+
supervision = daemonPid !== null ? "unmanaged" : "not-loaded";
|
|
667
|
+
else if (daemonPid !== null && !base.active)
|
|
668
|
+
supervision = "unmanaged";
|
|
669
|
+
else if (pidsComparable && daemonPid !== null && base.managerPid !== undefined && base.managerPid !== daemonPid) {
|
|
670
|
+
supervision = "unmanaged";
|
|
671
|
+
}
|
|
672
|
+
else
|
|
673
|
+
supervision = "managed";
|
|
674
|
+
return { ...base, supervision, daemonPid };
|
|
675
|
+
};
|
|
676
|
+
switch (host.platform) {
|
|
677
|
+
case "darwin": {
|
|
678
|
+
const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
679
|
+
const unitInstalled = fs.existsSync(plist);
|
|
680
|
+
const job = launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, host.uid);
|
|
681
|
+
const target = `gui/${host.uid}/${DAEMON_LAUNCHD_LABEL}`;
|
|
682
|
+
let fileNodeDirect;
|
|
683
|
+
try {
|
|
684
|
+
fileNodeDirect = unitInstalled && !launchdPlistProgramArguments(fs.readFileSync(plist, "utf8")).includes("--hq-launchd-task");
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
fileNodeDirect = false;
|
|
688
|
+
}
|
|
689
|
+
// What runs now is the loaded job, which differs from the file on disk
|
|
690
|
+
// after a rewrite whose reload failed: go by the job when launchd prints it.
|
|
691
|
+
const jobThroughApp = loadedJobRunsThroughApp(job);
|
|
692
|
+
const nodeDirect = job.loaded && job.arguments !== undefined ? !jobThroughApp : fileNodeDirect;
|
|
693
|
+
const warnings = [];
|
|
694
|
+
if (unitInstalled && daemonPlistNeedsRefresh(host.home)) {
|
|
695
|
+
warnings.push(`${plist} still runs through HQ.app, so launchd skips the daemon at login. Run: hq daemon start`);
|
|
696
|
+
}
|
|
697
|
+
else if (jobThroughApp) {
|
|
698
|
+
warnings.push(`launchd still runs the daemon's previous definition, which goes through HQ.app; ${plist} was rewritten but not reloaded. Run: hq daemon start`);
|
|
699
|
+
}
|
|
700
|
+
const pidMismatch = nodeDirect && job.pid !== undefined && daemonPid !== null && job.pid !== daemonPid;
|
|
701
|
+
const detail = job.loaded
|
|
702
|
+
? `launchd ${target}: ${job.state ?? "loaded"}${job.pid ? `, pid ${job.pid}` : ""}` +
|
|
703
|
+
(pidMismatch ? `, not the daemon holding the lock (pid ${daemonPid})` : "")
|
|
704
|
+
: `launchd has no ${target}${unitInstalled ? "" : ` and ${plist} is missing`}`;
|
|
705
|
+
return classify({
|
|
706
|
+
manager: "launchd",
|
|
707
|
+
unitInstalled,
|
|
708
|
+
loaded: job.loaded,
|
|
709
|
+
active: job.loaded && (job.pid !== undefined || job.state === "running"),
|
|
710
|
+
...(job.pid ? { managerPid: job.pid } : {}),
|
|
711
|
+
detail,
|
|
712
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
713
|
+
}, nodeDirect);
|
|
714
|
+
}
|
|
715
|
+
case "linux": {
|
|
716
|
+
const systemUnit = path.join(host.systemUnitDir, DAEMON_SYSTEMD_UNIT);
|
|
717
|
+
const userUnit = path.join(host.home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT);
|
|
718
|
+
const scope = fs.existsSync(systemUnit) ? "system" : "user";
|
|
719
|
+
const unitInstalled = scope === "system" || fs.existsSync(userUnit);
|
|
720
|
+
const info = systemdUnitInfo(DAEMON_SYSTEMD_UNIT, run, scope);
|
|
721
|
+
return classify({
|
|
722
|
+
manager: scope === "system" ? "systemd-system" : "systemd-user",
|
|
723
|
+
unitInstalled,
|
|
724
|
+
loaded: unitInstalled && info.enabled,
|
|
725
|
+
active: info.active,
|
|
726
|
+
...(info.pid ? { managerPid: info.pid } : {}),
|
|
727
|
+
detail: `systemd ${scope} ${DAEMON_SYSTEMD_UNIT}: ${info.enabledState}, ${info.activeState}${info.pid ? `, pid ${info.pid}` : ""}`,
|
|
728
|
+
}, true);
|
|
729
|
+
}
|
|
730
|
+
case "win32": {
|
|
731
|
+
const task = windowsTaskName({ windowsSid: host.windowsSid, windowsUser: host.windowsUser });
|
|
732
|
+
const probe = run("schtasks.exe", ["/Query", "/TN", task, "/FO", "CSV", "/NH"]);
|
|
733
|
+
const loaded = probe.status === 0;
|
|
734
|
+
const active = loaded && /,\s*"?Running"?\s*$/i.test(probe.stdout.replace(/\0/g, "").trim());
|
|
735
|
+
return classify({
|
|
736
|
+
manager: "schtasks",
|
|
737
|
+
unitInstalled: loaded,
|
|
738
|
+
loaded,
|
|
739
|
+
active,
|
|
740
|
+
detail: loaded ? `Scheduled Task ${task}${active ? " is running" : " is not running"}` : `no Scheduled Task ${task}`,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
default:
|
|
744
|
+
return { supervision: daemonPid !== null ? "unmanaged" : "not-loaded", manager: "none", unitInstalled: false, loaded: false, active: false, daemonPid, detail: `${host.platform} has no supported service manager` };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* `hq daemon start`: load the daemon's OS unit when the manager does not have
|
|
749
|
+
* it, then start it. The unit file must already exist (the caller installs it
|
|
750
|
+
* first when it does not). Throws when the manager refuses.
|
|
751
|
+
*/
|
|
752
|
+
export function startDaemonUnit(host, deps) {
|
|
753
|
+
const sleep = deps.sleep ?? blockingSleep;
|
|
754
|
+
// An older install's plist runs through HQ.app and is skipped at login:
|
|
755
|
+
// rewrite it first, so the unit this loads or starts is the fixed one.
|
|
756
|
+
const previousPid = deps.daemonPid();
|
|
757
|
+
const refresh = host.platform === "darwin" ? refreshDaemonPlist(host, { run: deps.run, sleep }) : undefined;
|
|
758
|
+
const withRefresh = refresh && (refresh.action === "rewritten" || refresh.action === "reloaded" || refresh.error) ? { refresh } : {};
|
|
759
|
+
const waitForManaged = (action, previousPid) => {
|
|
760
|
+
const waitMs = deps.waitMs ?? 15_000;
|
|
761
|
+
const ready = (s) => s.supervision === "managed" && s.daemonPid !== null && (previousPid === null || s.daemonPid !== previousPid);
|
|
762
|
+
let status = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
763
|
+
for (let waited = 0; waited < waitMs && !ready(status); waited += 500) {
|
|
764
|
+
sleep(500);
|
|
765
|
+
status = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
766
|
+
}
|
|
767
|
+
return { action, status, ...withRefresh };
|
|
768
|
+
};
|
|
769
|
+
if (refresh?.action === "reloaded")
|
|
770
|
+
return waitForManaged("reloaded", previousPid);
|
|
771
|
+
const before = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
772
|
+
if (before.supervision === "managed" && before.daemonPid !== null)
|
|
773
|
+
return { action: "already-running", status: before, ...withRefresh };
|
|
774
|
+
let action = "started";
|
|
775
|
+
switch (before.manager) {
|
|
776
|
+
case "launchd": {
|
|
777
|
+
const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
778
|
+
const target = launchdServiceTarget(plist, host.uid);
|
|
779
|
+
if (!before.loaded) {
|
|
780
|
+
const r = retryLaunchdBootstrap(plist, deps.run, host.uid, sleep);
|
|
781
|
+
if (!r.ok)
|
|
782
|
+
throw new Error(r.error);
|
|
783
|
+
action = "loaded";
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
const kick = deps.run("launchctl", ["kickstart", target]);
|
|
787
|
+
if (kick.status !== 0)
|
|
788
|
+
throw commandError(`launchctl kickstart ${target}`, kick);
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
case "systemd-user":
|
|
793
|
+
case "systemd-system": {
|
|
794
|
+
const prefix = before.manager === "systemd-user" ? ["--user"] : [];
|
|
795
|
+
if (before.manager === "systemd-system" && !host.isRoot) {
|
|
796
|
+
throw new Error("The daemon runs as a system unit on this box; starting it needs root. Run: sudo hq daemon start");
|
|
797
|
+
}
|
|
798
|
+
deps.run("systemctl", [...prefix, "daemon-reload"]);
|
|
799
|
+
const enable = deps.run("systemctl", [...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT]);
|
|
800
|
+
if (enable.status !== 0)
|
|
801
|
+
throw commandError(["systemctl", ...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT].join(" "), enable);
|
|
802
|
+
if (!before.loaded)
|
|
803
|
+
action = "loaded";
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case "schtasks": {
|
|
807
|
+
const task = windowsTaskName(host);
|
|
808
|
+
const r = deps.run("schtasks.exe", ["/Run", "/TN", task]);
|
|
809
|
+
if (r.status !== 0)
|
|
810
|
+
throw commandError("schtasks /Run", r);
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
case "none":
|
|
814
|
+
throw new Error(`${host.platform} has no supported service manager; start the daemon with: hq daemon run`);
|
|
815
|
+
}
|
|
816
|
+
// Wait for the manager's daemon to take the lock.
|
|
817
|
+
return waitForManaged(action, null);
|
|
818
|
+
}
|
|
510
819
|
/** A lock record names a different daemon than `previous` (new pid or new start time). */
|
|
511
820
|
function isNewDaemon(now, previous) {
|
|
512
821
|
return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* user can edit), and a restore checks each one lands in the system unit
|
|
17
17
|
* folder, so nobody but root decides what gets installed there.
|
|
18
18
|
*/
|
|
19
|
-
import type
|
|
19
|
+
import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
|
|
20
20
|
import { type LegacyUnit } from "./legacy.js";
|
|
21
21
|
export interface AdoptedUnit extends LegacyUnit {
|
|
22
22
|
/** Where the unit's definition (plist, unit file, task XML) was moved. */
|
|
@@ -43,6 +43,8 @@ export interface MigrateDeps {
|
|
|
43
43
|
uid: number;
|
|
44
44
|
};
|
|
45
45
|
now?: () => number;
|
|
46
|
+
/** Blocking sleep for launchd bootstrap retries (tests inject). */
|
|
47
|
+
sleep?: SleepFn;
|
|
46
48
|
/** Tests inject a failing writer. */
|
|
47
49
|
writeRecord?: (recordPath: string, units: AdoptedUnit[]) => void;
|
|
48
50
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { bootstrapLaunchd } from "../service-manager/launchd.js";
|
|
21
22
|
import { legacyMeshUnitsToRetire } from "./legacy.js";
|
|
22
23
|
import { ensurePrivateDir } from "./paths.js";
|
|
23
24
|
export function readAdoptedUnits(recordPath) {
|
|
@@ -102,7 +103,16 @@ function adoptOne(unit, deps) {
|
|
|
102
103
|
}
|
|
103
104
|
if (!fs.existsSync(unit.path))
|
|
104
105
|
return { movedTo: unit.path, definitionMissing: true };
|
|
105
|
-
|
|
106
|
+
try {
|
|
107
|
+
moveFile(unit.path, to);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// The agent is booted out but its plist did not move: load it again,
|
|
111
|
+
// so a failed takeover never leaves the service off.
|
|
112
|
+
const back = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
|
|
113
|
+
const reloaded = back.ok ? "it was loaded again" : `loading it again also failed (${back.error}); load it with: launchctl bootstrap gui/${deps.uid} ${unit.path}`;
|
|
114
|
+
throw new Error(`${err.message}; ${reloaded}`, { cause: err });
|
|
115
|
+
}
|
|
106
116
|
return { movedTo: to };
|
|
107
117
|
}
|
|
108
118
|
case "systemd-user":
|
|
@@ -198,7 +208,9 @@ function restoreOne(unit, deps) {
|
|
|
198
208
|
if (unit.definitionMissing)
|
|
199
209
|
throw new Error("the original LaunchAgent plist is missing; this unit cannot be restored");
|
|
200
210
|
moveBack(unit, unit.path);
|
|
201
|
-
|
|
211
|
+
const loaded = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
|
|
212
|
+
if (!loaded.ok)
|
|
213
|
+
throw new Error(`launchctl bootstrap gui/${deps.uid} ${unit.path} failed after ${loaded.attempts} attempts: ${loaded.error}`);
|
|
202
214
|
return;
|
|
203
215
|
}
|
|
204
216
|
case "systemd-user":
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade an older install's daemon LaunchAgent without a manual
|
|
3
|
+
* `hq daemon install`.
|
|
4
|
+
*
|
|
5
|
+
* Self-update and `hq update` restart the daemon through a control request and
|
|
6
|
+
* never rewrite its unit, so a Mac that only ever self-updated keeps the plist
|
|
7
|
+
* that runs through HQ.app and names its bundle, which launchd skips at login.
|
|
8
|
+
* When the managed daemon starts and finds that plist, it spawns a detached
|
|
9
|
+
* `hq daemon start`, which rewrites the plist and reloads it (see
|
|
10
|
+
* refreshDaemonPlist). The helper runs in its own session, outside the
|
|
11
|
+
* daemon's job, so the bootout that ends this daemon does not end the helper
|
|
12
|
+
* before it bootstraps the new definition.
|
|
13
|
+
*/
|
|
14
|
+
/** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
|
|
15
|
+
export declare const PLIST_REFRESH_RETRY_MS: number;
|
|
16
|
+
export declare const PLIST_REFRESH_MARKER = "plist-refresh.json";
|
|
17
|
+
export type PlistRefreshOutcome = "not-applicable" | "current" | "recent" | "spawned";
|
|
18
|
+
export interface PlistRefreshDeps {
|
|
19
|
+
platform: NodeJS.Platform;
|
|
20
|
+
/** Started by launchd (`daemon run --managed`) and not hosted by an app. */
|
|
21
|
+
managedByLaunchd: boolean;
|
|
22
|
+
home: string;
|
|
23
|
+
/** ~/.hq/daemon: holds the marker and the helper's log. */
|
|
24
|
+
daemonRoot: string;
|
|
25
|
+
nodeBinary: string;
|
|
26
|
+
hqBinary: string;
|
|
27
|
+
env: NodeJS.ProcessEnv;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
needsRefresh?: (home: string) => boolean;
|
|
30
|
+
spawnHelper?: (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv) => void;
|
|
31
|
+
log: (level: "info" | "warn", line: string) => void;
|
|
32
|
+
}
|
|
33
|
+
export declare function scheduleDaemonPlistRefresh(deps: PlistRefreshDeps): PlistRefreshOutcome;
|
|
34
|
+
//# sourceMappingURL=plist-refresh.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade an older install's daemon LaunchAgent without a manual
|
|
3
|
+
* `hq daemon install`.
|
|
4
|
+
*
|
|
5
|
+
* Self-update and `hq update` restart the daemon through a control request and
|
|
6
|
+
* never rewrite its unit, so a Mac that only ever self-updated keeps the plist
|
|
7
|
+
* that runs through HQ.app and names its bundle, which launchd skips at login.
|
|
8
|
+
* When the managed daemon starts and finds that plist, it spawns a detached
|
|
9
|
+
* `hq daemon start`, which rewrites the plist and reloads it (see
|
|
10
|
+
* refreshDaemonPlist). The helper runs in its own session, outside the
|
|
11
|
+
* daemon's job, so the bootout that ends this daemon does not end the helper
|
|
12
|
+
* before it bootstraps the new definition.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { daemonPlistNeedsRefresh } from "./install.js";
|
|
18
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
19
|
+
/** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
|
|
20
|
+
export const PLIST_REFRESH_RETRY_MS = 60 * 60_000;
|
|
21
|
+
export const PLIST_REFRESH_MARKER = "plist-refresh.json";
|
|
22
|
+
function defaultSpawnHelper(command, args, logPath, env) {
|
|
23
|
+
const fd = fs.openSync(logPath, "a", 0o600);
|
|
24
|
+
try {
|
|
25
|
+
const child = spawn(command, args, { detached: true, stdio: ["ignore", fd, fd], env });
|
|
26
|
+
child.unref();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
fs.closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function scheduleDaemonPlistRefresh(deps) {
|
|
33
|
+
if (deps.platform !== "darwin" || !deps.managedByLaunchd)
|
|
34
|
+
return "not-applicable";
|
|
35
|
+
if (!(deps.needsRefresh ?? daemonPlistNeedsRefresh)(deps.home))
|
|
36
|
+
return "current";
|
|
37
|
+
const now = (deps.now ?? Date.now)();
|
|
38
|
+
const marker = path.join(deps.daemonRoot, PLIST_REFRESH_MARKER);
|
|
39
|
+
try {
|
|
40
|
+
const last = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
41
|
+
if (typeof last.attemptedAt === "number" && now - last.attemptedAt < PLIST_REFRESH_RETRY_MS) {
|
|
42
|
+
deps.log("warn", "the daemon LaunchAgent still runs through HQ.app; a rewrite was tried within the last hour. Run: hq daemon start");
|
|
43
|
+
return "recent";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// No marker yet.
|
|
48
|
+
}
|
|
49
|
+
ensurePrivateDir(path.join(deps.daemonRoot, "logs"));
|
|
50
|
+
const tmp = `${marker}.${process.pid}.tmp`;
|
|
51
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ attemptedAt: now })}\n`, { mode: 0o600 });
|
|
52
|
+
fs.renameSync(tmp, marker);
|
|
53
|
+
// launchd marks its jobs' processes with XPC_SERVICE_NAME; the helper is not the job.
|
|
54
|
+
const env = { ...deps.env };
|
|
55
|
+
delete env.XPC_SERVICE_NAME;
|
|
56
|
+
const logPath = path.join(deps.daemonRoot, "logs", "plist-refresh.log");
|
|
57
|
+
(deps.spawnHelper ?? defaultSpawnHelper)(deps.nodeBinary, [deps.hqBinary, "daemon", "start"], logPath, env);
|
|
58
|
+
deps.log("info", "the daemon LaunchAgent runs through HQ.app, which launchd skips at login; started `hq daemon start` to rewrite and reload it " +
|
|
59
|
+
`(log: ${logPath})`);
|
|
60
|
+
return "spawned";
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=plist-refresh.js.map
|