@indigoai-us/hq-cli 5.54.0 → 5.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/reindex.d.ts +12 -0
- package/dist/commands/reindex.js +24 -2
- package/dist/utils/version-gate.js +32 -6
- package/package.json +1 -1
- package/src/commands/reindex.test.ts +94 -0
- package/src/commands/reindex.ts +41 -1
- package/src/utils/version-gate.test.ts +72 -0
- package/src/utils/version-gate.ts +39 -4
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Keeps a hidden `master-sync` alias for one release so an updated CLI still
|
|
11
11
|
* answers a not-yet-updated hook shim (and vice-versa) during rollout.
|
|
12
|
+
*
|
|
13
|
+
* Lock-wait policy: reindex shares one per-root operation lock with `sync` and
|
|
14
|
+
* `rescue`. By default acquisition waits UNBOUNDED for a live holder, which is
|
|
15
|
+
* what a human running `hq reindex` interactively wants. But when invoked from a
|
|
16
|
+
* Claude/Codex lifecycle hook (SessionStart / UserPromptSubmit / Stop /
|
|
17
|
+
* PostToolUse), waiting is exactly wrong: if a sync/rescue is mid-flight, the
|
|
18
|
+
* hook blocks the agent up to the host's per-hook timeout (the multi-minute
|
|
19
|
+
* "Claude won't load in the HQ folder" spinner). `--from-hook` makes the hook
|
|
20
|
+
* path refuse-fast (never wait); `--lock-timeout <sec>` bounds it explicitly.
|
|
21
|
+
* The bound is applied via the HQ_OP_LOCK_TIMEOUT env var that the hq-cloud
|
|
22
|
+
* operation lock honors, so it works even against an installed hq-cloud build
|
|
23
|
+
* that predates a typed lock-timeout option.
|
|
12
24
|
*/
|
|
13
25
|
import { Command } from 'commander';
|
|
14
26
|
export declare function registerReindexCommand(program: Command): void;
|
package/dist/commands/reindex.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="91aee9b3-dd72-5f05-99a8-d23b37f5eabe")}catch(e){}}();
|
|
3
3
|
import { reindex } from '@indigoai-us/hq-cloud';
|
|
4
4
|
export function registerReindexCommand(program) {
|
|
5
5
|
program
|
|
@@ -7,10 +7,32 @@ export function registerReindexCommand(program) {
|
|
|
7
7
|
.alias('master-sync')
|
|
8
8
|
.description('Surface namespaced skills, mirror the personal overlay into core/, and regenerate the workers registry')
|
|
9
9
|
.option('--repo-root <path>', 'HQ root to operate on (defaults to the current directory)')
|
|
10
|
+
.option('--from-hook', 'Invoked from a Claude/Codex lifecycle hook: never wait on the per-root operation lock — if a sync/rescue holds it, skip this reindex instead of blocking the session (equivalent to --lock-timeout 0)')
|
|
11
|
+
.option('--lock-timeout <seconds>', 'Bound the wait for the per-root operation lock (seconds). 0 = refuse immediately; omitted = wait indefinitely (the interactive default). --from-hook implies 0; an explicit --lock-timeout wins.')
|
|
10
12
|
.action((opts) => {
|
|
13
|
+
// Resolve the lock-wait bound. An explicit --lock-timeout wins; otherwise
|
|
14
|
+
// --from-hook forces 0 (refuse-fast) so a hook fired while a sync/rescue
|
|
15
|
+
// holds the shared per-root operation lock can never stall the agent.
|
|
16
|
+
let lockTimeoutSec;
|
|
17
|
+
if (opts.lockTimeout !== undefined) {
|
|
18
|
+
const parsed = Number(opts.lockTimeout);
|
|
19
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
20
|
+
lockTimeoutSec = parsed;
|
|
21
|
+
}
|
|
22
|
+
else if (opts.fromHook) {
|
|
23
|
+
lockTimeoutSec = 0;
|
|
24
|
+
}
|
|
25
|
+
// Apply via HQ_OP_LOCK_TIMEOUT — the env var the hq-cloud operation lock
|
|
26
|
+
// reads (acquireOperationLock → resolveWaitConfig). Going through the env
|
|
27
|
+
// (rather than a typed reindex() option) keeps this forward/backward
|
|
28
|
+
// compatible with any installed hq-cloud build. Never clobber a value the
|
|
29
|
+
// caller already set explicitly.
|
|
30
|
+
if (lockTimeoutSec !== undefined && process.env.HQ_OP_LOCK_TIMEOUT === undefined) {
|
|
31
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
|
|
32
|
+
}
|
|
11
33
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
12
34
|
process.exit(status);
|
|
13
35
|
});
|
|
14
36
|
}
|
|
15
37
|
//# sourceMappingURL=reindex.js.map
|
|
16
|
-
//# debugId=
|
|
38
|
+
//# debugId=91aee9b3-dd72-5f05-99a8-d23b37f5eabe
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* to silence both check + gate).
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
31
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00fa2aa9-f5d1-57a7-92db-c441c6bef906")}catch(e){}}();
|
|
32
32
|
import { spawnSync } from "node:child_process";
|
|
33
33
|
import { readFileSync } from "node:fs";
|
|
34
34
|
import path from "node:path";
|
|
@@ -185,12 +185,24 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
185
185
|
process.exit(75);
|
|
186
186
|
}
|
|
187
187
|
const runner = deps.runner ?? runUpdateCommand;
|
|
188
|
-
|
|
188
|
+
// Resolve the concrete install argv once so the sudo fallback below can re-run
|
|
189
|
+
// the EXACT same command under elevation.
|
|
190
|
+
let primaryCmd;
|
|
191
|
+
let primaryArgs;
|
|
192
|
+
if (prefix) {
|
|
193
|
+
primaryCmd = "npm";
|
|
194
|
+
primaryArgs = buildPrefixedInstallArgv(prefix);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const parts = (command ?? "").split(/\s+/).filter(Boolean);
|
|
198
|
+
primaryCmd = parts[0] ?? "";
|
|
199
|
+
primaryArgs = parts.slice(1);
|
|
200
|
+
}
|
|
201
|
+
let result = prefix
|
|
189
202
|
? (() => {
|
|
190
|
-
const args = buildPrefixedInstallArgv(prefix);
|
|
191
203
|
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
192
|
-
console.error(chalk.dim(` Running: npm ${
|
|
193
|
-
return performUpdateCommand("npm",
|
|
204
|
+
console.error(chalk.dim(` Running: npm ${primaryArgs.join(" ")}`));
|
|
205
|
+
return performUpdateCommand("npm", primaryArgs, runner);
|
|
194
206
|
})()
|
|
195
207
|
: (() => {
|
|
196
208
|
console.error(chalk.dim(` Running: ${command}`));
|
|
@@ -198,6 +210,20 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
198
210
|
? deps.performUpdateString(command)
|
|
199
211
|
: performUpdate(command, runner);
|
|
200
212
|
})();
|
|
213
|
+
// A root-owned global install (e.g. a system `/usr` install where the CLI runs
|
|
214
|
+
// unprivileged — the outpost agent boxes) can't rewrite the prefix's bin dir,
|
|
215
|
+
// so the install above fails with EACCES (`rename /usr/bin/hq`). Retry ONCE
|
|
216
|
+
// under non-interactive sudo: `sudo -n` fails fast WITHOUT a password prompt
|
|
217
|
+
// when passwordless sudo isn't configured, so interactive installs (e.g.
|
|
218
|
+
// Homebrew on macOS, where the first attempt already succeeded anyway) fall
|
|
219
|
+
// through to the manual path unchanged, while headless boxes with passwordless
|
|
220
|
+
// sudo self-update cleanly.
|
|
221
|
+
if (!result.ok && primaryCmd) {
|
|
222
|
+
console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
223
|
+
const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
|
|
224
|
+
if (sudoResult.ok)
|
|
225
|
+
result = sudoResult;
|
|
226
|
+
}
|
|
201
227
|
if (!result.ok) {
|
|
202
228
|
console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
|
|
203
229
|
const manual = prefix
|
|
@@ -253,4 +279,4 @@ export const __test__ = {
|
|
|
253
279
|
resolveRunningPrefix,
|
|
254
280
|
};
|
|
255
281
|
//# sourceMappingURL=version-gate.js.map
|
|
256
|
-
//# debugId=
|
|
282
|
+
//# debugId=00fa2aa9-f5d1-57a7-92db-c441c6bef906
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from "vitest";
|
|
10
|
+
|
|
11
|
+
// Mock the hq-cloud reindex() so the command runs without touching the FS.
|
|
12
|
+
vi.mock("@indigoai-us/hq-cloud", () => ({
|
|
13
|
+
reindex: vi.fn(() => ({ status: 0 })),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { reindex } from "@indigoai-us/hq-cloud";
|
|
18
|
+
import { registerReindexCommand } from "./reindex.js";
|
|
19
|
+
|
|
20
|
+
const reindexMock = reindex as unknown as MockInstance<typeof reindex>;
|
|
21
|
+
|
|
22
|
+
function buildProgram(): Command {
|
|
23
|
+
const program = new Command();
|
|
24
|
+
program.exitOverride();
|
|
25
|
+
program.configureOutput({
|
|
26
|
+
writeOut: () => undefined,
|
|
27
|
+
writeErr: () => undefined,
|
|
28
|
+
});
|
|
29
|
+
registerReindexCommand(program);
|
|
30
|
+
return program;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function run(...args: string[]): Promise<void> {
|
|
34
|
+
// process.exit is spied to throw; swallow that so parseAsync resolves.
|
|
35
|
+
try {
|
|
36
|
+
await buildProgram().parseAsync(["node", "hq", "reindex", ...args]);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (!(err instanceof Error) || !err.message.startsWith("__EXIT__:")) throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
43
|
+
let savedLockTimeout: string | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
savedLockTimeout = process.env.HQ_OP_LOCK_TIMEOUT;
|
|
48
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
49
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
50
|
+
throw new Error(`__EXIT__:${code ?? 0}`);
|
|
51
|
+
}) as never);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
if (savedLockTimeout === undefined) delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
57
|
+
else process.env.HQ_OP_LOCK_TIMEOUT = savedLockTimeout;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("hq reindex lock-wait policy", () => {
|
|
61
|
+
it("--from-hook forces a no-wait lock acquisition (HQ_OP_LOCK_TIMEOUT=0)", async () => {
|
|
62
|
+
await run("--from-hook");
|
|
63
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("0");
|
|
64
|
+
expect(reindexMock).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("--lock-timeout <sec> sets an explicit bounded wait", async () => {
|
|
69
|
+
await run("--lock-timeout", "5");
|
|
70
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("5");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("an explicit --lock-timeout wins over --from-hook", async () => {
|
|
74
|
+
await run("--from-hook", "--lock-timeout", "10");
|
|
75
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("10");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a plain interactive reindex leaves the wait unbounded (env unset)", async () => {
|
|
79
|
+
await run();
|
|
80
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBeUndefined();
|
|
81
|
+
expect(reindexMock).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("never clobbers an HQ_OP_LOCK_TIMEOUT the caller already set", async () => {
|
|
85
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "42";
|
|
86
|
+
await run("--from-hook");
|
|
87
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("42");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ignores an invalid --lock-timeout (negative / non-numeric)", async () => {
|
|
91
|
+
await run("--lock-timeout", "nope");
|
|
92
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/commands/reindex.ts
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Keeps a hidden `master-sync` alias for one release so an updated CLI still
|
|
11
11
|
* answers a not-yet-updated hook shim (and vice-versa) during rollout.
|
|
12
|
+
*
|
|
13
|
+
* Lock-wait policy: reindex shares one per-root operation lock with `sync` and
|
|
14
|
+
* `rescue`. By default acquisition waits UNBOUNDED for a live holder, which is
|
|
15
|
+
* what a human running `hq reindex` interactively wants. But when invoked from a
|
|
16
|
+
* Claude/Codex lifecycle hook (SessionStart / UserPromptSubmit / Stop /
|
|
17
|
+
* PostToolUse), waiting is exactly wrong: if a sync/rescue is mid-flight, the
|
|
18
|
+
* hook blocks the agent up to the host's per-hook timeout (the multi-minute
|
|
19
|
+
* "Claude won't load in the HQ folder" spinner). `--from-hook` makes the hook
|
|
20
|
+
* path refuse-fast (never wait); `--lock-timeout <sec>` bounds it explicitly.
|
|
21
|
+
* The bound is applied via the HQ_OP_LOCK_TIMEOUT env var that the hq-cloud
|
|
22
|
+
* operation lock honors, so it works even against an installed hq-cloud build
|
|
23
|
+
* that predates a typed lock-timeout option.
|
|
12
24
|
*/
|
|
13
25
|
import { Command } from 'commander';
|
|
14
26
|
import { reindex } from '@indigoai-us/hq-cloud';
|
|
@@ -21,7 +33,35 @@ export function registerReindexCommand(program: Command): void {
|
|
|
21
33
|
'Surface namespaced skills, mirror the personal overlay into core/, and regenerate the workers registry'
|
|
22
34
|
)
|
|
23
35
|
.option('--repo-root <path>', 'HQ root to operate on (defaults to the current directory)')
|
|
24
|
-
.
|
|
36
|
+
.option(
|
|
37
|
+
'--from-hook',
|
|
38
|
+
'Invoked from a Claude/Codex lifecycle hook: never wait on the per-root operation lock — if a sync/rescue holds it, skip this reindex instead of blocking the session (equivalent to --lock-timeout 0)'
|
|
39
|
+
)
|
|
40
|
+
.option(
|
|
41
|
+
'--lock-timeout <seconds>',
|
|
42
|
+
'Bound the wait for the per-root operation lock (seconds). 0 = refuse immediately; omitted = wait indefinitely (the interactive default). --from-hook implies 0; an explicit --lock-timeout wins.'
|
|
43
|
+
)
|
|
44
|
+
.action((opts: { repoRoot?: string; fromHook?: boolean; lockTimeout?: string }) => {
|
|
45
|
+
// Resolve the lock-wait bound. An explicit --lock-timeout wins; otherwise
|
|
46
|
+
// --from-hook forces 0 (refuse-fast) so a hook fired while a sync/rescue
|
|
47
|
+
// holds the shared per-root operation lock can never stall the agent.
|
|
48
|
+
let lockTimeoutSec: number | undefined;
|
|
49
|
+
if (opts.lockTimeout !== undefined) {
|
|
50
|
+
const parsed = Number(opts.lockTimeout);
|
|
51
|
+
if (Number.isFinite(parsed) && parsed >= 0) lockTimeoutSec = parsed;
|
|
52
|
+
} else if (opts.fromHook) {
|
|
53
|
+
lockTimeoutSec = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Apply via HQ_OP_LOCK_TIMEOUT — the env var the hq-cloud operation lock
|
|
57
|
+
// reads (acquireOperationLock → resolveWaitConfig). Going through the env
|
|
58
|
+
// (rather than a typed reindex() option) keeps this forward/backward
|
|
59
|
+
// compatible with any installed hq-cloud build. Never clobber a value the
|
|
60
|
+
// caller already set explicitly.
|
|
61
|
+
if (lockTimeoutSec !== undefined && process.env.HQ_OP_LOCK_TIMEOUT === undefined) {
|
|
62
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
26
66
|
process.exit(status);
|
|
27
67
|
});
|
|
@@ -364,4 +364,76 @@ describe("enforceVersionGate — hard-update path", () => {
|
|
|
364
364
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
365
365
|
expect(performUpdateString).toHaveBeenCalledWith(updateCommand);
|
|
366
366
|
});
|
|
367
|
+
|
|
368
|
+
it("retries under `sudo -n` when the unprivileged install fails (root-owned global)", async () => {
|
|
369
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
370
|
+
const exitSpy = vi
|
|
371
|
+
.spyOn(process, "exit")
|
|
372
|
+
.mockImplementation(((code?: number) => {
|
|
373
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
374
|
+
}) as never);
|
|
375
|
+
// First (unprivileged) npm install fails with EACCES; the sudo -n retry succeeds.
|
|
376
|
+
const runner = vi
|
|
377
|
+
.fn()
|
|
378
|
+
.mockImplementation((cmd: string) =>
|
|
379
|
+
cmd === "sudo" ? { ok: true } : { ok: false, detail: "EACCES" },
|
|
380
|
+
);
|
|
381
|
+
const { __test__ } = await loadModule();
|
|
382
|
+
const prefix = "/usr";
|
|
383
|
+
|
|
384
|
+
expect(() =>
|
|
385
|
+
__test__.enforceUpdateRequired(
|
|
386
|
+
{
|
|
387
|
+
clientId: "hq-cli",
|
|
388
|
+
currentVersion: "5.10.0",
|
|
389
|
+
minVersion: "5.20.0",
|
|
390
|
+
latestVersion: "5.24.0",
|
|
391
|
+
updateRequired: true,
|
|
392
|
+
updateRecommended: false,
|
|
393
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
394
|
+
},
|
|
395
|
+
{ resolvePrefix: () => prefix, runner },
|
|
396
|
+
),
|
|
397
|
+
).toThrow(/__process_exit__:0/); // succeeds after the sudo retry
|
|
398
|
+
|
|
399
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
400
|
+
const installArgs = [
|
|
401
|
+
"install",
|
|
402
|
+
"-g",
|
|
403
|
+
"--prefix",
|
|
404
|
+
prefix,
|
|
405
|
+
"@indigoai-us/hq-cli@latest",
|
|
406
|
+
];
|
|
407
|
+
expect(runner).toHaveBeenCalledWith("npm", installArgs);
|
|
408
|
+
expect(runner).toHaveBeenCalledWith("sudo", ["-n", "npm", ...installArgs]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("exits 75 when BOTH the unprivileged install and the sudo -n retry fail", async () => {
|
|
412
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
413
|
+
const exitSpy = vi
|
|
414
|
+
.spyOn(process, "exit")
|
|
415
|
+
.mockImplementation(((code?: number) => {
|
|
416
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
417
|
+
}) as never);
|
|
418
|
+
const runner = vi.fn().mockReturnValue({ ok: false, detail: "EACCES" });
|
|
419
|
+
const { __test__ } = await loadModule();
|
|
420
|
+
|
|
421
|
+
expect(() =>
|
|
422
|
+
__test__.enforceUpdateRequired(
|
|
423
|
+
{
|
|
424
|
+
clientId: "hq-cli",
|
|
425
|
+
currentVersion: "5.10.0",
|
|
426
|
+
minVersion: "5.20.0",
|
|
427
|
+
latestVersion: "5.24.0",
|
|
428
|
+
updateRequired: true,
|
|
429
|
+
updateRecommended: false,
|
|
430
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
431
|
+
},
|
|
432
|
+
{ resolvePrefix: () => "/usr", runner },
|
|
433
|
+
),
|
|
434
|
+
).toThrow(/__process_exit__:75/);
|
|
435
|
+
|
|
436
|
+
expect(exitSpy).toHaveBeenCalledWith(75);
|
|
437
|
+
expect(runner).toHaveBeenCalledWith("sudo", expect.arrayContaining(["-n"]));
|
|
438
|
+
});
|
|
367
439
|
});
|
|
@@ -236,12 +236,24 @@ function enforceUpdateRequired(
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
const runner = deps.runner ?? runUpdateCommand;
|
|
239
|
-
|
|
239
|
+
// Resolve the concrete install argv once so the sudo fallback below can re-run
|
|
240
|
+
// the EXACT same command under elevation.
|
|
241
|
+
let primaryCmd: string;
|
|
242
|
+
let primaryArgs: string[];
|
|
243
|
+
if (prefix) {
|
|
244
|
+
primaryCmd = "npm";
|
|
245
|
+
primaryArgs = buildPrefixedInstallArgv(prefix);
|
|
246
|
+
} else {
|
|
247
|
+
const parts = (command ?? "").split(/\s+/).filter(Boolean);
|
|
248
|
+
primaryCmd = parts[0] ?? "";
|
|
249
|
+
primaryArgs = parts.slice(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let result = prefix
|
|
240
253
|
? (() => {
|
|
241
|
-
const args = buildPrefixedInstallArgv(prefix);
|
|
242
254
|
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
243
|
-
console.error(chalk.dim(` Running: npm ${
|
|
244
|
-
return performUpdateCommand("npm",
|
|
255
|
+
console.error(chalk.dim(` Running: npm ${primaryArgs.join(" ")}`));
|
|
256
|
+
return performUpdateCommand("npm", primaryArgs, runner);
|
|
245
257
|
})()
|
|
246
258
|
: (() => {
|
|
247
259
|
console.error(chalk.dim(` Running: ${command}`));
|
|
@@ -249,6 +261,29 @@ function enforceUpdateRequired(
|
|
|
249
261
|
? deps.performUpdateString(command!)
|
|
250
262
|
: performUpdate(command!, runner);
|
|
251
263
|
})();
|
|
264
|
+
|
|
265
|
+
// A root-owned global install (e.g. a system `/usr` install where the CLI runs
|
|
266
|
+
// unprivileged — the outpost agent boxes) can't rewrite the prefix's bin dir,
|
|
267
|
+
// so the install above fails with EACCES (`rename /usr/bin/hq`). Retry ONCE
|
|
268
|
+
// under non-interactive sudo: `sudo -n` fails fast WITHOUT a password prompt
|
|
269
|
+
// when passwordless sudo isn't configured, so interactive installs (e.g.
|
|
270
|
+
// Homebrew on macOS, where the first attempt already succeeded anyway) fall
|
|
271
|
+
// through to the manual path unchanged, while headless boxes with passwordless
|
|
272
|
+
// sudo self-update cleanly.
|
|
273
|
+
if (!result.ok && primaryCmd) {
|
|
274
|
+
console.error(
|
|
275
|
+
chalk.dim(
|
|
276
|
+
` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
const sudoResult = performUpdateCommand(
|
|
280
|
+
"sudo",
|
|
281
|
+
["-n", primaryCmd, ...primaryArgs],
|
|
282
|
+
runner,
|
|
283
|
+
);
|
|
284
|
+
if (sudoResult.ok) result = sudoResult;
|
|
285
|
+
}
|
|
286
|
+
|
|
252
287
|
if (!result.ok) {
|
|
253
288
|
console.error(
|
|
254
289
|
chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`),
|