@indigoai-us/hq-cli 5.77.11 → 5.77.13

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 (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/commands/api-keys.js +53 -10
  3. package/dist/commands/outposts-heartbeat.d.ts +96 -0
  4. package/dist/commands/outposts-heartbeat.js +188 -0
  5. package/dist/commands/outposts.js +3 -0
  6. package/dist/commands/secrets.js +127 -21
  7. package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
  8. package/dist/outpost/session-heartbeat-publisher.js +117 -0
  9. package/dist/outpost/session-heartbeat.d.ts +210 -0
  10. package/dist/outpost/session-heartbeat.js +657 -0
  11. package/dist/utils/resolve-vault-credential.d.ts +30 -0
  12. package/dist/utils/resolve-vault-credential.js +48 -0
  13. package/dist/utils/vault-api.d.ts +8 -1
  14. package/dist/utils/vault-api.js +3 -2
  15. package/package.json +3 -1
  16. package/src/commands/api-keys.test.ts +75 -1
  17. package/src/commands/api-keys.ts +86 -10
  18. package/src/commands/outposts-heartbeat.test.ts +299 -0
  19. package/src/commands/outposts-heartbeat.ts +310 -0
  20. package/src/commands/outposts.ts +4 -0
  21. package/src/commands/secrets.test.ts +133 -0
  22. package/src/commands/secrets.ts +172 -29
  23. package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
  24. package/src/outpost/session-heartbeat-guard.test.ts +105 -0
  25. package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
  26. package/src/outpost/session-heartbeat-publisher.ts +186 -0
  27. package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
  28. package/src/outpost/session-heartbeat.test.ts +459 -0
  29. package/src/outpost/session-heartbeat.ts +877 -0
  30. package/src/packaging.test.ts +45 -0
  31. package/src/utils/resolve-vault-credential.test.ts +69 -0
  32. package/src/utils/resolve-vault-credential.ts +60 -0
  33. package/src/utils/vault-api.ts +13 -2
@@ -0,0 +1,48 @@
1
+ import { ensureCognitoToken } from "./cognito-session.js";
2
+ /** Vault API keys issued by `hq api-keys create` (hq-pro). */
3
+ export const HQ_API_KEY_PREFIX = "hqk_";
4
+ /**
5
+ * Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
6
+ * Does not validate prefix — use {@link resolveVaultCredential} for that.
7
+ */
8
+ export function peekHqApiKey() {
9
+ const raw = process.env.HQ_API_KEY;
10
+ if (raw === undefined)
11
+ return undefined;
12
+ const trimmed = raw.trim();
13
+ return trimmed.length > 0 ? trimmed : undefined;
14
+ }
15
+ /**
16
+ * Resolve vault auth for CLI commands.
17
+ *
18
+ * When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
19
+ * and Cognito is never used as a fallback (fail-closed). When unset, uses the
20
+ * cached Cognito session (interactive login if needed).
21
+ */
22
+ export async function resolveVaultCredential(options) {
23
+ const apiKey = peekHqApiKey();
24
+ if (apiKey !== undefined) {
25
+ if (!apiKey.startsWith(HQ_API_KEY_PREFIX)) {
26
+ throw new Error(`HQ_API_KEY must start with '${HQ_API_KEY_PREFIX}' (vault API key). ` +
27
+ `Got a value that is not a vault key — refusing to fall back to Cognito. ` +
28
+ `Unset HQ_API_KEY to use your session, or create a key with \`hq api-keys create\`.`);
29
+ }
30
+ return { kind: "api-key", token: apiKey };
31
+ }
32
+ const token = await ensureCognitoToken({
33
+ interactive: options?.interactive,
34
+ });
35
+ return { kind: "cognito", token };
36
+ }
37
+ /**
38
+ * Throw when HQ_API_KEY is set but the command only supports Cognito sessions
39
+ * (list, set, ACL, api-keys admin, etc.).
40
+ */
41
+ export function assertCognitoOnlyCommand(commandLabel) {
42
+ if (peekHqApiKey() === undefined)
43
+ return;
44
+ throw new Error(`HQ_API_KEY is set; '${commandLabel}' is not supported for API keys. ` +
45
+ `API keys support scoped secret reads via \`hq secrets get\`, \`hq secrets exec\`, ` +
46
+ `and \`hq secrets env\`. Unset HQ_API_KEY to use your Cognito session.`);
47
+ }
48
+ //# sourceMappingURL=resolve-vault-credential.js.map
@@ -1,4 +1,11 @@
1
1
  export interface VaultApiOptions {
2
+ /**
3
+ * Control-plane origin. Defaults to DEFAULT_VAULT_API_URL. Set it when a
4
+ * caller has been pointed at another deployment, so identity and every
5
+ * other call resolve against the SAME plane — a token minted for one and
6
+ * sent to another is simply rejected, which reads as an auth failure.
7
+ */
8
+ baseUrl?: string;
2
9
  token: string;
3
10
  path: string;
4
11
  method?: string;
@@ -20,7 +27,7 @@ export declare function vaultApiFetchPublic(opts: {
20
27
  }): Promise<Response>;
21
28
  export declare function looksLikeCompanyUid(ref: string): boolean;
22
29
  export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
23
- export declare function resolveCallerPersonUid(token: string): Promise<string>;
30
+ export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
24
31
  export declare function getEntityUid(token: string, opts: {
25
32
  personal?: boolean;
26
33
  companySlug?: string;
@@ -63,7 +63,7 @@ async function peekPlanLimitStatus(response) {
63
63
  }
64
64
  }
65
65
  export async function vaultApiFetch(opts) {
66
- const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
66
+ const url = new URL(opts.path, opts.baseUrl ?? DEFAULT_VAULT_API_URL);
67
67
  if (opts.query) {
68
68
  for (const [k, v] of Object.entries(opts.query)) {
69
69
  url.searchParams.set(k, v);
@@ -274,10 +274,11 @@ export async function getCompanyUid(token, companySlug) {
274
274
  }
275
275
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
276
276
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
277
- export async function resolveCallerPersonUid(token) {
277
+ export async function resolveCallerPersonUid(token, baseUrl) {
278
278
  const res = await vaultApiFetch({
279
279
  token,
280
280
  path: '/entity/by-type/person',
281
+ baseUrl,
281
282
  });
282
283
  if (!res.ok) {
283
284
  throw new Error("Failed to fetch person entity — run `hq login` and try again");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.11",
3
+ "version": "5.77.13",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "clean": "rm -rf dist"
22
22
  },
23
23
  "dependencies": {
24
+ "@aws-sdk/client-iot-data-plane": "^3.1096.0",
24
25
  "@aws-sdk/client-s3": "^3.1049.0",
25
26
  "@indigoai-us/hq-cloud": "^6.14.27",
26
27
  "@indigoai-us/hq-onboarding": "^0.1.0",
@@ -42,6 +43,7 @@
42
43
  "@types/node": "^22.0.0",
43
44
  "@types/semver": "^7.5.8",
44
45
  "@vitest/coverage-v8": "4.1.6",
46
+ "aws-sdk-client-mock": "^4.1.0",
45
47
  "eslint": "^10.5.0",
46
48
  "typescript": "^5.7.0",
47
49
  "typescript-eslint": "^8.61.1",
@@ -126,10 +126,78 @@ describe("hq api-keys create", () => {
126
126
  .map((call) => call.map((value) => String(value)).join(" "))
127
127
  .join("\n");
128
128
  expect(printed).toContain("Store this key now");
129
- expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(1);
129
+ expect(printed).toContain("export HQ_API_KEY=");
130
+ expect(printed).toContain("hq secrets get");
131
+ // Key value appears in Store line + export line only (not in list metadata).
132
+ expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(2);
130
133
  expect(errSpy).not.toHaveBeenCalled();
131
134
  expect(exitSpy).not.toHaveBeenCalled();
132
135
  });
136
+
137
+ it("includes deploy scope when --deploy-app is set", async () => {
138
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
139
+ jsonResponse({
140
+ apiKey: {
141
+ keyId: "key_deploy",
142
+ companyUid: "cmp_acme",
143
+ name: "Deploy key",
144
+ scope: {
145
+ allowedPrefixes: ["CI"],
146
+ permission: "read",
147
+ deploy: {
148
+ apps: ["my-app", "other-app"],
149
+ capabilities: ["deploy:write"],
150
+ },
151
+ },
152
+ status: "active",
153
+ createdAt: "2026-06-19T12:00:00.000Z",
154
+ lastUsedAt: null,
155
+ expiresAt: null,
156
+ },
157
+ key: { value: "hqk_deploy_secret" },
158
+ }),
159
+ );
160
+
161
+ const program = buildProgram();
162
+ await program.parseAsync(
163
+ [
164
+ "api-keys",
165
+ "create",
166
+ "--name",
167
+ "Deploy key",
168
+ "--scope",
169
+ "CI",
170
+ "--deploy-app",
171
+ "my-app",
172
+ "--deploy-app",
173
+ "other-app",
174
+ ],
175
+ { from: "user" },
176
+ );
177
+
178
+ expect(vaultApiFetch).toHaveBeenCalledWith({
179
+ token: "test-token",
180
+ path: "/v1/api-keys",
181
+ method: "POST",
182
+ body: {
183
+ companyUid: "cmp_acme",
184
+ name: "Deploy key",
185
+ allowedPrefixes: ["CI"],
186
+ permission: "read",
187
+ deploy: {
188
+ apps: ["my-app", "other-app"],
189
+ capabilities: ["deploy:write"],
190
+ },
191
+ },
192
+ });
193
+
194
+ const printed = logSpy.mock.calls
195
+ .map((call) => call.map((value) => String(value)).join(" "))
196
+ .join("\n");
197
+ expect(printed).toContain("Deploy apps:");
198
+ expect(printed).toContain("my-app, other-app");
199
+ expect(printed).toContain("hq-deploy API");
200
+ });
133
201
  });
134
202
 
135
203
  describe("hq api-keys list", () => {
@@ -145,6 +213,10 @@ describe("hq api-keys list", () => {
145
213
  scope: {
146
214
  allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
147
215
  permission: "read",
216
+ deploy: {
217
+ apps: ["preview-app"],
218
+ capabilities: ["deploy:write"],
219
+ },
148
220
  },
149
221
  status: "active",
150
222
  createdAt: "2026-06-19T12:00:00.000Z",
@@ -173,9 +245,11 @@ describe("hq api-keys list", () => {
173
245
  .map((call) => call.map((value) => String(value)).join(" "))
174
246
  .join("\n");
175
247
  expect(printed).toContain("KEY ID");
248
+ expect(printed).toContain("DEPLOY");
176
249
  expect(printed).toContain("key_123");
177
250
  expect(printed).toContain("CI key");
178
251
  expect(printed).toContain("HQ_PRO/HQ_PROD, HQ_PRO/HQ_DEV");
252
+ expect(printed).toContain("preview-app");
179
253
  expect(printed).not.toContain("hqk_should_not_print");
180
254
  expect(exitSpy).not.toHaveBeenCalled();
181
255
  });
@@ -1,8 +1,14 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
+ import { assertCognitoOnlyCommand } from "../utils/resolve-vault-credential.js";
4
5
  import { getCompanyUid, vaultApiFetch } from "./secrets.js";
5
6
 
7
+ async function requireCognitoForApiKeys(label: string): Promise<string> {
8
+ assertCognitoOnlyCommand(label);
9
+ return ensureCognitoToken();
10
+ }
11
+
6
12
  type ApiKeyPermission = "read" | "write" | "admin";
7
13
 
8
14
  interface ApiKeyMetadata {
@@ -12,6 +18,10 @@ interface ApiKeyMetadata {
12
18
  scope: {
13
19
  allowedPrefixes: string[];
14
20
  permission: ApiKeyPermission;
21
+ deploy?: {
22
+ apps: string[];
23
+ capabilities: string[];
24
+ };
15
25
  };
16
26
  status: string;
17
27
  createdAt: string;
@@ -42,6 +52,7 @@ interface ApiKeysGroupOptions {
42
52
  interface CreateApiKeyOptions {
43
53
  name: string;
44
54
  scope: string[];
55
+ deployApp: string[];
45
56
  permission: string;
46
57
  expires?: string;
47
58
  }
@@ -58,6 +69,13 @@ function formatPrefixes(prefixes: string[]): string {
58
69
  return prefixes.length > 0 ? prefixes.join(", ") : "-";
59
70
  }
60
71
 
72
+ function formatDeployApps(
73
+ deploy?: { apps: string[]; capabilities: string[] },
74
+ ): string {
75
+ if (!deploy?.apps?.length) return "-";
76
+ return deploy.apps.join(", ");
77
+ }
78
+
61
79
  function parsePermission(value: string): ApiKeyPermission {
62
80
  if (value === "read" || value === "write" || value === "admin") {
63
81
  return value;
@@ -107,6 +125,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
107
125
  name: apiKey.name,
108
126
  permission: apiKey.scope.permission,
109
127
  prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
128
+ deployApps: formatDeployApps(apiKey.scope.deploy),
110
129
  status: apiKey.status,
111
130
  lastUsedAt: formatMaybe(apiKey.lastUsedAt),
112
131
  expiresAt: formatMaybe(apiKey.expiresAt),
@@ -119,6 +138,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
119
138
  ...rows.map((row) => row.permission.length),
120
139
  );
121
140
  const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
141
+ const deployWidth = Math.max(6, ...rows.map((row) => row.deployApps.length));
122
142
  const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
123
143
  const lastUsedWidth = Math.max(
124
144
  11,
@@ -134,6 +154,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
134
154
  "NAME".padEnd(nameWidth),
135
155
  "PERMISSION".padEnd(permissionWidth),
136
156
  "PREFIXES".padEnd(prefixesWidth),
157
+ "DEPLOY".padEnd(deployWidth),
137
158
  "STATUS".padEnd(statusWidth),
138
159
  "LAST USED".padEnd(lastUsedWidth),
139
160
  "EXPIRES".padEnd(expiresWidth),
@@ -147,6 +168,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
147
168
  row.name.padEnd(nameWidth),
148
169
  row.permission.padEnd(permissionWidth),
149
170
  row.prefixes.padEnd(prefixesWidth),
171
+ row.deployApps.padEnd(deployWidth),
150
172
  row.status.padEnd(statusWidth),
151
173
  row.lastUsedAt.padEnd(lastUsedWidth),
152
174
  row.expiresAt.padEnd(expiresWidth),
@@ -163,32 +185,42 @@ export function registerApiKeysCommand(program: Command): Command {
163
185
 
164
186
  apiKeys
165
187
  .command("create")
166
- .description("Create a new API key")
188
+ .description(
189
+ "Create a new API key (vault secrets and/or scoped deploy via --deploy-app)",
190
+ )
167
191
  .requiredOption("--name <label>", "Human-readable label for the API key")
168
192
  .option(
169
193
  "--scope <prefix>",
170
- "Allowed prefix (repeatable)",
194
+ "Allowed secret prefix (repeatable)",
195
+ collectRepeatedOption,
196
+ [],
197
+ )
198
+ .option(
199
+ "--deploy-app <id>",
200
+ "Deploy app id/slug allowed for publish (repeatable)",
171
201
  collectRepeatedOption,
172
202
  [],
173
203
  )
174
204
  .option(
175
205
  "--permission <level>",
176
- "Permission level: read | write | admin",
206
+ "Secret permission level: read | write | admin (required when --scope is set)",
177
207
  "read",
178
208
  )
179
209
  .option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
180
210
  .action(async (opts: CreateApiKeyOptions) => {
181
211
  try {
182
- if (opts.scope.length === 0) {
212
+ if (opts.scope.length === 0 && opts.deployApp.length === 0) {
183
213
  console.error(
184
- chalk.red("Error: at least one --scope <prefix> is required."),
214
+ chalk.red(
215
+ "Error: provide at least one --scope <prefix> and/or --deploy-app <id>.",
216
+ ),
185
217
  );
186
218
  process.exit(1);
187
219
  }
188
220
 
189
221
  const permission = parsePermission(opts.permission);
190
222
  const expiresAt = parseExpires(opts.expires);
191
- const token = await ensureCognitoToken();
223
+ const token = await requireCognitoForApiKeys("api-keys create");
192
224
  const companyUid = await getCompanyUid(
193
225
  token,
194
226
  (apiKeys.opts() as ApiKeysGroupOptions).company,
@@ -201,8 +233,20 @@ export function registerApiKeysCommand(program: Command): Command {
201
233
  body: {
202
234
  companyUid,
203
235
  name: opts.name,
204
- allowedPrefixes: opts.scope,
205
- permission,
236
+ ...(opts.scope.length > 0
237
+ ? { allowedPrefixes: opts.scope, permission }
238
+ : {}),
239
+ ...(opts.deployApp.length > 0
240
+ ? {
241
+ deploy: {
242
+ apps: opts.deployApp,
243
+ capabilities: ["deploy:write"],
244
+ },
245
+ }
246
+ : {}),
247
+ ...(opts.scope.length === 0 && opts.deployApp.length > 0
248
+ ? { permission }
249
+ : {}),
206
250
  ...(expiresAt ? { expiresAt } : {}),
207
251
  },
208
252
  });
@@ -225,10 +269,42 @@ export function registerApiKeysCommand(program: Command): Command {
225
269
  console.log(
226
270
  ` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`,
227
271
  );
272
+ console.log(
273
+ ` Deploy apps: ${formatDeployApps(data.apiKey.scope.deploy)}`,
274
+ );
228
275
  console.log(` Status: ${data.apiKey.status}`);
229
276
  console.log(` Created: ${data.apiKey.createdAt}`);
230
277
  console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
231
278
  console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
279
+ console.log("");
280
+ console.log(chalk.bold("Usage"));
281
+ console.log(
282
+ " This key acts as you (Cognito identity), limited to the scopes above.",
283
+ );
284
+ console.log(" Export it for automation (never falls back to a session):");
285
+ console.log(`\n export HQ_API_KEY='${data.key.value}'\n`);
286
+ console.log(" Vault secrets:");
287
+ console.log(" hq secrets get <NAME> --reveal");
288
+ console.log(" hq secrets exec --only <NAME> -- <command>");
289
+ console.log(
290
+ " Or HTTP: POST /v1/keys/secrets/fetch with Authorization: Bearer <key>",
291
+ );
292
+ if (data.apiKey.scope.deploy?.apps?.length) {
293
+ console.log(" Deploy (scoped apps only):");
294
+ console.log(
295
+ " Authorization: Bearer <key> against the hq-deploy API",
296
+ );
297
+ console.log(
298
+ chalk.dim(
299
+ " Cannot change access-mode, password, or mint hqd_ keys.",
300
+ ),
301
+ );
302
+ }
303
+ console.log(
304
+ chalk.dim(
305
+ " Unsupported under HQ_API_KEY: secrets list/set/share/acl (use a Cognito session).",
306
+ ),
307
+ );
232
308
  } catch (err) {
233
309
  console.error(
234
310
  chalk.red("Error:"),
@@ -243,7 +319,7 @@ export function registerApiKeysCommand(program: Command): Command {
243
319
  .description("List API keys for a company")
244
320
  .action(async () => {
245
321
  try {
246
- const token = await ensureCognitoToken();
322
+ const token = await requireCognitoForApiKeys("api-keys list");
247
323
  const companyUid = await getCompanyUid(
248
324
  token,
249
325
  (apiKeys.opts() as ApiKeysGroupOptions).company,
@@ -280,7 +356,7 @@ export function registerApiKeysCommand(program: Command): Command {
280
356
  .description("Revoke an API key")
281
357
  .action(async (keyId: string) => {
282
358
  try {
283
- const token = await ensureCognitoToken();
359
+ const token = await requireCognitoForApiKeys("api-keys revoke");
284
360
  const res = await vaultApiFetch({
285
361
  token,
286
362
  path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,