@indigoai-us/hq-cli 5.18.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.
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -163,5 +163,42 @@ export declare function assertSingleSelector(opts: {
|
|
|
163
163
|
personal?: boolean;
|
|
164
164
|
company?: string;
|
|
165
165
|
}, command: string): void;
|
|
166
|
+
/**
|
|
167
|
+
* Per-company pull resolution helper used by `hq sync pull --company <slug>`
|
|
168
|
+
* (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
|
|
169
|
+
* does for sync-now. Pulled out so the action handler stays thin AND so
|
|
170
|
+
* unit tests can exercise the banner / strict-refusal decision without
|
|
171
|
+
* spinning up commander + a real VaultClient.
|
|
172
|
+
*
|
|
173
|
+
* Input shape:
|
|
174
|
+
* - `targetCompany` — slug or UID the caller passed to `--company`. If
|
|
175
|
+
* undefined, the helper short-circuits to a "no resolution" result
|
|
176
|
+
* (the action handler falls back to .hq/config.json via sync()).
|
|
177
|
+
* - `client` — minimal VaultClient surface: listMyMemberships + entity.get
|
|
178
|
+
* + getMembershipSyncConfig.
|
|
179
|
+
*
|
|
180
|
+
* Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
|
|
181
|
+
* if the membership / sync-config call failed. Both undefined is a clean
|
|
182
|
+
* degradation — the caller pulls without a banner.
|
|
183
|
+
*/
|
|
184
|
+
export interface PerCompanyPullResolveClient {
|
|
185
|
+
listMyMemberships(): Promise<Array<{
|
|
186
|
+
companyUid: string;
|
|
187
|
+
membershipKey: string;
|
|
188
|
+
}>>;
|
|
189
|
+
getMembershipSyncConfig(membershipKey: string): Promise<{
|
|
190
|
+
syncMode: MembershipSyncConfig["syncMode"];
|
|
191
|
+
}>;
|
|
192
|
+
entity: {
|
|
193
|
+
get(uid: string): Promise<{
|
|
194
|
+
slug?: string;
|
|
195
|
+
}>;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export interface PerCompanyPullResolveResult {
|
|
199
|
+
resolvedCompanyUid: string | undefined;
|
|
200
|
+
resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
|
|
201
|
+
}
|
|
202
|
+
export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
|
|
166
203
|
export declare function registerCloudCommands(program: Command): void;
|
|
167
204
|
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
16
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
@@ -252,6 +252,54 @@ export function assertSingleSelector(opts, command) {
|
|
|
252
252
|
`--company; got: ${selectors.join(", ")}.`);
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
|
+
export async function resolvePerCompanyPullPlan(client, targetCompany) {
|
|
256
|
+
if (!targetCompany)
|
|
257
|
+
return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
258
|
+
try {
|
|
259
|
+
const memberships = await client.listMyMemberships();
|
|
260
|
+
// Direct UID / membershipKey match first (cheapest).
|
|
261
|
+
const direct = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
|
|
262
|
+
if (direct) {
|
|
263
|
+
let mode;
|
|
264
|
+
try {
|
|
265
|
+
const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
|
|
266
|
+
mode = cfg.syncMode;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
mode = undefined;
|
|
270
|
+
}
|
|
271
|
+
return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
|
|
272
|
+
}
|
|
273
|
+
// Slug match — listMyMemberships returns companyUid only, so fan out
|
|
274
|
+
// entity.get to find the row whose slug matches the caller's input.
|
|
275
|
+
for (const m of memberships) {
|
|
276
|
+
try {
|
|
277
|
+
const entity = await client.entity.get(m.companyUid);
|
|
278
|
+
if (entity.slug === targetCompany) {
|
|
279
|
+
let mode;
|
|
280
|
+
try {
|
|
281
|
+
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
282
|
+
mode = cfg.syncMode;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
mode = undefined;
|
|
286
|
+
}
|
|
287
|
+
return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Entity not visible — skip and continue. Worst case the loop ends
|
|
292
|
+
// with no match and we return undefined for both — the pull still
|
|
293
|
+
// proceeds, banner just stays quiet.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// listMyMemberships failed — degrade silently. Sync still works without
|
|
299
|
+
// the banner; this matches the runPullAll catch behavior.
|
|
300
|
+
}
|
|
301
|
+
return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
302
|
+
}
|
|
255
303
|
export function registerCloudCommands(program) {
|
|
256
304
|
program
|
|
257
305
|
.command("push")
|
|
@@ -482,10 +530,37 @@ export function registerCloudCommands(program) {
|
|
|
482
530
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
483
531
|
console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
|
|
484
532
|
const accessToken = await ensureCognitoToken();
|
|
533
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
534
|
+
// US-011 (2026-05-21 fix): resolve the caller's sync-config for
|
|
535
|
+
// the targeted membership BEFORE the pull, so we can (a) emit the
|
|
536
|
+
// narrow-hint banner after success if still on all-mode and
|
|
537
|
+
// (b) respect strict-mode refusal mirror of the --all + sync-now
|
|
538
|
+
// paths. Failure to resolve degrades silently — pull still works,
|
|
539
|
+
// banner just stays quiet (same as the catch in runPullAll).
|
|
540
|
+
const narrowHintLevel = resolveBannerLevel();
|
|
541
|
+
const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(new VaultClient(vaultConfig), options.company);
|
|
542
|
+
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
543
|
+
// Default banner level is 'hint' which never triggers refusal —
|
|
544
|
+
// wired now so future hq-core-staging releases can flip the
|
|
545
|
+
// default to 'strict' without re-touching this command.
|
|
546
|
+
if (resolvedMode === "all" &&
|
|
547
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
548
|
+
options.modeAll !== true &&
|
|
549
|
+
resolvedCompanyUid) {
|
|
550
|
+
emitNarrowHint({
|
|
551
|
+
companyUid: resolvedCompanyUid,
|
|
552
|
+
syncMode: resolvedMode,
|
|
553
|
+
level: narrowHintLevel,
|
|
554
|
+
});
|
|
555
|
+
console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
556
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
557
|
+
"to migrate, or re-run with --mode-all."));
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
485
560
|
const result = await sync({
|
|
486
561
|
company: options.company,
|
|
487
562
|
onConflict: options.onConflict,
|
|
488
|
-
vaultConfig
|
|
563
|
+
vaultConfig,
|
|
489
564
|
hqRoot: options.hqRoot,
|
|
490
565
|
});
|
|
491
566
|
if (result.aborted) {
|
|
@@ -493,6 +568,16 @@ export function registerCloudCommands(program) {
|
|
|
493
568
|
process.exit(1);
|
|
494
569
|
}
|
|
495
570
|
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
571
|
+
// US-011 (2026-05-21 fix): emit the hint banner after success
|
|
572
|
+
// so it appears alongside the summary line. Mirrors the wiring
|
|
573
|
+
// in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
|
|
574
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
575
|
+
emitNarrowHint({
|
|
576
|
+
companyUid: resolvedCompanyUid,
|
|
577
|
+
syncMode: resolvedMode,
|
|
578
|
+
level: narrowHintLevel,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
496
581
|
}
|
|
497
582
|
catch (err) {
|
|
498
583
|
console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
|
|
@@ -978,4 +1063,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
978
1063
|
}
|
|
979
1064
|
}
|
|
980
1065
|
//# sourceMappingURL=cloud.js.map
|
|
981
|
-
//# debugId=
|
|
1066
|
+
//# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
|
package/package.json
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `resolvePerCompanyPullPlan` — the helper extracted from the
|
|
3
|
+
* `hq sync pull --company <slug>` action handler so the US-011 banner +
|
|
4
|
+
* strict-refusal wiring can be exercised without commander / a real
|
|
5
|
+
* VaultClient (US-011 fix, 2026-05-21).
|
|
6
|
+
*
|
|
7
|
+
* The action handler itself stays a thin orchestrator. These tests cover
|
|
8
|
+
* the membership-resolution decision tree, which is the part that broke
|
|
9
|
+
* on the pre-fix code path (banner silently never fired because the
|
|
10
|
+
* handler never looked up the sync-config).
|
|
11
|
+
*
|
|
12
|
+
* Coverage:
|
|
13
|
+
* 1. Undefined targetCompany short-circuits to no-op.
|
|
14
|
+
* 2. Direct UID match returns mode + companyUid.
|
|
15
|
+
* 3. Direct membershipKey match returns mode + companyUid.
|
|
16
|
+
* 4. Slug match via entity.get returns mode + companyUid.
|
|
17
|
+
* 5. listMyMemberships error degrades silently to no resolution.
|
|
18
|
+
* 6. getMembershipSyncConfig error degrades to undefined mode but keeps companyUid.
|
|
19
|
+
* 7. Slug iteration stops at first match (doesn't fan out to all entities).
|
|
20
|
+
* 8. No matching membership returns no resolution.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it, vi } from "vitest";
|
|
24
|
+
import {
|
|
25
|
+
resolvePerCompanyPullPlan,
|
|
26
|
+
type PerCompanyPullResolveClient,
|
|
27
|
+
} from "./cloud.js";
|
|
28
|
+
|
|
29
|
+
function makeClient(opts: {
|
|
30
|
+
memberships?: Array<{ companyUid: string; membershipKey: string }>;
|
|
31
|
+
syncConfigs?: Record<string, { syncMode: "shared" | "all" | "custom" }>;
|
|
32
|
+
entities?: Record<string, { slug?: string }>;
|
|
33
|
+
failListMemberships?: boolean;
|
|
34
|
+
failSyncConfigFor?: Set<string>;
|
|
35
|
+
failEntityFor?: Set<string>;
|
|
36
|
+
}): PerCompanyPullResolveClient & {
|
|
37
|
+
_entityCalls: () => string[];
|
|
38
|
+
} {
|
|
39
|
+
const entityCalls: string[] = [];
|
|
40
|
+
return {
|
|
41
|
+
listMyMemberships: vi.fn(async () => {
|
|
42
|
+
if (opts.failListMemberships) throw new Error("listMyMemberships boom");
|
|
43
|
+
return opts.memberships ?? [];
|
|
44
|
+
}),
|
|
45
|
+
getMembershipSyncConfig: vi.fn(async (key: string) => {
|
|
46
|
+
if (opts.failSyncConfigFor?.has(key))
|
|
47
|
+
throw new Error(`getMembershipSyncConfig boom for ${key}`);
|
|
48
|
+
const cfg = (opts.syncConfigs ?? {})[key];
|
|
49
|
+
if (!cfg) throw new Error(`no fake config for ${key}`);
|
|
50
|
+
return cfg;
|
|
51
|
+
}),
|
|
52
|
+
entity: {
|
|
53
|
+
get: vi.fn(async (uid: string) => {
|
|
54
|
+
entityCalls.push(uid);
|
|
55
|
+
if (opts.failEntityFor?.has(uid))
|
|
56
|
+
throw new Error(`entity.get boom for ${uid}`);
|
|
57
|
+
return (opts.entities ?? {})[uid] ?? {};
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
_entityCalls: () => entityCalls,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("resolvePerCompanyPullPlan (US-011 per-company pull fix)", () => {
|
|
65
|
+
it("returns undefined+undefined when targetCompany is undefined", async () => {
|
|
66
|
+
const client = makeClient({});
|
|
67
|
+
const result = await resolvePerCompanyPullPlan(client, undefined);
|
|
68
|
+
expect(result.resolvedCompanyUid).toBeUndefined();
|
|
69
|
+
expect(result.resolvedMode).toBeUndefined();
|
|
70
|
+
// Should not call any API — short-circuit.
|
|
71
|
+
expect(client.listMyMemberships).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("matches by direct companyUid and returns the live syncMode", async () => {
|
|
75
|
+
const client = makeClient({
|
|
76
|
+
memberships: [
|
|
77
|
+
{ companyUid: "cmp_personal", membershipKey: "mbr_personal" },
|
|
78
|
+
{ companyUid: "cmp_indigo", membershipKey: "mbr_indigo" },
|
|
79
|
+
],
|
|
80
|
+
syncConfigs: {
|
|
81
|
+
mbr_personal: { syncMode: "all" },
|
|
82
|
+
mbr_indigo: { syncMode: "shared" },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const result = await resolvePerCompanyPullPlan(client, "cmp_indigo");
|
|
86
|
+
expect(result.resolvedCompanyUid).toBe("cmp_indigo");
|
|
87
|
+
expect(result.resolvedMode).toBe("shared");
|
|
88
|
+
// Slug-fallback loop must not fire — no entity calls.
|
|
89
|
+
expect(client._entityCalls()).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("matches by direct membershipKey", async () => {
|
|
93
|
+
const client = makeClient({
|
|
94
|
+
memberships: [
|
|
95
|
+
{ companyUid: "cmp_x", membershipKey: "mbr_special" },
|
|
96
|
+
],
|
|
97
|
+
syncConfigs: { mbr_special: { syncMode: "custom" } },
|
|
98
|
+
});
|
|
99
|
+
const result = await resolvePerCompanyPullPlan(client, "mbr_special");
|
|
100
|
+
expect(result.resolvedCompanyUid).toBe("cmp_x");
|
|
101
|
+
expect(result.resolvedMode).toBe("custom");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("matches by slug via entity.get fan-out", async () => {
|
|
105
|
+
const client = makeClient({
|
|
106
|
+
memberships: [
|
|
107
|
+
{ companyUid: "cmp_one", membershipKey: "mbr_one" },
|
|
108
|
+
{ companyUid: "cmp_two", membershipKey: "mbr_two" },
|
|
109
|
+
],
|
|
110
|
+
syncConfigs: { mbr_two: { syncMode: "all" } },
|
|
111
|
+
entities: {
|
|
112
|
+
cmp_one: { slug: "foo" },
|
|
113
|
+
cmp_two: { slug: "personal" },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const result = await resolvePerCompanyPullPlan(client, "personal");
|
|
117
|
+
expect(result.resolvedCompanyUid).toBe("cmp_two");
|
|
118
|
+
expect(result.resolvedMode).toBe("all");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("stops slug iteration at the first match — does not fan out", async () => {
|
|
122
|
+
const client = makeClient({
|
|
123
|
+
memberships: [
|
|
124
|
+
{ companyUid: "cmp_a", membershipKey: "mbr_a" },
|
|
125
|
+
{ companyUid: "cmp_b", membershipKey: "mbr_b" },
|
|
126
|
+
{ companyUid: "cmp_c", membershipKey: "mbr_c" },
|
|
127
|
+
],
|
|
128
|
+
syncConfigs: { mbr_b: { syncMode: "shared" } },
|
|
129
|
+
entities: {
|
|
130
|
+
cmp_a: { slug: "alpha" },
|
|
131
|
+
cmp_b: { slug: "beta" },
|
|
132
|
+
cmp_c: { slug: "gamma" },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const result = await resolvePerCompanyPullPlan(client, "beta");
|
|
136
|
+
expect(result.resolvedCompanyUid).toBe("cmp_b");
|
|
137
|
+
expect(result.resolvedMode).toBe("shared");
|
|
138
|
+
// cmp_a was probed, cmp_b matched, cmp_c never touched.
|
|
139
|
+
expect(client._entityCalls()).toEqual(["cmp_a", "cmp_b"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns undefined for both when no matching membership/slug", async () => {
|
|
143
|
+
const client = makeClient({
|
|
144
|
+
memberships: [
|
|
145
|
+
{ companyUid: "cmp_x", membershipKey: "mbr_x" },
|
|
146
|
+
],
|
|
147
|
+
entities: { cmp_x: { slug: "foo" } },
|
|
148
|
+
});
|
|
149
|
+
const result = await resolvePerCompanyPullPlan(client, "doesnotexist");
|
|
150
|
+
expect(result.resolvedCompanyUid).toBeUndefined();
|
|
151
|
+
expect(result.resolvedMode).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("degrades silently when listMyMemberships fails — no banner, pull continues", async () => {
|
|
155
|
+
const client = makeClient({ failListMemberships: true });
|
|
156
|
+
const result = await resolvePerCompanyPullPlan(client, "personal");
|
|
157
|
+
expect(result.resolvedCompanyUid).toBeUndefined();
|
|
158
|
+
expect(result.resolvedMode).toBeUndefined();
|
|
159
|
+
// Sync-config never reached.
|
|
160
|
+
expect(client.getMembershipSyncConfig).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("on direct match, keeps companyUid even when getMembershipSyncConfig throws", async () => {
|
|
164
|
+
const client = makeClient({
|
|
165
|
+
memberships: [{ companyUid: "cmp_p", membershipKey: "mbr_p" }],
|
|
166
|
+
failSyncConfigFor: new Set(["mbr_p"]),
|
|
167
|
+
});
|
|
168
|
+
const result = await resolvePerCompanyPullPlan(client, "cmp_p");
|
|
169
|
+
expect(result.resolvedCompanyUid).toBe("cmp_p");
|
|
170
|
+
// Mode unknown — caller will skip banner emit (it only fires when mode='all').
|
|
171
|
+
expect(result.resolvedMode).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("on slug-fallback match, skips broken entity rows and keeps looking", async () => {
|
|
175
|
+
const client = makeClient({
|
|
176
|
+
memberships: [
|
|
177
|
+
{ companyUid: "cmp_broken", membershipKey: "mbr_broken" },
|
|
178
|
+
{ companyUid: "cmp_good", membershipKey: "mbr_good" },
|
|
179
|
+
],
|
|
180
|
+
syncConfigs: { mbr_good: { syncMode: "all" } },
|
|
181
|
+
entities: { cmp_good: { slug: "wanted" } },
|
|
182
|
+
failEntityFor: new Set(["cmp_broken"]),
|
|
183
|
+
});
|
|
184
|
+
const result = await resolvePerCompanyPullPlan(client, "wanted");
|
|
185
|
+
expect(result.resolvedCompanyUid).toBe("cmp_good");
|
|
186
|
+
expect(result.resolvedMode).toBe("all");
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -473,6 +473,86 @@ export function assertSingleSelector(opts: {
|
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Per-company pull resolution helper used by `hq sync pull --company <slug>`
|
|
478
|
+
* (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
|
|
479
|
+
* does for sync-now. Pulled out so the action handler stays thin AND so
|
|
480
|
+
* unit tests can exercise the banner / strict-refusal decision without
|
|
481
|
+
* spinning up commander + a real VaultClient.
|
|
482
|
+
*
|
|
483
|
+
* Input shape:
|
|
484
|
+
* - `targetCompany` — slug or UID the caller passed to `--company`. If
|
|
485
|
+
* undefined, the helper short-circuits to a "no resolution" result
|
|
486
|
+
* (the action handler falls back to .hq/config.json via sync()).
|
|
487
|
+
* - `client` — minimal VaultClient surface: listMyMemberships + entity.get
|
|
488
|
+
* + getMembershipSyncConfig.
|
|
489
|
+
*
|
|
490
|
+
* Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
|
|
491
|
+
* if the membership / sync-config call failed. Both undefined is a clean
|
|
492
|
+
* degradation — the caller pulls without a banner.
|
|
493
|
+
*/
|
|
494
|
+
export interface PerCompanyPullResolveClient {
|
|
495
|
+
listMyMemberships(): Promise<Array<{ companyUid: string; membershipKey: string }>>;
|
|
496
|
+
getMembershipSyncConfig(
|
|
497
|
+
membershipKey: string,
|
|
498
|
+
): Promise<{ syncMode: MembershipSyncConfig["syncMode"] }>;
|
|
499
|
+
entity: { get(uid: string): Promise<{ slug?: string }> };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface PerCompanyPullResolveResult {
|
|
503
|
+
resolvedCompanyUid: string | undefined;
|
|
504
|
+
resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function resolvePerCompanyPullPlan(
|
|
508
|
+
client: PerCompanyPullResolveClient,
|
|
509
|
+
targetCompany: string | undefined,
|
|
510
|
+
): Promise<PerCompanyPullResolveResult> {
|
|
511
|
+
if (!targetCompany) return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
512
|
+
try {
|
|
513
|
+
const memberships = await client.listMyMemberships();
|
|
514
|
+
// Direct UID / membershipKey match first (cheapest).
|
|
515
|
+
const direct = memberships.find(
|
|
516
|
+
(m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
|
|
517
|
+
);
|
|
518
|
+
if (direct) {
|
|
519
|
+
let mode: MembershipSyncConfig["syncMode"] | undefined;
|
|
520
|
+
try {
|
|
521
|
+
const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
|
|
522
|
+
mode = cfg.syncMode;
|
|
523
|
+
} catch {
|
|
524
|
+
mode = undefined;
|
|
525
|
+
}
|
|
526
|
+
return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
|
|
527
|
+
}
|
|
528
|
+
// Slug match — listMyMemberships returns companyUid only, so fan out
|
|
529
|
+
// entity.get to find the row whose slug matches the caller's input.
|
|
530
|
+
for (const m of memberships) {
|
|
531
|
+
try {
|
|
532
|
+
const entity = await client.entity.get(m.companyUid);
|
|
533
|
+
if (entity.slug === targetCompany) {
|
|
534
|
+
let mode: MembershipSyncConfig["syncMode"] | undefined;
|
|
535
|
+
try {
|
|
536
|
+
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
537
|
+
mode = cfg.syncMode;
|
|
538
|
+
} catch {
|
|
539
|
+
mode = undefined;
|
|
540
|
+
}
|
|
541
|
+
return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
// Entity not visible — skip and continue. Worst case the loop ends
|
|
545
|
+
// with no match and we return undefined for both — the pull still
|
|
546
|
+
// proceeds, banner just stays quiet.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// listMyMemberships failed — degrade silently. Sync still works without
|
|
551
|
+
// the banner; this matches the runPullAll catch behavior.
|
|
552
|
+
}
|
|
553
|
+
return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
554
|
+
}
|
|
555
|
+
|
|
476
556
|
export function registerCloudCommands(program: Command): void {
|
|
477
557
|
program
|
|
478
558
|
.command("push")
|
|
@@ -817,10 +897,50 @@ export function registerCloudCommands(program: Command): void {
|
|
|
817
897
|
console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
|
|
818
898
|
|
|
819
899
|
const accessToken = await ensureCognitoToken();
|
|
900
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
901
|
+
|
|
902
|
+
// US-011 (2026-05-21 fix): resolve the caller's sync-config for
|
|
903
|
+
// the targeted membership BEFORE the pull, so we can (a) emit the
|
|
904
|
+
// narrow-hint banner after success if still on all-mode and
|
|
905
|
+
// (b) respect strict-mode refusal mirror of the --all + sync-now
|
|
906
|
+
// paths. Failure to resolve degrades silently — pull still works,
|
|
907
|
+
// banner just stays quiet (same as the catch in runPullAll).
|
|
908
|
+
const narrowHintLevel: BannerLevel = resolveBannerLevel();
|
|
909
|
+
const { resolvedCompanyUid, resolvedMode } =
|
|
910
|
+
await resolvePerCompanyPullPlan(
|
|
911
|
+
new VaultClient(vaultConfig),
|
|
912
|
+
options.company,
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
916
|
+
// Default banner level is 'hint' which never triggers refusal —
|
|
917
|
+
// wired now so future hq-core-staging releases can flip the
|
|
918
|
+
// default to 'strict' without re-touching this command.
|
|
919
|
+
if (
|
|
920
|
+
resolvedMode === "all" &&
|
|
921
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
922
|
+
options.modeAll !== true &&
|
|
923
|
+
resolvedCompanyUid
|
|
924
|
+
) {
|
|
925
|
+
emitNarrowHint({
|
|
926
|
+
companyUid: resolvedCompanyUid,
|
|
927
|
+
syncMode: resolvedMode,
|
|
928
|
+
level: narrowHintLevel,
|
|
929
|
+
});
|
|
930
|
+
console.error(
|
|
931
|
+
chalk.red(
|
|
932
|
+
"\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
933
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
934
|
+
"to migrate, or re-run with --mode-all.",
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
process.exit(1);
|
|
938
|
+
}
|
|
939
|
+
|
|
820
940
|
const result = await sync({
|
|
821
941
|
company: options.company,
|
|
822
942
|
onConflict: options.onConflict,
|
|
823
|
-
vaultConfig
|
|
943
|
+
vaultConfig,
|
|
824
944
|
hqRoot: options.hqRoot,
|
|
825
945
|
});
|
|
826
946
|
|
|
@@ -838,6 +958,17 @@ export function registerCloudCommands(program: Command): void {
|
|
|
838
958
|
`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
|
|
839
959
|
),
|
|
840
960
|
);
|
|
961
|
+
|
|
962
|
+
// US-011 (2026-05-21 fix): emit the hint banner after success
|
|
963
|
+
// so it appears alongside the summary line. Mirrors the wiring
|
|
964
|
+
// in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
|
|
965
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
966
|
+
emitNarrowHint({
|
|
967
|
+
companyUid: resolvedCompanyUid,
|
|
968
|
+
syncMode: resolvedMode,
|
|
969
|
+
level: narrowHintLevel,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
841
972
|
} catch (err) {
|
|
842
973
|
console.error(
|
|
843
974
|
chalk.red("\n✗ Pull failed:"),
|