@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.
@@ -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
 
@@ -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: { id: string; script: string; attestation: string },
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,