@indigoai-us/hq-cli 5.17.0 → 5.18.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/commands/cloud.d.ts +26 -1
- package/dist/commands/cloud.js +128 -10
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.ts +195 -4
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* 5. Empty memberships AND no person entity → no sync calls, clean result.
|
|
15
15
|
* 6. Multiple person entities → canonical pick (oldest createdAt, uid tiebreak).
|
|
16
16
|
* 7. hqRoot is forwarded verbatim to every sync() call.
|
|
17
|
+
* 8. US-011 — narrow-hint banner emits per all-mode membership.
|
|
18
|
+
* 9. US-011 — strict-mode refuses all-mode legs without --mode-all.
|
|
19
|
+
* 10. US-011 — strict-mode + --mode-all proceeds.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
|
-
import { describe, expect, it, vi } from "vitest";
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
20
23
|
|
|
21
24
|
import {
|
|
22
25
|
pullAll,
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
type SyncCallOptions,
|
|
26
29
|
type SyncCallResult,
|
|
27
30
|
} from "./cloud.js";
|
|
31
|
+
import { _resetShownForTests } from "../lib/narrow-hint-banner.js";
|
|
28
32
|
|
|
29
33
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
30
34
|
|
|
@@ -299,6 +303,171 @@ describe("pullAll", () => {
|
|
|
299
303
|
expect(sync.calls[0].journalSlug).toBe("personal");
|
|
300
304
|
});
|
|
301
305
|
|
|
306
|
+
// ── 8/9/10. US-011 narrow-hint banner ─────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("narrow-hint banner (US-011)", () => {
|
|
309
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
310
|
+
let captured: string[];
|
|
311
|
+
|
|
312
|
+
beforeEach(() => {
|
|
313
|
+
_resetShownForTests();
|
|
314
|
+
captured = [];
|
|
315
|
+
stderrSpy = vi
|
|
316
|
+
.spyOn(process.stderr, "write")
|
|
317
|
+
.mockImplementation((chunk: string | Uint8Array) => {
|
|
318
|
+
captured.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
319
|
+
return true;
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterEach(() => {
|
|
324
|
+
stderrSpy.mockRestore();
|
|
325
|
+
delete process.env.HQ_SYNC_NARROW_HINT;
|
|
326
|
+
delete process.env.HQ_SYNC_NARROW_HINT_LEVEL;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
function makeVaultClientWithSyncConfig(opts: {
|
|
330
|
+
memberships: Array<{ companyUid: string; membershipKey: string }>;
|
|
331
|
+
syncModes: Record<string, "shared" | "all" | "custom">;
|
|
332
|
+
entitiesBySlug?: Record<string, { slug: string; name?: string }>;
|
|
333
|
+
}): PullAllVaultClient {
|
|
334
|
+
return {
|
|
335
|
+
listMyMemberships: vi.fn().mockResolvedValue(opts.memberships),
|
|
336
|
+
listPersonEntities: vi.fn().mockResolvedValue([]),
|
|
337
|
+
getEntity: vi
|
|
338
|
+
.fn()
|
|
339
|
+
.mockImplementation(async (uid: string) =>
|
|
340
|
+
opts.entitiesBySlug?.[uid] ?? null,
|
|
341
|
+
),
|
|
342
|
+
getMembershipSyncConfig: vi
|
|
343
|
+
.fn()
|
|
344
|
+
.mockImplementation(async (membershipKey: string) => {
|
|
345
|
+
const m = opts.memberships.find(
|
|
346
|
+
(m) => m.membershipKey === membershipKey,
|
|
347
|
+
);
|
|
348
|
+
const companyUid = m?.companyUid ?? "";
|
|
349
|
+
return {
|
|
350
|
+
membershipId: membershipKey,
|
|
351
|
+
syncMode: opts.syncModes[companyUid] ?? "all",
|
|
352
|
+
};
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it("emits one hint per all-mode membership and skips shared/custom", async () => {
|
|
358
|
+
const vaultClient = makeVaultClientWithSyncConfig({
|
|
359
|
+
memberships: [
|
|
360
|
+
{ companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
|
|
361
|
+
{ companyUid: "cmp_globex", membershipKey: "psn_1#cmp_globex" },
|
|
362
|
+
{ companyUid: "cmp_init", membershipKey: "psn_1#cmp_init" },
|
|
363
|
+
],
|
|
364
|
+
syncModes: {
|
|
365
|
+
cmp_acme: "all",
|
|
366
|
+
cmp_globex: "shared",
|
|
367
|
+
cmp_init: "custom",
|
|
368
|
+
},
|
|
369
|
+
entitiesBySlug: {
|
|
370
|
+
cmp_acme: { slug: "acme" },
|
|
371
|
+
cmp_globex: { slug: "globex" },
|
|
372
|
+
cmp_init: { slug: "init" },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const sync = makeSyncSpy();
|
|
376
|
+
|
|
377
|
+
const result = await pullAll(
|
|
378
|
+
{ hqRoot: "/tmp/hq", narrowHintLevel: "hint" },
|
|
379
|
+
{ vaultClient, sync: sync.fn },
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
expect(result.errors).toEqual([]);
|
|
383
|
+
expect(result.attempted).toBe(3);
|
|
384
|
+
// sync ran for all three (no strict refusal at 'hint' level)
|
|
385
|
+
expect(sync.calls).toHaveLength(3);
|
|
386
|
+
|
|
387
|
+
const joined = captured.join("");
|
|
388
|
+
expect(joined).toMatch(/switch to shared-mode sync/i);
|
|
389
|
+
// exactly one banner line — only cmp_acme is on 'all'
|
|
390
|
+
const bannerLines = captured.filter((c) => /shared-mode sync/i.test(c));
|
|
391
|
+
expect(bannerLines).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("strict-mode refuses all-mode legs and short-circuits sync()", async () => {
|
|
395
|
+
const vaultClient = makeVaultClientWithSyncConfig({
|
|
396
|
+
memberships: [
|
|
397
|
+
{ companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
|
|
398
|
+
{ companyUid: "cmp_globex", membershipKey: "psn_1#cmp_globex" },
|
|
399
|
+
],
|
|
400
|
+
syncModes: {
|
|
401
|
+
cmp_acme: "all",
|
|
402
|
+
cmp_globex: "shared",
|
|
403
|
+
},
|
|
404
|
+
entitiesBySlug: {
|
|
405
|
+
cmp_acme: { slug: "acme" },
|
|
406
|
+
cmp_globex: { slug: "globex" },
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
const sync = makeSyncSpy();
|
|
410
|
+
|
|
411
|
+
const result = await pullAll(
|
|
412
|
+
{ hqRoot: "/tmp/hq", narrowHintLevel: "strict" },
|
|
413
|
+
{ vaultClient, sync: sync.fn },
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// acme refused (no sync() call), globex still synced
|
|
417
|
+
expect(sync.calls.map((c) => c.company)).toEqual(["cmp_globex"]);
|
|
418
|
+
expect(result.errors).toEqual([
|
|
419
|
+
{
|
|
420
|
+
company: "acme",
|
|
421
|
+
message: expect.stringContaining("Refusing to pull all-mode"),
|
|
422
|
+
},
|
|
423
|
+
]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("strict-mode + modeAllOverride lets all-mode legs proceed", async () => {
|
|
427
|
+
const vaultClient = makeVaultClientWithSyncConfig({
|
|
428
|
+
memberships: [
|
|
429
|
+
{ companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
|
|
430
|
+
],
|
|
431
|
+
syncModes: { cmp_acme: "all" },
|
|
432
|
+
entitiesBySlug: { cmp_acme: { slug: "acme" } },
|
|
433
|
+
});
|
|
434
|
+
const sync = makeSyncSpy();
|
|
435
|
+
|
|
436
|
+
const result = await pullAll(
|
|
437
|
+
{
|
|
438
|
+
hqRoot: "/tmp/hq",
|
|
439
|
+
narrowHintLevel: "strict",
|
|
440
|
+
modeAllOverride: true,
|
|
441
|
+
},
|
|
442
|
+
{ vaultClient, sync: sync.fn },
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(sync.calls).toHaveLength(1);
|
|
446
|
+
expect(result.errors).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("HQ_SYNC_NARROW_HINT=off suppresses the banner", async () => {
|
|
450
|
+
process.env.HQ_SYNC_NARROW_HINT = "off";
|
|
451
|
+
const vaultClient = makeVaultClientWithSyncConfig({
|
|
452
|
+
memberships: [
|
|
453
|
+
{ companyUid: "cmp_acme", membershipKey: "psn_1#cmp_acme" },
|
|
454
|
+
],
|
|
455
|
+
syncModes: { cmp_acme: "all" },
|
|
456
|
+
entitiesBySlug: { cmp_acme: { slug: "acme" } },
|
|
457
|
+
});
|
|
458
|
+
const sync = makeSyncSpy();
|
|
459
|
+
|
|
460
|
+
await pullAll(
|
|
461
|
+
{ hqRoot: "/tmp/hq", narrowHintLevel: "hint" },
|
|
462
|
+
{ vaultClient, sync: sync.fn },
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const joined = captured.join("");
|
|
466
|
+
expect(joined).not.toMatch(/switch to shared-mode sync/i);
|
|
467
|
+
expect(sync.calls).toHaveLength(1);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
302
471
|
// ── 7. hqRoot passthrough ─────────────────────────────────────────────────
|
|
303
472
|
|
|
304
473
|
it("forwards hqRoot verbatim to every sync() call", async () => {
|
package/src/commands/cloud.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
computePersonalVaultPaths,
|
|
29
29
|
type ConflictStrategy,
|
|
30
30
|
type EntityContext,
|
|
31
|
+
type MembershipSyncConfig,
|
|
31
32
|
type SyncProgressEvent,
|
|
32
33
|
type UploadAuthor,
|
|
33
34
|
} from "@indigoai-us/hq-cloud";
|
|
@@ -37,6 +38,12 @@ import {
|
|
|
37
38
|
ensureCognitoToken,
|
|
38
39
|
buildVaultConfig,
|
|
39
40
|
} from "../utils/cognito-session.js";
|
|
41
|
+
import {
|
|
42
|
+
emitNarrowHint,
|
|
43
|
+
isStrictRefusal,
|
|
44
|
+
resolveBannerLevel,
|
|
45
|
+
type BannerLevel,
|
|
46
|
+
} from "../lib/narrow-hint-banner.js";
|
|
40
47
|
|
|
41
48
|
interface CommonSyncOptions {
|
|
42
49
|
hqRoot: string;
|
|
@@ -53,7 +60,9 @@ interface CommonSyncOptions {
|
|
|
53
60
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
61
|
|
|
55
62
|
export interface PullAllVaultClient {
|
|
56
|
-
listMyMemberships(): Promise<
|
|
63
|
+
listMyMemberships(): Promise<
|
|
64
|
+
Array<{ companyUid: string; membershipKey?: string }>
|
|
65
|
+
>;
|
|
57
66
|
listPersonEntities(): Promise<
|
|
58
67
|
Array<{
|
|
59
68
|
uid: string;
|
|
@@ -64,6 +73,15 @@ export interface PullAllVaultClient {
|
|
|
64
73
|
}>
|
|
65
74
|
>;
|
|
66
75
|
getEntity(uid: string): Promise<{ slug?: string; name?: string } | null>;
|
|
76
|
+
/**
|
|
77
|
+
* US-011: optional — when present, `pullAll` calls it once per
|
|
78
|
+
* membership to surface the narrow-hint banner for all-mode owners.
|
|
79
|
+
* Absent on legacy adapters (push-all et al.) where the banner is not
|
|
80
|
+
* applicable.
|
|
81
|
+
*/
|
|
82
|
+
getMembershipSyncConfig?: (
|
|
83
|
+
membershipId: string,
|
|
84
|
+
) => Promise<MembershipSyncConfig>;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
export interface SyncCallOptions {
|
|
@@ -91,6 +109,22 @@ export interface PullAllDeps {
|
|
|
91
109
|
export interface PullAllOptions {
|
|
92
110
|
hqRoot: string;
|
|
93
111
|
onConflict?: ConflictStrategy;
|
|
112
|
+
/**
|
|
113
|
+
* US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
|
|
114
|
+
* — see `resolveBannerLevel` for the env-driven override. The
|
|
115
|
+
* `'strict'` level causes `pullAll` to refuse to sync any membership
|
|
116
|
+
* still on `syncMode: 'all'` unless `modeAllOverride` is true.
|
|
117
|
+
*
|
|
118
|
+
* TODO(hq-core-staging release N+2): default flips to 'warning'.
|
|
119
|
+
* TODO(hq-core-staging release N+3): default flips to 'strict'.
|
|
120
|
+
*/
|
|
121
|
+
narrowHintLevel?: BannerLevel;
|
|
122
|
+
/**
|
|
123
|
+
* US-011: when `true`, strict-mode does NOT refuse all-mode
|
|
124
|
+
* memberships — the operator has explicitly opted into keeping the
|
|
125
|
+
* legacy behavior for this run via `--mode-all`.
|
|
126
|
+
*/
|
|
127
|
+
modeAllOverride?: boolean;
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
export interface PullAllRow {
|
|
@@ -168,6 +202,9 @@ export interface PullAllResult {
|
|
|
168
202
|
interface PlanEntry {
|
|
169
203
|
slug: string;
|
|
170
204
|
syncOptions: SyncCallOptions;
|
|
205
|
+
/** US-011: tracked so we can resolve sync-config + emit the narrow hint. */
|
|
206
|
+
companyUid?: string;
|
|
207
|
+
membershipKey?: string;
|
|
171
208
|
}
|
|
172
209
|
|
|
173
210
|
// Oldest-first by createdAt, ties broken by uid lexicographic — matches
|
|
@@ -191,6 +228,9 @@ export async function pullAll(
|
|
|
191
228
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
192
229
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
193
230
|
|
|
231
|
+
const narrowHintLevel: BannerLevel = options.narrowHintLevel ?? "hint";
|
|
232
|
+
const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
|
|
233
|
+
|
|
194
234
|
const plan: PlanEntry[] = [];
|
|
195
235
|
for (const m of memberships) {
|
|
196
236
|
let slug = m.companyUid;
|
|
@@ -202,6 +242,8 @@ export async function pullAll(
|
|
|
202
242
|
}
|
|
203
243
|
plan.push({
|
|
204
244
|
slug,
|
|
245
|
+
companyUid: m.companyUid,
|
|
246
|
+
...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
|
|
205
247
|
syncOptions: {
|
|
206
248
|
company: m.companyUid,
|
|
207
249
|
hqRoot: options.hqRoot,
|
|
@@ -235,12 +277,63 @@ export async function pullAll(
|
|
|
235
277
|
|
|
236
278
|
for (const entry of plan) {
|
|
237
279
|
result.attempted += 1;
|
|
280
|
+
|
|
281
|
+
// US-011: resolve the membership's effective sync mode so we can
|
|
282
|
+
// either nudge an all-mode owner toward `hq sync narrow` OR refuse
|
|
283
|
+
// the leg outright when strict-mode is on and the operator didn't
|
|
284
|
+
// pass `--mode-all`. Sync-config lookup is best-effort — a 404 or
|
|
285
|
+
// network blip should never block the sync itself, so we fall back
|
|
286
|
+
// to syncMode='all' (the legacy default) and skip the banner.
|
|
287
|
+
let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
|
|
288
|
+
if (entry.membershipKey && getSyncConfig) {
|
|
289
|
+
try {
|
|
290
|
+
const cfg = await getSyncConfig(entry.membershipKey);
|
|
291
|
+
resolvedMode = cfg.syncMode;
|
|
292
|
+
} catch {
|
|
293
|
+
resolvedMode = undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (
|
|
298
|
+
resolvedMode === "all" &&
|
|
299
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
300
|
+
!options.modeAllOverride &&
|
|
301
|
+
entry.companyUid
|
|
302
|
+
) {
|
|
303
|
+
// Emit the strict-level banner once, then mark the leg as errored
|
|
304
|
+
// without invoking sync(). The operator either narrows the
|
|
305
|
+
// membership (`hq sync narrow --apply`) or passes `--mode-all` to
|
|
306
|
+
// opt back in.
|
|
307
|
+
emitNarrowHint({
|
|
308
|
+
companyUid: entry.companyUid,
|
|
309
|
+
syncMode: resolvedMode,
|
|
310
|
+
level: narrowHintLevel,
|
|
311
|
+
});
|
|
312
|
+
const message =
|
|
313
|
+
"Refusing to pull all-mode membership in strict mode. " +
|
|
314
|
+
"Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
|
|
315
|
+
result.errors.push({ company: entry.slug, message });
|
|
316
|
+
result.perCompany.push({ slug: entry.slug, error: message });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
238
320
|
try {
|
|
239
321
|
const r = await deps.sync(entry.syncOptions);
|
|
240
322
|
result.filesDownloaded += r.filesDownloaded;
|
|
241
323
|
result.bytesDownloaded += r.bytesDownloaded;
|
|
242
324
|
result.conflicts += r.conflicts;
|
|
243
325
|
result.perCompany.push({ slug: entry.slug, result: r });
|
|
326
|
+
|
|
327
|
+
// Banner emitted AFTER the leg succeeds so it appears alongside
|
|
328
|
+
// the per-company summary line and doesn't get scrolled off by
|
|
329
|
+
// sync chatter.
|
|
330
|
+
if (resolvedMode === "all" && entry.companyUid) {
|
|
331
|
+
emitNarrowHint({
|
|
332
|
+
companyUid: entry.companyUid,
|
|
333
|
+
syncMode: resolvedMode,
|
|
334
|
+
level: narrowHintLevel,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
244
337
|
} catch (err) {
|
|
245
338
|
const message = err instanceof Error ? err.message : String(err);
|
|
246
339
|
result.errors.push({ company: entry.slug, message });
|
|
@@ -681,12 +774,20 @@ export function registerCloudCommands(program: Command): void {
|
|
|
681
774
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
682
775
|
"and --all.",
|
|
683
776
|
)
|
|
777
|
+
.option(
|
|
778
|
+
"--mode-all",
|
|
779
|
+
"US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
780
|
+
"Has no effect today (default narrow-hint level is 'hint'); " +
|
|
781
|
+
"wired so future hq-core-staging releases can flip the default to " +
|
|
782
|
+
"'strict' without re-touching this command.",
|
|
783
|
+
)
|
|
684
784
|
.action(
|
|
685
785
|
async (
|
|
686
786
|
options: CommonSyncOptions & {
|
|
687
787
|
onConflict?: ConflictStrategy;
|
|
688
788
|
all?: boolean;
|
|
689
789
|
personal?: boolean;
|
|
790
|
+
modeAll?: boolean;
|
|
690
791
|
},
|
|
691
792
|
) => {
|
|
692
793
|
try {
|
|
@@ -699,7 +800,11 @@ export function registerCloudCommands(program: Command): void {
|
|
|
699
800
|
process.exit(1);
|
|
700
801
|
}
|
|
701
802
|
if (options.all) {
|
|
702
|
-
await runPullAll(
|
|
803
|
+
await runPullAll(
|
|
804
|
+
options.hqRoot,
|
|
805
|
+
options.onConflict,
|
|
806
|
+
options.modeAll === true,
|
|
807
|
+
);
|
|
703
808
|
return;
|
|
704
809
|
}
|
|
705
810
|
if (options.personal) {
|
|
@@ -833,6 +938,12 @@ export function registerCloudCommands(program: Command): void {
|
|
|
833
938
|
"Sync the caller's canonical personal vault bidirectionally. " +
|
|
834
939
|
"Mutually exclusive with --company and --all.",
|
|
835
940
|
)
|
|
941
|
+
.option(
|
|
942
|
+
"--mode-all",
|
|
943
|
+
"US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
944
|
+
"No-op today; wired so future hq-core-staging releases can flip " +
|
|
945
|
+
"the default narrow-hint level to 'strict'.",
|
|
946
|
+
)
|
|
836
947
|
.action(
|
|
837
948
|
async (
|
|
838
949
|
options: CommonSyncOptions & {
|
|
@@ -840,6 +951,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
840
951
|
message?: string;
|
|
841
952
|
all?: boolean;
|
|
842
953
|
personal?: boolean;
|
|
954
|
+
modeAll?: boolean;
|
|
843
955
|
},
|
|
844
956
|
) => {
|
|
845
957
|
try {
|
|
@@ -849,6 +961,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
849
961
|
options.hqRoot,
|
|
850
962
|
options.message,
|
|
851
963
|
options.onConflict,
|
|
964
|
+
options.modeAll === true,
|
|
852
965
|
);
|
|
853
966
|
return;
|
|
854
967
|
}
|
|
@@ -858,6 +971,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
858
971
|
options.personal === true,
|
|
859
972
|
options.message,
|
|
860
973
|
options.onConflict,
|
|
974
|
+
options.modeAll === true,
|
|
861
975
|
);
|
|
862
976
|
} catch (err) {
|
|
863
977
|
console.error(
|
|
@@ -873,6 +987,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
873
987
|
async function runPullAll(
|
|
874
988
|
hqRoot: string,
|
|
875
989
|
onConflict?: ConflictStrategy,
|
|
990
|
+
modeAllOverride?: boolean,
|
|
876
991
|
): Promise<void> {
|
|
877
992
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
878
993
|
console.log(` HQ root: ${hqRoot}`);
|
|
@@ -894,10 +1009,17 @@ async function runPullAll(
|
|
|
894
1009
|
return null;
|
|
895
1010
|
}
|
|
896
1011
|
},
|
|
1012
|
+
getMembershipSyncConfig: (id: string) =>
|
|
1013
|
+
realClient.getMembershipSyncConfig(id),
|
|
897
1014
|
};
|
|
898
1015
|
|
|
899
1016
|
result = await pullAll(
|
|
900
|
-
{
|
|
1017
|
+
{
|
|
1018
|
+
hqRoot,
|
|
1019
|
+
...(onConflict ? { onConflict } : {}),
|
|
1020
|
+
narrowHintLevel: resolveBannerLevel(),
|
|
1021
|
+
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
1022
|
+
},
|
|
901
1023
|
{
|
|
902
1024
|
vaultClient: adapter,
|
|
903
1025
|
sync: (opts) =>
|
|
@@ -1095,6 +1217,7 @@ async function runNowSingle(
|
|
|
1095
1217
|
personal: boolean,
|
|
1096
1218
|
message?: string,
|
|
1097
1219
|
onConflict?: ConflictStrategy,
|
|
1220
|
+
modeAllOverride?: boolean,
|
|
1098
1221
|
): Promise<void> {
|
|
1099
1222
|
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
1100
1223
|
console.log(` HQ root: ${hqRoot}`);
|
|
@@ -1168,6 +1291,59 @@ async function runNowSingle(
|
|
|
1168
1291
|
process.exit(1);
|
|
1169
1292
|
}
|
|
1170
1293
|
|
|
1294
|
+
// US-011: resolve membership sync-config so we can either nudge an
|
|
1295
|
+
// all-mode owner or refuse the pull when strict-mode is on. Skipped
|
|
1296
|
+
// for personal targets (personal vault has no membership row) and
|
|
1297
|
+
// for resolution failures (best-effort — never block sync). The
|
|
1298
|
+
// lookup runs BEFORE the pull leg so strict refusal can short-circuit
|
|
1299
|
+
// without burning a sync.
|
|
1300
|
+
const narrowHintLevel: BannerLevel = resolveBannerLevel();
|
|
1301
|
+
let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
|
|
1302
|
+
let resolvedCompanyUid: string | undefined;
|
|
1303
|
+
if (!personalMode && targetCompany) {
|
|
1304
|
+
try {
|
|
1305
|
+
const client = new VaultClient(vaultConfig);
|
|
1306
|
+
const memberships = await client.listMyMemberships();
|
|
1307
|
+
const match = memberships.find(
|
|
1308
|
+
(m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
|
|
1309
|
+
);
|
|
1310
|
+
if (match) {
|
|
1311
|
+
resolvedCompanyUid = match.companyUid;
|
|
1312
|
+
try {
|
|
1313
|
+
const cfg = await client.getMembershipSyncConfig(
|
|
1314
|
+
match.membershipKey,
|
|
1315
|
+
);
|
|
1316
|
+
resolvedMode = cfg.syncMode;
|
|
1317
|
+
} catch {
|
|
1318
|
+
resolvedMode = undefined;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
} catch {
|
|
1322
|
+
resolvedMode = undefined;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (
|
|
1327
|
+
resolvedMode === "all" &&
|
|
1328
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
1329
|
+
!modeAllOverride &&
|
|
1330
|
+
resolvedCompanyUid
|
|
1331
|
+
) {
|
|
1332
|
+
emitNarrowHint({
|
|
1333
|
+
companyUid: resolvedCompanyUid,
|
|
1334
|
+
syncMode: resolvedMode,
|
|
1335
|
+
level: narrowHintLevel,
|
|
1336
|
+
});
|
|
1337
|
+
console.error(
|
|
1338
|
+
chalk.red(
|
|
1339
|
+
"\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
1340
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
1341
|
+
"to migrate, or re-run with --mode-all.",
|
|
1342
|
+
),
|
|
1343
|
+
);
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1171
1347
|
console.log(chalk.dim(" → pull leg"));
|
|
1172
1348
|
const pullResult = await sync({
|
|
1173
1349
|
company: targetCompany,
|
|
@@ -1188,6 +1364,17 @@ async function runNowSingle(
|
|
|
1188
1364
|
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
1189
1365
|
process.exit(1);
|
|
1190
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
// US-011: emit the hint banner after a successful pull so it
|
|
1369
|
+
// appears at the bottom of the summary rather than mid-stream.
|
|
1370
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
1371
|
+
emitNarrowHint({
|
|
1372
|
+
companyUid: resolvedCompanyUid,
|
|
1373
|
+
syncMode: resolvedMode,
|
|
1374
|
+
level: narrowHintLevel,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1191
1378
|
console.log(chalk.green("\n✓ Sync now complete"));
|
|
1192
1379
|
} catch (err) {
|
|
1193
1380
|
console.error(
|
|
@@ -1202,6 +1389,7 @@ async function runNowAll(
|
|
|
1202
1389
|
hqRoot: string,
|
|
1203
1390
|
message?: string,
|
|
1204
1391
|
onConflict?: ConflictStrategy,
|
|
1392
|
+
modeAllOverride?: boolean,
|
|
1205
1393
|
): Promise<void> {
|
|
1206
1394
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
1207
1395
|
console.log(` HQ root: ${hqRoot}`);
|
|
@@ -1213,7 +1401,10 @@ async function runNowAll(
|
|
|
1213
1401
|
console.log(chalk.dim("→ push --all"));
|
|
1214
1402
|
await runPushAll(hqRoot, message, onConflict);
|
|
1215
1403
|
console.log(chalk.dim("\n→ pull --all"));
|
|
1216
|
-
|
|
1404
|
+
// US-011: forward --mode-all so the strict refusal applies to the
|
|
1405
|
+
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
1406
|
+
// pull-side).
|
|
1407
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
1217
1408
|
}
|
|
1218
1409
|
|
|
1219
1410
|
/**
|