@indigoai-us/hq-cli 5.345.147 → 5.345.148
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 +30 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +297 -3
- package/dist/command-catalog.generated.js +368 -3
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +17 -0
- package/dist/commands/core-refresh-vault-access-native.js +214 -19
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +9 -2
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +7 -0
- package/dist/commands/install-global.js +129 -35
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +36 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +6 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +3 -0
- package/dist/lib/doctor/checks/anywhere.js +23 -8
- package/dist/lib/doctor/checks/cli-path.d.ts +11 -0
- package/dist/lib/doctor/checks/cli-path.js +45 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +16 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +10 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/user-path.d.ts +48 -0
- package/dist/lib/windows/user-path.js +114 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* never touch launchctl, systemctl or schtasks.
|
|
17
17
|
*/
|
|
18
18
|
import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
|
|
19
|
+
import { type PidLockRecord } from "../mesh/live/daemon/pid-lock.js";
|
|
19
20
|
import type { MachineType } from "./machine-type.js";
|
|
20
21
|
export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
21
22
|
export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
@@ -60,12 +61,28 @@ export interface DaemonUnitResult {
|
|
|
60
61
|
running: boolean;
|
|
61
62
|
error?: string;
|
|
62
63
|
manualCommand?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Windows: the task is registered but no new daemon process appeared within
|
|
66
|
+
* WINDOWS_START_WAIT_MS of `schtasks /Run`. With no interactive session the
|
|
67
|
+
* logon trigger starts it at the next sign-in; with one, it did not start.
|
|
68
|
+
* The caller decides which, since only it can check the session.
|
|
69
|
+
*/
|
|
70
|
+
startPending?: true;
|
|
71
|
+
/** Windows: an older daemon may still be running; HQ could not verify it, so did not stop it. */
|
|
72
|
+
warning?: string;
|
|
63
73
|
}
|
|
64
74
|
export interface InstallDeps {
|
|
65
75
|
run: RunCommand;
|
|
66
|
-
/** Blocking sleep for the launchd reload wait (tests inject). */
|
|
76
|
+
/** Blocking sleep for the launchd reload wait and the Windows start wait (tests inject). */
|
|
67
77
|
sleep?: SleepFn;
|
|
78
|
+
/**
|
|
79
|
+
* The running daemon's lock record (pid and start time), or null. Default:
|
|
80
|
+
* the daemon lock, when it names a live `hq daemon run` process.
|
|
81
|
+
*/
|
|
82
|
+
runningDaemon?: () => PidLockRecord | null;
|
|
68
83
|
}
|
|
84
|
+
/** How long install waits for the Windows task to start a daemon process. */
|
|
85
|
+
export declare const WINDOWS_START_WAIT_MS = 10000;
|
|
69
86
|
export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
70
87
|
/**
|
|
71
88
|
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
@@ -76,10 +93,39 @@ export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineT
|
|
|
76
93
|
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
77
94
|
export declare function installScopeFor(machineType: MachineType): InstallScope;
|
|
78
95
|
export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
|
|
96
|
+
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
97
|
+
export declare function oldDaemonWarning(pid: number, reason: string): string;
|
|
79
98
|
export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
|
|
99
|
+
/**
|
|
100
|
+
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
101
|
+
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|
|
102
|
+
* the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
|
|
103
|
+
* depend on the display language, and it needs no query.exe (absent on
|
|
104
|
+
* Windows Home). Constrained Language Mode refuses Add-Type, so there the
|
|
105
|
+
* script prints `unavailable|<mode>` and lists nothing.
|
|
106
|
+
*/
|
|
107
|
+
export declare const WINDOWS_SESSIONS_SCRIPT: string;
|
|
108
|
+
/** One Windows session from WINDOWS_SESSIONS_SCRIPT output. */
|
|
109
|
+
export interface WindowsSession {
|
|
110
|
+
id: number;
|
|
111
|
+
/** WTS_CONNECTSTATE_CLASS: 0 is WTSActive. */
|
|
112
|
+
state: number;
|
|
113
|
+
/** `DOMAIN\user`, a bare user name, or "" when nobody is signed in to it. */
|
|
114
|
+
user: string;
|
|
115
|
+
}
|
|
116
|
+
export declare function parseWindowsSessions(stdout: string): WindowsSession[];
|
|
117
|
+
/**
|
|
118
|
+
* Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
|
|
119
|
+
* A disconnected session does not count: an interactive-token task cannot
|
|
120
|
+
* start in it. Returns "unavailable" when PowerShell runs in Constrained
|
|
121
|
+
* Language Mode, which refuses the check, and null when the check failed in
|
|
122
|
+
* any other way.
|
|
123
|
+
*/
|
|
124
|
+
export declare function windowsInteractiveSession(run: RunCommand, user: string): boolean | null | "unavailable";
|
|
80
125
|
export declare function uninstallDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): {
|
|
81
126
|
kind: DaemonUnitPlan["kind"];
|
|
82
127
|
unitPath: string;
|
|
83
128
|
removed: boolean;
|
|
129
|
+
warning?: string;
|
|
84
130
|
};
|
|
85
131
|
//# sourceMappingURL=install.d.ts.map
|
|
@@ -17,15 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { activateLaunchd, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
20
|
+
import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
21
21
|
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
22
22
|
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
23
23
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
24
24
|
import { userToolDirs } from "./tools.js";
|
|
25
|
+
import { liveDaemonLock, verifyDaemonForKill } from "./lock.js";
|
|
26
|
+
import { daemonPaths } from "./paths.js";
|
|
25
27
|
export const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
26
28
|
export const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
27
29
|
export const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
|
|
28
30
|
export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
31
|
+
/** How long install waits for the Windows task to start a daemon process. */
|
|
32
|
+
export const WINDOWS_START_WAIT_MS = 10_000;
|
|
33
|
+
const WINDOWS_START_POLL_MS = 500;
|
|
29
34
|
export const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
30
35
|
/**
|
|
31
36
|
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
@@ -209,7 +214,39 @@ export function planDaemonUnit(host, options) {
|
|
|
209
214
|
function commandError(what, r) {
|
|
210
215
|
return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
|
|
211
216
|
}
|
|
212
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Stop the Windows task and the recorded daemon's whole process tree. Ending
|
|
219
|
+
* the task stops only its root process (conhost when --headless is used);
|
|
220
|
+
* node and the services it runs keep going.
|
|
221
|
+
*
|
|
222
|
+
* The pid comes from the lock file, which can be stale: after the daemon
|
|
223
|
+
* exits, Windows may give its pid to another program. The process is checked
|
|
224
|
+
* immediately before the kill and killed only when it is verifiably the
|
|
225
|
+
* daemon. Returns a warning when a process was left running unverified.
|
|
226
|
+
*/
|
|
227
|
+
function endWindowsDaemon(run, owner) {
|
|
228
|
+
run("schtasks.exe", ["/End", "/TN", DAEMON_WINDOWS_TASK]);
|
|
229
|
+
if (!owner || owner.pid === process.pid)
|
|
230
|
+
return undefined;
|
|
231
|
+
const check = verifyDaemonForKill(owner, run);
|
|
232
|
+
if (check.verdict === "daemon") {
|
|
233
|
+
run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (check.verdict === "gone")
|
|
237
|
+
return undefined;
|
|
238
|
+
return oldDaemonWarning(owner.pid, check.reason);
|
|
239
|
+
}
|
|
240
|
+
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
241
|
+
export function oldDaemonWarning(pid, reason) {
|
|
242
|
+
return [
|
|
243
|
+
`An older HQ daemon may still be running. HQ did not stop process ${pid} because ${reason}.`,
|
|
244
|
+
"To find a running HQ daemon:",
|
|
245
|
+
` powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object CommandLine -match 'daemon run' | Select-Object ProcessId,CommandLine"`,
|
|
246
|
+
"Stop the process it lists with: taskkill /PID <ProcessId> /T /F",
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
249
|
+
function activate(plan, host, run, sleep, previous = null) {
|
|
213
250
|
switch (plan.kind) {
|
|
214
251
|
case "launchd":
|
|
215
252
|
activateLaunchd(plan.unitPath, run, host.uid, { sleep });
|
|
@@ -231,13 +268,17 @@ function activate(plan, host, run, sleep) {
|
|
|
231
268
|
const create = run("schtasks.exe", ["/Create", "/TN", DAEMON_WINDOWS_TASK, "/XML", plan.unitPath, "/F"]);
|
|
232
269
|
if (create.status !== 0)
|
|
233
270
|
throw commandError("schtasks /Create", create);
|
|
271
|
+
// `/Create /F` leaves a running instance alone and `/Run` is ignored
|
|
272
|
+
// while one runs, so a daemon from an earlier install would keep the old
|
|
273
|
+
// settings. Stop it first so `/Run` starts a new one.
|
|
274
|
+
const warning = previous ? endWindowsDaemon(run, previous) : undefined;
|
|
234
275
|
const start = run("schtasks.exe", ["/Run", "/TN", DAEMON_WINDOWS_TASK]);
|
|
235
276
|
if (start.status !== 0)
|
|
236
277
|
throw commandError("schtasks /Run", start);
|
|
237
|
-
return;
|
|
278
|
+
return warning;
|
|
238
279
|
}
|
|
239
280
|
case "manual":
|
|
240
|
-
return;
|
|
281
|
+
return undefined;
|
|
241
282
|
}
|
|
242
283
|
}
|
|
243
284
|
export function installDaemonUnit(host, options, deps) {
|
|
@@ -286,8 +327,32 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
286
327
|
else
|
|
287
328
|
fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
|
|
288
329
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
330
|
+
const lockPath = daemonPaths(host.home, { HQ_DAEMON_DIR: host.daemonDir }).lockPath;
|
|
331
|
+
const runningDaemon = deps.runningDaemon ?? (() => liveDaemonLock(lockPath));
|
|
332
|
+
const previous = plan.kind === "schtasks" ? runningDaemon() : null;
|
|
333
|
+
const warning = plan.kind !== "launchd" ? activate(plan, host, deps.run, deps.sleep, previous) : undefined;
|
|
334
|
+
const warn = warning ? { warning } : {};
|
|
335
|
+
if (plan.kind === "schtasks") {
|
|
336
|
+
// schtasks /Run exits 0 even when the task cannot run: an
|
|
337
|
+
// InteractiveToken task with no signed-in session stays at "has not yet
|
|
338
|
+
// run" (267011). Report running only once a daemon started by this
|
|
339
|
+
// install holds the lock; a daemon from before the install does not count.
|
|
340
|
+
const outcome = waitForNewDaemon(runningDaemon, previous, deps.sleep ?? blockingSleep);
|
|
341
|
+
if (outcome === "started")
|
|
342
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true, ...warn };
|
|
343
|
+
if (outcome === "previous") {
|
|
344
|
+
return {
|
|
345
|
+
kind: plan.kind,
|
|
346
|
+
unitPath: plan.unitPath,
|
|
347
|
+
installed: true,
|
|
348
|
+
running: false,
|
|
349
|
+
error: `the daemon from before this install (pid ${previous?.pid}) is still running, ` +
|
|
350
|
+
"so the new task did not start. Run hq daemon uninstall, then hq daemon install",
|
|
351
|
+
...warn,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: false, startPending: true, ...warn };
|
|
355
|
+
}
|
|
291
356
|
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
292
357
|
}
|
|
293
358
|
catch (err) {
|
|
@@ -300,6 +365,112 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
300
365
|
};
|
|
301
366
|
}
|
|
302
367
|
}
|
|
368
|
+
/** A lock record names a different daemon than `previous` (new pid or new start time). */
|
|
369
|
+
function isNewDaemon(now, previous) {
|
|
370
|
+
return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Wait up to WINDOWS_START_WAIT_MS for a daemon started after `previous`.
|
|
374
|
+
* "previous" means the daemon from before the install still holds the lock at
|
|
375
|
+
* the end of the wait; "none" means no daemon is running.
|
|
376
|
+
*/
|
|
377
|
+
function waitForNewDaemon(runningDaemon, previous, sleep) {
|
|
378
|
+
for (let waited = 0;; waited += WINDOWS_START_POLL_MS) {
|
|
379
|
+
const now = runningDaemon();
|
|
380
|
+
if (now && isNewDaemon(now, previous))
|
|
381
|
+
return "started";
|
|
382
|
+
if (waited >= WINDOWS_START_WAIT_MS)
|
|
383
|
+
return now ? "previous" : "none";
|
|
384
|
+
sleep(WINDOWS_START_POLL_MS);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
389
|
+
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|
|
390
|
+
* the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
|
|
391
|
+
* depend on the display language, and it needs no query.exe (absent on
|
|
392
|
+
* Windows Home). Constrained Language Mode refuses Add-Type, so there the
|
|
393
|
+
* script prints `unavailable|<mode>` and lists nothing.
|
|
394
|
+
*/
|
|
395
|
+
export const WINDOWS_SESSIONS_SCRIPT = [
|
|
396
|
+
"$ErrorActionPreference = 'Stop'",
|
|
397
|
+
"$mode = [string]$ExecutionContext.SessionState.LanguageMode",
|
|
398
|
+
"if ($mode -ne 'FullLanguage') { 'unavailable|' + $mode; exit 0 }",
|
|
399
|
+
"Add-Type -TypeDefinition @'",
|
|
400
|
+
"using System;",
|
|
401
|
+
"using System.Runtime.InteropServices;",
|
|
402
|
+
"public static class HqWts {",
|
|
403
|
+
' [DllImport("wtsapi32.dll", SetLastError = true)]',
|
|
404
|
+
" public static extern bool WTSEnumerateSessionsW(IntPtr server, int reserved, int version, out IntPtr info, out int count);",
|
|
405
|
+
' [DllImport("wtsapi32.dll", SetLastError = true)]',
|
|
406
|
+
" public static extern bool WTSQuerySessionInformationW(IntPtr server, int sessionId, int infoClass, out IntPtr buffer, out int bytes);",
|
|
407
|
+
' [DllImport("wtsapi32.dll")]',
|
|
408
|
+
" public static extern void WTSFreeMemory(IntPtr memory);",
|
|
409
|
+
" static string Query(int id, int infoClass) {",
|
|
410
|
+
" IntPtr buffer; int bytes;",
|
|
411
|
+
' if (!WTSQuerySessionInformationW(IntPtr.Zero, id, infoClass, out buffer, out bytes)) return "";',
|
|
412
|
+
" try { return Marshal.PtrToStringUni(buffer); } finally { WTSFreeMemory(buffer); }",
|
|
413
|
+
" }",
|
|
414
|
+
" public static string[] List() {",
|
|
415
|
+
" IntPtr info; int count;",
|
|
416
|
+
' if (!WTSEnumerateSessionsW(IntPtr.Zero, 0, 1, out info, out count)) throw new System.ComponentModel.Win32Exception();',
|
|
417
|
+
" try {",
|
|
418
|
+
" int size = 3 * IntPtr.Size;",
|
|
419
|
+
" string[] lines = new string[count];",
|
|
420
|
+
" for (int i = 0; i < count; i++) {",
|
|
421
|
+
" IntPtr row = new IntPtr(info.ToInt64() + (long)i * size);",
|
|
422
|
+
" int id = Marshal.ReadInt32(row);",
|
|
423
|
+
" int state = Marshal.ReadInt32(row, 2 * IntPtr.Size);",
|
|
424
|
+
" string user = Query(id, 5);",
|
|
425
|
+
" string domain = Query(id, 7);",
|
|
426
|
+
' lines[i] = id + "|" + state + "|" + (domain.Length > 0 ? domain + "\\\\" + user : user);',
|
|
427
|
+
" }",
|
|
428
|
+
" return lines;",
|
|
429
|
+
" } finally { WTSFreeMemory(info); }",
|
|
430
|
+
" }",
|
|
431
|
+
"}",
|
|
432
|
+
"'@",
|
|
433
|
+
"[HqWts]::List() | ForEach-Object { $_ }",
|
|
434
|
+
].join("\n");
|
|
435
|
+
const WTS_ACTIVE = 0;
|
|
436
|
+
export function parseWindowsSessions(stdout) {
|
|
437
|
+
const sessions = [];
|
|
438
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
439
|
+
const match = /^(\d+)\|(\d+)\|(.*)$/.exec(line.trim());
|
|
440
|
+
if (match)
|
|
441
|
+
sessions.push({ id: Number(match[1]), state: Number(match[2]), user: match[3].trim() });
|
|
442
|
+
}
|
|
443
|
+
return sessions;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
|
|
447
|
+
* A disconnected session does not count: an interactive-token task cannot
|
|
448
|
+
* start in it. Returns "unavailable" when PowerShell runs in Constrained
|
|
449
|
+
* Language Mode, which refuses the check, and null when the check failed in
|
|
450
|
+
* any other way.
|
|
451
|
+
*/
|
|
452
|
+
export function windowsInteractiveSession(run, user) {
|
|
453
|
+
const encoded = Buffer.from(WINDOWS_SESSIONS_SCRIPT, "utf16le").toString("base64");
|
|
454
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
455
|
+
if (r.status !== 0)
|
|
456
|
+
return null;
|
|
457
|
+
if (/^unavailable\|/m.test(r.stdout.trim()))
|
|
458
|
+
return "unavailable";
|
|
459
|
+
const sessions = parseWindowsSessions(r.stdout);
|
|
460
|
+
if (sessions.length === 0)
|
|
461
|
+
return null;
|
|
462
|
+
const wanted = user.trim().toLowerCase();
|
|
463
|
+
const bare = (wanted.split("\\").pop() ?? "").trim();
|
|
464
|
+
if (!bare)
|
|
465
|
+
return null;
|
|
466
|
+
return sessions.some((s) => {
|
|
467
|
+
if (s.state !== WTS_ACTIVE || !s.user)
|
|
468
|
+
return false;
|
|
469
|
+
const name = s.user.toLowerCase();
|
|
470
|
+
// Compare the full DOMAIN\user when both sides have a domain.
|
|
471
|
+
return name.includes("\\") && wanted.includes("\\") ? name === wanted : name.split("\\").pop() === bare;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
303
474
|
export function uninstallDaemonUnit(host, options, deps) {
|
|
304
475
|
// A system uninstall does not need the login user; only the unit path matters.
|
|
305
476
|
const plan = host.platform === "linux" && options.scope === "system"
|
|
@@ -312,6 +483,7 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
312
483
|
(plan.kind === "schtasks" && deps.run("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK]).status === 0);
|
|
313
484
|
if (!present)
|
|
314
485
|
return { kind: plan.kind, unitPath: plan.unitPath, removed: false };
|
|
486
|
+
let warning;
|
|
315
487
|
switch (plan.kind) {
|
|
316
488
|
case "launchd":
|
|
317
489
|
deactivateLaunchd(plan.unitPath, deps.run, host.uid);
|
|
@@ -330,14 +502,8 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
330
502
|
break;
|
|
331
503
|
}
|
|
332
504
|
case "schtasks": {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// used); node and the services it runs keep going. End the recorded
|
|
336
|
-
// daemon's whole process tree.
|
|
337
|
-
const owner = readPidLock(path.join(host.daemonDir, "daemon.pid"));
|
|
338
|
-
if (owner && owner.pid !== process.pid) {
|
|
339
|
-
deps.run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
|
|
340
|
-
}
|
|
505
|
+
// The lock may be stale; endWindowsDaemon kills only a verified daemon.
|
|
506
|
+
warning = endWindowsDaemon(deps.run, readPidLock(path.join(host.daemonDir, "daemon.pid")));
|
|
341
507
|
const del = deps.run("schtasks.exe", ["/Delete", "/TN", DAEMON_WINDOWS_TASK, "/F"]);
|
|
342
508
|
// Keep the XML so a later uninstall still finds the task.
|
|
343
509
|
if (del.status !== 0)
|
|
@@ -346,6 +512,6 @@ export function uninstallDaemonUnit(host, options, deps) {
|
|
|
346
512
|
}
|
|
347
513
|
}
|
|
348
514
|
fs.rmSync(plan.unitPath, { force: true });
|
|
349
|
-
return { kind: plan.kind, unitPath: plan.unitPath, removed: true };
|
|
515
|
+
return { kind: plan.kind, unitPath: plan.unitPath, removed: true, ...(warning ? { warning } : {}) };
|
|
350
516
|
}
|
|
351
517
|
//# sourceMappingURL=install.js.map
|
|
@@ -31,8 +31,38 @@ export interface DaemonProcessDeps {
|
|
|
31
31
|
* The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
|
|
32
32
|
* lock file often belongs to an unrelated program; that must not keep the
|
|
33
33
|
* daemon from starting.
|
|
34
|
+
*
|
|
35
|
+
* An unreadable command line counts as the daemon here, so a second daemon is
|
|
36
|
+
* never started next to one HQ cannot inspect. Never use this to decide a
|
|
37
|
+
* kill: use `verifyDaemonForKill`, which treats an unreadable command line as
|
|
38
|
+
* "not the daemon".
|
|
34
39
|
*/
|
|
35
40
|
export declare function isDaemonProcess(pid: number, deps?: DaemonProcessDeps): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* PowerShell that prints a Windows process's creation time (UTC, ISO 8601) on
|
|
43
|
+
* the first line and its command line on the rest, or nothing when no process
|
|
44
|
+
* has that id. It uses only a cmdlet and DateTime methods, so it also runs in
|
|
45
|
+
* Constrained Language Mode. `-ErrorAction Stop` makes a WMI failure exit
|
|
46
|
+
* non-zero, so it reads as "could not read", never as "no such process".
|
|
47
|
+
*/
|
|
48
|
+
export declare function windowsProcessIdentityScript(pid: number): string;
|
|
49
|
+
export type DaemonKillCheck = {
|
|
50
|
+
verdict: "daemon";
|
|
51
|
+
} | {
|
|
52
|
+
verdict: "gone";
|
|
53
|
+
} | {
|
|
54
|
+
verdict: "unverified";
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether the Windows process in `owner` may be killed as the HQ
|
|
59
|
+
* daemon. Run this immediately before the kill. It is the daemon only when the
|
|
60
|
+
* process exists, its command line is readable and is `hq daemon run`, and it
|
|
61
|
+
* was created no later than the lock was written. A process created after the
|
|
62
|
+
* lock took over a pid the old daemon left behind. Anything that cannot be
|
|
63
|
+
* read is "unverified", and the caller must not kill.
|
|
64
|
+
*/
|
|
65
|
+
export declare function verifyDaemonForKill(owner: PidLockRecord, run: RunCommand): DaemonKillCheck;
|
|
36
66
|
export type DaemonLockResult = {
|
|
37
67
|
ok: true;
|
|
38
68
|
} | {
|
|
@@ -41,6 +71,8 @@ export type DaemonLockResult = {
|
|
|
41
71
|
};
|
|
42
72
|
export declare function acquireDaemonLock(lockPath: string, pid?: number, alive?: (pid: number) => boolean, now?: () => number): DaemonLockResult;
|
|
43
73
|
export declare function releaseDaemonLock(lockPath: string, pid?: number): void;
|
|
74
|
+
/** The live supervisor's lock record (pid and start time), or null when none is running. */
|
|
75
|
+
export declare function liveDaemonLock(lockPath: string, alive?: (pid: number) => boolean): PidLockRecord | null;
|
|
44
76
|
/** The live supervisor's pid, or null when none is running. */
|
|
45
77
|
export declare function runningDaemonPid(lockPath: string, alive?: (pid: number) => boolean): number | null;
|
|
46
78
|
export {};
|
package/dist/lib/daemon/lock.js
CHANGED
|
@@ -53,6 +53,11 @@ export function processCommandLine(pid, deps = defaultCommandLineDeps()) {
|
|
|
53
53
|
* The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
|
|
54
54
|
* lock file often belongs to an unrelated program; that must not keep the
|
|
55
55
|
* daemon from starting.
|
|
56
|
+
*
|
|
57
|
+
* An unreadable command line counts as the daemon here, so a second daemon is
|
|
58
|
+
* never started next to one HQ cannot inspect. Never use this to decide a
|
|
59
|
+
* kill: use `verifyDaemonForKill`, which treats an unreadable command line as
|
|
60
|
+
* "not the daemon".
|
|
56
61
|
*/
|
|
57
62
|
export function isDaemonProcess(pid, deps = { alive: (p) => isProcessAlive(p), commandLine: (p) => processCommandLine(p) }) {
|
|
58
63
|
if (!deps.alive(pid))
|
|
@@ -60,6 +65,50 @@ export function isDaemonProcess(pid, deps = { alive: (p) => isProcessAlive(p), c
|
|
|
60
65
|
const cmd = deps.commandLine(pid);
|
|
61
66
|
return cmd === null || /\bdaemon\s+run\b/.test(cmd);
|
|
62
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* PowerShell that prints a Windows process's creation time (UTC, ISO 8601) on
|
|
70
|
+
* the first line and its command line on the rest, or nothing when no process
|
|
71
|
+
* has that id. It uses only a cmdlet and DateTime methods, so it also runs in
|
|
72
|
+
* Constrained Language Mode. `-ErrorAction Stop` makes a WMI failure exit
|
|
73
|
+
* non-zero, so it reads as "could not read", never as "no such process".
|
|
74
|
+
*/
|
|
75
|
+
export function windowsProcessIdentityScript(pid) {
|
|
76
|
+
return (`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}" -ErrorAction Stop; ` +
|
|
77
|
+
"if ($p) { $p.CreationDate.ToUniversalTime().ToString('o'); [string]$p.CommandLine }");
|
|
78
|
+
}
|
|
79
|
+
/** Seconds a daemon's creation time may follow its lock's ts (whole-second rounding and clock skew). */
|
|
80
|
+
const LOCK_TS_SLACK_SECONDS = 2;
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether the Windows process in `owner` may be killed as the HQ
|
|
83
|
+
* daemon. Run this immediately before the kill. It is the daemon only when the
|
|
84
|
+
* process exists, its command line is readable and is `hq daemon run`, and it
|
|
85
|
+
* was created no later than the lock was written. A process created after the
|
|
86
|
+
* lock took over a pid the old daemon left behind. Anything that cannot be
|
|
87
|
+
* read is "unverified", and the caller must not kill.
|
|
88
|
+
*/
|
|
89
|
+
export function verifyDaemonForKill(owner, run) {
|
|
90
|
+
const encoded = Buffer.from(windowsProcessIdentityScript(owner.pid), "utf16le").toString("base64");
|
|
91
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
92
|
+
if (r.status !== 0)
|
|
93
|
+
return { verdict: "unverified", reason: "HQ could not read the process" };
|
|
94
|
+
const lines = r.stdout.split(/\r?\n/).map((l) => l.trim());
|
|
95
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
96
|
+
lines.pop();
|
|
97
|
+
if (lines.length === 0)
|
|
98
|
+
return { verdict: "gone" };
|
|
99
|
+
const commandLine = lines.slice(1).join(" ").trim();
|
|
100
|
+
if (!commandLine)
|
|
101
|
+
return { verdict: "unverified", reason: "HQ could not read its command line" };
|
|
102
|
+
if (!/\bdaemon\s+run\b/.test(commandLine))
|
|
103
|
+
return { verdict: "unverified", reason: "it is not hq daemon run" };
|
|
104
|
+
const created = Date.parse(lines[0]);
|
|
105
|
+
if (!Number.isFinite(created))
|
|
106
|
+
return { verdict: "unverified", reason: "HQ could not read its start time" };
|
|
107
|
+
if (Math.floor(created / 1000) > owner.ts + LOCK_TS_SLACK_SECONDS) {
|
|
108
|
+
return { verdict: "unverified", reason: "it started after the daemon lock was written, so it is a different process" };
|
|
109
|
+
}
|
|
110
|
+
return { verdict: "daemon" };
|
|
111
|
+
}
|
|
63
112
|
function claimTakeover(claim, pid, now) {
|
|
64
113
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
65
114
|
try {
|
|
@@ -130,9 +179,13 @@ export function releaseDaemonLock(lockPath, pid = process.pid) {
|
|
|
130
179
|
if (owner?.pid === pid)
|
|
131
180
|
fs.rmSync(lockPath, { force: true });
|
|
132
181
|
}
|
|
182
|
+
/** The live supervisor's lock record (pid and start time), or null when none is running. */
|
|
183
|
+
export function liveDaemonLock(lockPath, alive = isDaemonProcess) {
|
|
184
|
+
const owner = readPidLock(lockPath);
|
|
185
|
+
return owner && alive(owner.pid) ? owner : null;
|
|
186
|
+
}
|
|
133
187
|
/** The live supervisor's pid, or null when none is running. */
|
|
134
188
|
export function runningDaemonPid(lockPath, alive = isDaemonProcess) {
|
|
135
|
-
|
|
136
|
-
return owner && alive(owner.pid) ? owner.pid : null;
|
|
189
|
+
return liveDaemonLock(lockPath, alive)?.pid ?? null;
|
|
137
190
|
}
|
|
138
191
|
//# sourceMappingURL=lock.js.map
|
|
@@ -3,6 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { runDoctor as runDoctorCommand, } from "../../../commands/doctor.js";
|
|
5
5
|
import { createAnywhereFamily } from "../checks/anywhere.js";
|
|
6
|
+
import { createTempHomesFamily } from "../checks/temp-homes.js";
|
|
6
7
|
import { createDefaultRegistry } from "../registry.js";
|
|
7
8
|
/**
|
|
8
9
|
* Run doctor tests against synthetic state. The production registry normally
|
|
@@ -45,6 +46,9 @@ export async function runDoctorForTest(options = {}) {
|
|
|
45
46
|
// Command tests exercise doctor rendering and exit behavior offline. The
|
|
46
47
|
// real person preference is covered in the anywhere-family tests.
|
|
47
48
|
registry.register(createAnywhereFamily({ personSettingReader: async () => false }));
|
|
49
|
+
// The temp-homes family walks the machine's real temp folders; scan
|
|
50
|
+
// none here. Its behaviour is covered with fake trees in its own tests.
|
|
51
|
+
registry.register(createTempHomesFamily({ roots: [] }));
|
|
48
52
|
}
|
|
49
53
|
return await runDoctorCommand({ ...options, registry, integrations: false });
|
|
50
54
|
}
|
|
@@ -17,6 +17,7 @@ export declare const REMEDIATION: {
|
|
|
17
17
|
readonly repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex";
|
|
18
18
|
readonly daemonInstall: "hq daemon install";
|
|
19
19
|
readonly daemonRestart: "hq daemon restart --daemon";
|
|
20
|
+
readonly daemonStatus: "hq daemon status";
|
|
20
21
|
readonly link: "hq link <repo-dir> <company>";
|
|
21
22
|
};
|
|
22
23
|
interface DaemonServiceState {
|
|
@@ -45,6 +46,8 @@ export interface AnywhereDeps {
|
|
|
45
46
|
/** The hq-cli version installed on this machine. Default: this CLI's version. */
|
|
46
47
|
cliVersion?: string;
|
|
47
48
|
serviceState?: () => DaemonServiceState;
|
|
49
|
+
/** Is an hqd process alive? Default: the daemon lock names a live hqd pid. */
|
|
50
|
+
daemonRunning?: () => boolean;
|
|
48
51
|
now?: () => Date;
|
|
49
52
|
}
|
|
50
53
|
export declare function createAnywhereFamily(deps?: AnywhereDeps): CheckFamily;
|
|
@@ -23,8 +23,10 @@ import { daemonPresenceLabel, heartbeatFile, readHeartbeat } from "../../daemon/
|
|
|
23
23
|
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK } from "../../daemon/install.js";
|
|
24
24
|
import { hostedDaemonRunning } from "../../daemon/handoff.js";
|
|
25
25
|
import { daemonPaths } from "../../daemon/paths.js";
|
|
26
|
+
import { runningDaemonPid } from "../../daemon/lock.js";
|
|
26
27
|
import { hqdSocketPath } from "../../daemon/protocol.js";
|
|
27
28
|
import { readRegistry, registryDir, registryFile } from "../../registry/registry.js";
|
|
29
|
+
import { skillCopySource } from "../../../utils/safe-link.js";
|
|
28
30
|
import { anywhereRuntimeEnabled } from "../../../commands/anywhere-runtime-flag.js";
|
|
29
31
|
import { readHqAnywherePersonSetting } from "../../anywhere-person-setting.js";
|
|
30
32
|
const ANYWHERE_FAMILY_ID = "anywhere";
|
|
@@ -35,6 +37,7 @@ export const REMEDIATION = {
|
|
|
35
37
|
repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex",
|
|
36
38
|
daemonInstall: "hq daemon install",
|
|
37
39
|
daemonRestart: "hq daemon restart --daemon",
|
|
40
|
+
daemonStatus: "hq daemon status",
|
|
38
41
|
link: "hq link <repo-dir> <company>",
|
|
39
42
|
};
|
|
40
43
|
export function createAnywhereFamily(deps = {}) {
|
|
@@ -118,7 +121,12 @@ async function runAnywhereChecks(context, deps = {}) {
|
|
|
118
121
|
const serviceState = runtimeEnabled && installed
|
|
119
122
|
? (deps.serviceState ?? (() => defaultServiceState(home)))()
|
|
120
123
|
: undefined;
|
|
121
|
-
|
|
124
|
+
// A missing Windows pipe usually means hqd never started; only blame the
|
|
125
|
+
// pipe ACL when a daemon process is actually alive.
|
|
126
|
+
const daemonRunning = health.reachable
|
|
127
|
+
? true
|
|
128
|
+
: (deps.daemonRunning ?? (() => runningDaemonPid(daemonPaths(home, env).lockPath) !== null))();
|
|
129
|
+
const healthCheck = runtimeHealthCheck(health, healthUnsupported, serviceState, daemonRunning);
|
|
122
130
|
if (!runtimeEnabled)
|
|
123
131
|
return [healthCheck, settingCheck];
|
|
124
132
|
const remoteModes = [
|
|
@@ -179,7 +187,7 @@ function unreachableHealth(socketPath) {
|
|
|
179
187
|
socket: { path: socketPath, transport: process.platform === "win32" ? "windows_named_pipe" : "unix_socket", mode: null, ownerMatchesUser: null, userOnly: null },
|
|
180
188
|
};
|
|
181
189
|
}
|
|
182
|
-
function runtimeHealthCheck(health, healthUnsupported = false, serviceState) {
|
|
190
|
+
function runtimeHealthCheck(health, healthUnsupported = false, serviceState, daemonRunning = true) {
|
|
183
191
|
const { flag, resolution, socket } = health;
|
|
184
192
|
const age = flag.lastSuccessfulReadAgeMs === null
|
|
185
193
|
? "unknown"
|
|
@@ -195,9 +203,12 @@ function runtimeHealthCheck(health, healthUnsupported = false, serviceState) {
|
|
|
195
203
|
const socketSecurity = socket.transport === "windows_named_pipe"
|
|
196
204
|
? "Windows named pipe (mode and owner are not reported)"
|
|
197
205
|
: `mode ${socket.mode ?? "unknown"}; owner matches current user: ${socket.ownerMatchesUser === null ? "unknown" : socket.ownerMatchesUser ? "yes" : "no"}; user-only: ${socket.userOnly === null ? "unknown" : socket.userOnly ? "yes" : "no"}`;
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
const pipeWithoutDaemon = socket.transport === "windows_named_pipe" && !daemonRunning;
|
|
207
|
+
const unreachableMessage = pipeWithoutDaemon
|
|
208
|
+
? `hqd is not running, so its named pipe ${socket.path} does not exist. Run ${REMEDIATION.daemonStatus} to see why it has not started.`
|
|
209
|
+
: socket.transport === "windows_named_pipe"
|
|
210
|
+
? `hqd is not reachable on ${socket.path}; secure Windows named-pipe setup may have refused an unsafe ACL`
|
|
211
|
+
: `hqd is not reachable on ${socket.path}; version, uptime, flag state, and cwd binding are unavailable`;
|
|
201
212
|
const socketNotUserOnly = health.reachable && socket.transport === "unix_socket" && (socket.userOnly !== true || socket.ownerMatchesUser !== true);
|
|
202
213
|
const warningNotes = [
|
|
203
214
|
healthUnsupported ? "resident hqd does not support detailed health; restart hqd for full diagnostics" : null,
|
|
@@ -207,7 +218,8 @@ function runtimeHealthCheck(health, healthUnsupported = false, serviceState) {
|
|
|
207
218
|
? `hqd reachable; version ${health.version ?? "unknown"}; uptime ${health.uptimeMs === null ? "unknown" : `${new Intl.NumberFormat("en-US").format(health.uptimeMs)} ms`}; runtime flag ${flagLabel}; last successful flag read ${age} ago; held through read error: ${flag.heldThroughReadError ? "yes" : "no"}; cwd binding ${binding}; socket ${socket.path}; ${socketSecurity}${warningNotes.length ? `; ${warningNotes.join("; ")}` : ""}`
|
|
208
219
|
: unreachableMessage;
|
|
209
220
|
const remediation = !health.reachable && serviceState
|
|
210
|
-
? serviceState.installed ? REMEDIATION.
|
|
221
|
+
? !serviceState.installed ? REMEDIATION.daemonInstall
|
|
222
|
+
: pipeWithoutDaemon ? REMEDIATION.daemonStatus : REMEDIATION.daemonRestart
|
|
211
223
|
: healthUnsupported || socketNotUserOnly ? REMEDIATION.daemonRestart : undefined;
|
|
212
224
|
return {
|
|
213
225
|
status: health.reachable && warningNotes.length === 0 ? "PASS" : "WARN",
|
|
@@ -336,12 +348,15 @@ function checkSkillLinks(runtime, dir, fix, repair = fix) {
|
|
|
336
348
|
return result("WARN", id, `${dir} does not exist`, fix, dir);
|
|
337
349
|
}
|
|
338
350
|
const links = names.filter((n) => fs.lstatSync(path.join(dir, n)).isSymbolicLink());
|
|
339
|
-
|
|
351
|
+
// Windows falls back to marked copies when it refuses both link types.
|
|
352
|
+
const copies = names.filter((n) => skillCopySource(path.join(dir, n)) !== null);
|
|
353
|
+
if (links.length === 0 && copies.length === 0)
|
|
340
354
|
return result("WARN", id, `no hq-* skill links in ${dir}`, fix, dir);
|
|
341
355
|
const broken = links.filter((n) => !fs.existsSync(path.join(dir, n)));
|
|
342
356
|
if (broken.length > 0)
|
|
343
357
|
return result("FAIL", id, `${broken.length} broken skill links in ${dir}: ${broken.slice(0, 5).join(", ")}`, repair, dir);
|
|
344
|
-
|
|
358
|
+
const copied = copies.length > 0 ? ` (${copies.length} more are copies that do not follow HQ skill updates)` : "";
|
|
359
|
+
return result("PASS", id, `${links.length} hq-* skill links in ${dir} resolve${copied}`, undefined, dir);
|
|
345
360
|
}
|
|
346
361
|
export function defaultServiceState(home, options = {}) {
|
|
347
362
|
const platform = options.platform ?? process.platform;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ShimPathCheck } from "../../windows/user-path.js";
|
|
2
|
+
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
3
|
+
export declare const CLI_PATH_FAMILY_ID = "cli-path";
|
|
4
|
+
export declare const CLI_PATH_CHECK_ID = "cli-path.npm-shim-on-path";
|
|
5
|
+
/**
|
|
6
|
+
* Windows: is the folder holding the npm `hq.cmd` shim on the PATH a new
|
|
7
|
+
* terminal gets? Reads the registry only; never edits PATH.
|
|
8
|
+
*/
|
|
9
|
+
export declare const cliPathFamily: CheckFamily;
|
|
10
|
+
export declare function checkCliPath(_context: CheckContext, check?: () => ShimPathCheck | null): CheckResult[];
|
|
11
|
+
//# sourceMappingURL=cli-path.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { checkShimOnPath, pathFixCommand, shimPathWarning } from "../../windows/user-path.js";
|
|
2
|
+
export const CLI_PATH_FAMILY_ID = "cli-path";
|
|
3
|
+
export const CLI_PATH_CHECK_ID = "cli-path.npm-shim-on-path";
|
|
4
|
+
/**
|
|
5
|
+
* Windows: is the folder holding the npm `hq.cmd` shim on the PATH a new
|
|
6
|
+
* terminal gets? Reads the registry only; never edits PATH.
|
|
7
|
+
*/
|
|
8
|
+
export const cliPathFamily = {
|
|
9
|
+
id: CLI_PATH_FAMILY_ID,
|
|
10
|
+
title: "hq command on PATH",
|
|
11
|
+
run: (context) => Promise.resolve(checkCliPath(context)),
|
|
12
|
+
};
|
|
13
|
+
export function checkCliPath(_context, check = () => checkShimOnPath()) {
|
|
14
|
+
const result = check();
|
|
15
|
+
if (!result) {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
status: "NA",
|
|
19
|
+
checkId: CLI_PATH_CHECK_ID,
|
|
20
|
+
message: "Not a Windows npm global install, so there is no hq.cmd shim folder to check.",
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
if (result.onPath) {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
status: "PASS",
|
|
28
|
+
checkId: CLI_PATH_CHECK_ID,
|
|
29
|
+
target: result.shimDir,
|
|
30
|
+
message: `The hq command folder ${result.shimDir} is on your Windows PATH.`,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
status: "WARN",
|
|
37
|
+
checkId: CLI_PATH_CHECK_ID,
|
|
38
|
+
target: result.shimDir,
|
|
39
|
+
message: shimPathWarning(result)[0],
|
|
40
|
+
details: { source: result.source },
|
|
41
|
+
remediation: `In PowerShell, then open a new terminal: ${pathFixCommand(result.shimDir)}`,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=cli-path.js.map
|