@indigoai-us/hq-cli 5.115.6 → 5.117.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/CHANGELOG.md +169 -11
- package/dist/command-catalog.generated.d.ts +220 -2
- package/dist/command-catalog.generated.js +281 -2
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +105 -0
- package/dist/commands/agent-enroll.js +273 -0
- package/dist/commands/agent-kit.d.ts +53 -0
- package/dist/commands/agent-kit.js +260 -0
- package/dist/commands/agent-mcp.d.ts +22 -0
- package/dist/commands/agent-mcp.js +104 -0
- package/dist/commands/agent-probe.d.ts +71 -0
- package/dist/commands/agent-probe.js +294 -0
- package/dist/commands/agent.d.ts +12 -0
- package/dist/commands/agent.js +23 -0
- package/dist/commands/agents.d.ts +27 -0
- package/dist/commands/agents.js +280 -6
- package/dist/commands/bot.d.ts +140 -1
- package/dist/commands/bot.js +757 -22
- package/dist/commands/secrets.js +17 -5
- package/dist/lib/agent-kit/creds.d.ts +60 -0
- package/dist/lib/agent-kit/creds.js +123 -0
- package/dist/lib/agent-kit/kit-config.d.ts +29 -0
- package/dist/lib/agent-kit/kit-config.js +54 -0
- package/dist/lib/agent-kit/log.d.ts +17 -0
- package/dist/lib/agent-kit/log.js +46 -0
- package/dist/lib/agent-kit/mcp/jsonrpc.d.ts +84 -0
- package/dist/lib/agent-kit/mcp/jsonrpc.js +164 -0
- package/dist/lib/agent-kit/mcp/tools.d.ts +45 -0
- package/dist/lib/agent-kit/mcp/tools.js +280 -0
- package/dist/lib/agent-kit/paths.d.ts +42 -0
- package/dist/lib/agent-kit/paths.js +56 -0
- package/dist/lib/agent-kit/run/heartbeat.d.ts +52 -0
- package/dist/lib/agent-kit/run/heartbeat.js +97 -0
- package/dist/lib/agent-kit/run/inbox.d.ts +59 -0
- package/dist/lib/agent-kit/run/inbox.js +152 -0
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +58 -0
- package/dist/lib/agent-kit/run/mesh-listener.js +193 -0
- package/dist/lib/agent-kit/run/sync.d.ts +33 -0
- package/dist/lib/agent-kit/run/sync.js +58 -0
- package/dist/lib/agent-kit/services.d.ts +21 -0
- package/dist/lib/agent-kit/services.js +46 -0
- package/dist/lib/agent-kit/skills.d.ts +18 -0
- package/dist/lib/agent-kit/skills.js +149 -0
- package/dist/lib/bot/api.d.ts +51 -0
- package/dist/lib/bot/api.js +32 -0
- package/dist/lib/bot/daemon.d.ts +17 -0
- package/dist/lib/bot/daemon.js +44 -3
- package/dist/lib/bot/index.d.ts +4 -0
- package/dist/lib/bot/index.js +4 -0
- package/dist/lib/bot/inflight.d.ts +14 -0
- package/dist/lib/bot/local-config.d.ts +70 -0
- package/dist/lib/bot/local-config.js +147 -0
- package/dist/lib/bot/local-name.d.ts +54 -0
- package/dist/lib/bot/local-name.js +114 -0
- package/dist/lib/bot/run.d.ts +9 -0
- package/dist/lib/bot/run.js +117 -24
- package/dist/lib/bot/runnable.d.ts +51 -0
- package/dist/lib/bot/runnable.js +65 -0
- package/dist/lib/bot/self-heal.d.ts +52 -0
- package/dist/lib/bot/self-heal.js +79 -0
- package/dist/lib/bot/split.d.ts +32 -0
- package/dist/lib/bot/split.js +241 -0
- package/dist/lib/service-manager/index.d.ts +43 -0
- package/dist/lib/service-manager/index.js +114 -0
- package/dist/lib/service-manager/launchd.d.ts +23 -0
- package/dist/lib/service-manager/launchd.js +81 -0
- package/dist/lib/service-manager/systemd.d.ts +19 -0
- package/dist/lib/service-manager/systemd.js +72 -0
- package/dist/lib/service-manager/types.d.ts +32 -0
- package/dist/lib/service-manager/types.js +26 -0
- package/dist/utils/self-update.js +2 -30
- package/dist/utils/update-command-supervisor.cjs +194 -0
- package/dist/utils/version-gate.d.ts +18 -0
- package/dist/utils/version-gate.js +126 -7
- package/package.json +2 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux systemd USER unit rendering + activation for kit services.
|
|
3
|
+
*
|
|
4
|
+
* Units live at ~/.config/systemd/user/<name>.service and are enabled with
|
|
5
|
+
* `systemctl --user enable --now`. A host without a user session bus (some
|
|
6
|
+
* containers, headless boxes) cannot run user units; the kit's Docker image
|
|
7
|
+
* is the escape hatch there. Command execution is injected for tests.
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { shellQuote, } from "./types.js";
|
|
11
|
+
export const SYSTEMD_PATH_ENV = "/usr/local/bin:/usr/bin:/bin";
|
|
12
|
+
export function systemdUserDir(home) {
|
|
13
|
+
return path.join(home, ".config", "systemd", "user");
|
|
14
|
+
}
|
|
15
|
+
export function systemdUnitName(spec) {
|
|
16
|
+
return `hq-agent-${spec.name}.service`;
|
|
17
|
+
}
|
|
18
|
+
export function systemdUnitPath(home, spec) {
|
|
19
|
+
return path.join(systemdUserDir(home), systemdUnitName(spec));
|
|
20
|
+
}
|
|
21
|
+
export function renderSystemdUserUnit(spec, host) {
|
|
22
|
+
const exec = [host.nodeBinary, host.hqBinary, ...spec.args].map(shellQuote).join(" ");
|
|
23
|
+
const env = {
|
|
24
|
+
HOME: host.home,
|
|
25
|
+
PATH: SYSTEMD_PATH_ENV,
|
|
26
|
+
...spec.env,
|
|
27
|
+
};
|
|
28
|
+
const envLines = Object.entries(env)
|
|
29
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
30
|
+
.map(([k, v]) => `Environment=${shellQuote(`${k}=${v}`)}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
return `[Unit]
|
|
33
|
+
Description=${spec.description}
|
|
34
|
+
After=network-online.target
|
|
35
|
+
Wants=network-online.target
|
|
36
|
+
|
|
37
|
+
[Service]
|
|
38
|
+
Type=simple
|
|
39
|
+
ExecStart=${exec}
|
|
40
|
+
WorkingDirectory=${spec.workingDir}
|
|
41
|
+
Restart=always
|
|
42
|
+
RestartSec=${spec.restartSec}
|
|
43
|
+
${envLines}
|
|
44
|
+
StandardOutput=append:${spec.logPath}
|
|
45
|
+
StandardError=append:${spec.logPath}
|
|
46
|
+
|
|
47
|
+
[Install]
|
|
48
|
+
WantedBy=default.target
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
export function activateSystemd(unitName, run) {
|
|
52
|
+
const reload = run("systemctl", ["--user", "daemon-reload"]);
|
|
53
|
+
if (reload.status !== 0) {
|
|
54
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr.trim() || `exit ${reload.status}`}. ` +
|
|
55
|
+
`No user session bus? Use the hq-agent-kit Docker image instead.`);
|
|
56
|
+
}
|
|
57
|
+
const r = run("systemctl", ["--user", "enable", "--now", unitName]);
|
|
58
|
+
if (r.status !== 0) {
|
|
59
|
+
throw new Error(`systemctl --user enable --now ${unitName} failed: ${r.stderr.trim() || `exit ${r.status}`}`);
|
|
60
|
+
}
|
|
61
|
+
// A re-install with a changed unit must restart the running instance.
|
|
62
|
+
run("systemctl", ["--user", "restart", unitName]);
|
|
63
|
+
}
|
|
64
|
+
export function deactivateSystemd(unitName, run) {
|
|
65
|
+
run("systemctl", ["--user", "disable", "--now", unitName]);
|
|
66
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
67
|
+
}
|
|
68
|
+
export function systemdIsActive(unitName, run) {
|
|
69
|
+
const r = run("systemctl", ["--user", "is-active", "--quiet", unitName]);
|
|
70
|
+
return r.status === 0;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=systemd.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral description of a user-level background service. The
|
|
3
|
+
* launchd and systemd renderers turn one of these into a unit; the kit
|
|
4
|
+
* installer owns the list. Every service is just `node hq <args…>` so the
|
|
5
|
+
* unit files carry no logic of their own.
|
|
6
|
+
*/
|
|
7
|
+
export interface ServiceSpec {
|
|
8
|
+
/** Short name — unit file stem and log file stem (`sync`, `mesh`, …). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Reverse-DNS launchd label; systemd derives its unit name from `name`. */
|
|
11
|
+
label: string;
|
|
12
|
+
description: string;
|
|
13
|
+
/** Arguments passed to the hq binary (after `node hq`). */
|
|
14
|
+
args: string[];
|
|
15
|
+
/** Absolute log path (stdout + stderr appended). */
|
|
16
|
+
logPath: string;
|
|
17
|
+
workingDir: string;
|
|
18
|
+
/** Extra environment for the unit (HOME/PATH are always set). */
|
|
19
|
+
env: Record<string, string>;
|
|
20
|
+
/** Seconds to wait before a restart after exit. */
|
|
21
|
+
restartSec: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ServiceHostPaths {
|
|
24
|
+
home: string;
|
|
25
|
+
nodeBinary: string;
|
|
26
|
+
hqBinary: string;
|
|
27
|
+
}
|
|
28
|
+
export type ServicePlatform = "darwin" | "linux" | "other";
|
|
29
|
+
export declare function detectServicePlatform(platform?: NodeJS.Platform): ServicePlatform;
|
|
30
|
+
export declare function escapeXml(value: string): string;
|
|
31
|
+
export declare function shellQuote(value: string): string;
|
|
32
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral description of a user-level background service. The
|
|
3
|
+
* launchd and systemd renderers turn one of these into a unit; the kit
|
|
4
|
+
* installer owns the list. Every service is just `node hq <args…>` so the
|
|
5
|
+
* unit files carry no logic of their own.
|
|
6
|
+
*/
|
|
7
|
+
export function detectServicePlatform(platform = process.platform) {
|
|
8
|
+
if (platform === "darwin")
|
|
9
|
+
return "darwin";
|
|
10
|
+
if (platform === "linux")
|
|
11
|
+
return "linux";
|
|
12
|
+
return "other";
|
|
13
|
+
}
|
|
14
|
+
export function escapeXml(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """);
|
|
20
|
+
}
|
|
21
|
+
export function shellQuote(value) {
|
|
22
|
+
if (/^[A-Za-z0-9_./:=-]+$/.test(value))
|
|
23
|
+
return value;
|
|
24
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
|
|
|
60
60
|
import semver from "semver";
|
|
61
61
|
import chalk from "chalk";
|
|
62
62
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
63
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence,
|
|
63
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
import { markLatestIneffective } from "./version-check.js";
|
|
66
66
|
/**
|
|
@@ -70,8 +70,6 @@ import { markLatestIneffective } from "./version-check.js";
|
|
|
70
70
|
export const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
|
|
71
71
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
|
|
72
72
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
73
|
-
/** Tail of captured package-manager stderr kept for the failure warning. */
|
|
74
|
-
const DETAIL_MAX_CHARS = 400;
|
|
75
73
|
/** npm `latest` for this package, or null on any failure (offline, 5xx, bad body). */
|
|
76
74
|
async function fetchLatestVersion() {
|
|
77
75
|
try {
|
|
@@ -115,33 +113,7 @@ export function runUpdateQuiet(cmd, args, env) {
|
|
|
115
113
|
// the installer down with it mid-write. See `openInstallOutput`.
|
|
116
114
|
const output = openInstallOutput(false);
|
|
117
115
|
try {
|
|
118
|
-
|
|
119
|
-
// `inOwnProcessGroup`: the install must survive a signal aimed at this
|
|
120
|
-
// process, or a killed `hq` leaves a corrupted global install behind.
|
|
121
|
-
const result = spawnSync(plan.cmd, plan.args, inOwnProcessGroup({
|
|
122
|
-
stdio: output.stdio,
|
|
123
|
-
shell: plan.shell,
|
|
124
|
-
...(env ? { env } : {}),
|
|
125
|
-
}));
|
|
126
|
-
if (result.error) {
|
|
127
|
-
const code = result.error.code;
|
|
128
|
-
return { ok: false, code, detail: result.error.message };
|
|
129
|
-
}
|
|
130
|
-
if (result.status !== 0) {
|
|
131
|
-
const tail = output.stderrTail().slice(-DETAIL_MAX_CHARS);
|
|
132
|
-
return {
|
|
133
|
-
ok: false,
|
|
134
|
-
detail: tail || `exit ${result.status ?? "signal"}`,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
return { ok: true };
|
|
138
|
-
}
|
|
139
|
-
catch (err) {
|
|
140
|
-
return {
|
|
141
|
-
ok: false,
|
|
142
|
-
code: err?.code,
|
|
143
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
144
|
-
};
|
|
116
|
+
return runSupervisedUpdateCommand(cmd, args, output, env);
|
|
145
117
|
}
|
|
146
118
|
finally {
|
|
147
119
|
output.dispose();
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawn: nativeSpawn } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
|
|
6
|
+
function windowsTaskkillArgs(pid) {
|
|
7
|
+
return ["/PID", String(pid), "/T", "/F"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function errorInfo(error) {
|
|
11
|
+
return {
|
|
12
|
+
code: error && typeof error.code === "string" ? error.code : undefined,
|
|
13
|
+
detail:
|
|
14
|
+
error && typeof error.message === "string" ? error.message : String(error ?? "unknown error"),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeMarker(filename, contents, log) {
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(filename, contents);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const { detail } = errorInfo(error);
|
|
23
|
+
log(`[hq update supervisor] failed to record updater status: ${detail}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spawn and supervise one package-manager update. On POSIX the detached child
|
|
29
|
+
* is its own process-group leader, so signals reach the leader and every
|
|
30
|
+
* descendant. Windows command shims run below a shell, therefore taskkill is
|
|
31
|
+
* used there to terminate the full descendant tree instead of only that shell.
|
|
32
|
+
*
|
|
33
|
+
* Dependencies are injectable to pin failure handling without requiring root
|
|
34
|
+
* privileges or a Windows host in the test suite.
|
|
35
|
+
*/
|
|
36
|
+
function runUpdateSupervisor(config, dependencies = {}) {
|
|
37
|
+
const spawn = dependencies.spawn || nativeSpawn;
|
|
38
|
+
const signal = dependencies.kill || process.kill.bind(process);
|
|
39
|
+
const setTimer = dependencies.setTimeout || setTimeout;
|
|
40
|
+
const clearTimer = dependencies.clearTimeout || clearTimeout;
|
|
41
|
+
const platform = dependencies.platform || process.platform;
|
|
42
|
+
const exit = dependencies.exit || ((code) => process.exit(code));
|
|
43
|
+
const log = dependencies.log || console.error;
|
|
44
|
+
|
|
45
|
+
let child;
|
|
46
|
+
let deadlineTimer;
|
|
47
|
+
let graceTimer;
|
|
48
|
+
let reapTimer;
|
|
49
|
+
let finished = false;
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
let childClosed = false;
|
|
52
|
+
let treeTerminationConfirmed = false;
|
|
53
|
+
|
|
54
|
+
function clear(timer) {
|
|
55
|
+
if (timer !== undefined) clearTimer(timer);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function finish(code, options = {}) {
|
|
59
|
+
if (finished) return;
|
|
60
|
+
finished = true;
|
|
61
|
+
clear(deadlineTimer);
|
|
62
|
+
clear(graceTimer);
|
|
63
|
+
clear(reapTimer);
|
|
64
|
+
if (options.timedOut) writeMarker(config.timeoutMarker, "timed-out", log);
|
|
65
|
+
if (options.error) writeMarker(config.errorMarker, JSON.stringify(options.error), log);
|
|
66
|
+
exit(code);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function timeoutFailure(code, detail) {
|
|
70
|
+
finish(124, {
|
|
71
|
+
timedOut: true,
|
|
72
|
+
error: { code, detail },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function timeoutAfterReap() {
|
|
77
|
+
finish(124, { timedOut: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function waitForReap(after) {
|
|
81
|
+
if (childClosed) {
|
|
82
|
+
timeoutAfterReap();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
reapTimer = setTimer(() => {
|
|
86
|
+
timeoutFailure(
|
|
87
|
+
"EREAP",
|
|
88
|
+
`timed out after ${config.timeoutMs}ms and ${after} did not close the updater process`,
|
|
89
|
+
);
|
|
90
|
+
}, config.reapMs);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function signalProcessGroup(signalName) {
|
|
94
|
+
if (!child || !child.pid) {
|
|
95
|
+
timeoutFailure("ESRCH", `failed to send ${signalName}: updater has no process ID`);
|
|
96
|
+
return "failed";
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
signal(-child.pid, signalName);
|
|
100
|
+
return "sent";
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const { code, detail } = errorInfo(error);
|
|
103
|
+
if (code === "ESRCH") return "gone";
|
|
104
|
+
// An EPERM here is possible after the sudo retry: waiting would violate
|
|
105
|
+
// the deadline, while claiming the process tree was reaped would lie.
|
|
106
|
+
timeoutFailure(code, `failed to send ${signalName} to updater process group: ${detail}`);
|
|
107
|
+
return "failed";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function terminateWindowsTree() {
|
|
112
|
+
if (!child || !child.pid) {
|
|
113
|
+
timeoutFailure("ESRCH", "failed to start taskkill: updater has no process ID");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let killer;
|
|
117
|
+
try {
|
|
118
|
+
killer = spawn("taskkill", windowsTaskkillArgs(child.pid), {
|
|
119
|
+
stdio: "ignore",
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const { code, detail } = errorInfo(error);
|
|
124
|
+
timeoutFailure(code, `failed to start taskkill for updater process tree: ${detail}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
killer.once("error", (error) => {
|
|
128
|
+
const { code, detail } = errorInfo(error);
|
|
129
|
+
timeoutFailure(code, `taskkill failed for updater process tree: ${detail}`);
|
|
130
|
+
});
|
|
131
|
+
killer.once("close", (code) => {
|
|
132
|
+
if (code !== 0) {
|
|
133
|
+
timeoutFailure("ETASKKILL", `taskkill exited ${code ?? "without a status"} for updater process tree`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
treeTerminationConfirmed = true;
|
|
137
|
+
waitForReap("taskkill");
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
child = spawn(config.cmd, config.args, {
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
145
|
+
shell: config.shell,
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const { code, detail } = errorInfo(error);
|
|
149
|
+
finish(127, { error: { code, detail } });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
child.once("error", (error) => {
|
|
154
|
+
const { code, detail } = errorInfo(error);
|
|
155
|
+
finish(127, { error: { code, detail } });
|
|
156
|
+
});
|
|
157
|
+
child.once("close", (code) => {
|
|
158
|
+
childClosed = true;
|
|
159
|
+
if (!timedOut) {
|
|
160
|
+
finish(code === 0 ? 0 : (code ?? 1));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (treeTerminationConfirmed) timeoutAfterReap();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
deadlineTimer = setTimer(() => {
|
|
167
|
+
if (finished) return;
|
|
168
|
+
timedOut = true;
|
|
169
|
+
if (platform === "win32") {
|
|
170
|
+
terminateWindowsTree();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const term = signalProcessGroup("SIGTERM");
|
|
174
|
+
if (term === "failed") return;
|
|
175
|
+
if (term === "gone") {
|
|
176
|
+
treeTerminationConfirmed = true;
|
|
177
|
+
waitForReap("the already-exited process group");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
graceTimer = setTimer(() => {
|
|
181
|
+
if (finished) return;
|
|
182
|
+
const kill = signalProcessGroup("SIGKILL");
|
|
183
|
+
if (kill === "failed") return;
|
|
184
|
+
treeTerminationConfirmed = true;
|
|
185
|
+
waitForReap(kill === "gone" ? "the already-exited process group" : "SIGKILL");
|
|
186
|
+
}, config.graceMs);
|
|
187
|
+
}, config.timeoutMs);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (require.main === module) {
|
|
191
|
+
runUpdateSupervisor(JSON.parse(process.argv[2]));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { runUpdateSupervisor, windowsTaskkillArgs };
|
|
@@ -283,6 +283,7 @@ export { buildSpawnPlan, quoteForWindowsShell };
|
|
|
283
283
|
* protection silently disappearing.
|
|
284
284
|
*/
|
|
285
285
|
export declare function inOwnProcessGroup<T extends object>(options: T): T;
|
|
286
|
+
declare function updateTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
286
287
|
/**
|
|
287
288
|
* Where an install's output goes — the other half of surviving a killed parent.
|
|
288
289
|
*
|
|
@@ -307,9 +308,21 @@ export interface InstallOutput {
|
|
|
307
308
|
stdio: ("ignore" | "inherit" | number)[];
|
|
308
309
|
/** Captured stderr tail, or "" when output was inherited by a terminal. */
|
|
309
310
|
stderrTail: () => string;
|
|
311
|
+
/** Private side-channel from the supervisor, kept out of installer output. */
|
|
312
|
+
timeoutMarker: string;
|
|
313
|
+
/** Private spawn-error side-channel that preserves the prior result contract. */
|
|
314
|
+
errorMarker: string;
|
|
315
|
+
/** Whether the asynchronous installer supervisor reported a deadline expiry. */
|
|
316
|
+
timedOut: () => boolean;
|
|
317
|
+
/** Original package-manager launch error, if the supervisor could start. */
|
|
318
|
+
spawnError: () => {
|
|
319
|
+
code?: string;
|
|
320
|
+
detail?: string;
|
|
321
|
+
} | undefined;
|
|
310
322
|
dispose: () => void;
|
|
311
323
|
}
|
|
312
324
|
export declare function openInstallOutput(verbose: boolean, isTty?: boolean): InstallOutput;
|
|
325
|
+
export declare function runSupervisedUpdateCommand(cmd: string, args: string[], output: InstallOutput, env?: NodeJS.ProcessEnv): UpdateResult;
|
|
313
326
|
export declare function runUpdateCommand(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
|
|
314
327
|
declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner, env?: NodeJS.ProcessEnv): UpdateResult;
|
|
315
328
|
declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
|
|
@@ -385,6 +398,9 @@ interface EnforceUpdateDeps {
|
|
|
385
398
|
* Exit codes:
|
|
386
399
|
* 0 — update succeeded; user must rerun their command
|
|
387
400
|
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
401
|
+
*
|
|
402
|
+
* A timed-out updater is the exception: it has been forcibly reaped, so the
|
|
403
|
+
* gate returns and lets the user's command run on the current version.
|
|
388
404
|
*/
|
|
389
405
|
declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void;
|
|
390
406
|
/**
|
|
@@ -418,6 +434,7 @@ export declare function shouldSkipGate(argv: readonly string[]): boolean;
|
|
|
418
434
|
export declare const __test__: {
|
|
419
435
|
CLIENT_ID: string;
|
|
420
436
|
CONVERGENCE_TIMEOUT_MS: number;
|
|
437
|
+
DEFAULT_UPDATE_TIMEOUT_MS: number;
|
|
421
438
|
ENDPOINT_PATH: string;
|
|
422
439
|
FETCH_TIMEOUT_MS: number;
|
|
423
440
|
checkUpdateConvergence: typeof checkUpdateConvergence;
|
|
@@ -448,5 +465,6 @@ export declare const __test__: {
|
|
|
448
465
|
resolveRunningInstall: typeof resolveRunningInstall;
|
|
449
466
|
resolveRunningManager: typeof resolveRunningManager;
|
|
450
467
|
resolveRunningPrefix: typeof resolveRunningPrefix;
|
|
468
|
+
updateTimeoutMs: typeof updateTimeoutMs;
|
|
451
469
|
};
|
|
452
470
|
//# sourceMappingURL=version-gate.d.ts.map
|
|
@@ -534,16 +534,74 @@ export function inOwnProcessGroup(options) {
|
|
|
534
534
|
}
|
|
535
535
|
/** Tail of captured installer stderr kept to explain a failure. */
|
|
536
536
|
const INSTALL_DETAIL_MAX_CHARS = 400;
|
|
537
|
+
/** Give a package-manager update ample time without letting it wedge every CLI call. */
|
|
538
|
+
const DEFAULT_UPDATE_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
539
|
+
/** Node clamps longer timer delays to 1ms, so accepting them would invert the deadline. */
|
|
540
|
+
const MAX_UPDATE_TIMEOUT_MS = 2_147_483_647;
|
|
541
|
+
/** Let a well-behaved updater stop before force-killing the rest of its group. */
|
|
542
|
+
const UPDATE_TERMINATE_GRACE_MS = 1_000;
|
|
543
|
+
/** A successful group kill must still yield a close event so the supervisor reaps its child. */
|
|
544
|
+
const UPDATE_REAP_GRACE_MS = 1_000;
|
|
545
|
+
function updateTimeoutMs(env = process.env) {
|
|
546
|
+
const value = env.HQ_UPDATE_TIMEOUT_MS;
|
|
547
|
+
if (value === undefined || value.trim() === "")
|
|
548
|
+
return DEFAULT_UPDATE_TIMEOUT_MS;
|
|
549
|
+
const parsed = Number(value);
|
|
550
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_UPDATE_TIMEOUT_MS) {
|
|
551
|
+
return parsed;
|
|
552
|
+
}
|
|
553
|
+
// Falling back rather than clamping is deliberate: a clamp would silently
|
|
554
|
+
// substitute a materially different deadline. The warning tells operators
|
|
555
|
+
// their requested value was rejected before any update begins.
|
|
556
|
+
console.error(chalk.yellow(`⚠ Ignoring HQ_UPDATE_TIMEOUT_MS=${JSON.stringify(value)}: expected a positive integer no greater than ${MAX_UPDATE_TIMEOUT_MS}ms; using ${DEFAULT_UPDATE_TIMEOUT_MS}ms.`));
|
|
557
|
+
return DEFAULT_UPDATE_TIMEOUT_MS;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The actual installer runs in a small asynchronous supervisor, rather than
|
|
561
|
+
* directly under `spawnSync`. `spawnSync({ timeout })` only signals its direct
|
|
562
|
+
* child. That is unsafe here because the installer deliberately runs in its
|
|
563
|
+
* own process group: the leader can exit while its descendants keep the global
|
|
564
|
+
* store and registry connection alive. The CommonJS file is copied next to the
|
|
565
|
+
* compiled module for production and is directly runnable from source tests.
|
|
566
|
+
*/
|
|
567
|
+
const UPDATE_COMMAND_SUPERVISOR = fileURLToPath(new URL("./update-command-supervisor.cjs", import.meta.url));
|
|
568
|
+
function readSupervisorSpawnError(errorMarker) {
|
|
569
|
+
try {
|
|
570
|
+
const value = JSON.parse(readFileSync(errorMarker, "utf-8"));
|
|
571
|
+
const code = typeof value.code === "string" ? value.code : undefined;
|
|
572
|
+
const detail = typeof value.detail === "string" ? value.detail : undefined;
|
|
573
|
+
return code === undefined && detail === undefined ? undefined : { code, detail };
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
537
579
|
export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true && process.stderr.isTTY === true) {
|
|
538
580
|
if (verbose && isTty) {
|
|
581
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "hq-cli-install-"));
|
|
582
|
+
const timeoutMarker = path.join(dir, "timed-out");
|
|
583
|
+
const errorMarker = path.join(dir, "spawn-error.json");
|
|
539
584
|
return {
|
|
540
585
|
stdio: ["ignore", "inherit", "inherit"],
|
|
541
586
|
stderrTail: () => "",
|
|
542
|
-
|
|
587
|
+
timeoutMarker,
|
|
588
|
+
errorMarker,
|
|
589
|
+
timedOut: () => existsSync(timeoutMarker),
|
|
590
|
+
spawnError: () => readSupervisorSpawnError(errorMarker),
|
|
591
|
+
dispose: () => {
|
|
592
|
+
try {
|
|
593
|
+
rmSync(dir, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Best-effort cleanup of the supervisor's timeout marker.
|
|
597
|
+
}
|
|
598
|
+
},
|
|
543
599
|
};
|
|
544
600
|
}
|
|
545
601
|
const dir = mkdtempSync(path.join(os.tmpdir(), "hq-cli-install-"));
|
|
546
602
|
const errPath = path.join(dir, "stderr.log");
|
|
603
|
+
const timeoutPath = path.join(dir, "timed-out");
|
|
604
|
+
const errorPath = path.join(dir, "spawn-error.json");
|
|
547
605
|
const out = openSync(path.join(dir, "stdout.log"), "a");
|
|
548
606
|
const err = openSync(errPath, "a");
|
|
549
607
|
return {
|
|
@@ -556,6 +614,10 @@ export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true
|
|
|
556
614
|
return "";
|
|
557
615
|
}
|
|
558
616
|
},
|
|
617
|
+
timeoutMarker: timeoutPath,
|
|
618
|
+
errorMarker: errorPath,
|
|
619
|
+
timedOut: () => existsSync(timeoutPath),
|
|
620
|
+
spawnError: () => readSupervisorSpawnError(errorPath),
|
|
559
621
|
dispose: () => {
|
|
560
622
|
for (const fd of [out, err]) {
|
|
561
623
|
try {
|
|
@@ -574,13 +636,28 @@ export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true
|
|
|
574
636
|
},
|
|
575
637
|
};
|
|
576
638
|
}
|
|
577
|
-
export function
|
|
578
|
-
const output = openInstallOutput(true);
|
|
639
|
+
export function runSupervisedUpdateCommand(cmd, args, output, env) {
|
|
579
640
|
try {
|
|
580
641
|
const plan = buildSpawnPlan(cmd, args);
|
|
581
|
-
const
|
|
642
|
+
const timeoutMs = updateTimeoutMs(env);
|
|
643
|
+
// The outer sync spawn waits only for our supervisor. The supervisor owns
|
|
644
|
+
// the wall-clock deadline, sends TERM to the updater's whole process group,
|
|
645
|
+
// waits briefly, sends KILL to survivors, and waits for its direct child so
|
|
646
|
+
// the completed result leaves no zombie behind.
|
|
647
|
+
const result = spawnSync(process.execPath, [
|
|
648
|
+
UPDATE_COMMAND_SUPERVISOR,
|
|
649
|
+
JSON.stringify({
|
|
650
|
+
cmd: plan.cmd,
|
|
651
|
+
args: plan.args,
|
|
652
|
+
shell: plan.shell,
|
|
653
|
+
timeoutMs,
|
|
654
|
+
graceMs: UPDATE_TERMINATE_GRACE_MS,
|
|
655
|
+
reapMs: UPDATE_REAP_GRACE_MS,
|
|
656
|
+
timeoutMarker: output.timeoutMarker,
|
|
657
|
+
errorMarker: output.errorMarker,
|
|
658
|
+
}),
|
|
659
|
+
], inOwnProcessGroup({
|
|
582
660
|
stdio: output.stdio,
|
|
583
|
-
shell: plan.shell,
|
|
584
661
|
...(env ? { env } : {}),
|
|
585
662
|
}));
|
|
586
663
|
// spawnSync reports a missing executable via `error`, not a throw.
|
|
@@ -588,6 +665,26 @@ export function runUpdateCommand(cmd, args, env) {
|
|
|
588
665
|
const code = result.error.code;
|
|
589
666
|
return { ok: false, code, detail: result.error.message };
|
|
590
667
|
}
|
|
668
|
+
if (output.timedOut()) {
|
|
669
|
+
const command = [cmd, ...args].join(" ");
|
|
670
|
+
const supervisorError = output.spawnError();
|
|
671
|
+
const detail = [
|
|
672
|
+
`timed out after ${timeoutMs}ms: ${command}`,
|
|
673
|
+
supervisorError?.detail,
|
|
674
|
+
]
|
|
675
|
+
.filter(Boolean)
|
|
676
|
+
.join("; ");
|
|
677
|
+
console.error(chalk.red(`✗ Update ${detail}.`));
|
|
678
|
+
return { ok: false, code: "ETIMEDOUT", detail };
|
|
679
|
+
}
|
|
680
|
+
const spawnedError = output.spawnError();
|
|
681
|
+
if (spawnedError) {
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
code: spawnedError.code,
|
|
685
|
+
detail: spawnedError.detail ?? "failed to start updater",
|
|
686
|
+
};
|
|
687
|
+
}
|
|
591
688
|
if (result.status !== 0) {
|
|
592
689
|
// When output went to a file rather than the user's terminal, its tail is
|
|
593
690
|
// the only explanation anyone will ever see for this failure.
|
|
@@ -606,6 +703,12 @@ export function runUpdateCommand(cmd, args, env) {
|
|
|
606
703
|
detail: err instanceof Error ? err.message : String(err),
|
|
607
704
|
};
|
|
608
705
|
}
|
|
706
|
+
}
|
|
707
|
+
export function runUpdateCommand(cmd, args, env) {
|
|
708
|
+
const output = openInstallOutput(true);
|
|
709
|
+
try {
|
|
710
|
+
return runSupervisedUpdateCommand(cmd, args, output, env);
|
|
711
|
+
}
|
|
609
712
|
finally {
|
|
610
713
|
output.dispose();
|
|
611
714
|
}
|
|
@@ -796,6 +899,9 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
796
899
|
* Exit codes:
|
|
797
900
|
* 0 — update succeeded; user must rerun their command
|
|
798
901
|
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
902
|
+
*
|
|
903
|
+
* A timed-out updater is the exception: it has been forcibly reaped, so the
|
|
904
|
+
* gate returns and lets the user's command run on the current version.
|
|
799
905
|
*/
|
|
800
906
|
function enforceUpdateRequired(decision, deps = {}) {
|
|
801
907
|
const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
|
|
@@ -858,6 +964,8 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
858
964
|
// unexpected throw from the install attempt.
|
|
859
965
|
lock.release();
|
|
860
966
|
}
|
|
967
|
+
if (exitCode === "continue")
|
|
968
|
+
return;
|
|
861
969
|
process.exit(exitCode);
|
|
862
970
|
}
|
|
863
971
|
/**
|
|
@@ -923,7 +1031,7 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
923
1031
|
// artifacts and retry the install ONCE. Guarded on `cleaned.length > 0` so a
|
|
924
1032
|
// plain EACCES on an otherwise-healthy prefix falls straight through to the
|
|
925
1033
|
// sudo retry below without a redundant reinstall attempt.
|
|
926
|
-
if (!result.ok && prefix) {
|
|
1034
|
+
if (!result.ok && result.code !== "ETIMEDOUT" && prefix) {
|
|
927
1035
|
const cleaner = deps.cleanStale ?? cleanStalePartialInstall;
|
|
928
1036
|
const cleaned = cleaner(prefix);
|
|
929
1037
|
if (cleaned.length > 0) {
|
|
@@ -943,7 +1051,7 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
943
1051
|
// Never for pnpm or Bun: elevation changes the package manager's global home,
|
|
944
1052
|
// leaving the user's shim untouched while reporting success. A failed
|
|
945
1053
|
// non-npm update must surface instead.
|
|
946
|
-
if (!result.ok && primaryCmd && !isManagedOutsideNpm) {
|
|
1054
|
+
if (!result.ok && result.code !== "ETIMEDOUT" && primaryCmd && !isManagedOutsideNpm) {
|
|
947
1055
|
console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
948
1056
|
const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
|
|
949
1057
|
if (sudoResult.ok)
|
|
@@ -973,6 +1081,15 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
973
1081
|
if (isManagedOutsideNpm && result.code === "ENOENT" && command) {
|
|
974
1082
|
console.error(chalk.dim(` (hq-pro suggests \`${command}\` — that is for npm-managed installs; use it only if you have switched this install to npm.)`));
|
|
975
1083
|
}
|
|
1084
|
+
// The self-update is allowed to fail, but a deadline expiry must not turn
|
|
1085
|
+
// into a second, indefinite denial of the user's command. The supervisor
|
|
1086
|
+
// either reaped the group or recorded why the OS denied that signal; in
|
|
1087
|
+
// neither case did an update land. Continue on the current CLI rather than
|
|
1088
|
+
// pretending it did or aborting the command that caused the gate to run.
|
|
1089
|
+
if (result.code === "ETIMEDOUT") {
|
|
1090
|
+
console.error(chalk.dim(" Continuing on the current hq-cli version."));
|
|
1091
|
+
return "continue";
|
|
1092
|
+
}
|
|
976
1093
|
return 75;
|
|
977
1094
|
}
|
|
978
1095
|
// Read-your-writes: a "successful" install into the npm prefix does not
|
|
@@ -1028,6 +1145,7 @@ export function shouldSkipGate(argv) {
|
|
|
1028
1145
|
export const __test__ = {
|
|
1029
1146
|
CLIENT_ID,
|
|
1030
1147
|
CONVERGENCE_TIMEOUT_MS,
|
|
1148
|
+
DEFAULT_UPDATE_TIMEOUT_MS,
|
|
1031
1149
|
ENDPOINT_PATH,
|
|
1032
1150
|
FETCH_TIMEOUT_MS,
|
|
1033
1151
|
checkUpdateConvergence,
|
|
@@ -1058,5 +1176,6 @@ export const __test__ = {
|
|
|
1058
1176
|
resolveRunningInstall,
|
|
1059
1177
|
resolveRunningManager,
|
|
1060
1178
|
resolveRunningPrefix,
|
|
1179
|
+
updateTimeoutMs,
|
|
1061
1180
|
};
|
|
1062
1181
|
//# sourceMappingURL=version-gate.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.117.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
37
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
37
|
+
"@indigoai-us/hq-cloud": "~6.16.48",
|
|
38
38
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
39
39
|
"@sentry/node": "^10.49.0",
|
|
40
40
|
"@tobilu/qmd": "2.5.3",
|