@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq sync mode` (sync-mode.ts).
|
|
3
|
+
*
|
|
4
|
+
* Stubs VaultClient with the minimal `SyncModeVaultClient` interface — no
|
|
5
|
+
* network. Coverage:
|
|
6
|
+
* - validateMode happy + reject path
|
|
7
|
+
* - parseCustomPaths edge cases
|
|
8
|
+
* - readActiveCompanySlug reads `.hq/config.json`
|
|
9
|
+
* - setSyncMode resolves slug → membershipId, calls PUT, returns audit
|
|
10
|
+
* - setSyncMode rejects when slug doesn't match any membership
|
|
11
|
+
* - showSyncModes fans out across memberships, sorts by slug
|
|
12
|
+
* - formatShowTable renders headers + rows
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
LEGAL_SYNC_MODES,
|
|
22
|
+
formatShowTable,
|
|
23
|
+
parseCustomPaths,
|
|
24
|
+
readActiveCompanySlug,
|
|
25
|
+
setSyncMode,
|
|
26
|
+
showSyncModes,
|
|
27
|
+
validateMode,
|
|
28
|
+
type ShowSyncModesRow,
|
|
29
|
+
type SyncModeVaultClient,
|
|
30
|
+
} from "./sync-mode.js";
|
|
31
|
+
import type { Membership, MembershipSyncConfig } from "@indigoai-us/hq-cloud";
|
|
32
|
+
|
|
33
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function fakeMembership(overrides: Partial<Membership> = {}): Membership {
|
|
36
|
+
return {
|
|
37
|
+
membershipKey: "mbr_acme_123",
|
|
38
|
+
personUid: "prs_alice",
|
|
39
|
+
companyUid: "cmp_acme",
|
|
40
|
+
role: "member" as Membership["role"],
|
|
41
|
+
status: "active" as Membership["status"],
|
|
42
|
+
invitedBy: "prs_admin",
|
|
43
|
+
invitedAt: "2026-01-01T00:00:00Z",
|
|
44
|
+
...overrides,
|
|
45
|
+
} as Membership;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fakeConfig(
|
|
49
|
+
overrides: Partial<MembershipSyncConfig> = {},
|
|
50
|
+
): MembershipSyncConfig {
|
|
51
|
+
return {
|
|
52
|
+
membershipId: "mbr_acme_123",
|
|
53
|
+
syncMode: "shared",
|
|
54
|
+
isDefault: true,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface StubOpts {
|
|
60
|
+
memberships?: Membership[];
|
|
61
|
+
configs?: Record<string, MembershipSyncConfig>;
|
|
62
|
+
setResult?: MembershipSyncConfig;
|
|
63
|
+
entities?: Record<string, { uid: string; slug: string; name?: string }>;
|
|
64
|
+
failGetConfig?: Set<string>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeStubClient(opts: StubOpts): {
|
|
68
|
+
client: SyncModeVaultClient;
|
|
69
|
+
spies: {
|
|
70
|
+
listMyMemberships: ReturnType<typeof vi.fn>;
|
|
71
|
+
getMembershipSyncConfig: ReturnType<typeof vi.fn>;
|
|
72
|
+
setMembershipSyncConfig: ReturnType<typeof vi.fn>;
|
|
73
|
+
entityGet: ReturnType<typeof vi.fn>;
|
|
74
|
+
};
|
|
75
|
+
} {
|
|
76
|
+
const listMyMemberships = vi
|
|
77
|
+
.fn<[], Promise<Membership[]>>()
|
|
78
|
+
.mockResolvedValue(opts.memberships ?? []);
|
|
79
|
+
const getMembershipSyncConfig = vi
|
|
80
|
+
.fn<[string], Promise<MembershipSyncConfig>>()
|
|
81
|
+
.mockImplementation(async (id: string) => {
|
|
82
|
+
if (opts.failGetConfig?.has(id)) {
|
|
83
|
+
throw new Error(`fake getMembershipSyncConfig failure for ${id}`);
|
|
84
|
+
}
|
|
85
|
+
const cfg = opts.configs?.[id];
|
|
86
|
+
if (cfg) return cfg;
|
|
87
|
+
return { membershipId: id, syncMode: "shared", isDefault: true };
|
|
88
|
+
});
|
|
89
|
+
const setMembershipSyncConfig = vi
|
|
90
|
+
.fn<
|
|
91
|
+
[string, { syncMode: MembershipSyncConfig["syncMode"]; customPaths?: string[] }],
|
|
92
|
+
Promise<MembershipSyncConfig>
|
|
93
|
+
>()
|
|
94
|
+
.mockImplementation(async (id, partial) => {
|
|
95
|
+
if (opts.setResult) return opts.setResult;
|
|
96
|
+
return {
|
|
97
|
+
membershipId: id,
|
|
98
|
+
syncMode: partial.syncMode,
|
|
99
|
+
customPaths: partial.customPaths,
|
|
100
|
+
isDefault: false,
|
|
101
|
+
updatedAt: "2026-05-20T12:00:00Z",
|
|
102
|
+
updatedBy: "prs_alice",
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const entityGet = vi
|
|
106
|
+
.fn<
|
|
107
|
+
[string],
|
|
108
|
+
Promise<{ uid: string; slug: string; name?: string }>
|
|
109
|
+
>()
|
|
110
|
+
.mockImplementation(async (uid: string) => {
|
|
111
|
+
const e = opts.entities?.[uid];
|
|
112
|
+
if (!e) throw new Error(`unknown entity ${uid}`);
|
|
113
|
+
return e;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
client: {
|
|
118
|
+
listMyMemberships,
|
|
119
|
+
getMembershipSyncConfig,
|
|
120
|
+
setMembershipSyncConfig,
|
|
121
|
+
entity: { get: entityGet },
|
|
122
|
+
},
|
|
123
|
+
spies: {
|
|
124
|
+
listMyMemberships,
|
|
125
|
+
getMembershipSyncConfig,
|
|
126
|
+
setMembershipSyncConfig,
|
|
127
|
+
entityGet,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe("validateMode", () => {
|
|
135
|
+
it("accepts all three legal modes", () => {
|
|
136
|
+
for (const m of LEGAL_SYNC_MODES) {
|
|
137
|
+
expect(validateMode(m)).toBe(m);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects unknown modes with the legal set in the error", () => {
|
|
142
|
+
expect(() => validateMode("none")).toThrow(/Invalid sync mode 'none'/);
|
|
143
|
+
expect(() => validateMode("none")).toThrow(/shared, all, custom/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects empty string", () => {
|
|
147
|
+
expect(() => validateMode("")).toThrow(/Invalid sync mode/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("parseCustomPaths", () => {
|
|
152
|
+
it("returns undefined for undefined/empty", () => {
|
|
153
|
+
expect(parseCustomPaths(undefined)).toBeUndefined();
|
|
154
|
+
expect(parseCustomPaths("")).toBeUndefined();
|
|
155
|
+
expect(parseCustomPaths(",,")).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("trims + drops empty entries", () => {
|
|
159
|
+
expect(parseCustomPaths(" a/, b/c/ , ,d/")).toEqual(["a/", "b/c/", "d/"]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("readActiveCompanySlug", () => {
|
|
164
|
+
let tmpRoot: string;
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-mode-test-"));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns undefined when .hq/config.json is missing", () => {
|
|
175
|
+
expect(readActiveCompanySlug(tmpRoot)).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("reads activeCompany when present", () => {
|
|
179
|
+
fs.mkdirSync(path.join(tmpRoot, ".hq"));
|
|
180
|
+
fs.writeFileSync(
|
|
181
|
+
path.join(tmpRoot, ".hq", "config.json"),
|
|
182
|
+
JSON.stringify({ activeCompany: "acme" }),
|
|
183
|
+
);
|
|
184
|
+
expect(readActiveCompanySlug(tmpRoot)).toBe("acme");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns undefined on bad JSON", () => {
|
|
188
|
+
fs.mkdirSync(path.join(tmpRoot, ".hq"));
|
|
189
|
+
fs.writeFileSync(path.join(tmpRoot, ".hq", "config.json"), "not json");
|
|
190
|
+
expect(readActiveCompanySlug(tmpRoot)).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns undefined when activeCompany isn't a string", () => {
|
|
194
|
+
fs.mkdirSync(path.join(tmpRoot, ".hq"));
|
|
195
|
+
fs.writeFileSync(
|
|
196
|
+
path.join(tmpRoot, ".hq", "config.json"),
|
|
197
|
+
JSON.stringify({ activeCompany: 42 }),
|
|
198
|
+
);
|
|
199
|
+
expect(readActiveCompanySlug(tmpRoot)).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── setSyncMode orchestrator ────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("setSyncMode", () => {
|
|
206
|
+
it("resolves slug → membership, PUTs new mode, returns audit", async () => {
|
|
207
|
+
const { client, spies } = makeStubClient({
|
|
208
|
+
memberships: [
|
|
209
|
+
fakeMembership({ membershipKey: "mbr_acme_123", companyUid: "cmp_acme" }),
|
|
210
|
+
fakeMembership({ membershipKey: "mbr_other_999", companyUid: "cmp_other" }),
|
|
211
|
+
],
|
|
212
|
+
configs: {
|
|
213
|
+
mbr_acme_123: fakeConfig({ syncMode: "shared", isDefault: true }),
|
|
214
|
+
},
|
|
215
|
+
entities: {
|
|
216
|
+
cmp_acme: { uid: "cmp_acme", slug: "acme", name: "Acme" },
|
|
217
|
+
cmp_other: { uid: "cmp_other", slug: "other" },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await setSyncMode({
|
|
222
|
+
mode: "all",
|
|
223
|
+
companySlug: "acme",
|
|
224
|
+
vaultClient: client,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(result.membershipId).toBe("mbr_acme_123");
|
|
228
|
+
expect(result.companySlug).toBe("acme");
|
|
229
|
+
expect(result.previousMode).toBe("shared");
|
|
230
|
+
expect(result.previousWasDefault).toBe(true);
|
|
231
|
+
expect(result.newMode).toBe("all");
|
|
232
|
+
expect(result.newConfig.isDefault).toBe(false);
|
|
233
|
+
|
|
234
|
+
expect(spies.listMyMemberships).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(spies.getMembershipSyncConfig).toHaveBeenCalledWith("mbr_acme_123");
|
|
236
|
+
expect(spies.setMembershipSyncConfig).toHaveBeenCalledWith(
|
|
237
|
+
"mbr_acme_123",
|
|
238
|
+
{ syncMode: "all", customPaths: undefined },
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("forwards customPaths on `custom` mode", async () => {
|
|
243
|
+
const { client, spies } = makeStubClient({
|
|
244
|
+
memberships: [fakeMembership()],
|
|
245
|
+
entities: { cmp_acme: { uid: "cmp_acme", slug: "acme" } },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await setSyncMode({
|
|
249
|
+
mode: "custom",
|
|
250
|
+
companySlug: "acme",
|
|
251
|
+
customPaths: ["projects/", "knowledge/"],
|
|
252
|
+
vaultClient: client,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(spies.setMembershipSyncConfig).toHaveBeenCalledWith(
|
|
256
|
+
"mbr_acme_123",
|
|
257
|
+
{ syncMode: "custom", customPaths: ["projects/", "knowledge/"] },
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("throws a helpful error when slug doesn't match any membership", async () => {
|
|
262
|
+
const { client } = makeStubClient({
|
|
263
|
+
memberships: [fakeMembership()],
|
|
264
|
+
entities: { cmp_acme: { uid: "cmp_acme", slug: "acme" } },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
setSyncMode({ mode: "all", companySlug: "ghost", vaultClient: client }),
|
|
269
|
+
).rejects.toThrow(/No membership found for company 'ghost'.*acme/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("throws (none) when the caller has zero memberships", async () => {
|
|
273
|
+
const { client } = makeStubClient({});
|
|
274
|
+
await expect(
|
|
275
|
+
setSyncMode({ mode: "all", companySlug: "acme", vaultClient: client }),
|
|
276
|
+
).rejects.toThrow(/\(none\)/);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ── showSyncModes orchestrator ──────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
describe("showSyncModes", () => {
|
|
283
|
+
it("returns rows sorted by company slug", async () => {
|
|
284
|
+
const { client } = makeStubClient({
|
|
285
|
+
memberships: [
|
|
286
|
+
fakeMembership({ membershipKey: "mbr_z", companyUid: "cmp_z" }),
|
|
287
|
+
fakeMembership({ membershipKey: "mbr_a", companyUid: "cmp_a" }),
|
|
288
|
+
],
|
|
289
|
+
configs: {
|
|
290
|
+
mbr_z: { membershipId: "mbr_z", syncMode: "all", isDefault: false, updatedAt: "2026-05-20T00:00:00Z" },
|
|
291
|
+
mbr_a: { membershipId: "mbr_a", syncMode: "shared", isDefault: true },
|
|
292
|
+
},
|
|
293
|
+
entities: {
|
|
294
|
+
cmp_a: { uid: "cmp_a", slug: "alpha", name: "Alpha" },
|
|
295
|
+
cmp_z: { uid: "cmp_z", slug: "zeta" },
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const rows = await showSyncModes({ vaultClient: client });
|
|
300
|
+
expect(rows.map((r) => r.companySlug)).toEqual(["alpha", "zeta"]);
|
|
301
|
+
expect(rows[0]).toMatchObject({
|
|
302
|
+
companySlug: "alpha",
|
|
303
|
+
syncMode: "shared",
|
|
304
|
+
isDefault: true,
|
|
305
|
+
});
|
|
306
|
+
expect(rows[1]).toMatchObject({
|
|
307
|
+
companySlug: "zeta",
|
|
308
|
+
syncMode: "all",
|
|
309
|
+
isDefault: false,
|
|
310
|
+
updatedAt: "2026-05-20T00:00:00Z",
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns empty array when caller has no memberships", async () => {
|
|
315
|
+
const { client } = makeStubClient({});
|
|
316
|
+
expect(await showSyncModes({ vaultClient: client })).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("falls back to default config when getMembershipSyncConfig throws", async () => {
|
|
320
|
+
const { client } = makeStubClient({
|
|
321
|
+
memberships: [fakeMembership()],
|
|
322
|
+
failGetConfig: new Set(["mbr_acme_123"]),
|
|
323
|
+
entities: { cmp_acme: { uid: "cmp_acme", slug: "acme" } },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const rows = await showSyncModes({ vaultClient: client });
|
|
327
|
+
expect(rows).toHaveLength(1);
|
|
328
|
+
expect(rows[0].syncMode).toBe("shared");
|
|
329
|
+
expect(rows[0].isDefault).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ── formatShowTable ────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
describe("formatShowTable", () => {
|
|
336
|
+
it("renders a helpful message when there are no rows", () => {
|
|
337
|
+
expect(formatShowTable([])).toMatch(/No memberships found/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("includes headers + every row's slug + mode", () => {
|
|
341
|
+
const rows: ShowSyncModesRow[] = [
|
|
342
|
+
{
|
|
343
|
+
companySlug: "alpha",
|
|
344
|
+
membershipId: "mbr_a",
|
|
345
|
+
syncMode: "shared",
|
|
346
|
+
isDefault: true,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
companySlug: "zeta",
|
|
350
|
+
membershipId: "mbr_z",
|
|
351
|
+
syncMode: "all",
|
|
352
|
+
isDefault: false,
|
|
353
|
+
updatedAt: "2026-05-20T00:00:00Z",
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
const out = formatShowTable(rows);
|
|
357
|
+
expect(out).toContain("COMPANY");
|
|
358
|
+
expect(out).toContain("MODE");
|
|
359
|
+
expect(out).toContain("alpha");
|
|
360
|
+
expect(out).toContain("zeta");
|
|
361
|
+
expect(out).toContain("shared (default)");
|
|
362
|
+
expect(out).toContain("all");
|
|
363
|
+
expect(out).toContain("mbr_a");
|
|
364
|
+
expect(out).toContain("mbr_z");
|
|
365
|
+
});
|
|
366
|
+
});
|