@indigoai-us/hq-cli 5.47.9 → 5.47.11

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.
@@ -1,15 +1,20 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="263d7b10-622e-56c3-8c82-ebb294510b1b")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fd3738da-3061-5784-bb7c-02021291aecc")}catch(e){}}();
3
3
  import * as crypto from "node:crypto";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import * as os from "node:os";
7
7
  const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
8
8
  const KEY_PATH = path.join(CACHE_DIR, ".key");
9
- const TTL_MS = 5 * 60 * 1000;
9
+ const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
10
+ const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
11
+ const LEGACY_TIMESTAMP_BYTES = 8;
12
+ const TIMESTAMP_BYTES = 8;
13
+ const TTL_BYTES = 8;
10
14
  const ALGORITHM = "aes-256-gcm";
11
15
  const IV_BYTES = 12;
12
16
  const AUTH_TAG_BYTES = 16;
17
+ export const DEFAULT_SECRETS_CACHE_TTL_MS = 5 * 60 * 1000;
13
18
  function ensureCacheDir(companyUid) {
14
19
  const dir = path.join(CACHE_DIR, companyUid);
15
20
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -53,21 +58,40 @@ export function readCache(companyUid, name) {
53
58
  catch {
54
59
  return null;
55
60
  }
56
- // Format: [8 bytes timestamp][12 bytes IV][16 bytes authTag][...ciphertext]
57
- const headerLen = 8 + IV_BYTES + AUTH_TAG_BYTES;
58
- if (raw.length < headerLen)
59
- return null;
60
- const timestampMs = Number(raw.readBigInt64BE(0));
61
- if (Date.now() - timestampMs > TTL_MS) {
61
+ let timestampMs;
62
+ let ttlMs;
63
+ let ivStart;
64
+ let authTagStart;
65
+ let ciphertextStart;
66
+ if (raw.length >=
67
+ CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
68
+ raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
69
+ timestampMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES));
70
+ ttlMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES));
71
+ ivStart = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
72
+ authTagStart = ivStart + IV_BYTES;
73
+ ciphertextStart = authTagStart + AUTH_TAG_BYTES;
74
+ }
75
+ else {
76
+ const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
77
+ if (raw.length < headerLen)
78
+ return null;
79
+ timestampMs = Number(raw.readBigInt64BE(0));
80
+ ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS;
81
+ ivStart = LEGACY_TIMESTAMP_BYTES;
82
+ authTagStart = ivStart + IV_BYTES;
83
+ ciphertextStart = authTagStart + AUTH_TAG_BYTES;
84
+ }
85
+ if (ttlMs <= 0 || Date.now() - timestampMs > ttlMs) {
62
86
  try {
63
87
  fs.unlinkSync(filePath);
64
88
  }
65
89
  catch { /* ok */ }
66
90
  return null;
67
91
  }
68
- const iv = raw.subarray(8, 8 + IV_BYTES);
69
- const authTag = raw.subarray(8 + IV_BYTES, headerLen);
70
- const ciphertext = raw.subarray(headerLen);
92
+ const iv = raw.subarray(ivStart, authTagStart);
93
+ const authTag = raw.subarray(authTagStart, ciphertextStart);
94
+ const ciphertext = raw.subarray(ciphertextStart);
71
95
  let key;
72
96
  try {
73
97
  key = getOrCreateKey();
@@ -89,10 +113,12 @@ export function readCache(companyUid, name) {
89
113
  return null;
90
114
  }
91
115
  }
92
- export function writeCache(companyUid, name, value) {
116
+ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS) {
93
117
  try {
94
118
  if (!validateInputs(companyUid, name))
95
119
  return;
120
+ if (ttlMs <= 0)
121
+ return;
96
122
  ensureCacheDir(companyUid);
97
123
  const key = getOrCreateKey();
98
124
  const iv = crypto.randomBytes(IV_BYTES);
@@ -101,7 +127,9 @@ export function writeCache(companyUid, name, value) {
101
127
  const authTag = cipher.getAuthTag();
102
128
  const timestamp = Buffer.alloc(8);
103
129
  timestamp.writeBigInt64BE(BigInt(Date.now()));
104
- const out = Buffer.concat([timestamp, iv, authTag, encrypted]);
130
+ const ttl = Buffer.alloc(8);
131
+ ttl.writeBigInt64BE(BigInt(ttlMs));
132
+ const out = Buffer.concat([CACHE_FORMAT_MAGIC, timestamp, ttl, iv, authTag, encrypted]);
105
133
  const filePath = path.join(CACHE_DIR, companyUid, name);
106
134
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
107
135
  const tmpPath = `${filePath}.tmp.${process.pid}`;
@@ -135,4 +163,4 @@ export function clearAllCache() {
135
163
  return { removed };
136
164
  }
137
165
  //# sourceMappingURL=secrets-cache.js.map
138
- //# debugId=263d7b10-622e-56c3-8c82-ebb294510b1b
166
+ //# debugId=fd3738da-3061-5784-bb7c-02021291aecc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.47.9",
3
+ "version": "5.47.11",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Unit tests for `hq members invite|list|revoke` (members.ts).
2
+ * Unit tests for `hq members invite|list|revoke|set-role` (members.ts).
3
3
  *
4
4
  * Coverage:
5
5
  * - detectTarget — pure validation for email vs personUid vs invalid
@@ -7,10 +7,31 @@
7
7
  * - inviteMember — email + personUid targets, --paths gating, HTTP errors
8
8
  * - listPendingInvites — happy path + 403
9
9
  * - revokeInvite — happy path + 404
10
+ * - set-role CLI surface — forwards membershipKey/newRole and surfaces 403
10
11
  */
11
12
 
13
+ import { Command } from "commander";
12
14
  import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
13
15
 
16
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
17
+ const original =
18
+ await importOriginal<typeof import("../utils/cognito-session.js")>();
19
+ return {
20
+ ...original,
21
+ ensureCognitoToken: vi.fn(async () => "test-token"),
22
+ };
23
+ });
24
+
25
+ vi.mock("../utils/vault-api.js", async (importOriginal) => {
26
+ const original = await importOriginal<typeof import("../utils/vault-api.js")>();
27
+ return {
28
+ ...original,
29
+ getCompanyUid: vi.fn(async () => "cmp_acme"),
30
+ };
31
+ });
32
+
33
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
34
+ import { getCompanyUid } from "../utils/vault-api.js";
14
35
  import {
15
36
  InviteHttpError,
16
37
  detectTarget,
@@ -18,6 +39,7 @@ import {
18
39
  getCallerPersonUid,
19
40
  inviteMember,
20
41
  listPendingInvites,
42
+ registerMembersCommand,
21
43
  resendInvite,
22
44
  resolveRevokeTargetToMembershipKey,
23
45
  revokeInvite,
@@ -31,15 +53,27 @@ function jsonResponse(status: number, body: unknown): Response {
31
53
  }
32
54
 
33
55
  let fetchSpy: MockInstance<typeof fetch>;
56
+ const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
57
+ const mockGetCompanyUid = vi.mocked(getCompanyUid);
34
58
 
35
59
  beforeEach(() => {
60
+ vi.clearAllMocks();
36
61
  fetchSpy = vi.spyOn(globalThis, "fetch");
62
+ mockEnsureCognitoToken.mockResolvedValue("test-token");
63
+ mockGetCompanyUid.mockResolvedValue("cmp_acme");
37
64
  });
38
65
 
39
66
  afterEach(() => {
40
67
  vi.restoreAllMocks();
41
68
  });
42
69
 
70
+ function buildMembersProgram(): Command {
71
+ const program = new Command();
72
+ program.name("hq").exitOverride();
73
+ registerMembersCommand(program);
74
+ return program;
75
+ }
76
+
43
77
  // ---------------------------------------------------------------------------
44
78
  // detectTarget
45
79
  // ---------------------------------------------------------------------------
@@ -731,6 +765,63 @@ describe("revokeInvite", () => {
731
765
  });
732
766
  });
733
767
 
768
+ // ---------------------------------------------------------------------------
769
+ // registerMembersCommand set-role
770
+ // ---------------------------------------------------------------------------
771
+
772
+ describe("registerMembersCommand set-role", () => {
773
+ it("forwards membershipKey + newRole to /membership/role", async () => {
774
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
775
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
776
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
777
+
778
+ await buildMembersProgram().parseAsync(
779
+ ["members", "--company", "acme", "set-role", "agt_ops#cmp_acme", "admin"],
780
+ { from: "user" },
781
+ );
782
+
783
+ expect(mockEnsureCognitoToken).toHaveBeenCalledTimes(1);
784
+ expect(mockGetCompanyUid).toHaveBeenCalledWith("test-token", "acme");
785
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
786
+
787
+ const call = fetchSpy.mock.calls[0];
788
+ expect(String(call[0])).toMatch(/\/membership\/role$/);
789
+ expect(call[1]?.method).toBe("POST");
790
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
791
+ expect(body).toEqual({
792
+ companyUid: "cmp_acme",
793
+ membershipKey: "agt_ops#cmp_acme",
794
+ newRole: "admin",
795
+ });
796
+ expect(logSpy).toHaveBeenCalledWith(
797
+ expect.stringContaining("Updated role for 'agt_ops#cmp_acme' to admin"),
798
+ );
799
+ expect(errSpy).not.toHaveBeenCalled();
800
+ });
801
+
802
+ it("surfaces a backend 403 verbatim and exits non-zero", async () => {
803
+ fetchSpy.mockResolvedValueOnce(
804
+ jsonResponse(403, { error: "Only owners can change member roles." }),
805
+ );
806
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
807
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
808
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
809
+ throw new Error(`__EXIT__:${code ?? 0}`);
810
+ }) as never);
811
+
812
+ await expect(
813
+ buildMembersProgram().parseAsync(
814
+ ["members", "--company", "acme", "set-role", "agt_ops#cmp_acme", "admin"],
815
+ { from: "user" },
816
+ ),
817
+ ).rejects.toThrow("__EXIT__:1");
818
+
819
+ expect(errSpy).toHaveBeenCalledWith(
820
+ expect.stringContaining("Only owners can change member roles."),
821
+ );
822
+ });
823
+ });
824
+
734
825
  // ---------------------------------------------------------------------------
735
826
  // resolveRevokeTargetToMembershipKey
736
827
  // ---------------------------------------------------------------------------
@@ -6,8 +6,10 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
6
6
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
7
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
8
  export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
9
+ const VALID_MEMBER_SET_ROLES = new Set(["admin", "member"]);
9
10
 
10
11
  export type Role = "owner" | "admin" | "member" | "guest";
12
+ type MemberSetRole = "admin" | "member";
11
13
 
12
14
  export interface PendingInvite {
13
15
  membershipKey: string;
@@ -455,12 +457,71 @@ export async function revokeInvite(
455
457
  }
456
458
  }
457
459
 
460
+ async function setMemberRole(
461
+ token: string,
462
+ companyUid: string,
463
+ membershipKey: string,
464
+ newRole: MemberSetRole,
465
+ ): Promise<void> {
466
+ if (!VALID_MEMBER_SET_ROLES.has(newRole)) {
467
+ throw new Error(
468
+ `Invalid role '${newRole}': must be one of admin, member`,
469
+ );
470
+ }
471
+
472
+ const res = await vaultApiFetch({
473
+ token,
474
+ path: "/membership/role",
475
+ method: "POST",
476
+ body: { companyUid, membershipKey, newRole },
477
+ });
478
+ if (!res.ok) {
479
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
480
+ throw new InviteHttpError(
481
+ res.status,
482
+ err.message ?? err.error ?? res.statusText,
483
+ err.code,
484
+ );
485
+ }
486
+ }
487
+
458
488
  export function registerMembersCommand(program: Command): void {
459
489
  const members = program
460
490
  .command("members")
461
491
  .description("Manage company memberships and invites")
462
492
  .option("--company <slug>", "Company slug (resolves to companyUid)");
463
493
 
494
+ members
495
+ .command("set-role <membershipKey> <role>")
496
+ .description("Change a member or agent role to admin or member")
497
+ .action(async (membershipKey: string, role: string) => {
498
+ try {
499
+ const token = await ensureCognitoToken();
500
+ const companySlug = members.opts().company as string | undefined;
501
+ const companyUid = await getCompanyUid(token, companySlug);
502
+
503
+ await setMemberRole(
504
+ token,
505
+ companyUid,
506
+ membershipKey,
507
+ role as MemberSetRole,
508
+ );
509
+ console.log(
510
+ chalk.green(`Updated role for '${membershipKey}' to ${role}`),
511
+ );
512
+ } catch (err) {
513
+ if (err instanceof InviteHttpError) {
514
+ console.error(chalk.red(err.message));
515
+ process.exit(1);
516
+ }
517
+ console.error(
518
+ chalk.red("Error:"),
519
+ err instanceof Error ? err.message : String(err),
520
+ );
521
+ process.exit(1);
522
+ }
523
+ });
524
+
464
525
  members
465
526
  .command("invite <target>")
466
527
  .description(
@@ -9,6 +9,7 @@ import { installHqPlugin, prewarmHqSecrets, type PluginState } from '../run/hq-p
9
9
  vi.mock('../utils/secrets-cache.js', () => {
10
10
  const store = new Map<string, string>();
11
11
  return {
12
+ DEFAULT_SECRETS_CACHE_TTL_MS: 300000,
12
13
  readCache: (uid: string, name: string): string | null =>
13
14
  store.get(`${uid}\0${name}`) ?? null,
14
15
  writeCache: (uid: string, name: string, value: string): void => {
@@ -46,7 +47,10 @@ test('.env.local overrides hq()-resolved value', async () => {
46
47
  // fetchBatch returns "vault-value" — if .env.local is correctly wired,
47
48
  // graph resolution should still surface "local-value" because the local
48
49
  // file wins precedence.
49
- fetchBatch: async () => ({ secrets: [{ name: 'KEY', value: 'vault-value' }], errors: [] }),
50
+ fetchBatch: async () => ({
51
+ secrets: [{ name: 'KEY', value: 'vault-value', cacheTtlMs: 0 }],
52
+ errors: [],
53
+ }),
50
54
  };
51
55
 
52
56
  let state!: PluginState;
@@ -4,9 +4,27 @@ import * as path from 'node:path';
4
4
  import * as fs from 'node:fs';
5
5
  import { internal } from 'varlock';
6
6
  import { ensureCognitoToken } from '../utils/cognito-session.js';
7
+ import { computeSha256 } from '../utils/integrity.js';
7
8
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
8
9
  import { discoverSchemas } from '../run/discover-schemas.js';
9
10
  import { installHqPlugin, prewarmHqSecrets, type PluginState, type InstallHqPluginOpts } from '../run/hq-plugin.js';
11
+ import type { SecretLoadResponse, SecretUsage } from './secrets.js';
12
+
13
+ async function buildRunUsage(scriptPath?: string): Promise<SecretUsage | undefined> {
14
+ if (!scriptPath) {
15
+ return undefined;
16
+ }
17
+ const resolvedPath = path.resolve(scriptPath);
18
+ return {
19
+ channel: 'run',
20
+ script: {
21
+ scriptId: resolvedPath,
22
+ path: resolvedPath,
23
+ sha256: await computeSha256(resolvedPath),
24
+ attestationLevel: 'self-asserted-hash',
25
+ },
26
+ };
27
+ }
10
28
 
11
29
  export function registerRunCommand(program: Command): void {
12
30
  program
@@ -14,9 +32,10 @@ export function registerRunCommand(program: Command): void {
14
32
  .description('Load secrets from .env.schema and run a command with them injected')
15
33
  .option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
16
34
  .option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
35
+ .option('--script <path>', 'Attach local script identity for script-locked secrets')
17
36
  .option('--check', 'Resolve schema and validate vars without executing the command')
18
37
  .allowUnknownOption(true)
19
- .action(async (opts: { company?: string; schema?: string; check?: boolean }) => {
38
+ .action(async (opts: { company?: string; schema?: string; script?: string; check?: boolean }) => {
20
39
  try {
21
40
  const dashIndex = process.argv.indexOf('--');
22
41
  const childArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : [];
@@ -59,27 +78,39 @@ export function registerRunCommand(program: Command): void {
59
78
 
60
79
  const token = await ensureCognitoToken();
61
80
  const uid = await getCompanyUid(token, slug);
81
+ const usage = await buildRunUsage(opts.script);
62
82
 
63
- const fetchBatch: InstallHqPluginOpts['fetchBatch'] = async (companyUid, names) => {
83
+ const fetchBatch: InstallHqPluginOpts['fetchBatch'] = async (companyUid, names, requestUsage) => {
64
84
  const res = await vaultApiFetch({
65
85
  token,
66
86
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
67
87
  method: 'POST',
68
- body: { names },
88
+ body: requestUsage ? { names, usage: requestUsage } : { names },
69
89
  });
70
90
  if (!res.ok) {
71
- const body = await res.json().catch(() => ({})) as Record<string, string>;
72
- throw new Error(`Failed to batch-load secrets: ${body.error ?? res.statusText}`);
91
+ const body = await res.json().catch(() => ({})) as Record<string, unknown>;
92
+ const message =
93
+ typeof body.message === 'string'
94
+ ? body.message
95
+ : typeof body.error === 'string'
96
+ ? body.error
97
+ : res.statusText;
98
+ if (
99
+ res.status >= 400 &&
100
+ res.status < 500 &&
101
+ typeof body.code === 'string'
102
+ ) {
103
+ throw new Error(message);
104
+ }
105
+ throw new Error(`Failed to batch-load secrets: ${message}`);
73
106
  }
74
- return res.json() as Promise<{
75
- secrets: Array<{ name: string; value: string }>;
76
- errors: Array<{ name: string; code: string; message?: string }>;
77
- }>;
107
+ return res.json() as Promise<SecretLoadResponse>;
78
108
  };
79
109
 
80
110
  const pluginOpts: InstallHqPluginOpts = {
81
111
  companyOverride: opts.company,
82
112
  resolveCompanyUid: async () => uid,
113
+ usage,
83
114
  fetchBatch,
84
115
  };
85
116