@indigoai-us/hq-cli 5.75.0 → 5.77.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.
Files changed (49) hide show
  1. package/dist/commands/agents.js +8 -3
  2. package/dist/commands/files.d.ts +61 -0
  3. package/dist/commands/files.js +274 -0
  4. package/dist/commands/mcp-registration.d.ts +4 -5
  5. package/dist/commands/mcp-registration.js +5 -4
  6. package/dist/commands/outposts.d.ts +20 -4
  7. package/dist/commands/outposts.js +79 -10
  8. package/dist/commands/pack-install.d.ts +14 -17
  9. package/dist/commands/pack-install.js +53 -29
  10. package/dist/commands/pkg-install.js +3 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +9 -3
  13. package/dist/commands/secrets.js +189 -87
  14. package/dist/run/hq-plugin.js +94 -31
  15. package/dist/utils/billing-gate.d.ts +15 -0
  16. package/dist/utils/billing-gate.js +35 -0
  17. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  18. package/dist/utils/sandbox-runner-client.js +1 -0
  19. package/dist/utils/secrets-cache.d.ts +4 -5
  20. package/dist/utils/secrets-cache.js +5 -8
  21. package/package.json +3 -2
  22. package/pnpm-workspace.yaml +2 -0
  23. package/src/commands/agents.test.ts +41 -0
  24. package/src/commands/agents.ts +7 -3
  25. package/src/commands/files-recovery.test.ts +361 -0
  26. package/src/commands/files.ts +410 -0
  27. package/src/commands/mcp-registration.ts +9 -9
  28. package/src/commands/outposts.test.ts +155 -24
  29. package/src/commands/outposts.ts +199 -45
  30. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  31. package/src/commands/pack-install.test.ts +5 -1
  32. package/src/commands/pack-install.ts +67 -29
  33. package/src/commands/pkg-install.ts +3 -1
  34. package/src/commands/run.test.ts +45 -0
  35. package/src/commands/run.ts +20 -4
  36. package/src/commands/secrets.test.ts +366 -25
  37. package/src/commands/secrets.ts +222 -96
  38. package/src/run/hq-plugin.test.ts +186 -10
  39. package/src/run/hq-plugin.ts +102 -32
  40. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  41. package/src/utils/billing-gate.ts +46 -0
  42. package/src/utils/pack-contributions.test.ts +90 -31
  43. package/src/utils/sandbox-runner-client.test.ts +28 -0
  44. package/src/utils/sandbox-runner-client.ts +2 -0
  45. package/src/utils/secrets-cache.ts +5 -8
  46. package/test/commands/signals.test.ts +2 -2
  47. package/test/commands/sources.test.ts +2 -2
  48. package/test/helpers/vault-service-mock.ts +76 -17
  49. package/test/sources-signals/smoke.test.ts +2 -2
@@ -1,9 +1,19 @@
1
1
  import { ResolutionError } from 'varlock/plugin-lib';
2
- import { DEFAULT_SECRETS_CACHE_TTL_MS, readCache, writeCache, } from '../utils/secrets-cache.js';
3
- function normalizeCacheTtlMs(cacheTtlMs) {
4
- return typeof cacheTtlMs === 'number'
5
- ? cacheTtlMs
6
- : DEFAULT_SECRETS_CACHE_TTL_MS;
2
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, } from '../utils/secrets-cache.js';
3
+ function normalizeCacheTtlMs(secret) {
4
+ if (secret.tier === 'sensitive' ||
5
+ secret.tier === 'nuclear' ||
6
+ secret.scriptLock?.mode === 'enforced') {
7
+ return 0;
8
+ }
9
+ if (secret.cacheTtlMs === undefined) {
10
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
11
+ }
12
+ return typeof secret.cacheTtlMs === 'number' &&
13
+ Number.isFinite(secret.cacheTtlMs) &&
14
+ secret.cacheTtlMs > 0
15
+ ? secret.cacheTtlMs
16
+ : 0;
7
17
  }
8
18
  export function installHqPlugin(graph /* EnvGraph */, opts) {
9
19
  const pluginState = {
@@ -41,9 +51,9 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
41
51
  impliesSensitive: true,
42
52
  argsSchema: { type: 'array', arrayMaxLength: 1 },
43
53
  resolve: async function () {
44
- // Cache-only read. `pluginState` is captured by this inner-class closure;
45
- // `prewarmHqSecrets(graph, opts, state)` populates `state.uid` and
46
- // `state.errorsByName` before `graph.resolveEnvValues()` calls us.
54
+ // `pluginState` is captured by this inner-class closure;
55
+ // `prewarmHqSecrets(graph, opts, state)` server-authorizes and populates
56
+ // the in-memory values before `graph.resolveEnvValues()` calls us.
47
57
  const explicit = this.arrArgs?.[0]?.staticValue;
48
58
  const secretName = (typeof explicit === 'string' && explicit) ? explicit : this._ownerKey;
49
59
  if (!secretName) {
@@ -66,11 +76,6 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
66
76
  }
67
77
  throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
68
78
  }
69
- // Sentinel-check style throughout: `readCache` returns `string | null`
70
- // (verified at `hq/packages/hq-cli/src/utils/secrets-cache.ts:45`); `pluginState.uid`
71
- // is `string | null` per `PluginState`. Use `== null` (covers null AND undefined defensively)
72
- // for both — do not mix in truthy checks like `if (!x)`, which would silently swallow a
73
- // legitimate empty-string value if the contract ever loosened.
74
79
  if (pluginState.uid == null) {
75
80
  throw new ResolutionError('Internal error: prewarmHqSecrets was not called before resolveEnvValues');
76
81
  }
@@ -78,11 +83,7 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
78
83
  if (inMemory != null) {
79
84
  return inMemory;
80
85
  }
81
- const cached = readCache(pluginState.uid, secretName); // string | null
82
- if (cached == null) {
83
- throw new ResolutionError(`Internal error: pre-warm did not populate cache for "${secretName}"`);
84
- }
85
- return cached;
86
+ throw new ResolutionError(`Secret "${secretName}" was not returned by vault after server authorization`);
86
87
  },
87
88
  };
88
89
  // Captured during process(parent); used by resolve() to fall back to the var key.
@@ -144,22 +145,84 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
144
145
  if (uniqueNames.length > 100) {
145
146
  throw new Error(`hq run supports at most 100 hq() resolvers per schema; got ${uniqueNames.length}`);
146
147
  }
147
- const result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
148
- for (const s of result.secrets) {
149
- if (s.value == null) {
150
- continue;
148
+ state.loadedSecretsByName.clear();
149
+ state.errorsByName = new Map();
150
+ let result;
151
+ try {
152
+ result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
153
+ if (!Array.isArray(result.secrets) || !Array.isArray(result.errors)) {
154
+ throw new Error('Invalid secret load response from vault');
151
155
  }
152
- state.loadedSecretsByName.set(s.name, s.value);
153
- const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
154
- if (cacheTtlMs > 0) {
155
- writeCache(uid, s.name, s.value, cacheTtlMs);
156
+ const errorsByName = new Map();
157
+ const returnedNames = new Set();
158
+ const requestedNames = new Set(uniqueNames);
159
+ const seenNames = new Set();
160
+ for (const rawSecret of result.secrets) {
161
+ if (!rawSecret || typeof rawSecret !== 'object') {
162
+ throw new Error('Invalid secret load response from vault');
163
+ }
164
+ const s = rawSecret;
165
+ if (typeof s.name !== 'string' ||
166
+ !requestedNames.has(s.name) ||
167
+ seenNames.has(s.name) ||
168
+ (s.value != null && typeof s.value !== 'string')) {
169
+ throw new Error('Invalid secret load response from vault');
170
+ }
171
+ seenNames.add(s.name);
172
+ if (s.value == null) {
173
+ errorsByName.set(s.name, {
174
+ code: 'not_returned',
175
+ message: 'not returned by vault after server authorization',
176
+ });
177
+ removeCacheEntry(uid, s.name);
178
+ continue;
179
+ }
180
+ returnedNames.add(s.name);
181
+ state.loadedSecretsByName.set(s.name, s.value);
182
+ const cacheTtlMs = normalizeCacheTtlMs(s);
183
+ if (cacheTtlMs > 0) {
184
+ writeCache(uid, s.name, s.value, cacheTtlMs);
185
+ }
186
+ else {
187
+ removeCacheEntry(uid, s.name);
188
+ }
189
+ }
190
+ for (const rawError of result.errors) {
191
+ if (!rawError || typeof rawError !== 'object') {
192
+ throw new Error('Invalid secret load response from vault');
193
+ }
194
+ const e = rawError;
195
+ if (typeof e.name !== 'string' ||
196
+ !requestedNames.has(e.name) ||
197
+ seenNames.has(e.name) ||
198
+ typeof e.code !== 'string' ||
199
+ (e.message !== undefined && typeof e.message !== 'string')) {
200
+ throw new Error('Invalid secret load response from vault');
201
+ }
202
+ seenNames.add(e.name);
203
+ errorsByName.set(e.name, { code: e.code, message: e.message });
204
+ state.loadedSecretsByName.delete(e.name);
205
+ removeCacheEntry(uid, e.name);
156
206
  }
207
+ for (const name of uniqueNames) {
208
+ if (!returnedNames.has(name) && !errorsByName.has(name)) {
209
+ errorsByName.set(name, {
210
+ code: 'not_returned',
211
+ message: 'not returned by vault after server authorization',
212
+ });
213
+ removeCacheEntry(uid, name);
214
+ }
215
+ }
216
+ state.errorsByName = errorsByName;
217
+ state.uid = uid;
157
218
  }
158
- const errorsByName = new Map();
159
- for (const e of result.errors) {
160
- errorsByName.set(e.name, { code: e.code, message: e.message });
219
+ catch (err) {
220
+ state.loadedSecretsByName.clear();
221
+ state.errorsByName = new Map();
222
+ for (const name of uniqueNames) {
223
+ removeCacheEntry(uid, name);
224
+ }
225
+ throw err;
161
226
  }
162
- state.errorsByName = errorsByName;
163
- state.uid = uid;
164
227
  }
165
228
  //# sourceMappingURL=hq-plugin.js.map
@@ -74,4 +74,19 @@ export declare function mintPaymentLink(token: string, setup: BillingSetupAction
74
74
  * to act on. Never prints tokens or secrets.
75
75
  */
76
76
  export declare function surfaceBillingRequired(token: string, billing: BillingErrorPayload): Promise<string | null>;
77
+ /**
78
+ * Status-aware surface for a 402 billing block. hq-pro's envelope carries two
79
+ * distinct remediations that must never be conflated (mirroring the server's
80
+ * own P1-C classification):
81
+ * - `payment_failed` — a card EXISTS and the charge was DECLINED. The
82
+ * server's `message` already carries the friendly decline copy ("Your
83
+ * card was declined…", "insufficient funds", …). Telling this user
84
+ * "No card on file" sends them down the wrong remediation path entirely
85
+ * (observed live 2026-07-20: a declined $80 Outpost proration surfaced
86
+ * as "no card", triggering a hunt for a missing card that existed).
87
+ * The capture link still surfaces — as the way to UPDATE the card.
88
+ * - anything else (`billing_required`) — genuinely no usable card on
89
+ * file; the existing add-a-card copy is correct.
90
+ */
91
+ export declare function surfaceBillingBlocked(token: string, billing: BillingErrorPayload, serverMessage?: string): Promise<string | null>;
77
92
  //# sourceMappingURL=billing-gate.d.ts.map
@@ -121,4 +121,39 @@ export async function surfaceBillingRequired(token, billing) {
121
121
  console.log(chalk.dim("Once a card is added, re-run the same command."));
122
122
  return url;
123
123
  }
124
+ /**
125
+ * Status-aware surface for a 402 billing block. hq-pro's envelope carries two
126
+ * distinct remediations that must never be conflated (mirroring the server's
127
+ * own P1-C classification):
128
+ * - `payment_failed` — a card EXISTS and the charge was DECLINED. The
129
+ * server's `message` already carries the friendly decline copy ("Your
130
+ * card was declined…", "insufficient funds", …). Telling this user
131
+ * "No card on file" sends them down the wrong remediation path entirely
132
+ * (observed live 2026-07-20: a declined $80 Outpost proration surfaced
133
+ * as "no card", triggering a hunt for a missing card that existed).
134
+ * The capture link still surfaces — as the way to UPDATE the card.
135
+ * - anything else (`billing_required`) — genuinely no usable card on
136
+ * file; the existing add-a-card copy is correct.
137
+ */
138
+ export async function surfaceBillingBlocked(token, billing, serverMessage) {
139
+ if (billing.status !== "payment_failed") {
140
+ return surfaceBillingRequired(token, billing);
141
+ }
142
+ console.error(chalk.yellow(serverMessage?.trim() ||
143
+ "Your payment failed. Try a different card or contact your bank."));
144
+ if (!billing.setup)
145
+ return null;
146
+ try {
147
+ const url = await mintPaymentLink(token, billing.setup);
148
+ console.log("Update or replace the card here (safe to share with whoever owns billing):\n " +
149
+ chalk.cyan(url));
150
+ console.log(chalk.dim("Once the payment method is sorted, re-run the same command."));
151
+ return url;
152
+ }
153
+ catch {
154
+ // Link minting is best-effort on the decline path — the decline reason
155
+ // above is the essential part; the console billing page also works.
156
+ return null;
157
+ }
158
+ }
124
159
  //# sourceMappingURL=billing-gate.js.map
@@ -12,6 +12,7 @@ export interface SandboxRunnerJob {
12
12
  jobId: string;
13
13
  status: SandboxRunnerState;
14
14
  output?: string;
15
+ error?: string;
15
16
  exitCode?: number;
16
17
  success?: boolean;
17
18
  }
@@ -36,6 +36,7 @@ function normalizeJob(body, jobIdFallback) {
36
36
  : jobIdFallback ?? requireString(body, "jobId"),
37
37
  status,
38
38
  output: typeof body.output === "string" ? body.output : undefined,
39
+ error: typeof body.error === "string" ? body.error : undefined,
39
40
  exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
40
41
  success: typeof body.success === "boolean" ? body.success : undefined,
41
42
  };
@@ -3,11 +3,10 @@ export declare function readCache(companyUid: string, name: string): string | nu
3
3
  export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number): void;
4
4
  /**
5
5
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
6
- * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
7
- * registration) that have no `--company` flag and no network token and so cannot
8
- * resolve a single active company UID up front: they instead probe every cached
9
- * scope for a given secret name. Returns `[]` when the cache root is absent or
10
- * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
6
+ * secrets-cache directory on disk. Install-time MCP registration may use an
7
+ * exactly-one result as a scope hint before reauthorizing every value online; it
8
+ * never reads cached plaintext through this helper. Returns `[]` when the cache
9
+ * root is absent or unreadable.
11
10
  */
12
11
  export declare function listSecretCacheScopes(): string[];
13
12
  export declare function removeCacheEntry(companyUid: string, name: string): void;
@@ -140,11 +140,10 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
140
140
  }
141
141
  /**
142
142
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
143
- * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
144
- * registration) that have no `--company` flag and no network token and so cannot
145
- * resolve a single active company UID up front: they instead probe every cached
146
- * scope for a given secret name. Returns `[]` when the cache root is absent or
147
- * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
143
+ * secrets-cache directory on disk. Install-time MCP registration may use an
144
+ * exactly-one result as a scope hint before reauthorizing every value online; it
145
+ * never reads cached plaintext through this helper. Returns `[]` when the cache
146
+ * root is absent or unreadable.
148
147
  */
149
148
  export function listSecretCacheScopes() {
150
149
  try {
@@ -152,9 +151,7 @@ export function listSecretCacheScopes() {
152
151
  .readdirSync(CACHE_DIR, { withFileTypes: true })
153
152
  .filter((e) => e.isDirectory())
154
153
  .map((e) => e.name)
155
- // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
156
- // rejects anything with `/` or `..`, so this is belt-and-suspenders.
157
- .filter((name) => !name.startsWith("."));
154
+ .filter((name) => /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(name));
158
155
  }
159
156
  catch {
160
157
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.75.0",
3
+ "version": "5.77.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "build": "node scripts/generate-dsn.mjs && tsc && node scripts/chmod-bins.mjs",
12
12
  "prepublishOnly": "npm run build",
13
13
  "typecheck": "tsc --noEmit",
14
+ "gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
14
15
  "lint": "eslint .",
15
16
  "test": "vitest run",
16
17
  "test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
@@ -21,7 +22,7 @@
21
22
  },
22
23
  "dependencies": {
23
24
  "@aws-sdk/client-s3": "^3.1049.0",
24
- "@indigoai-us/hq-cloud": "^6.14.4",
25
+ "@indigoai-us/hq-cloud": "^6.14.14",
25
26
  "@indigoai-us/hq-onboarding": "^0.1.0",
26
27
  "@sentry/node": "^10.49.0",
27
28
  "better-sqlite3": "^12.11.1",
@@ -1,2 +1,4 @@
1
1
  allowBuilds:
2
2
  better-sqlite3: true
3
+ minimumReleaseAgeExclude:
4
+ - '@indigoai-us/hq-cloud@6.14.14'
@@ -363,6 +363,47 @@ describe("hq agents provision (billing gate)", () => {
363
363
  expect(printed).toContain("https://checkout.stripe.com/card");
364
364
  });
365
365
 
366
+ it("surfaces the DECLINE reason on 402 payment_failed (message-first decoding)", async () => {
367
+ // The payment_failed envelope carries BOTH the generic error AND the
368
+ // friendly decline message — the decode must prefer `message` or the
369
+ // decline copy is lost and the operator sees "add a card" for a card
370
+ // that exists and was declined.
371
+ fetchSpy
372
+ .mockResolvedValueOnce(
373
+ jsonResponse(402, {
374
+ error: "payment required",
375
+ message:
376
+ "Your card was declined. Try a different card or contact your bank.",
377
+ code: "PAYMENT_FAILED",
378
+ billing: {
379
+ status: "payment_failed",
380
+ setup: {
381
+ payerType: "company",
382
+ path: "/v1/billing/checkout/org",
383
+ method: "POST",
384
+ body: { companyUid: "cmp_acme" },
385
+ },
386
+ },
387
+ }),
388
+ )
389
+ .mockResolvedValueOnce(
390
+ jsonResponse(200, { url: "https://checkout.stripe.com/update" }),
391
+ );
392
+
393
+ const logSpyLocal = vi.spyOn(console, "log").mockImplementation(() => {});
394
+ const errSpyLocal = vi.spyOn(console, "error").mockImplementation(() => {});
395
+ await expect(
396
+ run(["agents", "--company", "acme", "provision", "Ops Bot", "--yes"]),
397
+ ).rejects.toThrow("process.exit(1)");
398
+
399
+ const printed = [...errSpyLocal.mock.calls, ...logSpyLocal.mock.calls]
400
+ .map((c) => c.map(String).join(" "))
401
+ .join("\n");
402
+ expect(printed).toContain("declined");
403
+ expect(printed).not.toContain("No card on file");
404
+ expect(printed).toContain("https://checkout.stripe.com/update");
405
+ });
406
+
366
407
  it("requires --api-key-env for --auth-mode apiKey (before any charge)", async () => {
367
408
  await expect(
368
409
  run([
@@ -31,7 +31,7 @@ import {
31
31
  AGENT_PRICE_CENTS,
32
32
  confirmChargeOrExit,
33
33
  parseBillingPayload,
34
- surfaceBillingRequired,
34
+ surfaceBillingBlocked,
35
35
  type BillingErrorPayload,
36
36
  } from "../utils/billing-gate.js";
37
37
 
@@ -125,7 +125,11 @@ export async function agentsRequest<T>(opts: {
125
125
  };
126
126
  throw new AgentsHttpError(
127
127
  res.status,
128
- body.error ?? body.message ?? res.statusText,
128
+ // `message` FIRST: on a payment_failed envelope it carries the friendly
129
+ // decline copy ("Your card was declined…") while `error` is the generic
130
+ // "payment required" — error-first would feed surfaceBillingBlocked the
131
+ // generic string and lose the decline reason (mirrors outpostRequest).
132
+ body.message ?? body.error ?? res.statusText,
129
133
  body.code,
130
134
  parseBillingPayload(body),
131
135
  );
@@ -592,7 +596,7 @@ export function registerAgentsCommand(program: Command): void {
592
596
  err.status === 402 &&
593
597
  err.billing
594
598
  ) {
595
- await surfaceBillingRequired(token, err.billing);
599
+ await surfaceBillingBlocked(token, err.billing, err.message);
596
600
  process.exit(1);
597
601
  }
598
602
  throw err;