@indigoai-us/hq-cli 5.77.7 → 5.77.9

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.
@@ -72,20 +72,95 @@ export function npmPrefixFromPackageDir(pkgDir) {
72
72
  return "/";
73
73
  return prefix || null;
74
74
  }
75
- export function resolveRunningPrefix() {
75
+ /** `$PNPM_HOME/global`, normalised to forward slashes, when PNPM_HOME is set. */
76
+ function pnpmHomeGlobalRoot() {
77
+ const home = process.env.PNPM_HOME;
78
+ if (!home)
79
+ return null;
80
+ const normalized = home.replace(/\\/g, "/").replace(/\/+$/, "");
81
+ return normalized ? `${normalized}/global` : null;
82
+ }
83
+ /**
84
+ * Whether the running package lives inside a pnpm-managed **global** install.
85
+ *
86
+ * pnpm does not use npm's `<prefix>/lib/node_modules` layout. A global
87
+ * `pnpm add -g` puts the package in a versioned content store under the
88
+ * `global/<store-layout-version>` root and exposes it through a generated shim
89
+ * on PATH:
90
+ *
91
+ * $PNPM_HOME/hq <- shim on PATH
92
+ * $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli <- symlink
93
+ * $PNPM_HOME/global/5/.pnpm/@indigoai-us+hq-cli@5.61.0/node_modules/…
94
+ *
95
+ * Both the symlinked and the resolved (`.pnpm`) form are recognised, because
96
+ * whether `import.meta.url` reports the link or its target depends on how node
97
+ * resolved the entrypoint.
98
+ *
99
+ * A bare `.pnpm` segment is deliberately NOT enough. It also appears in a local
100
+ * project dependency (`<proj>/node_modules/.pnpm/@indigoai-us+hq-cli@…`) and in
101
+ * a `pnpm dlx` cache. Neither of those is what `pnpm add -g` updates, so
102
+ * treating them as global would mutate the user's global install as a side
103
+ * effect of a local invocation while the copy actually running stayed stale —
104
+ * i.e. the gate would re-fire and re-install globally on every subsequent run.
105
+ * Those layouts fall through to the ordinary npm-prefix/manual handling.
106
+ */
107
+ export function isPnpmManagedPackageDir(pkgDir) {
108
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
109
+ const segments = normalized.split("/").filter(Boolean);
110
+ // `global/<digits>` — pnpm's global root, in either the symlinked or the
111
+ // resolved (`.pnpm`) form, and independent of where PNPM_HOME points.
112
+ for (let i = 0; i < segments.length - 1; i += 1) {
113
+ if (segments[i] === "global" && /^\d+$/.test(segments[i + 1]))
114
+ return true;
115
+ }
116
+ // Belt-and-braces for a future pnpm layout whose store dir is not numeric:
117
+ // anything under `$PNPM_HOME/global` is global by construction. Still keyed
118
+ // on the `global` segment so `$PNPM_HOME/store/**` (dlx caches) stays out.
119
+ const globalRoot = pnpmHomeGlobalRoot();
120
+ if (globalRoot) {
121
+ if (normalized === globalRoot || normalized.startsWith(`${globalRoot}/`)) {
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+ export function resolveRunningInstall() {
76
128
  try {
77
- const pkgRoot = findRunningPackageRoot();
78
- if (!pkgRoot)
79
- return null;
80
- return npmPrefixFromPackageDir(pkgRoot);
129
+ const packageRoot = findRunningPackageRoot();
130
+ if (!packageRoot)
131
+ return { manager: "npm", prefix: null, packageRoot: null };
132
+ if (isPnpmManagedPackageDir(packageRoot)) {
133
+ return { manager: "pnpm", prefix: null, packageRoot };
134
+ }
135
+ return {
136
+ manager: "npm",
137
+ prefix: npmPrefixFromPackageDir(packageRoot),
138
+ packageRoot,
139
+ };
81
140
  }
82
141
  catch {
83
- return null;
142
+ return { manager: "npm", prefix: null, packageRoot: null };
84
143
  }
85
144
  }
145
+ /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
146
+ export function resolveRunningManager() {
147
+ return resolveRunningInstall().manager;
148
+ }
149
+ /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
150
+ export function resolveRunningPrefix() {
151
+ return resolveRunningInstall().prefix;
152
+ }
86
153
  export function buildPrefixedInstallArgv(prefix) {
87
154
  return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
88
155
  }
156
+ /**
157
+ * Argv for updating a pnpm-managed global install. `pnpm add -g` rewrites the
158
+ * PATH shim as part of the install, so the next invocation genuinely resolves
159
+ * the new version — which is the whole point of routing here instead of npm.
160
+ */
161
+ export function buildPnpmInstallArgv() {
162
+ return ["add", "-g", LATEST_PACKAGE_SPEC];
163
+ }
89
164
  const nodeStaleInstallFs = {
90
165
  readdirSync: (dir) => readdirSync(dir),
91
166
  existsSync,
@@ -205,9 +280,44 @@ async function fetchVersionDecision() {
205
280
  return null;
206
281
  }
207
282
  }
283
+ /**
284
+ * Quote an argv entry for a Windows `cmd.exe` invocation. Needed because Node
285
+ * does NOT quote argv when spawning with `shell: true` on Windows — it joins
286
+ * the array with spaces — so an npm prefix like `C:\Program Files\…` would be
287
+ * split into two arguments.
288
+ */
289
+ export function quoteForWindowsShell(arg) {
290
+ if (arg === "")
291
+ return '""';
292
+ if (!/[\s"^&|<>()]/.test(arg))
293
+ return arg;
294
+ return `"${arg.replace(/"/g, '\\"')}"`;
295
+ }
296
+ /**
297
+ * How to hand `<cmd> <args…>` to `spawnSync` on this platform.
298
+ *
299
+ * On Windows both `npm` and `pnpm` are `.cmd` shims, and since the
300
+ * CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat` file without
301
+ * a shell. Without this the update would fail with EINVAL/ENOENT on every
302
+ * Windows install — including the pnpm layouts this gate claims to detect.
303
+ */
304
+ export function buildSpawnPlan(cmd, args, platform = process.platform) {
305
+ if (platform !== "win32")
306
+ return { cmd, args: [...args], shell: false };
307
+ return { cmd, args: args.map(quoteForWindowsShell), shell: true };
308
+ }
208
309
  function runUpdateCommand(cmd, args) {
209
310
  try {
210
- const result = spawnSync(cmd, args, { stdio: "inherit" });
311
+ const plan = buildSpawnPlan(cmd, args);
312
+ const result = spawnSync(plan.cmd, plan.args, {
313
+ stdio: "inherit",
314
+ shell: plan.shell,
315
+ });
316
+ // spawnSync reports a missing executable via `error`, not a throw.
317
+ if (result.error) {
318
+ const code = result.error.code;
319
+ return { ok: false, code, detail: result.error.message };
320
+ }
211
321
  if (result.status !== 0) {
212
322
  return {
213
323
  ok: false,
@@ -217,7 +327,11 @@ function runUpdateCommand(cmd, args) {
217
327
  return { ok: true };
218
328
  }
219
329
  catch (err) {
220
- return { ok: false, detail: err instanceof Error ? err.message : String(err) };
330
+ return {
331
+ ok: false,
332
+ code: err?.code,
333
+ detail: err instanceof Error ? err.message : String(err),
334
+ };
221
335
  }
222
336
  }
223
337
  function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
@@ -231,15 +345,38 @@ function performUpdate(command, runner = runUpdateCommand) {
231
345
  const args = parts.slice(1);
232
346
  return performUpdateCommand(cmd, args, runner);
233
347
  }
348
+ /**
349
+ * The command a user should run by hand for this install layout. hq-pro's
350
+ * `updateCommand` is npm-shaped for every client, so a pnpm-managed install
351
+ * must never be told to run it: `npm install -g …` drops a fresh copy under the
352
+ * npm global prefix while pnpm's PATH shim keeps resolving the old build. That
353
+ * is verbatim the reported symptom (a stale shim still reporting 5.61.0 after a
354
+ * "successful" update), so both the hard gate and the soft nudge below have to
355
+ * speak the running manager's language.
356
+ */
357
+ function manualUpdateCommand(install, decision) {
358
+ if (install.manager === "pnpm")
359
+ return `pnpm ${buildPnpmInstallArgv().join(" ")}`;
360
+ if (install.prefix)
361
+ return `npm ${buildPrefixedInstallArgv(install.prefix).join(" ")}`;
362
+ return decision.updateCommand;
363
+ }
234
364
  /**
235
365
  * Soft notify when the server says we're below `latestVersion` but still ≥
236
366
  * `minVersion`. Single chalk-yellow line on stderr; never blocks.
367
+ *
368
+ * This path fires for EVERY version below latest (the hard gate only fires
369
+ * below `minVersion`), so it is the far more frequently seen of the two and
370
+ * must be manager-aware for the same reason the gate is.
237
371
  */
238
- function nudgeUpdateRecommended(decision) {
372
+ function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
239
373
  const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
240
374
  console.error(msg);
241
- if (decision.updateCommand) {
242
- console.error(chalk.dim(` Update: ${decision.updateCommand}`));
375
+ const command = install.manager === "pnpm"
376
+ ? `pnpm ${buildPnpmInstallArgv().join(" ")}`
377
+ : decision.updateCommand;
378
+ if (command) {
379
+ console.error(chalk.dim(` Update: ${command}`));
243
380
  }
244
381
  }
245
382
  /**
@@ -258,8 +395,14 @@ function enforceUpdateRequired(decision, deps = {}) {
258
395
  if (decision.message)
259
396
  console.error(chalk.dim(` ${decision.message}`));
260
397
  const command = decision.updateCommand;
261
- const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
262
- if (!command && !prefix) {
398
+ // ONE package-root walk decides both the manager and the prefix. A
399
+ // pnpm-managed install must be updated by pnpm; the server's `updateCommand`
400
+ // and any npm prefix are both wrong for that layout, so `resolveRunningInstall`
401
+ // already reports `prefix: null` there and neither is consulted below.
402
+ const install = (deps.resolveInstall ?? resolveRunningInstall)();
403
+ const isPnpm = install.manager === "pnpm";
404
+ const prefix = install.prefix;
405
+ if (!isPnpm && !command && !prefix) {
263
406
  console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
264
407
  if (decision.downloadUrl) {
265
408
  console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
@@ -271,7 +414,11 @@ function enforceUpdateRequired(decision, deps = {}) {
271
414
  // the EXACT same command under elevation.
272
415
  let primaryCmd;
273
416
  let primaryArgs;
274
- if (prefix) {
417
+ if (isPnpm) {
418
+ primaryCmd = "pnpm";
419
+ primaryArgs = buildPnpmInstallArgv();
420
+ }
421
+ else if (prefix) {
275
422
  primaryCmd = "npm";
276
423
  primaryArgs = buildPrefixedInstallArgv(prefix);
277
424
  }
@@ -280,18 +427,23 @@ function enforceUpdateRequired(decision, deps = {}) {
280
427
  primaryCmd = parts[0] ?? "";
281
428
  primaryArgs = parts.slice(1);
282
429
  }
283
- let result = prefix
284
- ? (() => {
285
- console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
286
- console.error(chalk.dim(` Running: npm ${primaryArgs.join(" ")}`));
287
- return performUpdateCommand("npm", primaryArgs, runner);
288
- })()
289
- : (() => {
290
- console.error(chalk.dim(` Running: ${command}`));
291
- return deps.performUpdateString
292
- ? deps.performUpdateString(command)
293
- : performUpdate(command, runner);
294
- })();
430
+ let result;
431
+ if (isPnpm) {
432
+ console.error(chalk.dim(" Detected a pnpm-managed global install; updating with pnpm"));
433
+ console.error(chalk.dim(` Running: pnpm ${primaryArgs.join(" ")}`));
434
+ result = performUpdateCommand("pnpm", primaryArgs, runner);
435
+ }
436
+ else if (prefix) {
437
+ console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
438
+ console.error(chalk.dim(` Running: npm ${primaryArgs.join(" ")}`));
439
+ result = performUpdateCommand("npm", primaryArgs, runner);
440
+ }
441
+ else {
442
+ console.error(chalk.dim(` Running: ${command}`));
443
+ result = deps.performUpdateString
444
+ ? deps.performUpdateString(command)
445
+ : performUpdate(command, runner);
446
+ }
295
447
  // A partial/corrupt global install leaves npm unable to atomically rename its
296
448
  // freshly-unpacked package over a leftover directory, so the install above
297
449
  // fails with ENOTEMPTY (e.g. a prior interrupted `npm install -g` left a
@@ -316,7 +468,11 @@ function enforceUpdateRequired(decision, deps = {}) {
316
468
  // Homebrew on macOS, where the first attempt already succeeded anyway) fall
317
469
  // through to the manual path unchanged, while headless boxes with passwordless
318
470
  // sudo self-update cleanly.
319
- if (!result.ok && primaryCmd) {
471
+ //
472
+ // Never for pnpm: `sudo -n pnpm add -g` installs into ROOT's PNPM_HOME, which
473
+ // leaves the user's shim untouched while reporting success — the same silent
474
+ // no-op this fix exists to remove. A failed pnpm update must surface instead.
475
+ if (!result.ok && primaryCmd && !isPnpm) {
320
476
  console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
321
477
  const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
322
478
  if (sudoResult.ok)
@@ -324,10 +480,21 @@ function enforceUpdateRequired(decision, deps = {}) {
324
480
  }
325
481
  if (!result.ok) {
326
482
  console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
327
- const manual = prefix
328
- ? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
329
- : command;
483
+ // The package manager itself is missing from this environment — the usual
484
+ // cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
485
+ // never sourced the shell profile which puts PNPM_HOME (or nvm's npm) on
486
+ // PATH. Say so, because "exit 75" alone reads as a permissions problem.
487
+ if (result.code === "ENOENT") {
488
+ console.error(chalk.red(` \`${primaryCmd}\` was not found on PATH in this environment (launchd, cron and other minimal-PATH parents commonly lack it).`));
489
+ }
490
+ const manual = manualUpdateCommand(install, decision) ?? command;
330
491
  console.error(chalk.dim(` Try manually: ${manual}`));
492
+ // Only informational, and only when we could not run the right manager at
493
+ // all: hq-pro's command is npm-shaped, so following it on a pnpm layout is
494
+ // what produced the stale-shim loop in the first place.
495
+ if (isPnpm && result.code === "ENOENT" && command) {
496
+ 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.)`));
497
+ }
331
498
  process.exit(75);
332
499
  }
333
500
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
@@ -349,11 +516,16 @@ export async function enforceVersionGate() {
349
516
  const decision = await fetchVersionDecision();
350
517
  if (!decision)
351
518
  return; // best-effort: silent on any failure
519
+ if (!decision.updateRequired && !decision.updateRecommended)
520
+ return;
521
+ // Resolved once, here, so the up-to-date case never pays for the walk and
522
+ // neither downstream path repeats it.
523
+ const install = resolveRunningInstall();
352
524
  if (decision.updateRequired) {
353
- enforceUpdateRequired(decision); // exits process
525
+ enforceUpdateRequired(decision, { resolveInstall: () => install }); // exits process
354
526
  }
355
527
  if (decision.updateRecommended) {
356
- nudgeUpdateRecommended(decision);
528
+ nudgeUpdateRecommended(decision, install);
357
529
  }
358
530
  }
359
531
  /**
@@ -368,13 +540,20 @@ export const __test__ = {
368
540
  CLIENT_ID,
369
541
  ENDPOINT_PATH,
370
542
  FETCH_TIMEOUT_MS,
543
+ buildPnpmInstallArgv,
371
544
  buildPrefixedInstallArgv,
545
+ buildSpawnPlan,
372
546
  cleanStalePartialInstall,
373
547
  enforceUpdateRequired,
548
+ isPnpmManagedPackageDir,
374
549
  npmPrefixFromPackageDir,
550
+ nudgeUpdateRecommended,
375
551
  performUpdate,
376
552
  performUpdateCommand,
553
+ quoteForWindowsShell,
377
554
  runUpdateCommand,
555
+ resolveRunningInstall,
556
+ resolveRunningManager,
378
557
  resolveRunningPrefix,
379
558
  };
380
559
  //# 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.77.7",
3
+ "version": "5.77.9",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,14 +15,25 @@
15
15
  * yields an actionable cross-tenant permission message.
16
16
  */
17
17
 
18
+ import { Command } from "commander";
18
19
  import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
19
20
 
21
+ const { ensureCognitoTokenSpy } = vi.hoisted(() => ({
22
+ ensureCognitoTokenSpy: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => ({
26
+ ...(await importOriginal()),
27
+ ensureCognitoToken: ensureCognitoTokenSpy,
28
+ }));
29
+
20
30
  import {
21
31
  GrantHttpError,
22
32
  formatGrantHttpError,
23
33
  grantGroup,
24
34
  listInboundGrants,
25
35
  listOutboundGrants,
36
+ registerGroupGrantsCommand,
26
37
  revokeGroupGrant,
27
38
  } from "./group-grants.js";
28
39
 
@@ -41,8 +52,16 @@ beforeEach(() => {
41
52
 
42
53
  afterEach(() => {
43
54
  vi.restoreAllMocks();
55
+ ensureCognitoTokenSpy.mockReset();
44
56
  });
45
57
 
58
+ async function runCli(argv: string[]): Promise<void> {
59
+ const program = new Command();
60
+ program.exitOverride();
61
+ registerGroupGrantsCommand(program);
62
+ await program.parseAsync(["node", "hq", ...argv]);
63
+ }
64
+
46
65
  // ---------------------------------------------------------------------------
47
66
  // grantGroup — story e2e #1 (authorized) + validation
48
67
  // ---------------------------------------------------------------------------
@@ -242,7 +261,7 @@ describe("revokeGroupGrant", () => {
242
261
  // ---------------------------------------------------------------------------
243
262
 
244
263
  describe("listOutboundGrants", () => {
245
- it("GETs /group-grants/outbound with sourceCompanyUid and optional groupId", async () => {
264
+ it("GETs /group-grants/outbound with both required query parameters", async () => {
246
265
  fetchSpy.mockResolvedValueOnce(
247
266
  jsonResponse(200, {
248
267
  grants: [
@@ -267,7 +286,27 @@ describe("listOutboundGrants", () => {
267
286
 
268
287
  it("returns [] when the server returns no grants", async () => {
269
288
  fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
270
- expect(await listOutboundGrants("test-token", "cmp_a")).toEqual([]);
289
+ expect(await listOutboundGrants("test-token", "cmp_a", "grp_eng")).toEqual([]);
290
+ });
291
+
292
+ it.each(["", "eng"]) (
293
+ "rejects invalid group id %j before calling the API",
294
+ async (groupId) => {
295
+ await expect(
296
+ listOutboundGrants("test-token", "cmp_a", groupId),
297
+ ).rejects.toThrow(/Invalid group id/);
298
+ expect(fetchSpy).not.toHaveBeenCalled();
299
+ },
300
+ );
301
+ });
302
+
303
+ describe("group-grants outbound command", () => {
304
+ it("rejects a missing --group before authentication or fetch", async () => {
305
+ await expect(runCli(["group-grants", "outbound"])).rejects.toMatchObject({
306
+ code: "commander.missingMandatoryOptionValue",
307
+ });
308
+ expect(ensureCognitoTokenSpy).not.toHaveBeenCalled();
309
+ expect(fetchSpy).not.toHaveBeenCalled();
271
310
  });
272
311
  });
273
312
 
@@ -189,15 +189,18 @@ export async function revokeGroupGrant(
189
189
  export async function listOutboundGrants(
190
190
  token: string,
191
191
  sourceCompanyUid: string,
192
- groupId?: string,
192
+ groupId: string,
193
193
  ): Promise<GroupGrant[]> {
194
- const query: Record<string, string> = { sourceCompanyUid };
195
- if (groupId) query.groupId = groupId;
194
+ if (!GROUP_ID_PATTERN.test(groupId)) {
195
+ throw new Error(
196
+ `Invalid group id '${groupId}': must match grp_<alphanumeric, underscore, hyphen>`,
197
+ );
198
+ }
196
199
 
197
200
  const res = await vaultApiFetch({
198
201
  token,
199
202
  path: "/group-grants/outbound",
200
- query,
203
+ query: { sourceCompanyUid, groupId },
201
204
  });
202
205
 
203
206
  if (!res.ok) {
@@ -385,13 +388,13 @@ export function registerGroupGrantsCommand(program: Command): void {
385
388
  grants
386
389
  .command("outbound")
387
390
  .description(
388
- "List grants the source company's groups hold on other companies",
391
+ "List grants a source-company group holds on other companies",
389
392
  )
390
- .option(
393
+ .requiredOption(
391
394
  "--group <groupId>",
392
- "Filter to a single group id",
395
+ "Group id to inspect",
393
396
  )
394
- .action(async (opts: { group?: string }) => {
397
+ .action(async (opts: { group: string }) => {
395
398
  try {
396
399
  const token = await ensureCognitoToken();
397
400
  const sourceSlug = grants.opts().company as string | undefined;
@@ -42,7 +42,7 @@ function writeSettings(root: string, settings: unknown): void {
42
42
  }
43
43
 
44
44
  function makeHqRoot(settings: unknown = HEALTHY_SETTINGS): string {
45
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-reindex-"));
45
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-reindex-")));
46
46
  tempRoots.push(root);
47
47
  fs.mkdirSync(path.join(root, ".claude"), { recursive: true });
48
48
  fs.mkdirSync(path.join(root, "companies"), { recursive: true });
@@ -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
  },