@indigoai-us/hq-cloud 6.5.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/bin/sync-runner.d.ts +4 -35
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +14 -104
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +19 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/sync-scope.test.js +67 -0
  8. package/dist/cli/sync-scope.test.js.map +1 -1
  9. package/dist/cli/sync.d.ts +19 -0
  10. package/dist/cli/sync.d.ts.map +1 -1
  11. package/dist/cli/sync.js +62 -19
  12. package/dist/cli/sync.js.map +1 -1
  13. package/dist/cognito-auth.d.ts +27 -0
  14. package/dist/cognito-auth.d.ts.map +1 -1
  15. package/dist/cognito-auth.js +97 -0
  16. package/dist/cognito-auth.js.map +1 -1
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/machine-auth.test.d.ts +14 -0
  22. package/dist/machine-auth.test.d.ts.map +1 -0
  23. package/dist/machine-auth.test.js +216 -0
  24. package/dist/machine-auth.test.js.map +1 -0
  25. package/dist/s3.d.ts.map +1 -1
  26. package/dist/s3.js +15 -5
  27. package/dist/s3.js.map +1 -1
  28. package/dist/s3.test.js +71 -2
  29. package/dist/s3.test.js.map +1 -1
  30. package/dist/scope-shrink.d.ts +70 -7
  31. package/dist/scope-shrink.d.ts.map +1 -1
  32. package/dist/scope-shrink.js +102 -23
  33. package/dist/scope-shrink.js.map +1 -1
  34. package/dist/scope-shrink.test.js +63 -0
  35. package/dist/scope-shrink.test.js.map +1 -1
  36. package/dist/sync/pull-scope.d.ts +50 -0
  37. package/dist/sync/pull-scope.d.ts.map +1 -0
  38. package/dist/sync/pull-scope.js +129 -0
  39. package/dist/sync/pull-scope.js.map +1 -0
  40. package/package.json +2 -2
  41. package/src/bin/sync-runner.test.ts +23 -0
  42. package/src/bin/sync-runner.ts +19 -116
  43. package/src/cli/sync-scope.test.ts +84 -0
  44. package/src/cli/sync.ts +90 -17
  45. package/src/cognito-auth.ts +159 -0
  46. package/src/index.ts +21 -1
  47. package/src/machine-auth.test.ts +279 -0
  48. package/src/s3.test.ts +91 -1
  49. package/src/s3.ts +15 -5
  50. package/src/scope-shrink.test.ts +71 -0
  51. package/src/scope-shrink.ts +164 -20
  52. package/src/sync/pull-scope.ts +161 -0
@@ -209,10 +209,56 @@ function classifyOrphan(
209
209
  return { path: relPath, entry, clean: true };
210
210
  }
211
211
 
212
+ /**
213
+ * Where a scope-shrink error is going to be rendered, so the structured error
214
+ * can carry advice that is ACTUALLY FOLLOWABLE from that entry point.
215
+ *
216
+ * - `"cli"` — a human at a terminal running `hq sync pull|now`. They can
217
+ * re-run with `--force-scope-shrink` or run the guided
218
+ * `hq sync narrow --apply` ritual.
219
+ * - `"runner"` — the menubar's `hq-sync-runner`. It accepts NO such flag
220
+ * (DEV-1768 fix #2: the old "pass --force-scope-shrink" advice
221
+ * was impossible to follow from here), so the only followable
222
+ * action is to open a terminal and run `hq sync narrow --apply`.
223
+ * In practice the runner pulls with `scopeShrinkPolicy:
224
+ * "auto-recover"` and never throws this — but the context keeps
225
+ * the message honest if it ever surfaces.
226
+ * - `"engine"` — unknown/library caller; generic advice.
227
+ */
228
+ export type ScopeShrinkAdviceContext = "cli" | "runner" | "engine";
229
+
230
+ /** Followable next-step advice for a blocked scope shrink, per entry point. */
231
+ function scopeShrinkAdvice(ctx: ScopeShrinkAdviceContext): string {
232
+ switch (ctx) {
233
+ case "cli":
234
+ return (
235
+ "Re-run with `--force-scope-shrink` to disown them now (dirty files " +
236
+ "are KEPT on disk, only un-tracked from sync), or run " +
237
+ "`hq sync narrow --apply` to migrate with a confirmation prompt."
238
+ );
239
+ case "runner":
240
+ return (
241
+ "The menubar sync cannot take this flag — open a terminal and run " +
242
+ "`hq sync narrow --apply` to migrate this membership (you confirm the " +
243
+ "file list), or `hq sync now --force-scope-shrink` once to proceed " +
244
+ "(dirty files are KEPT on disk, only un-tracked)."
245
+ );
246
+ default:
247
+ return (
248
+ "Run `hq sync narrow --apply` to migrate with confirmation, or pass " +
249
+ "`forceScopeShrink` (dirty files are kept on disk, only un-tracked)."
250
+ );
251
+ }
252
+ }
253
+
212
254
  /**
213
255
  * Structured error thrown when the engine refuses to proceed because a scope
214
256
  * shrink would orphan dirty files. The CLI catches this and renders the
215
257
  * operator-facing message; the engine never prints directly.
258
+ *
259
+ * `adviceContext` makes the message FOLLOWABLE from each entry point — the old
260
+ * fixed "pass --force-scope-shrink" line was impossible to act on from the
261
+ * menubar runner, which rejects that flag (DEV-1768 fix #2).
216
262
  */
217
263
  export class ScopeShrinkBlockedError extends Error {
218
264
  readonly code = "SCOPE_SHRINK_BLOCKED";
@@ -222,11 +268,12 @@ export class ScopeShrinkBlockedError extends Error {
222
268
  public readonly toMode: PullRecord["syncMode"],
223
269
  public readonly dirty: OrphanClassification[],
224
270
  public readonly clean: OrphanClassification[],
271
+ public readonly adviceContext: ScopeShrinkAdviceContext = "engine",
225
272
  ) {
226
273
  super(
227
274
  `Sync scope shrank for ${companyUid} (${fromMode} → ${toMode}); ` +
228
- `${dirty.length} dirty file(s) outside the new scope would be ` +
229
- `pruned from the journal. Resolve or pass --force-scope-shrink.`,
275
+ `${dirty.length} locally-modified file(s) outside the new scope ` +
276
+ `would be un-tracked from sync. ${scopeShrinkAdvice(adviceContext)}`,
230
277
  );
231
278
  this.name = "ScopeShrinkBlockedError";
232
279
  }
@@ -249,17 +296,31 @@ export class ScopeShrinkLargePruneError extends Error {
249
296
  public readonly toMode: PullRecord["syncMode"],
250
297
  public readonly cleanCount: number,
251
298
  public readonly cap: number,
299
+ public readonly adviceContext: ScopeShrinkAdviceContext = "engine",
252
300
  ) {
253
301
  super(
254
- `Refusing to auto-prune ${cleanCount} local file(s) for ${companyUid} ` +
302
+ `Refusing to auto-move ${cleanCount} local file(s) for ${companyUid} ` +
255
303
  `(${toMode} scope) in one sync — exceeds the safety cap of ${cap}. ` +
256
- `Run \`hq sync narrow --apply\` to migrate with confirmation, raise ` +
257
- `HQ_SYNC_MAX_AUTO_PRUNE, or pass --force-scope-shrink.`,
304
+ `Raise HQ_SYNC_MAX_AUTO_PRUNE, or ${scopeShrinkAdvice(adviceContext)}`,
258
305
  );
259
306
  this.name = "ScopeShrinkLargePruneError";
260
307
  }
261
308
  }
262
309
 
310
+ /**
311
+ * Disposition for CLEAN orphans (files provably unchanged since the last sync)
312
+ * that fall outside the new scope:
313
+ *
314
+ * - `"delete"` — `unlink` the local file. The legacy behavior; reserved
315
+ * for the explicit `hq sync narrow --apply` ritual, which
316
+ * already confirms the file list with the operator.
317
+ * - `"quarantine"` — MOVE the file into `quarantineRoot` instead of deleting
318
+ * it, so it stays recoverable. The conservative default
319
+ * for the automatic pull path: a background sync must
320
+ * never silently PURGE local files (DEV-1768 fix #3).
321
+ */
322
+ export type CleanOrphanDisposition = "delete" | "quarantine";
323
+
263
324
  export interface ApplyScopeShrinkInput {
264
325
  journal: SyncJournal;
265
326
  plan: ScopeShrinkPlan;
@@ -273,49 +334,132 @@ export interface ApplyScopeShrinkInput {
273
334
  */
274
335
  forceScopeShrink: boolean;
275
336
  reason?: "scope_shrink" | "narrow_apply" | "manual";
337
+ /**
338
+ * How to dispose of CLEAN orphans. Defaults to `"delete"` so existing
339
+ * callers (and the confirmed `narrow --apply` ritual) keep their behavior;
340
+ * the automatic pull path passes `"quarantine"`.
341
+ */
342
+ cleanDisposition?: CleanOrphanDisposition;
343
+ /**
344
+ * Absolute directory clean orphans are relocated into when
345
+ * `cleanDisposition === "quarantine"`. Each orphan moves to
346
+ * `<quarantineRoot>/<orphan.path>` (parent dirs created). REQUIRED when
347
+ * quarantining; if absent, the function falls back to `"delete"` so it can
348
+ * never get stuck unable to make progress.
349
+ */
350
+ quarantineRoot?: string;
276
351
  }
277
352
 
278
353
  export interface ApplyScopeShrinkResult {
354
+ /** Clean orphans `unlink`ed from disk (only when disposition is `delete`). */
279
355
  cleanRemoved: number;
356
+ /** Clean orphans MOVED to quarantine (only when disposition is `quarantine`). */
357
+ cleanQuarantined: number;
358
+ /** Dirty orphans tombstoned in the journal (file LEFT on disk). */
280
359
  dirtyTombstoned: number;
360
+ /** Named paths deleted — for explicit, non-silent operator reporting. */
361
+ removedPaths: string[];
362
+ /** Named paths moved to quarantine — for explicit reporting. */
363
+ quarantinedPaths: string[];
364
+ /** Named dirty paths un-tracked but KEPT on disk — for explicit reporting. */
365
+ dirtyKeptPaths: string[];
366
+ /** Absolute quarantine directory used (when anything was quarantined). */
367
+ quarantineRoot?: string;
368
+ }
369
+
370
+ /**
371
+ * Move a clean orphan from the working tree into the quarantine tree,
372
+ * preserving its relative path. Same-device `rename` first (cheap, atomic);
373
+ * cross-device falls back to copy+unlink. A missing source is a no-op (the
374
+ * user already removed it — harmless). Returns true iff the file was relocated
375
+ * (or was already absent), false only on an unexpected error the caller should
376
+ * surface.
377
+ */
378
+ function quarantineOrphan(
379
+ srcAbs: string,
380
+ destAbs: string,
381
+ ): void {
382
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
383
+ try {
384
+ fs.renameSync(srcAbs, destAbs);
385
+ } catch (err) {
386
+ const code = (err as NodeJS.ErrnoException).code;
387
+ if (code === "ENOENT") return; // source already gone — nothing to move
388
+ if (code === "EXDEV") {
389
+ // Cross-device move: copy then unlink. cpSync handles files + symlinks.
390
+ fs.cpSync(srcAbs, destAbs, { recursive: true, verbatimSymlinks: true });
391
+ fs.rmSync(srcAbs, { recursive: true, force: true });
392
+ return;
393
+ }
394
+ throw err;
395
+ }
281
396
  }
282
397
 
283
398
  /**
284
- * Apply a scope-shrink plan: delete clean orphans on disk + tombstone their
285
- * journal entries. With `forceScopeShrink: true`, dirty orphans are
286
- * preserved on disk but their journal entries are also tombstoned.
399
+ * Apply a scope-shrink plan: dispose of clean orphans (delete OR quarantine)
400
+ * + tombstone their journal entries. With `forceScopeShrink: true`, dirty
401
+ * orphans are PRESERVED on disk and only their journal entries are tombstoned
402
+ * (so they stop being re-flagged on every pull — the idempotent recovery seam).
287
403
  *
288
- * Returns counts for the audit log row (`scope_shrink_blocked` /
289
- * `scope_shrink_forced`).
404
+ * Returns counts AND named paths so the caller can report exactly what moved /
405
+ * was un-tracked — never a silent purge (DEV-1768 fix #3).
290
406
  */
291
407
  export function applyScopeShrink(
292
408
  input: ApplyScopeShrinkInput,
293
409
  ): ApplyScopeShrinkResult {
294
410
  const { journal, plan, hqRoot, forceScopeShrink } = input;
295
411
  const reason = input.reason ?? "scope_shrink";
412
+ // Quarantine only when explicitly asked AND a destination is provided;
413
+ // otherwise fall back to delete so we always make progress.
414
+ const quarantining =
415
+ input.cleanDisposition === "quarantine" && !!input.quarantineRoot;
296
416
  let cleanRemoved = 0;
417
+ let cleanQuarantined = 0;
297
418
  let dirtyTombstoned = 0;
419
+ const removedPaths: string[] = [];
420
+ const quarantinedPaths: string[] = [];
421
+ const dirtyKeptPaths: string[] = [];
298
422
 
299
423
  for (const orphan of plan.clean) {
300
424
  const absPath = path.join(hqRoot, orphan.path);
301
- try {
302
- fs.unlinkSync(absPath);
303
- } catch (err) {
304
- const code = (err as NodeJS.ErrnoException).code;
305
- if (code !== "ENOENT") throw err; // missing-on-disk is fine; anything else escalates
425
+ if (quarantining) {
426
+ const destAbs = path.join(input.quarantineRoot!, orphan.path);
427
+ quarantineOrphan(absPath, destAbs);
428
+ tombstoneEntry(journal, orphan.path, reason);
429
+ cleanQuarantined++;
430
+ quarantinedPaths.push(orphan.path);
431
+ } else {
432
+ try {
433
+ fs.unlinkSync(absPath);
434
+ } catch (err) {
435
+ const code = (err as NodeJS.ErrnoException).code;
436
+ if (code !== "ENOENT") throw err; // missing-on-disk is fine; anything else escalates
437
+ }
438
+ tombstoneEntry(journal, orphan.path, reason);
439
+ cleanRemoved++;
440
+ removedPaths.push(orphan.path);
306
441
  }
307
- tombstoneEntry(journal, orphan.path, reason);
308
- cleanRemoved++;
309
442
  }
310
443
 
311
444
  if (forceScopeShrink) {
312
445
  for (const orphan of plan.dirty) {
313
- // Do NOT delete the file — that's the entire point of the `--force`
314
- // contract: keep dirty content on disk, prune only the journal entry.
446
+ // Do NOT delete the file — keep dirty content on disk, prune only the
447
+ // journal entry so it stops being re-flagged as an orphan on each pull.
315
448
  tombstoneEntry(journal, orphan.path, reason);
316
449
  dirtyTombstoned++;
450
+ dirtyKeptPaths.push(orphan.path);
317
451
  }
318
452
  }
319
453
 
320
- return { cleanRemoved, dirtyTombstoned };
454
+ return {
455
+ cleanRemoved,
456
+ cleanQuarantined,
457
+ dirtyTombstoned,
458
+ removedPaths,
459
+ quarantinedPaths,
460
+ dirtyKeptPaths,
461
+ ...(quarantining && quarantinedPaths.length > 0
462
+ ? { quarantineRoot: input.quarantineRoot }
463
+ : {}),
464
+ };
321
465
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Effective per-company PULL scope resolution (US-005), shared between
3
+ * `hq-sync-runner` (the menubar's background sync) and `hq sync pull|now`
4
+ * (the hq-cli foreground sync).
5
+ *
6
+ * BACKGROUND — why this lives in one place. The runner historically owned
7
+ * this resolver while hq-cli's pull paths resolved the membership's sync-mode
8
+ * only to drive the narrow-hint banner and then called `sync()` WITHOUT a
9
+ * `syncMode`/`prefixSet`. That meant every hq-cli pull silently ran
10
+ * `syncMode: "all"` and stamped an `all`-mode `PullRecord` (prefixSet `[""]`)
11
+ * — even for a membership whose real mode is `shared`/`custom`. For an owner
12
+ * (whose STS is wide by role-bypass) that journals the WHOLE company. The next
13
+ * menubar runner sync then resolves the REAL narrowed scope, scope-shrinks
14
+ * against the seeded `[""]`, and — if any out-of-scope file was edited locally
15
+ * — throws `ScopeShrinkBlockedError(all → shared)` and exits 2 on every
16
+ * subsequent run: a permanent wedge (DEV-1768 / feedback_f7663e92).
17
+ *
18
+ * The two surfaces drifting apart is exactly what caused the wedge, so the
19
+ * resolver is defined ONCE here and consumed by both. Any future change to how
20
+ * scope is derived now lands for the runner and the CLI in lockstep.
21
+ */
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import { coalescePrefixes, grantPathToPrefix } from "../prefix-coalesce.js";
25
+ import type {
26
+ ExplicitGrant,
27
+ MembershipSyncConfig,
28
+ SyncMode,
29
+ } from "../vault-client.js";
30
+
31
+ /**
32
+ * Minimal structural surface `resolvePullScope` needs. Both the runner's
33
+ * `VaultClientSurface` and hq-cli's concrete `VaultClient` satisfy it, so
34
+ * either can be passed without an adapter.
35
+ */
36
+ export interface PullScopeClient {
37
+ listMyMemberships(): Promise<
38
+ Array<{ companyUid: string; membershipKey: string }>
39
+ >;
40
+ getMembershipSyncConfig?: (
41
+ membershipId: string,
42
+ ) => Promise<MembershipSyncConfig>;
43
+ listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
44
+ }
45
+
46
+ /**
47
+ * Effective download scope for one company leg (US-005). Resolved per company
48
+ * just before its pull, then handed to `sync()` as `{ syncMode, prefixSet }`.
49
+ */
50
+ export interface PullScope {
51
+ syncMode: SyncMode;
52
+ /** Coalesced company-relative prefixes; omitted/undefined for `all`. */
53
+ prefixSet?: string[];
54
+ }
55
+
56
+ /**
57
+ * Resolve the effective download scope for a company target.
58
+ *
59
+ * - `all` → no prefix set; full-bucket pull (legacy behavior).
60
+ * - `shared` → coalesced caller explicit grants (company-relative paths,
61
+ * same namespace as `RemoteFile.key`).
62
+ * - `custom` → coalesced `customPaths` from the sync-config row.
63
+ *
64
+ * DEGRADE-TO-`all` CONTRACT: any failure (missing client method, membership
65
+ * not found, network error, grant fetch error) returns `{ syncMode: "all" }`.
66
+ * A transient failure must NEVER silently narrow scope — that would prune the
67
+ * local tree. A genuinely-empty grant list (the method exists and returns `[]`)
68
+ * is a real "nothing shared with me" and IS allowed to narrow.
69
+ */
70
+ export async function resolvePullScope(
71
+ client: PullScopeClient,
72
+ companyUid: string,
73
+ // Company slug — required to normalize grant paths (which may be anchored
74
+ // at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
75
+ slug: string,
76
+ // Local HQ root — used to read the per-machine pin set (`.hq/pins.json`).
77
+ // When omitted, pins are simply not unioned (no behavior change).
78
+ hqRoot?: string,
79
+ ): Promise<PullScope> {
80
+ if (!client.getMembershipSyncConfig) return { syncMode: "all" };
81
+ try {
82
+ const memberships = await client.listMyMemberships();
83
+ const m = memberships.find((x) => x.companyUid === companyUid);
84
+ if (!m) return { syncMode: "all" };
85
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
86
+ if (cfg.syncMode === "all") return { syncMode: "all" };
87
+
88
+ // Pins are company-relative prefixes a user explicitly materialized via
89
+ // `hq files get`. They're unioned into the scope so a scoped pull keeps
90
+ // them instead of pruning them as out-of-scope orphans. Pins only WIDEN
91
+ // scope, never narrow — and `all` mode (handled above) ignores them since
92
+ // it pulls everything anyway.
93
+ const pinPrefixes = hqRoot ? readPinnedPrefixes(hqRoot, slug) : [];
94
+
95
+ if (cfg.syncMode === "custom") {
96
+ const customPrefixes = (cfg.customPaths ?? []).map((p) =>
97
+ grantPathToPrefix(p, slug),
98
+ );
99
+ // A bare-everything entry ("" — e.g. a `*` path) collapses under
100
+ // `coalescePrefixes` (which drops empties) to "nothing", which would
101
+ // prune the whole tree. An everything-scope is semantically `all`.
102
+ if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
103
+ return {
104
+ syncMode: "custom",
105
+ prefixSet: coalescePrefixes([...customPrefixes, ...pinPrefixes]),
106
+ };
107
+ }
108
+ // shared: scope to the caller's explicit grants. Real grant paths are
109
+ // inconsistent — full (`companies/<slug>/x/*`), slug-anchored
110
+ // (`<slug>/x/*`), company-relative (`x/*`), bare globs (`*`), and exact
111
+ // files all coexist in production — so each is normalized via
112
+ // `grantPathToPrefix` into a company-relative, startsWith-friendly prefix
113
+ // (the namespace the engine's `RemoteFile.key`s live in) before coalescing.
114
+ //
115
+ // SAFETY: if the client can't fetch grants, we must NOT fall through to an
116
+ // empty `shared` scope — that would tell the engine "nothing is in scope"
117
+ // and scope-shrink would prune every clean local file. Degrade to `all`
118
+ // instead. A genuinely-empty grant list (the method exists and returns
119
+ // []) is a real "nothing shared with me" and is allowed to narrow.
120
+ if (!client.listMyExplicitGrants) return { syncMode: "all" };
121
+ const grants = await client.listMyExplicitGrants(companyUid);
122
+ const sharedPrefixes = grants.map((g) => grantPathToPrefix(g.path, slug));
123
+ // A wildcard grant (`*`) normalizes to "" = everything. Since
124
+ // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
125
+ // treat any such grant as full-access `all` rather than risk pruning.
126
+ if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
127
+ return {
128
+ syncMode: "shared",
129
+ prefixSet: coalescePrefixes([...sharedPrefixes, ...pinPrefixes]),
130
+ };
131
+ } catch {
132
+ // Degrade to `all` — never prune on a resolution failure.
133
+ return { syncMode: "all" };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Read the per-machine pin set (`<hqRoot>/.hq/pins.json`) and return the
139
+ * company-relative pinned prefixes for `slug`. These are prefixes the user
140
+ * materialized on demand via `hq files get` that must survive a scoped pull.
141
+ *
142
+ * Tolerant by construction: a missing, unreadable, or malformed file yields
143
+ * `[]` (no pins) — pins only ever widen scope, so "no pins" is the safe
144
+ * default. Empty-string entries are dropped (an everything-pin is meaningless
145
+ * here; `all` mode already covers that case).
146
+ */
147
+ export function readPinnedPrefixes(hqRoot: string, slug: string): string[] {
148
+ try {
149
+ const raw = fs.readFileSync(path.join(hqRoot, ".hq", "pins.json"), "utf-8");
150
+ const parsed = JSON.parse(raw) as { pins?: Record<string, unknown> };
151
+ const list = parsed?.pins?.[slug];
152
+ if (Array.isArray(list)) {
153
+ return list.filter(
154
+ (p): p is string => typeof p === "string" && p.length > 0,
155
+ );
156
+ }
157
+ } catch {
158
+ /* missing / unreadable / malformed → no pins */
159
+ }
160
+ return [];
161
+ }