@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.
- package/dist/bin/sync-runner.d.ts +4 -35
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -104
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +19 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +67 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +19 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +62 -19
- package/dist/cli/sync.js.map +1 -1
- package/dist/cognito-auth.d.ts +27 -0
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +97 -0
- package/dist/cognito-auth.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.d.ts +14 -0
- package/dist/machine-auth.test.d.ts.map +1 -0
- package/dist/machine-auth.test.js +216 -0
- package/dist/machine-auth.test.js.map +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +15 -5
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -2
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +70 -7
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +102 -23
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +63 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +50 -0
- package/dist/sync/pull-scope.d.ts.map +1 -0
- package/dist/sync/pull-scope.js +129 -0
- package/dist/sync/pull-scope.js.map +1 -0
- package/package.json +2 -2
- package/src/bin/sync-runner.test.ts +23 -0
- package/src/bin/sync-runner.ts +19 -116
- package/src/cli/sync-scope.test.ts +84 -0
- package/src/cli/sync.ts +90 -17
- package/src/cognito-auth.ts +159 -0
- package/src/index.ts +21 -1
- package/src/machine-auth.test.ts +279 -0
- package/src/s3.test.ts +91 -1
- package/src/s3.ts +15 -5
- package/src/scope-shrink.test.ts +71 -0
- package/src/scope-shrink.ts +164 -20
- package/src/sync/pull-scope.ts +161 -0
package/src/scope-shrink.ts
CHANGED
|
@@ -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}
|
|
229
|
-
`
|
|
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-
|
|
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
|
-
`
|
|
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:
|
|
285
|
-
* journal entries. With `forceScopeShrink: true`, dirty
|
|
286
|
-
*
|
|
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
|
|
289
|
-
*
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 —
|
|
314
|
-
//
|
|
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 {
|
|
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
|
+
}
|