@indigoai-us/hq-cli 5.17.0 → 5.18.1

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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * `hq sync narrow` (US-007) — explicit migration ritual to flip a company's
3
+ * membership from `syncMode: all` to `syncMode: shared`, with a dry-run
4
+ * preview and a dirty-file safety gate.
5
+ *
6
+ * Two call shapes:
7
+ *
8
+ * hq sync narrow --dry-run [--company <slug>]
9
+ * Resolves the membership for <slug> (or the active company), fetches
10
+ * the caller's explicit grants, coalesces them, walks the local
11
+ * `companies/<slug>/` tree, and prints a table:
12
+ *
13
+ * files staying: N
14
+ * clean orphans: N (size)
15
+ * dirty orphans: N (size)
16
+ *
17
+ * Writes nothing — no journal, no local file, no server call beyond
18
+ * the read-only GET on grants + sync-config.
19
+ *
20
+ * hq sync narrow --apply [--yes] [--force] [--company <slug>]
21
+ * Same computation, then:
22
+ * - if `dirty > 0` and `--force` NOT set → abort with conflict report
23
+ * and a recovery message.
24
+ * - else delete clean orphans on disk, tombstone their journal
25
+ * entries, optionally delete dirty orphans too (with `--force`),
26
+ * then PUT sync-config to `shared`. The server's PUT writes the
27
+ * authoritative `MEMBERSHIP_SYNC_CONFIG_CHANGED` audit row; the
28
+ * CLI prints a local audit line as well.
29
+ *
30
+ * Refuses on already-`shared` or `custom` memberships (acceptance 5) —
31
+ * narrowing makes sense only as `all → shared`.
32
+ *
33
+ * Cross-package note: depends on hq-cloud helpers shipped in US-005
34
+ * (`coalescePrefixes`, `readJournal`/`writeJournal`, `tombstoneEntry`) and
35
+ * the VaultClient sync-config endpoints from US-003. While that hq-cloud
36
+ * release is unpublished, hq-cli pins `@indigoai-us/hq-cloud` to
37
+ * `file:../hq-cloud` via `pnpm.overrides`.
38
+ */
39
+
40
+ import { Command } from "commander";
41
+ import chalk from "chalk";
42
+ import * as readline from "node:readline";
43
+ import * as fs from "node:fs";
44
+
45
+ import {
46
+ VaultClient,
47
+ coalescePrefixes,
48
+ readJournal,
49
+ writeJournal,
50
+ tombstoneEntry,
51
+ type ExplicitGrant,
52
+ type Membership,
53
+ type MembershipSyncConfig,
54
+ type SyncMode,
55
+ type SyncJournal,
56
+ } from "@indigoai-us/hq-cloud";
57
+
58
+ import {
59
+ DEFAULT_HQ_ROOT,
60
+ ensureCognitoToken,
61
+ buildVaultConfig,
62
+ } from "../utils/cognito-session.js";
63
+ import { readActiveCompanySlug } from "./sync-mode.js";
64
+ import {
65
+ buildNarrowPlan,
66
+ formatBytes,
67
+ formatNarrowPlanSummary,
68
+ type NarrowPlan,
69
+ type NarrowDirtyFile,
70
+ } from "../lib/local-tree-diff.js";
71
+
72
+ // ── Types ───────────────────────────────────────────────────────────────────
73
+
74
+ /** Subset of VaultClient surface this command exercises (test seam). */
75
+ export interface SyncNarrowVaultClient {
76
+ listMyMemberships(): Promise<Membership[]>;
77
+ getMembershipSyncConfig(membershipId: string): Promise<MembershipSyncConfig>;
78
+ setMembershipSyncConfig(
79
+ membershipId: string,
80
+ partial: { syncMode: SyncMode; customPaths?: string[] },
81
+ ): Promise<MembershipSyncConfig>;
82
+ listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
83
+ entity: {
84
+ get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
85
+ };
86
+ }
87
+
88
+ /** Journal I/O seam — defaults to the real `readJournal`/`writeJournal`. */
89
+ export interface JournalIO {
90
+ read(slug: string): SyncJournal;
91
+ write(slug: string, journal: SyncJournal): void;
92
+ }
93
+
94
+ const realJournalIO: JournalIO = {
95
+ read: readJournal,
96
+ write: writeJournal,
97
+ };
98
+
99
+ /** Filesystem seam — defaults to real `fs` (only `unlinkSync` is exercised). */
100
+ export interface FsIO {
101
+ unlinkSync(p: string): void;
102
+ }
103
+
104
+ const realFsIO: FsIO = {
105
+ unlinkSync: fs.unlinkSync,
106
+ };
107
+
108
+ /** Confirmation prompt seam — defaults to a `readline` y/N gate. */
109
+ export type ConfirmFn = (message: string) => Promise<boolean>;
110
+
111
+ const realConfirm: ConfirmFn = async (message) => {
112
+ const rl = readline.createInterface({
113
+ input: process.stdin,
114
+ output: process.stdout,
115
+ });
116
+ return new Promise((resolve) => {
117
+ rl.question(`${message} [y/N] `, (answer) => {
118
+ rl.close();
119
+ resolve(/^y(es)?$/i.test(answer.trim()));
120
+ });
121
+ });
122
+ };
123
+
124
+ export interface ResolveTargetInput {
125
+ companySlug: string;
126
+ vaultClient: SyncNarrowVaultClient;
127
+ }
128
+
129
+ export interface ResolveTargetResult {
130
+ membership: Membership;
131
+ companyUid: string;
132
+ companySlug: string;
133
+ syncConfig: MembershipSyncConfig;
134
+ }
135
+
136
+ /**
137
+ * Resolve the caller's membership for `companySlug` and read its current
138
+ * sync-config. Throws if the slug doesn't match any membership or if the
139
+ * current mode isn't eligible for narrowing (`all` → `shared` only).
140
+ */
141
+ export async function resolveNarrowTarget(
142
+ input: ResolveTargetInput,
143
+ ): Promise<ResolveTargetResult> {
144
+ const { companySlug, vaultClient } = input;
145
+ const memberships = await vaultClient.listMyMemberships();
146
+ const enriched = await Promise.all(
147
+ memberships.map(async (m) => {
148
+ try {
149
+ const ent = await vaultClient.entity.get(m.companyUid);
150
+ return { membership: m, slug: ent.slug, name: ent.name };
151
+ } catch {
152
+ return { membership: m, slug: undefined, name: undefined };
153
+ }
154
+ }),
155
+ );
156
+
157
+ const match = enriched.find((row) => row.slug === companySlug);
158
+ if (!match) {
159
+ const known = enriched
160
+ .map((r) => r.slug)
161
+ .filter((s): s is string => !!s)
162
+ .join(", ");
163
+ throw new Error(
164
+ `No membership found for company '${companySlug}'. Memberships visible to you: ${
165
+ known || "(none)"
166
+ }.`,
167
+ );
168
+ }
169
+
170
+ const membership = match.membership;
171
+ const syncConfig = await vaultClient.getMembershipSyncConfig(
172
+ membership.membershipKey,
173
+ );
174
+
175
+ if (syncConfig.syncMode !== "all") {
176
+ throw new Error(
177
+ `Refusing to narrow: membership for '${companySlug}' is currently ` +
178
+ `syncMode='${syncConfig.syncMode}'. The narrow ritual only runs on ` +
179
+ `syncMode='all' memberships (shared → shared is a no-op; ` +
180
+ `custom requires \`hq sync mode shared\` directly).`,
181
+ );
182
+ }
183
+
184
+ return {
185
+ membership,
186
+ companyUid: membership.companyUid,
187
+ companySlug,
188
+ syncConfig,
189
+ };
190
+ }
191
+
192
+ export interface ComputePlanInput {
193
+ hqRoot: string;
194
+ companySlug: string;
195
+ companyUid: string;
196
+ vaultClient: SyncNarrowVaultClient;
197
+ journalIO?: JournalIO;
198
+ }
199
+
200
+ export interface ComputePlanResult {
201
+ plan: NarrowPlan;
202
+ prospectivePrefixSet: string[];
203
+ grants: ExplicitGrant[];
204
+ journal: SyncJournal;
205
+ }
206
+
207
+ /**
208
+ * Compute the narrow plan for one company: fetch explicit grants, coalesce,
209
+ * read the per-company journal, walk the local tree, classify.
210
+ *
211
+ * Reads only — never mutates the journal or hits a destructive endpoint.
212
+ * `apply()` consumes this and performs the destructive steps.
213
+ */
214
+ export async function computeNarrowPlan(
215
+ input: ComputePlanInput,
216
+ ): Promise<ComputePlanResult> {
217
+ const { hqRoot, companySlug, companyUid, vaultClient } = input;
218
+ const io = input.journalIO ?? realJournalIO;
219
+
220
+ const grants = await vaultClient.listMyExplicitGrants(companyUid);
221
+ const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
222
+ const journal = io.read(companySlug);
223
+
224
+ const plan = buildNarrowPlan({
225
+ hqRoot,
226
+ companySlug,
227
+ prospectivePrefixSet,
228
+ journal,
229
+ });
230
+
231
+ return { plan, prospectivePrefixSet, grants, journal };
232
+ }
233
+
234
+ export interface ApplyNarrowInput {
235
+ companySlug: string;
236
+ membershipId: string;
237
+ plan: NarrowPlan;
238
+ journal: SyncJournal;
239
+ vaultClient: SyncNarrowVaultClient;
240
+ /** If true, delete dirty orphans too (still tombstoned). */
241
+ force: boolean;
242
+ journalIO?: JournalIO;
243
+ fsIO?: FsIO;
244
+ }
245
+
246
+ export interface ApplyNarrowResult {
247
+ cleanDeleted: number;
248
+ cleanBytes: number;
249
+ dirtyDeleted: number;
250
+ dirtyBytes: number;
251
+ tombstoned: number;
252
+ newConfig: MembershipSyncConfig;
253
+ }
254
+
255
+ /**
256
+ * Apply the destructive side of a narrow:
257
+ * 1. Delete clean orphans (best-effort — ENOENT is ignored).
258
+ * 2. Tombstone their journal entries.
259
+ * 3. If `force`, also delete + tombstone dirty orphans.
260
+ * 4. Persist the journal.
261
+ * 5. PUT sync-config → `shared` (server writes the audit row).
262
+ *
263
+ * Order is fail-safe: if a delete throws partway, the journal write below
264
+ * won't run, but the in-progress deletions are already on disk — that's the
265
+ * "don't roll back I/O" contract from the spec. The CLI surfaces a
266
+ * recovery message when this throws.
267
+ *
268
+ * Pure-ish: no prompting (the CLI wrapper handles that), no console
269
+ * output. Just disk + network.
270
+ */
271
+ export async function applyNarrow(
272
+ input: ApplyNarrowInput,
273
+ ): Promise<ApplyNarrowResult> {
274
+ const { plan, journal, force, companySlug, membershipId, vaultClient } = input;
275
+ const journalIO = input.journalIO ?? realJournalIO;
276
+ const fsIO = input.fsIO ?? realFsIO;
277
+
278
+ let cleanDeleted = 0;
279
+ let dirtyDeleted = 0;
280
+ let tombstoned = 0;
281
+ const cleanBytes = plan.clean.reduce((sum, f) => sum + f.bytes, 0);
282
+ const dirtyBytes = force
283
+ ? plan.dirty.reduce((sum, f) => sum + f.bytes, 0)
284
+ : 0;
285
+
286
+ // 1. Delete clean orphans + tombstone.
287
+ for (const file of plan.clean) {
288
+ try {
289
+ fsIO.unlinkSync(file.absPath);
290
+ cleanDeleted++;
291
+ } catch (err) {
292
+ const code = (err as NodeJS.ErrnoException).code;
293
+ if (code !== "ENOENT") throw err;
294
+ }
295
+ tombstoneEntry(journal, file.relPath, "narrow_apply");
296
+ tombstoned++;
297
+ }
298
+
299
+ // 2. With --force, delete dirty orphans too.
300
+ if (force) {
301
+ for (const file of plan.dirty) {
302
+ try {
303
+ fsIO.unlinkSync(file.absPath);
304
+ dirtyDeleted++;
305
+ } catch (err) {
306
+ const code = (err as NodeJS.ErrnoException).code;
307
+ if (code !== "ENOENT") throw err;
308
+ }
309
+ // tombstone only if a journal entry exists — `not-in-journal` files
310
+ // never had one to tombstone.
311
+ if (journal.files[file.relPath]) {
312
+ tombstoneEntry(journal, file.relPath, "narrow_apply");
313
+ tombstoned++;
314
+ }
315
+ }
316
+ }
317
+
318
+ // 3. Persist journal.
319
+ journalIO.write(companySlug, journal);
320
+
321
+ // 4. PUT sync-config → shared. Server writes the authoritative
322
+ // MEMBERSHIP_SYNC_CONFIG_CHANGED audit row.
323
+ const newConfig = await vaultClient.setMembershipSyncConfig(membershipId, {
324
+ syncMode: "shared",
325
+ });
326
+
327
+ return {
328
+ cleanDeleted,
329
+ cleanBytes,
330
+ dirtyDeleted,
331
+ dirtyBytes,
332
+ tombstoned,
333
+ newConfig,
334
+ };
335
+ }
336
+
337
+ // ── Dirty-file conflict report (operator-facing) ───────────────────────────
338
+
339
+ /**
340
+ * Format the dirty-file conflict report shown on `--apply` when dirty files
341
+ * are present and `--force` is NOT set. The CLI prints this and exits
342
+ * non-zero so the operator can resolve before retrying.
343
+ */
344
+ export function formatDirtyConflictReport(
345
+ dirty: NarrowDirtyFile[],
346
+ companySlug: string,
347
+ ): string {
348
+ const lines: string[] = [];
349
+ lines.push(
350
+ `Aborting narrow for '${companySlug}': ${dirty.length} locally-modified file(s) ` +
351
+ `would be removed by this narrow.`,
352
+ );
353
+ lines.push("");
354
+ lines.push("Dirty files:");
355
+ const cap = 25;
356
+ for (const f of dirty.slice(0, cap)) {
357
+ lines.push(` ${f.relPath} (${formatBytes(f.bytes)}, ${f.reason})`);
358
+ }
359
+ if (dirty.length > cap) {
360
+ lines.push(` ... ${dirty.length - cap} more`);
361
+ }
362
+ lines.push("");
363
+ lines.push("Resolve before retrying:");
364
+ lines.push(" - commit or push the modifications via the normal sync flow;");
365
+ lines.push(" - move them outside the company folder; or");
366
+ lines.push(" - delete them locally if they really should be gone.");
367
+ lines.push("");
368
+ lines.push(
369
+ "Alternatively, re-run with --force to remove the dirty files anyway " +
370
+ "(destructive — there is no undo).",
371
+ );
372
+ return lines.join("\n");
373
+ }
374
+
375
+ // ── CLI registration ────────────────────────────────────────────────────────
376
+
377
+ interface SyncNarrowCliOptions {
378
+ dryRun?: boolean;
379
+ apply?: boolean;
380
+ yes?: boolean;
381
+ force?: boolean;
382
+ company?: string;
383
+ hqRoot: string;
384
+ }
385
+
386
+ /**
387
+ * Wire `hq sync narrow` onto the existing `sync` Commander group. Called
388
+ * from `src/index.ts` after `registerSyncModeCommand`.
389
+ */
390
+ export function registerSyncNarrowCommand(syncCmd: Command): void {
391
+ syncCmd
392
+ .command("narrow")
393
+ .description(
394
+ "Migrate a company's membership from syncMode='all' to 'shared' — " +
395
+ "dry-run previews diff, --apply performs the prune + flip",
396
+ )
397
+ .option(
398
+ "--dry-run",
399
+ "Compute + print the narrow plan; write nothing (default if neither --dry-run nor --apply is passed)",
400
+ )
401
+ .option("--apply", "Execute the narrow — delete clean orphans, flip sync-mode to 'shared'")
402
+ .option("--yes", "Skip the interactive confirmation prompt on --apply")
403
+ .option(
404
+ "--force",
405
+ "On --apply, also remove locally-modified (dirty) files (destructive — no undo)",
406
+ )
407
+ .option(
408
+ "--company <slug>",
409
+ "Company slug (defaults to the active company in <hq-root>/.hq/config.json)",
410
+ )
411
+ .option(
412
+ "--hq-root <path>",
413
+ `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
414
+ DEFAULT_HQ_ROOT,
415
+ )
416
+ .action(async (options: SyncNarrowCliOptions) => {
417
+ try {
418
+ if (options.dryRun && options.apply) {
419
+ throw new Error("--dry-run and --apply are mutually exclusive.");
420
+ }
421
+ // Default to dry-run when nothing is specified — safer.
422
+ const apply = !!options.apply;
423
+ const dryRun = !apply;
424
+
425
+ const companySlug =
426
+ options.company ?? readActiveCompanySlug(options.hqRoot);
427
+ if (!companySlug) {
428
+ throw new Error(
429
+ "No company specified. Pass --company <slug> or set activeCompany in <hq-root>/.hq/config.json.",
430
+ );
431
+ }
432
+
433
+ const accessToken = await ensureCognitoToken();
434
+ const vaultConfig = buildVaultConfig(accessToken);
435
+ const client = new VaultClient(vaultConfig);
436
+
437
+ const target = await resolveNarrowTarget({
438
+ companySlug,
439
+ vaultClient: client,
440
+ });
441
+
442
+ const { plan, prospectivePrefixSet, grants } = await computeNarrowPlan({
443
+ hqRoot: options.hqRoot,
444
+ companySlug: target.companySlug,
445
+ companyUid: target.companyUid,
446
+ vaultClient: client,
447
+ });
448
+
449
+ // Always print the summary.
450
+ console.log(
451
+ chalk.bold(
452
+ `${dryRun ? "Dry-run" : "Apply"}: narrow '${target.companySlug}' (${grants.length} explicit grant${
453
+ grants.length === 1 ? "" : "s"
454
+ }, ${prospectivePrefixSet.length} coalesced prefix${
455
+ prospectivePrefixSet.length === 1 ? "" : "es"
456
+ })`,
457
+ ),
458
+ );
459
+ console.log(formatNarrowPlanSummary(plan));
460
+
461
+ if (dryRun) {
462
+ if (plan.totalDirtyCount > 0) {
463
+ console.log("");
464
+ console.log(
465
+ chalk.yellow(
466
+ `Heads-up: ${plan.totalDirtyCount} dirty file(s) would block --apply unless --force is passed.`,
467
+ ),
468
+ );
469
+ }
470
+ return;
471
+ }
472
+
473
+ // --apply path
474
+ if (plan.totalDirtyCount > 0 && !options.force) {
475
+ console.error(chalk.red(formatDirtyConflictReport(plan.dirty, target.companySlug)));
476
+ process.exit(2);
477
+ return;
478
+ }
479
+
480
+ if (!options.yes) {
481
+ const force = !!options.force;
482
+ const dirtyTail = force
483
+ ? ` AND remove ${plan.totalDirtyCount} dirty file(s) (--force)`
484
+ : "";
485
+ const confirmed = await realConfirm(
486
+ `About to remove ${plan.totalCleanCount} clean file(s)${dirtyTail} and flip '${target.companySlug}' to syncMode='shared'. Proceed?`,
487
+ );
488
+ if (!confirmed) {
489
+ console.log("Cancelled. No changes applied.");
490
+ return;
491
+ }
492
+ }
493
+
494
+ // Re-read the journal at apply time to avoid races with concurrent syncs.
495
+ const journal = readJournal(target.companySlug);
496
+ const result = await applyNarrow({
497
+ companySlug: target.companySlug,
498
+ membershipId: target.membership.membershipKey,
499
+ plan,
500
+ journal,
501
+ vaultClient: client,
502
+ force: !!options.force,
503
+ });
504
+
505
+ console.log(
506
+ chalk.green("✓"),
507
+ `Narrow applied for ${chalk.bold(target.companySlug)}:`,
508
+ );
509
+ console.log(
510
+ chalk.dim(
511
+ ` removed: ${result.cleanDeleted} clean (${formatBytes(result.cleanBytes)})${
512
+ result.dirtyDeleted > 0
513
+ ? ` + ${result.dirtyDeleted} dirty (${formatBytes(result.dirtyBytes)})`
514
+ : ""
515
+ }`,
516
+ ),
517
+ );
518
+ console.log(
519
+ chalk.dim(` tombstoned: ${result.tombstoned} journal entr${result.tombstoned === 1 ? "y" : "ies"}`),
520
+ );
521
+ console.log(
522
+ chalk.dim(
523
+ ` syncMode: all → ${result.newConfig.syncMode}` +
524
+ (result.newConfig.updatedAt ? ` (updatedAt: ${result.newConfig.updatedAt})` : ""),
525
+ ),
526
+ );
527
+ console.log(
528
+ chalk.dim(
529
+ ` audit: server-side MEMBERSHIP_SYNC_CONFIG_CHANGED row written by PUT /v1/memberships/${target.membership.membershipKey}/sync-config`,
530
+ ),
531
+ );
532
+ } catch (err) {
533
+ console.error(
534
+ chalk.red("✗ sync narrow failed:"),
535
+ err instanceof Error ? err.message : String(err),
536
+ );
537
+ process.exit(1);
538
+ }
539
+ });
540
+ }
541
+
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import { registerSyncCommand } from "./commands/sync.js";
11
11
  import { registerListCommand } from "./commands/list.js";
12
12
  import { registerUpdateCommand } from "./commands/update.js";
13
13
  import { registerCloudCommands } from "./commands/cloud.js";
14
+ import { registerSyncModeCommand } from "./commands/sync-mode.js";
15
+ import { registerSyncNarrowCommand } from "./commands/sync-narrow.js";
14
16
  import { registerCloudProvisionCommands } from "./commands/cloud-provision.js";
15
17
  import { registerCloudDemoteCommands } from "./commands/cloud-demote.js";
16
18
  import { registerLoginCommand } from "./commands/login.js";
@@ -27,6 +29,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
27
29
  import { registerRunCommand } from "./commands/run.js";
28
30
  import { registerGroupsCommand } from "./commands/groups.js";
29
31
  import { registerFilesCommand } from "./commands/files.js";
32
+ import { registerFilesBrowseCommands } from "./commands/files-browse.js";
30
33
  import { registerMembersCommand } from "./commands/members.js";
31
34
  import { registerFeedbackCommand } from "./commands/feedback.js";
32
35
  import { registerMeetingsCommand } from "./commands/meetings.js";
@@ -91,6 +94,8 @@ const syncCmd = program
91
94
  .description("Cloud sync commands — sync HQ to S3 for mobile access");
92
95
 
93
96
  registerCloudCommands(syncCmd);
97
+ registerSyncModeCommand(syncCmd);
98
+ registerSyncNarrowCommand(syncCmd);
94
99
 
95
100
  // Cloud provisioning subcommand group (entity + bucket + initial sync)
96
101
  // Distinct from `hq sync` which assumes provisioning has already happened.
@@ -122,7 +127,10 @@ registerRunCommand(program);
122
127
  registerGroupsCommand(program);
123
128
 
124
129
  // Files ACL management (subcommand group — hq files share|unshare|acl)
125
- registerFilesCommand(program);
130
+ // `registerFilesCommand` returns the `files` group so we can attach the
131
+ // browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
132
+ const filesCmd = registerFilesCommand(program);
133
+ registerFilesBrowseCommands(filesCmd);
126
134
 
127
135
  // Membership management (subcommand group — hq members invite|list|revoke)
128
136
  registerMembersCommand(program);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Dependency-bump smoke for @indigoai-us/hq-cloud.
3
+ *
4
+ * After moving from `file:../hq-cloud` to the published `^5.23.0`, this
5
+ * test pins the contract: the SDK methods + types that hq-cli's
6
+ * sync-mode / sync-narrow / files-browse / narrow-hint-banner commands
7
+ * depend on must keep resolving from the npm-published package.
8
+ *
9
+ * If a future major bump on hq-cloud removes or renames any of these, the
10
+ * matching hq-cli command would only fail at runtime (or at the call site's
11
+ * own test). Locking it here catches the regression at install time in CI.
12
+ */
13
+
14
+ import { describe, it, expect } from "vitest";
15
+ import * as cloud from "@indigoai-us/hq-cloud";
16
+
17
+ describe("@indigoai-us/hq-cloud — pinned methods used by hq-cli", () => {
18
+ it("US-004 + US-008 prep — VaultClient carries the sync-browse methods", () => {
19
+ const proto = cloud.VaultClient.prototype as unknown as Record<
20
+ string,
21
+ unknown
22
+ >;
23
+ expect(typeof proto.listMyExplicitGrants).toBe("function");
24
+ expect(typeof proto.getMembershipSyncConfig).toBe("function");
25
+ expect(typeof proto.setMembershipSyncConfig).toBe("function");
26
+ expect(typeof proto.vend).toBe("function");
27
+ });
28
+
29
+ it("US-004 type aliases used by sync-mode + sync-narrow + files-browse compile-link", () => {
30
+ const _grant: cloud.ExplicitGrant = {
31
+ companyUid: "cmp_x",
32
+ path: "companies/x/",
33
+ permission: "read",
34
+ source: "person",
35
+ };
36
+ const _config: cloud.MembershipSyncConfig = {
37
+ membershipId: "mbr_x",
38
+ syncMode: "shared",
39
+ isDefault: false,
40
+ updatedAt: "2026-05-20T00:00:00Z",
41
+ updatedBy: "prs_x",
42
+ };
43
+ const _input: cloud.SetMembershipSyncConfigInput = { syncMode: "all" };
44
+ expect(_grant.source).toBe("person");
45
+ expect(_config.syncMode).toBe("shared");
46
+ expect(_input.syncMode).toBe("all");
47
+ });
48
+
49
+ it("US-008 prep — VendPurpose + VendInput keep purpose='browse' typecheckable", () => {
50
+ const _vendInput: cloud.VendInput = {
51
+ paths: ["companies/x/"],
52
+ operations: "read-only",
53
+ purpose: "browse",
54
+ };
55
+ expect(_vendInput.purpose).toBe("browse");
56
+ });
57
+
58
+ it("dep is the published @indigoai-us/hq-cloud, not a local file: link", async () => {
59
+ // The package.json of the loaded module must be the npm-published one
60
+ // (not file:../hq-cloud which was the dev wiring during US-006 onwards).
61
+ // We resolve the package.json path via Node's require resolver and read
62
+ // its `_resolved` (pnpm) or `version` field — both confirm origin.
63
+ const pkgPath = require.resolve(
64
+ "@indigoai-us/hq-cloud/package.json",
65
+ );
66
+ const pkg = await import(pkgPath, { with: { type: "json" } });
67
+ expect(pkg.default.name).toBe("@indigoai-us/hq-cloud");
68
+ // Must be ≥ 5.23.0 — the first published version carrying the sync-
69
+ // browse SDK + raw vend with purpose. A lower version means the bump
70
+ // never made it into the lockfile / pnpm-store.
71
+ const [maj, min] = pkg.default.version.split(".").map(Number);
72
+ expect(maj).toBeGreaterThanOrEqual(5);
73
+ if (maj === 5) expect(min).toBeGreaterThanOrEqual(23);
74
+ });
75
+ });