@indigoai-us/hq-cli 5.77.6 → 5.77.8
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 +16 -0
- package/dist/commands/group-grants.d.ts +1 -1
- package/dist/commands/group-grants.js +6 -6
- package/dist/commands/integrations.js +24 -1
- package/dist/commands/reindex.d.ts +5 -23
- package/dist/commands/reindex.js +206 -1
- package/dist/commands/secrets.js +2 -1
- package/dist/utils/version-gate.d.ts +36 -0
- package/dist/utils/version-gate.js +102 -1
- package/package.json +2 -2
- package/pnpm-workspace.yaml +1 -1
- package/src/commands/group-grants.test.ts +41 -2
- package/src/commands/group-grants.ts +11 -8
- package/src/commands/integrations.test.ts +118 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/reindex.test.ts +168 -3
- package/src/commands/reindex.ts +207 -1
- package/src/commands/secrets.test.ts +40 -0
- package/src/commands/secrets.ts +11 -2
- package/src/utils/version-gate.test.ts +176 -0
- package/src/utils/version-gate.ts +127 -1
|
@@ -1723,6 +1723,46 @@ describe("secrets reveal and policy controls", () => {
|
|
|
1723
1723
|
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1724
1724
|
});
|
|
1725
1725
|
|
|
1726
|
+
it("script approve hashes the local file while approving a remote runtime path", async () => {
|
|
1727
|
+
const scriptPath = join(tempDir, "approved.sh");
|
|
1728
|
+
const remotePath =
|
|
1729
|
+
"/home/ec2-user/hq-agent/companies/acme/scripts/approved.sh";
|
|
1730
|
+
const scriptBody = "#!/usr/bin/env bash\necho approved remotely\n";
|
|
1731
|
+
writeFileSync(scriptPath, scriptBody);
|
|
1732
|
+
const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
|
|
1733
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
1734
|
+
|
|
1735
|
+
const program = buildProgram();
|
|
1736
|
+
await program.parseAsync([
|
|
1737
|
+
"node",
|
|
1738
|
+
"hq",
|
|
1739
|
+
"secrets",
|
|
1740
|
+
"script",
|
|
1741
|
+
"approve",
|
|
1742
|
+
"LOCKED",
|
|
1743
|
+
"--id",
|
|
1744
|
+
"deploy-script",
|
|
1745
|
+
"--script",
|
|
1746
|
+
scriptPath,
|
|
1747
|
+
"--remote-path",
|
|
1748
|
+
remotePath,
|
|
1749
|
+
]);
|
|
1750
|
+
|
|
1751
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
1752
|
+
token: "test-token",
|
|
1753
|
+
path: "/secrets/prs_alice/policy/scripts",
|
|
1754
|
+
method: "POST",
|
|
1755
|
+
body: {
|
|
1756
|
+
path: "LOCKED",
|
|
1757
|
+
scriptId: "deploy-script",
|
|
1758
|
+
scriptPath: remotePath,
|
|
1759
|
+
sha256: expectedSha,
|
|
1760
|
+
attestationLevel: "self-asserted-hash",
|
|
1761
|
+
},
|
|
1762
|
+
});
|
|
1763
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1726
1766
|
it("script revoke hits the revoke endpoint", async () => {
|
|
1727
1767
|
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
1728
1768
|
|
package/src/commands/secrets.ts
CHANGED
|
@@ -1360,6 +1360,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1360
1360
|
.description("Approve a script for a secret path")
|
|
1361
1361
|
.requiredOption("--id <scriptId>", "Stable script identifier")
|
|
1362
1362
|
.requiredOption("--script <path>", "Path to the local script file")
|
|
1363
|
+
.option(
|
|
1364
|
+
"--remote-path <path>",
|
|
1365
|
+
"Script path reported by the target runtime (defaults to the local path)",
|
|
1366
|
+
)
|
|
1363
1367
|
.option(
|
|
1364
1368
|
"--attestation <level>",
|
|
1365
1369
|
"Attestation level",
|
|
@@ -1367,7 +1371,12 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1367
1371
|
)
|
|
1368
1372
|
.action(async (
|
|
1369
1373
|
secretPath: string,
|
|
1370
|
-
opts: {
|
|
1374
|
+
opts: {
|
|
1375
|
+
id: string;
|
|
1376
|
+
script: string;
|
|
1377
|
+
remotePath?: string;
|
|
1378
|
+
attestation: string;
|
|
1379
|
+
},
|
|
1371
1380
|
) => {
|
|
1372
1381
|
try {
|
|
1373
1382
|
rejectIfPersonal(secrets.opts(), "script approve");
|
|
@@ -1395,7 +1404,7 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1395
1404
|
body: {
|
|
1396
1405
|
path: secretPath,
|
|
1397
1406
|
scriptId: usage.script?.scriptId,
|
|
1398
|
-
scriptPath: usage.script?.path,
|
|
1407
|
+
scriptPath: opts.remotePath ?? usage.script?.path,
|
|
1399
1408
|
sha256: usage.script?.sha256,
|
|
1400
1409
|
attestationLevel: usage.script?.attestationLevel,
|
|
1401
1410
|
},
|
|
@@ -436,4 +436,180 @@ describe("enforceVersionGate — hard-update path", () => {
|
|
|
436
436
|
expect(exitSpy).toHaveBeenCalledWith(75);
|
|
437
437
|
expect(runner).toHaveBeenCalledWith("sudo", expect.arrayContaining(["-n"]));
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
it("cleans a stale partial install and retries once when the unprivileged install fails with ENOTEMPTY", async () => {
|
|
441
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
442
|
+
const exitSpy = vi
|
|
443
|
+
.spyOn(process, "exit")
|
|
444
|
+
.mockImplementation(((code?: number) => {
|
|
445
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
446
|
+
}) as never);
|
|
447
|
+
// The reported failure: `npm install -g --prefix <toolchain> …` fails with
|
|
448
|
+
// ENOTEMPTY renaming a partial `hq-cli`. sudo does NOT fix a corrupt dir —
|
|
449
|
+
// only removing the stale artifacts and reinstalling does. Before this fix
|
|
450
|
+
// the gate exhausted npm→sudo→exit 75, leaving `hq` broken (ENOENT).
|
|
451
|
+
let npmCalls = 0;
|
|
452
|
+
const runner = vi.fn().mockImplementation((cmd: string) => {
|
|
453
|
+
if (cmd === "sudo") return { ok: false, detail: "ENOTEMPTY" };
|
|
454
|
+
npmCalls += 1;
|
|
455
|
+
return npmCalls === 1 ? { ok: false, detail: "ENOTEMPTY" } : { ok: true };
|
|
456
|
+
});
|
|
457
|
+
const prefix =
|
|
458
|
+
"/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global";
|
|
459
|
+
const cleanStale = vi
|
|
460
|
+
.fn()
|
|
461
|
+
.mockReturnValue([`${prefix}/lib/node_modules/@indigoai-us/.hq-cli-0DY3ww6z`]);
|
|
462
|
+
const { __test__ } = await loadModule();
|
|
463
|
+
|
|
464
|
+
expect(() =>
|
|
465
|
+
__test__.enforceUpdateRequired(
|
|
466
|
+
{
|
|
467
|
+
clientId: "hq-cli",
|
|
468
|
+
currentVersion: "5.10.0",
|
|
469
|
+
minVersion: "5.20.0",
|
|
470
|
+
latestVersion: "5.24.0",
|
|
471
|
+
updateRequired: true,
|
|
472
|
+
updateRecommended: false,
|
|
473
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
474
|
+
},
|
|
475
|
+
{ resolvePrefix: () => prefix, runner, cleanStale },
|
|
476
|
+
),
|
|
477
|
+
).toThrow(/__process_exit__:0/); // recovers after the cleanup + retry
|
|
478
|
+
|
|
479
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
480
|
+
expect(cleanStale).toHaveBeenCalledWith(prefix);
|
|
481
|
+
const installArgs = [
|
|
482
|
+
"install",
|
|
483
|
+
"-g",
|
|
484
|
+
"--prefix",
|
|
485
|
+
prefix,
|
|
486
|
+
"@indigoai-us/hq-cli@latest",
|
|
487
|
+
];
|
|
488
|
+
// primary attempt + one retry after the stale artifacts were removed
|
|
489
|
+
expect(
|
|
490
|
+
runner.mock.calls.filter((c) => c[0] === "npm").length,
|
|
491
|
+
).toBe(2);
|
|
492
|
+
expect(runner).toHaveBeenCalledWith("npm", installArgs);
|
|
493
|
+
// sudo must NOT be reached — the ENOTEMPTY was fixed by cleanup, not perms
|
|
494
|
+
expect(runner).not.toHaveBeenCalledWith("sudo", expect.anything());
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("does NOT reinstall-after-clean when nothing stale exists (plain EACCES falls straight to sudo)", async () => {
|
|
498
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
499
|
+
const exitSpy = vi
|
|
500
|
+
.spyOn(process, "exit")
|
|
501
|
+
.mockImplementation(((code?: number) => {
|
|
502
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
503
|
+
}) as never);
|
|
504
|
+
const runner = vi
|
|
505
|
+
.fn()
|
|
506
|
+
.mockImplementation((cmd: string) =>
|
|
507
|
+
cmd === "sudo" ? { ok: true } : { ok: false, detail: "EACCES" },
|
|
508
|
+
);
|
|
509
|
+
const cleanStale = vi.fn().mockReturnValue([]); // healthy prefix, nothing to remove
|
|
510
|
+
const { __test__ } = await loadModule();
|
|
511
|
+
|
|
512
|
+
expect(() =>
|
|
513
|
+
__test__.enforceUpdateRequired(
|
|
514
|
+
{
|
|
515
|
+
clientId: "hq-cli",
|
|
516
|
+
currentVersion: "5.10.0",
|
|
517
|
+
minVersion: "5.20.0",
|
|
518
|
+
latestVersion: "5.24.0",
|
|
519
|
+
updateRequired: true,
|
|
520
|
+
updateRecommended: false,
|
|
521
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
522
|
+
},
|
|
523
|
+
{ resolvePrefix: () => "/usr", runner, cleanStale },
|
|
524
|
+
),
|
|
525
|
+
).toThrow(/__process_exit__:0/); // sudo retry succeeds
|
|
526
|
+
|
|
527
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
528
|
+
expect(cleanStale).toHaveBeenCalledWith("/usr");
|
|
529
|
+
// exactly ONE npm attempt (no redundant reinstall), then sudo
|
|
530
|
+
expect(runner.mock.calls.filter((c) => c[0] === "npm").length).toBe(1);
|
|
531
|
+
expect(runner).toHaveBeenCalledWith("sudo", expect.arrayContaining(["-n"]));
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
describe("cleanStalePartialInstall", () => {
|
|
536
|
+
type FakeTree = {
|
|
537
|
+
dirs: Record<string, string[]>;
|
|
538
|
+
packageJson: Record<string, string | null>;
|
|
539
|
+
existing: Set<string>;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
function makeFakeFs(tree: FakeTree) {
|
|
543
|
+
const removed: string[] = [];
|
|
544
|
+
const fs = {
|
|
545
|
+
readdirSync: (dir: string) => {
|
|
546
|
+
if (dir in tree.dirs) return tree.dirs[dir]!;
|
|
547
|
+
throw new Error(`ENOENT: ${dir}`);
|
|
548
|
+
},
|
|
549
|
+
existsSync: (target: string) => tree.existing.has(target),
|
|
550
|
+
readFileSync: (target: string) => {
|
|
551
|
+
const pkgDir = target.replace(/\/package\.json$/, "");
|
|
552
|
+
const content = tree.packageJson[pkgDir];
|
|
553
|
+
if (content == null) throw new Error(`ENOENT: ${target}`);
|
|
554
|
+
return content;
|
|
555
|
+
},
|
|
556
|
+
rmSync: (target: string) => {
|
|
557
|
+
removed.push(target);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
return { fs, removed };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it("removes the npm staging dir and a partial package dir, but keeps unrelated entries", async () => {
|
|
564
|
+
const { __test__ } = await loadModule();
|
|
565
|
+
const prefix = "/p";
|
|
566
|
+
const scopeDir = "/p/lib/node_modules/@indigoai-us";
|
|
567
|
+
const { fs, removed } = makeFakeFs({
|
|
568
|
+
dirs: {
|
|
569
|
+
[scopeDir]: [".hq-cli-0DY3ww6z", "hq-cli", "some-other-pkg"],
|
|
570
|
+
},
|
|
571
|
+
packageJson: { [`${scopeDir}/hq-cli`]: null }, // partial: package.json unreadable
|
|
572
|
+
existing: new Set([`${scopeDir}/hq-cli`]),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const result = __test__.cleanStalePartialInstall(prefix, fs);
|
|
576
|
+
|
|
577
|
+
expect(result).toContain(`${scopeDir}/.hq-cli-0DY3ww6z`);
|
|
578
|
+
expect(result).toContain(`${scopeDir}/hq-cli`);
|
|
579
|
+
expect(removed).not.toContain(`${scopeDir}/some-other-pkg`);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("never touches a healthy install (valid package.json with matching name)", async () => {
|
|
583
|
+
const { __test__ } = await loadModule();
|
|
584
|
+
const scopeDir = "/p/lib/node_modules/@indigoai-us";
|
|
585
|
+
const { fs, removed } = makeFakeFs({
|
|
586
|
+
dirs: { [scopeDir]: ["hq-cli"] }, // no staging leftovers
|
|
587
|
+
packageJson: {
|
|
588
|
+
[`${scopeDir}/hq-cli`]: JSON.stringify({
|
|
589
|
+
name: "@indigoai-us/hq-cli",
|
|
590
|
+
version: "5.24.0",
|
|
591
|
+
}),
|
|
592
|
+
},
|
|
593
|
+
existing: new Set([`${scopeDir}/hq-cli`]),
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const result = __test__.cleanStalePartialInstall("/p", fs);
|
|
597
|
+
|
|
598
|
+
expect(result).toEqual([]);
|
|
599
|
+
expect(removed).toEqual([]);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("also cleans a staging dir under the bare <prefix>/node_modules layout", async () => {
|
|
603
|
+
const { __test__ } = await loadModule();
|
|
604
|
+
const scopeDir = "/p/node_modules/@indigoai-us";
|
|
605
|
+
const { fs } = makeFakeFs({
|
|
606
|
+
dirs: { [scopeDir]: [".hq-cli-abc123"] },
|
|
607
|
+
packageJson: {},
|
|
608
|
+
existing: new Set(),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const result = __test__.cleanStalePartialInstall("/p", fs);
|
|
612
|
+
|
|
613
|
+
expect(result).toEqual([`${scopeDir}/.hq-cli-abc123`]);
|
|
614
|
+
});
|
|
439
615
|
});
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
|
-
import { readFileSync } from "node:fs";
|
|
32
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import chalk from "chalk";
|
|
@@ -104,6 +104,109 @@ export function buildPrefixedInstallArgv(prefix: string): string[] {
|
|
|
104
104
|
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
|
|
109
|
+
* cleanup logic is unit-testable without touching a real global prefix.
|
|
110
|
+
*/
|
|
111
|
+
export interface StaleInstallFs {
|
|
112
|
+
readdirSync: (dir: string) => string[];
|
|
113
|
+
existsSync: (target: string) => boolean;
|
|
114
|
+
readFileSync: (target: string, encoding: "utf-8") => string;
|
|
115
|
+
rmSync: (target: string, options: { recursive: boolean; force: boolean }) => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nodeStaleInstallFs: StaleInstallFs = {
|
|
119
|
+
readdirSync: (dir) => readdirSync(dir),
|
|
120
|
+
existsSync,
|
|
121
|
+
readFileSync: (target, encoding) => readFileSync(target, encoding),
|
|
122
|
+
rmSync,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function isHealthyPackageDir(pkgDir: string, fs: StaleInstallFs): boolean {
|
|
126
|
+
try {
|
|
127
|
+
const pkg = JSON.parse(
|
|
128
|
+
fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
|
|
129
|
+
) as { name?: unknown };
|
|
130
|
+
return pkg.name === CLI_NAME;
|
|
131
|
+
} catch {
|
|
132
|
+
return false; // missing / unreadable / malformed package.json ⇒ partial
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Remove leftover artifacts from an interrupted `npm install -g` so a retry can
|
|
138
|
+
* succeed. npm unpacks a package into a `.<pkg>-<rand>` staging dir alongside
|
|
139
|
+
* the final location and then renames it into place; if a previous run was
|
|
140
|
+
* killed mid-rename (or a half-written package dir survives), every subsequent
|
|
141
|
+
* install fails with `ENOTEMPTY` because npm cannot atomically rename over the
|
|
142
|
+
* non-empty leftover. npm does not self-heal this — the stale dir must be
|
|
143
|
+
* removed first.
|
|
144
|
+
*
|
|
145
|
+
* To stay safe we only ever delete:
|
|
146
|
+
* - dot-prefixed npm staging dirs for THIS package (`.hq-cli-*`), and
|
|
147
|
+
* - a package dir whose `package.json` is missing/unreadable or whose `name`
|
|
148
|
+
* is not exactly {@link CLI_NAME} (i.e. a genuinely partial/foreign dir).
|
|
149
|
+
*
|
|
150
|
+
* A healthy install (valid `package.json`, `name === CLI_NAME`) is never
|
|
151
|
+
* touched, so an ordinary version bump still flows through npm untouched.
|
|
152
|
+
*
|
|
153
|
+
* Returns the list of removed paths — empty when there was nothing to clean, so
|
|
154
|
+
* callers can gate a reinstall retry on `removed.length > 0`.
|
|
155
|
+
*/
|
|
156
|
+
export function cleanStalePartialInstall(
|
|
157
|
+
prefix: string,
|
|
158
|
+
fs: StaleInstallFs = nodeStaleInstallFs,
|
|
159
|
+
): string[] {
|
|
160
|
+
const removed: string[] = [];
|
|
161
|
+
const slash = CLI_NAME.indexOf("/");
|
|
162
|
+
const scope = slash === -1 ? null : CLI_NAME.slice(0, slash);
|
|
163
|
+
const leaf = slash === -1 ? CLI_NAME : CLI_NAME.slice(slash + 1);
|
|
164
|
+
const stagingPrefix = `.${leaf}-`;
|
|
165
|
+
|
|
166
|
+
// Global npm keeps packages under `<prefix>/lib/node_modules` (unix) while a
|
|
167
|
+
// bare `--prefix` dir (windows / some sandboxes) uses `<prefix>/node_modules`.
|
|
168
|
+
const nmRoots = [
|
|
169
|
+
path.join(prefix, "lib", "node_modules"),
|
|
170
|
+
path.join(prefix, "node_modules"),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const nmRoot of nmRoots) {
|
|
174
|
+
// For a scoped package the staging dir + final dir both live inside the
|
|
175
|
+
// scope dir (`.../@indigoai-us/.hq-cli-<rand>`, `.../@indigoai-us/hq-cli`).
|
|
176
|
+
const parentDir = scope ? path.join(nmRoot, scope) : nmRoot;
|
|
177
|
+
let entries: string[];
|
|
178
|
+
try {
|
|
179
|
+
entries = fs.readdirSync(parentDir);
|
|
180
|
+
} catch {
|
|
181
|
+
continue; // this node_modules / scope dir doesn't exist here
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.startsWith(stagingPrefix)) continue;
|
|
186
|
+
const target = path.join(parentDir, entry);
|
|
187
|
+
try {
|
|
188
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
189
|
+
removed.push(target);
|
|
190
|
+
} catch {
|
|
191
|
+
// best-effort: a dir we can't remove (perms) just means the retry
|
|
192
|
+
// still fails and we fall through to the sudo / manual path.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pkgDir = path.join(parentDir, leaf);
|
|
197
|
+
if (fs.existsSync(pkgDir) && !isHealthyPackageDir(pkgDir, fs)) {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmSync(pkgDir, { recursive: true, force: true });
|
|
200
|
+
removed.push(pkgDir);
|
|
201
|
+
} catch {
|
|
202
|
+
// best-effort (see above)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return removed;
|
|
208
|
+
}
|
|
209
|
+
|
|
107
210
|
/**
|
|
108
211
|
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
109
212
|
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
@@ -213,6 +316,7 @@ function enforceUpdateRequired(
|
|
|
213
316
|
performUpdateString?: (command: string) => UpdateResult;
|
|
214
317
|
resolvePrefix?: () => string | null;
|
|
215
318
|
runner?: UpdateRunner;
|
|
319
|
+
cleanStale?: (prefix: string) => string[];
|
|
216
320
|
} = {},
|
|
217
321
|
): never {
|
|
218
322
|
const banner = chalk.red.bold(
|
|
@@ -262,6 +366,27 @@ function enforceUpdateRequired(
|
|
|
262
366
|
: performUpdate(command!, runner);
|
|
263
367
|
})();
|
|
264
368
|
|
|
369
|
+
// A partial/corrupt global install leaves npm unable to atomically rename its
|
|
370
|
+
// freshly-unpacked package over a leftover directory, so the install above
|
|
371
|
+
// fails with ENOTEMPTY (e.g. a prior interrupted `npm install -g` left a
|
|
372
|
+
// half-written `hq-cli` package dir or a `.hq-cli-<rand>` staging dir under
|
|
373
|
+
// the prefix's node_modules). npm cannot self-heal this. Remove the stale
|
|
374
|
+
// artifacts and retry the install ONCE. Guarded on `cleaned.length > 0` so a
|
|
375
|
+
// plain EACCES on an otherwise-healthy prefix falls straight through to the
|
|
376
|
+
// sudo retry below without a redundant reinstall attempt.
|
|
377
|
+
if (!result.ok && prefix) {
|
|
378
|
+
const cleaner = deps.cleanStale ?? cleanStalePartialInstall;
|
|
379
|
+
const cleaned = cleaner(prefix);
|
|
380
|
+
if (cleaned.length > 0) {
|
|
381
|
+
console.error(
|
|
382
|
+
chalk.dim(
|
|
383
|
+
` Removing stale partial install artifacts and retrying: ${cleaned.join(", ")}`,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
result = performUpdateCommand("npm", primaryArgs, runner);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
265
390
|
// A root-owned global install (e.g. a system `/usr` install where the CLI runs
|
|
266
391
|
// unprivileged — the outpost agent boxes) can't rewrite the prefix's bin dir,
|
|
267
392
|
// so the install above fails with EACCES (`rename /usr/bin/hq`). Retry ONCE
|
|
@@ -341,6 +466,7 @@ export const __test__ = {
|
|
|
341
466
|
ENDPOINT_PATH,
|
|
342
467
|
FETCH_TIMEOUT_MS,
|
|
343
468
|
buildPrefixedInstallArgv,
|
|
469
|
+
cleanStalePartialInstall,
|
|
344
470
|
enforceUpdateRequired,
|
|
345
471
|
npmPrefixFromPackageDir,
|
|
346
472
|
performUpdate,
|