@indigoai-us/hq-cli 5.18.2 → 5.19.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.18.3] — 2026-05-21
6
+
7
+ ### Fixed
8
+
9
+ - **`hq members invite`: loud no-email warning + clearer next-steps.**
10
+ schemaVersion-2 invites only create the email-keyed pending DDB row —
11
+ the CLI does NOT send email. New output leads with a `chalk.yellow
12
+ ⚠ No email was sent` warning + a two-option "do ONE of: manually
13
+ notify / use hq-console UI" block. Previous wording read like passive
14
+ next-step copy; admins mistook it for "email sent." (#22)
15
+ - **`hq members revoke <bare-email>`: no more 404.** Accepts bare email
16
+ and bare `prs_*` personUids, wrapping them into the canonical
17
+ `email:<email>#<companyUid>` / `<personUid>#<companyUid>` membership
18
+ key shape before posting to the server. (#22)
19
+
20
+ ## [5.18.2] — 2026-05-21
21
+
22
+ ### Fixed
23
+
24
+ - **`hq members invite` + `hq members list` under schemaVersion 2 server.**
25
+ Aligns CLI with hq-pro membership schema-v2 (live-smoke surfaced regression). (#20, #21)
26
+
27
+ ## [5.18.1] — 2026-05-21
28
+
29
+ ### Fixed
30
+
31
+ - **Wire narrow-hint banner into per-company pull (US-011).** Banner now renders
32
+ on the per-company `hq sync pull` path, not just full-sync. (#18, #19)
33
+
34
+ ## [5.18.0] — 2026-05-21
35
+
36
+ ### Added — sync-browse-vs-sync
37
+
38
+ - **`hq sync mode <shared|narrow>`** — set per-membership sync mode (US-006).
39
+ - **`hq sync narrow --dry-run | --apply`** — preview/execute ACL narrowing for
40
+ the active company (US-007).
41
+ - **`hq files browse <prefix>` + `hq files cat <path>`** — read-only vault
42
+ browse via purpose=browse STS scope, distinct from sync (US-008).
43
+ - **Narrow-hint banner** — surfaces on `hq sync` when local membership is
44
+ shared-mode but cloud signals narrowing is recommended (US-011).
45
+ - Bumps `@indigoai-us/hq-cloud` to 5.23.0 (VaultClient SDK + sync engine
46
+ narrowing + journal v2). (#16, #17)
47
+
5
48
  ## [5.14.1] — 2026-05-14
6
49
 
7
50
  ### Fixed
@@ -153,6 +153,23 @@ export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Pro
153
153
  * no person entity (typically means they haven't run `hq onboard`).
154
154
  */
155
155
  export declare function resolveCanonicalPersonUid(vaultClient: PullAllVaultClient): Promise<string>;
156
+ /**
157
+ * Refuse `hq sync push --personal <path>` — the combination silently
158
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
159
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
160
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
161
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
162
+ * objects to a personal vault before being killed. Cleanup required a
163
+ * hand-rolled S3 sweep. Closes hq-cli#25.
164
+ *
165
+ * Refusal — not silent filtering — is intentional: explicit is better than
166
+ * implicit guesswork, and the legitimate "I want to push a subset of my
167
+ * personal vault" use case has a clean workaround (drop `--personal`, the
168
+ * subset upload targets the active company via standard semantics).
169
+ */
170
+ export declare function assertNoPersonalPositionalPaths(opts: {
171
+ personal?: boolean;
172
+ }, paths: string[] | undefined): void;
156
173
  /**
157
174
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
158
175
  * `--company` are mutually exclusive — at most one may be set per
@@ -13,7 +13,7 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
 
16
- !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]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}catch(e){}}();
16
+ !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]="f6dfc1de-3284-575a-b790-3dc10db2707c")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
@@ -234,6 +234,30 @@ export async function resolveCanonicalPersonUid(vaultClient) {
234
234
  }
235
235
  return pick.uid;
236
236
  }
237
+ /**
238
+ * Refuse `hq sync push --personal <path>` — the combination silently
239
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
240
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
241
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
242
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
243
+ * objects to a personal vault before being killed. Cleanup required a
244
+ * hand-rolled S3 sweep. Closes hq-cli#25.
245
+ *
246
+ * Refusal — not silent filtering — is intentional: explicit is better than
247
+ * implicit guesswork, and the legitimate "I want to push a subset of my
248
+ * personal vault" use case has a clean workaround (drop `--personal`, the
249
+ * subset upload targets the active company via standard semantics).
250
+ */
251
+ export function assertNoPersonalPositionalPaths(opts, paths) {
252
+ if (opts.personal && paths && paths.length > 0) {
253
+ throw new Error("`--personal` cannot be combined with explicit [paths]: " +
254
+ "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
255
+ "guard (skips .git/, companies/, repos/, workspace/), risking " +
256
+ "cross-scope upload of company data to the personal vault. " +
257
+ "Use bare `--personal` to push the whole personal scope, OR " +
258
+ "drop `--personal` to push specific paths to the active company.");
259
+ }
260
+ }
237
261
  /**
238
262
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
239
263
  * `--company` are mutually exclusive — at most one may be set per
@@ -372,6 +396,8 @@ export function registerCloudCommands(program) {
372
396
  "Cognito session, while --creds-from-stdin expects the caller " +
373
397
  "to have already resolved entity + credentials. Pick one.");
374
398
  }
399
+ // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
400
+ assertNoPersonalPositionalPaths(options, paths);
375
401
  log(chalk.bold("\nHQ Sync — Push"));
376
402
  log(` HQ root: ${options.hqRoot}`);
377
403
  // Resolve credentials. Two paths:
@@ -1063,4 +1089,4 @@ function resolveUploadAuthorFromCache() {
1063
1089
  }
1064
1090
  }
1065
1091
  //# sourceMappingURL=cloud.js.map
1066
- //# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
1092
+ //# debugId=f6dfc1de-3284-575a-b790-3dc10db2707c
@@ -72,8 +72,17 @@ export type S3ClientFactory = (input: {
72
72
  sessionToken: string;
73
73
  };
74
74
  }) => FilesBrowseS3Client;
75
- /** ACL provenance for a single listed key. */
76
- export type AclSource = "shared-with-you" | "role-bypass";
75
+ /**
76
+ * ACL provenance for a single listed key.
77
+ * - `shared-with-you`: an explicit grant the caller holds covers the key.
78
+ * - `role-bypass`: the caller has no covering explicit grant, but
79
+ * owner/admin role widened the browse-vend policy to include it.
80
+ * - `personal-vault`: the key lives in the caller's own person-entity
81
+ * vault, where no grants graph applies — the caller is the only
82
+ * principal with access by construction. Emitted only when
83
+ * `runBrowse({ personalMode: true })`.
84
+ */
85
+ export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
77
86
  export interface BrowseRow {
78
87
  key: string;
79
88
  size: number;
@@ -112,10 +121,30 @@ export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: s
112
121
  */
113
122
  export declare function formatBrowseTable(rows: BrowseRow[]): string;
114
123
  export interface RunBrowseInput {
115
- /** Vault path prefix, e.g. `companies/indigo/scratch/`. */
124
+ /**
125
+ * Vault path prefix.
126
+ * - Company mode (`personalMode: false | undefined`): must start with
127
+ * `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
128
+ * - Personal mode (`personalMode: true`): bucket-relative; empty string
129
+ * lists the whole personal vault root.
130
+ */
116
131
  pathPrefix: string;
117
- /** Caller-overridden company slug (defaults to slug parsed from path). */
132
+ /** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
118
133
  companySlug?: string;
134
+ /**
135
+ * Personal-vault mode. Skips the `companies/<slug>/` path requirement,
136
+ * resolves the entity via `entity.get(personalUid)` instead of the
137
+ * company namespace, omits the explicit-grants fetch (no grants graph
138
+ * on a person bucket), and marks every row's `aclSource` as
139
+ * `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
140
+ */
141
+ personalMode?: boolean;
142
+ /**
143
+ * Canonical person-entity UID (e.g. `prs_…`). Required when
144
+ * `personalMode: true`; ignored otherwise. Caller resolves via
145
+ * `resolveCanonicalPersonUid` to keep this orchestrator pure.
146
+ */
147
+ personalUid?: string;
119
148
  vaultClient: FilesBrowseVaultClient;
120
149
  s3Factory: S3ClientFactory;
121
150
  region: string;
@@ -137,7 +166,11 @@ export interface RunBrowseResult {
137
166
  */
138
167
  export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
139
168
  export interface RunCatInput {
140
- /** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
169
+ /**
170
+ * Single vault key.
171
+ * - Company mode: must be a `companies/<slug>/...` path.
172
+ * - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
173
+ */
141
174
  key: string;
142
175
  /**
143
176
  * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
@@ -146,6 +179,10 @@ export interface RunCatInput {
146
179
  out?: string;
147
180
  hqRoot: string;
148
181
  companySlug?: string;
182
+ /** Personal-vault mode — see `RunBrowseInput.personalMode`. */
183
+ personalMode?: boolean;
184
+ /** Canonical person-entity UID; required when `personalMode: true`. */
185
+ personalUid?: string;
149
186
  vaultClient: FilesBrowseVaultClient;
150
187
  s3Factory: S3ClientFactory;
151
188
  region: string;
@@ -29,7 +29,7 @@
29
29
  * `pnpm.overrides` until that release ships to npm.
30
30
  */
31
31
 
32
- !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]="7a4467c5-bef0-5f15-99f9-9db157497caa")}catch(e){}}();
32
+ !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]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
33
33
  import chalk from "chalk";
34
34
  import * as fs from "node:fs";
35
35
  import * as path from "node:path";
@@ -38,6 +38,7 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/clie
38
38
  import { VaultClient, } from "@indigoai-us/hq-cloud";
39
39
  import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
40
40
  import { getCompanyUid } from "../utils/vault-api.js";
41
+ import { resolveCanonicalPersonUid } from "./cloud.js";
41
42
  // ── Pure helpers ────────────────────────────────────────────────────────────
42
43
  /**
43
44
  * Parse the company slug from a vault prefix. Vault paths are anchored at
@@ -122,19 +123,43 @@ export function formatBrowseTable(rows) {
122
123
  * Pure-ish: no console output, no process.exit — caller renders + exits.
123
124
  */
124
125
  export async function runBrowse(input) {
125
- const { pathPrefix, vaultClient, s3Factory, region } = input;
126
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
127
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
128
- if (!entity) {
129
- throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
126
+ const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
127
+ // Branch by mode. Company mode parses slug from path and looks up by
128
+ // namespace; personal mode resolves the entity directly by the supplied
129
+ // person UID and skips the slug + grants machinery (a person bucket has
130
+ // no grants graph the owner is the only principal). The vend call is
131
+ // identical for both modes once we have the entity in hand.
132
+ let bucket;
133
+ let entityUid;
134
+ if (personalMode) {
135
+ if (!input.personalUid) {
136
+ throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
137
+ "resolveCanonicalPersonUid() before calling.");
138
+ }
139
+ const entity = await vaultClient.entity.get(input.personalUid);
140
+ if (!entity.bucketName) {
141
+ throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
142
+ }
143
+ entityUid = entity.uid;
144
+ bucket = entity.bucketName;
130
145
  }
131
- if (!entity.bucketName) {
132
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
146
+ else {
147
+ const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
148
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
149
+ if (!entity) {
150
+ throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
151
+ }
152
+ if (!entity.bucketName) {
153
+ throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
154
+ }
155
+ entityUid = entity.uid;
156
+ bucket = entity.bucketName;
133
157
  }
134
- const companyUid = entity.uid;
135
- const bucket = entity.bucketName;
136
158
  // Distinct vend call from sync — `purpose: 'browse'` opts the request
137
- // into the role-bypass-allowed code path on the server (US-009).
159
+ // into the role-bypass-allowed code path on the server (US-009). The
160
+ // personal mode vends against the person entity which is owner-only by
161
+ // construction; the vend response shape is identical so downstream
162
+ // S3Client construction doesn't branch.
138
163
  const vend = await vaultClient.vend({
139
164
  paths: [pathPrefix],
140
165
  operations: "read-only",
@@ -149,8 +174,12 @@ export async function runBrowse(input) {
149
174
  },
150
175
  });
151
176
  // Pull the caller's explicit-grant graph once so per-key classification
152
- // is O(grants) without N round-trips.
153
- const grants = await vaultClient.listMyExplicitGrants(companyUid);
177
+ // is O(grants) without N round-trips. Skipped in personal mode — the
178
+ // grants graph is a company concept; a person bucket marks every row
179
+ // as `"personal-vault"` directly.
180
+ const grants = personalMode
181
+ ? []
182
+ : await vaultClient.listMyExplicitGrants(entityUid);
154
183
  const rows = [];
155
184
  let continuationToken;
156
185
  do {
@@ -169,7 +198,7 @@ export async function runBrowse(input) {
169
198
  key: obj.Key,
170
199
  size: obj.Size ?? 0,
171
200
  lastModified: obj.LastModified,
172
- aclSource: classifyAclSource(obj.Key, grants),
201
+ aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
173
202
  });
174
203
  }
175
204
  continuationToken = resp.NextContinuationToken ?? undefined;
@@ -182,20 +211,37 @@ export async function runBrowse(input) {
182
211
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
183
212
  */
184
213
  export async function runCat(input) {
185
- const { key, vaultClient, s3Factory, region, hqRoot } = input;
186
- const slug = input.companySlug ?? parseCompanySlugFromPath(key);
214
+ const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
187
215
  // Acceptance 5: refuse BEFORE vending — no point pulling credentials
188
216
  // for a request we're already going to abort.
189
217
  let absOut;
190
218
  if (input.out !== undefined) {
191
219
  absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
192
220
  }
193
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
194
- if (!entity) {
195
- throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
221
+ // Same branch logic as runBrowse — see that function's doc-block for
222
+ // the personal-vs-company rationale.
223
+ let bucket;
224
+ if (personalMode) {
225
+ if (!input.personalUid) {
226
+ throw new Error("runCat: personalMode requires personalUid. Resolve via " +
227
+ "resolveCanonicalPersonUid() before calling.");
228
+ }
229
+ const entity = await vaultClient.entity.get(input.personalUid);
230
+ if (!entity.bucketName) {
231
+ throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
232
+ }
233
+ bucket = entity.bucketName;
196
234
  }
197
- if (!entity.bucketName) {
198
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
235
+ else {
236
+ const slug = input.companySlug ?? parseCompanySlugFromPath(key);
237
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
238
+ if (!entity) {
239
+ throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
240
+ }
241
+ if (!entity.bucketName) {
242
+ throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
243
+ }
244
+ bucket = entity.bucketName;
199
245
  }
200
246
  const vend = await vaultClient.vend({
201
247
  paths: [key],
@@ -210,7 +256,7 @@ export async function runCat(input) {
210
256
  sessionToken: vend.credentials.sessionToken,
211
257
  },
212
258
  });
213
- const resp = (await s3.send(new GetObjectCommand({ Bucket: entity.bucketName, Key: key })));
259
+ const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
214
260
  if (!resp.Body) {
215
261
  throw new Error(`GetObject for '${key}' returned no body.`);
216
262
  }
@@ -247,15 +293,47 @@ const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, cre
247
293
  */
248
294
  export function registerFilesBrowseCommands(filesCmd) {
249
295
  filesCmd
250
- .command("browse <path>")
251
- .description("List vault objects under <path> without syncing them locally. Uses the browse-vend path (role-bypass allowed).")
296
+ .command("browse [path]")
297
+ .description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
252
298
  .option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
299
+ .option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
300
+ "bucket-relative (omit it to list the vault root). Mutually exclusive " +
301
+ "with --company.")
253
302
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
254
303
  .action(async (pathArg, options) => {
255
304
  try {
305
+ if (options.personal && options.company) {
306
+ throw new Error("--personal and --company are mutually exclusive. Pick one.");
307
+ }
256
308
  const accessToken = await ensureCognitoToken();
257
309
  const vaultConfig = buildVaultConfig(accessToken);
258
310
  const client = new VaultClient(vaultConfig);
311
+ if (options.personal) {
312
+ // Personal-vault path. Resolve the caller's canonical person
313
+ // entity once; the orchestrator does the bucket lookup + vend.
314
+ // Empty [path] → list bucket root.
315
+ const personalUid = await resolveCanonicalPersonUid({
316
+ listMyMemberships: () => client.listMyMemberships(),
317
+ listPersonEntities: () => client.entity.listByType("person"),
318
+ getEntity: async () => null,
319
+ });
320
+ const result = await runBrowse({
321
+ pathPrefix: pathArg ?? "",
322
+ personalMode: true,
323
+ personalUid,
324
+ vaultClient: client,
325
+ s3Factory: defaultS3Factory,
326
+ region: DEFAULT_COGNITO.region,
327
+ });
328
+ console.log(formatBrowseTable(result.rows));
329
+ return;
330
+ }
331
+ // Company path. [path] is required here — the slug parse needs it.
332
+ if (!pathArg) {
333
+ throw new Error("browse: [path] is required when --personal is not set. " +
334
+ "Pass a companies/<slug>/... path, or add --personal to " +
335
+ "browse your personal vault.");
336
+ }
259
337
  // Resolve slug — CLI flag wins, otherwise parse from path arg.
260
338
  const slug = options.company ?? parseCompanySlugFromPath(pathArg);
261
339
  // If the user passed `--company` AND the path doesn't begin with
@@ -301,15 +379,41 @@ export function registerFilesBrowseCommands(filesCmd) {
301
379
  });
302
380
  filesCmd
303
381
  .command("cat <path>")
304
- .description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.")
382
+ .description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.")
305
383
  .option("--out <file>", "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.")
306
384
  .option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
385
+ .option("--personal", "Read from the caller's canonical personal vault. <path> is treated as " +
386
+ "bucket-relative. Mutually exclusive with --company.")
307
387
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
308
388
  .action(async (keyArg, options) => {
309
389
  try {
390
+ if (options.personal && options.company) {
391
+ throw new Error("--personal and --company are mutually exclusive. Pick one.");
392
+ }
310
393
  const accessToken = await ensureCognitoToken();
311
394
  const vaultConfig = buildVaultConfig(accessToken);
312
395
  const client = new VaultClient(vaultConfig);
396
+ if (options.personal) {
397
+ const personalUid = await resolveCanonicalPersonUid({
398
+ listMyMemberships: () => client.listMyMemberships(),
399
+ listPersonEntities: () => client.entity.listByType("person"),
400
+ getEntity: async () => null,
401
+ });
402
+ const result = await runCat({
403
+ key: keyArg,
404
+ out: options.out,
405
+ hqRoot: options.hqRoot,
406
+ personalMode: true,
407
+ personalUid,
408
+ vaultClient: client,
409
+ s3Factory: defaultS3Factory,
410
+ region: DEFAULT_COGNITO.region,
411
+ });
412
+ if (result.destination.kind === "file") {
413
+ console.error(chalk.green("✓"), `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`);
414
+ }
415
+ return;
416
+ }
313
417
  const slug = options.company ?? parseCompanySlugFromPath(keyArg);
314
418
  if (options.company !== undefined) {
315
419
  const fromPath = (() => {
@@ -345,4 +449,4 @@ export function registerFilesBrowseCommands(filesCmd) {
345
449
  });
346
450
  }
347
451
  //# sourceMappingURL=files-browse.js.map
348
- //# debugId=7a4467c5-bef0-5f15-99f9-9db157497caa
452
+ //# debugId=55a4ece7-6b33-5809-97c0-475c423bc012
@@ -66,6 +66,23 @@ export declare class InviteHttpError extends Error {
66
66
  }
67
67
  export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
68
68
  export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
69
+ /**
70
+ * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
71
+ * the server requires. Accepts three input forms:
72
+ *
73
+ * 1. Full membership key — already has `#<companyUid>`; passed through.
74
+ * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
75
+ * 2. Bare email — wrap as `email:<email>#<companyUid>`.
76
+ * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
77
+ * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
78
+ * so the server can decide. Server may 404 if the token doesn't resolve.
79
+ *
80
+ * Pure function — no I/O — so it's trivially unit-testable. The previous
81
+ * shape sent the user's raw arg straight through, which meant `hq members
82
+ * revoke alice@example.com` always 404'd ("Invite not found") even when
83
+ * that exact email was just shown by `hq members list`.
84
+ */
85
+ export declare function resolveRevokeTargetToMembershipKey(arg: string, companyUid: string): string;
69
86
  export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
70
87
  export declare function registerMembersCommand(program: Command): void;
71
88
  //# sourceMappingURL=members.d.ts.map
@@ -1,5 +1,5 @@
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]="20c76490-0485-592f-856b-3b20e403a392")}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]="c06b812a-ee4a-5701-a64b-cee2fcfef523")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -135,6 +135,34 @@ export async function listPendingInvites(token, companyUid) {
135
135
  const data = (await res.json());
136
136
  return data?.pending ?? data?.invites ?? [];
137
137
  }
138
+ /**
139
+ * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
140
+ * the server requires. Accepts three input forms:
141
+ *
142
+ * 1. Full membership key — already has `#<companyUid>`; passed through.
143
+ * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
144
+ * 2. Bare email — wrap as `email:<email>#<companyUid>`.
145
+ * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
146
+ * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
147
+ * so the server can decide. Server may 404 if the token doesn't resolve.
148
+ *
149
+ * Pure function — no I/O — so it's trivially unit-testable. The previous
150
+ * shape sent the user's raw arg straight through, which meant `hq members
151
+ * revoke alice@example.com` always 404'd ("Invite not found") even when
152
+ * that exact email was just shown by `hq members list`.
153
+ */
154
+ export function resolveRevokeTargetToMembershipKey(arg, companyUid) {
155
+ if (arg.includes("#"))
156
+ return arg;
157
+ const detected = detectTarget(arg);
158
+ if (detected?.type === "email") {
159
+ return `email:${detected.value}#${companyUid}`;
160
+ }
161
+ if (detected?.type === "person") {
162
+ return `${detected.value}#${companyUid}`;
163
+ }
164
+ return arg;
165
+ }
138
166
  export async function revokeInvite(token, tokenOrKey, companyUid) {
139
167
  const res = await vaultApiFetch({
140
168
  token,
@@ -183,16 +211,29 @@ export function registerMembersCommand(program) {
183
211
  else {
184
212
  // schemaVersion 2+ — email-keyed authoritative membership row.
185
213
  // No magic link to share; invitee accepts by signing into HQ.
214
+ //
215
+ // CRITICAL UX NOTE: this CLI command does NOT send any email.
216
+ // hq-pro only writes the DDB pending row; only the hq-console UI
217
+ // path triggers Resend. Operators who run `hq members invite`
218
+ // expecting an email to fly out get silently broken flows. The
219
+ // output below uses chalk.yellow + an explicit "no email sent"
220
+ // line so this never sneaks past again.
186
221
  const inviteeEmail = result.membership.inviteeEmail ??
187
222
  (typeof target === "string" && target.includes("@")
188
223
  ? target
189
224
  : undefined);
190
- console.log(chalk.bold("Next step:"));
191
- console.log(` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`);
192
- console.log(chalk.dim(" The pending membership row claims itself on first sign-in — no separate token redemption."));
225
+ console.log(chalk.yellow(" No email was sent. `hq members invite` only creates the pending membership row."));
226
+ console.log();
227
+ console.log(chalk.bold("To complete the invite, do ONE of:"));
228
+ console.log(` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`);
229
+ console.log(` at https://hq.getindigo.ai with that email address.`);
230
+ console.log(` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`);
231
+ console.log(` invite instead — the UI path triggers an automated email via Resend.`);
232
+ console.log();
233
+ console.log(chalk.dim("The pending membership row claims itself on the invitee's first sign-in."));
193
234
  if (result.membership.membershipKey) {
194
235
  console.log();
195
- console.log(chalk.dim(` Membership key: ${result.membership.membershipKey}`));
236
+ console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
196
237
  }
197
238
  }
198
239
  }
@@ -253,15 +294,16 @@ export function registerMembersCommand(program) {
253
294
  }
254
295
  });
255
296
  members
256
- .command("revoke <tokenOrKey>")
257
- .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
258
- .action(async (tokenOrKey) => {
297
+ .command("revoke <target>")
298
+ .description("Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.")
299
+ .action(async (target) => {
259
300
  try {
260
301
  const token = await ensureCognitoToken();
261
302
  const companySlug = members.opts().company;
262
303
  const companyUid = await getCompanyUid(token, companySlug);
263
- await revokeInvite(token, tokenOrKey, companyUid);
264
- console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
304
+ const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
305
+ await revokeInvite(token, membershipKey, companyUid);
306
+ console.log(chalk.green(`Revoked invite '${membershipKey}'`));
265
307
  }
266
308
  catch (err) {
267
309
  if (err instanceof InviteHttpError) {
@@ -281,4 +323,4 @@ export function registerMembersCommand(program) {
281
323
  });
282
324
  }
283
325
  //# sourceMappingURL=members.js.map
284
- //# debugId=20c76490-0485-592f-856b-3b20e403a392
326
+ //# debugId=c06b812a-ee4a-5701-a64b-cee2fcfef523
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.18.2",
3
+ "version": "5.19.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {