@indigoai-us/hq-cli 5.12.1 → 5.12.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.12.2] — 2026-05-10
4
+
5
+ ### Added
6
+
7
+ - **`hq sync pull --all`** — fan out across every company you're a member of
8
+ plus your personal vault into `<hq-root>` in one shot. Companies land at
9
+ `<hq-root>/companies/<slug>`; the personal vault syncs at `<hq-root>` itself.
10
+ Mirrors the orchestration `hq-sync-runner --companies --direction pull` does
11
+ but reachable from the CLI front-end. Used by the Outpost cloud-init to
12
+ prime `/home/ec2-user/hq` on first boot.
13
+
3
14
  ## [5.12.1] — 2026-05-09
4
15
 
5
16
  ### Fixed
@@ -13,5 +13,62 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { Command } from "commander";
16
+ import { type ConflictStrategy } from "@indigoai-us/hq-cloud";
17
+ export interface PullAllVaultClient {
18
+ listMyMemberships(): Promise<Array<{
19
+ companyUid: string;
20
+ }>>;
21
+ listPersonEntities(): Promise<Array<{
22
+ uid: string;
23
+ type: string;
24
+ slug: string;
25
+ bucketName?: string;
26
+ createdAt: string;
27
+ }>>;
28
+ getEntity(uid: string): Promise<{
29
+ slug?: string;
30
+ name?: string;
31
+ } | null>;
32
+ }
33
+ export interface SyncCallOptions {
34
+ company: string;
35
+ hqRoot: string;
36
+ onConflict?: ConflictStrategy;
37
+ personalMode?: boolean;
38
+ journalSlug?: string;
39
+ }
40
+ export interface SyncCallResult {
41
+ filesDownloaded: number;
42
+ bytesDownloaded: number;
43
+ filesSkipped: number;
44
+ conflicts: number;
45
+ conflictPaths: string[];
46
+ aborted: boolean;
47
+ }
48
+ export interface PullAllDeps {
49
+ vaultClient: PullAllVaultClient;
50
+ sync: (options: SyncCallOptions) => Promise<SyncCallResult>;
51
+ }
52
+ export interface PullAllOptions {
53
+ hqRoot: string;
54
+ onConflict?: ConflictStrategy;
55
+ }
56
+ export interface PullAllRow {
57
+ slug: string;
58
+ result?: SyncCallResult;
59
+ error?: string;
60
+ }
61
+ export interface PullAllResult {
62
+ attempted: number;
63
+ filesDownloaded: number;
64
+ bytesDownloaded: number;
65
+ conflicts: number;
66
+ errors: Array<{
67
+ company: string;
68
+ message: string;
69
+ }>;
70
+ perCompany: PullAllRow[];
71
+ }
72
+ export declare function pullAll(options: PullAllOptions, deps: PullAllDeps): Promise<PullAllResult>;
16
73
  export declare function registerCloudCommands(program: Command): void;
17
74
  //# sourceMappingURL=cloud.d.ts.map
@@ -13,12 +13,86 @@
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]="213f074e-13c2-5b95-9519-813adf9adfa9")}catch(e){}}();
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]="e7f5f66e-78d5-5787-acb4-fe35424f69c9")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import { share, sync, readJournal, getJournalPath, loadCachedTokens, } from "@indigoai-us/hq-cloud";
20
+ import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, } from "@indigoai-us/hq-cloud";
21
21
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
22
+ // Oldest-first by createdAt, ties broken by uid lexicographic — matches
23
+ // `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
24
+ // the same person bucket that `hq-sync-runner` picks.
25
+ function pickCanonicalPerson(persons) {
26
+ const onlyPersons = persons.filter((e) => e.type === "person");
27
+ if (onlyPersons.length === 0)
28
+ return null;
29
+ return [...onlyPersons].sort((a, b) => {
30
+ if (a.createdAt !== b.createdAt)
31
+ return a.createdAt < b.createdAt ? -1 : 1;
32
+ return a.uid < b.uid ? -1 : 1;
33
+ })[0];
34
+ }
35
+ export async function pullAll(options, deps) {
36
+ const memberships = await deps.vaultClient.listMyMemberships();
37
+ const persons = await deps.vaultClient.listPersonEntities();
38
+ const plan = [];
39
+ for (const m of memberships) {
40
+ let slug = m.companyUid;
41
+ try {
42
+ const info = await deps.vaultClient.getEntity(m.companyUid);
43
+ if (info?.slug)
44
+ slug = info.slug;
45
+ }
46
+ catch {
47
+ // Best-effort — keep UID as the row label rather than aborting the run.
48
+ }
49
+ plan.push({
50
+ slug,
51
+ syncOptions: {
52
+ company: m.companyUid,
53
+ hqRoot: options.hqRoot,
54
+ ...(options.onConflict ? { onConflict: options.onConflict } : {}),
55
+ },
56
+ });
57
+ }
58
+ const personal = pickCanonicalPerson(persons);
59
+ if (personal) {
60
+ plan.push({
61
+ slug: "personal",
62
+ syncOptions: {
63
+ company: personal.uid,
64
+ hqRoot: options.hqRoot,
65
+ personalMode: true,
66
+ journalSlug: "personal",
67
+ ...(options.onConflict ? { onConflict: options.onConflict } : {}),
68
+ },
69
+ });
70
+ }
71
+ const result = {
72
+ attempted: 0,
73
+ filesDownloaded: 0,
74
+ bytesDownloaded: 0,
75
+ conflicts: 0,
76
+ errors: [],
77
+ perCompany: [],
78
+ };
79
+ for (const entry of plan) {
80
+ result.attempted += 1;
81
+ try {
82
+ const r = await deps.sync(entry.syncOptions);
83
+ result.filesDownloaded += r.filesDownloaded;
84
+ result.bytesDownloaded += r.bytesDownloaded;
85
+ result.conflicts += r.conflicts;
86
+ result.perCompany.push({ slug: entry.slug, result: r });
87
+ }
88
+ catch (err) {
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ result.errors.push({ company: entry.slug, message });
91
+ result.perCompany.push({ slug: entry.slug, error: message });
92
+ }
93
+ }
94
+ return result;
95
+ }
22
96
  export function registerCloudCommands(program) {
23
97
  program
24
98
  .command("push")
@@ -143,7 +217,14 @@ export function registerCloudCommands(program) {
143
217
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
144
218
  .option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
145
219
  .option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
220
+ .option("--all", "Pull every company you are a member of plus your personal vault " +
221
+ "into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
222
+ "the personal vault syncs at <hq-root>. Ignores --company.")
146
223
  .action(async (options) => {
224
+ if (options.all) {
225
+ await runPullAll(options.hqRoot, options.onConflict);
226
+ return;
227
+ }
147
228
  try {
148
229
  console.log(chalk.bold("\nHQ Sync — Pull"));
149
230
  console.log(` HQ root: ${options.hqRoot}`);
@@ -211,6 +292,68 @@ export function registerCloudCommands(program) {
211
292
  }
212
293
  });
213
294
  }
295
+ async function runPullAll(hqRoot, onConflict) {
296
+ console.log(chalk.bold("\nHQ Sync — Pull (all)"));
297
+ console.log(` HQ root: ${hqRoot}`);
298
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
299
+ let result;
300
+ try {
301
+ const accessToken = await ensureCognitoToken();
302
+ const vaultConfig = buildVaultConfig(accessToken);
303
+ const realClient = new VaultClient(vaultConfig);
304
+ const adapter = {
305
+ listMyMemberships: () => realClient.listMyMemberships(),
306
+ listPersonEntities: () => realClient.entity.listByType("person"),
307
+ getEntity: async (uid) => {
308
+ try {
309
+ return await realClient.entity.get(uid);
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ },
315
+ };
316
+ result = await pullAll({ hqRoot, ...(onConflict ? { onConflict } : {}) }, {
317
+ vaultClient: adapter,
318
+ sync: (opts) => sync({
319
+ company: opts.company,
320
+ hqRoot: opts.hqRoot,
321
+ vaultConfig,
322
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
323
+ ...(opts.personalMode !== undefined
324
+ ? { personalMode: opts.personalMode }
325
+ : {}),
326
+ ...(opts.journalSlug !== undefined
327
+ ? { journalSlug: opts.journalSlug }
328
+ : {}),
329
+ }),
330
+ });
331
+ }
332
+ catch (err) {
333
+ console.error(chalk.red("\n✗ Pull-all failed:"), err instanceof Error ? err.message : String(err));
334
+ process.exit(1);
335
+ }
336
+ for (const row of result.perCompany) {
337
+ if (row.error) {
338
+ console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
339
+ }
340
+ else if (row.result) {
341
+ const r = row.result;
342
+ const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
343
+ console.log(` ${status} ${row.slug}: ${r.filesDownloaded} file(s), ` +
344
+ `${formatBytes(r.bytesDownloaded)}, ` +
345
+ `${r.filesSkipped} skipped, ${r.conflicts} conflict(s)` +
346
+ (r.aborted ? " — aborted" : ""));
347
+ }
348
+ }
349
+ const errored = result.errors.length;
350
+ const summary = `\nPulled ${result.filesDownloaded} file(s) ` +
351
+ `(${formatBytes(result.bytesDownloaded)}) across ${result.attempted} ` +
352
+ `target(s); ${result.conflicts} conflict(s); ${errored} error(s)`;
353
+ console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
354
+ if (errored > 0)
355
+ process.exit(1);
356
+ }
214
357
  function formatBytes(bytes) {
215
358
  if (bytes === 0)
216
359
  return "0 B";
@@ -266,4 +409,4 @@ function resolveUploadAuthorFromCache() {
266
409
  }
267
410
  }
268
411
  //# sourceMappingURL=cloud.js.map
269
- //# debugId=213f074e-13c2-5b95-9519-813adf9adfa9
412
+ //# debugId=e7f5f66e-78d5-5787-acb4-fe35424f69c9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.12.1",
3
+ "version": "5.12.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Unit tests for `pullAll` — the orchestrator behind `hq sync pull --all`.
3
+ *
4
+ * Mirrors the dependency-injection layout used in `cloud-provision.test.ts`:
5
+ * the action handler in `cloud.ts` resolves a real `VaultClient` + `sync`,
6
+ * but `pullAll` itself takes them as deps so we can exercise the fanout
7
+ * logic without network or filesystem.
8
+ *
9
+ * Coverage:
10
+ * 1. Happy path — companies + personal both fan out.
11
+ * 2. Conflict strategy is forwarded to every sync() call.
12
+ * 3. Per-company errors don't abort the rest of the fanout.
13
+ * 4. No person entity → companies-only plan, no personal target.
14
+ * 5. Empty memberships AND no person entity → no sync calls, clean result.
15
+ * 6. Multiple person entities → canonical pick (oldest createdAt, uid tiebreak).
16
+ * 7. hqRoot is forwarded verbatim to every sync() call.
17
+ */
18
+
19
+ import { describe, expect, it, vi } from "vitest";
20
+
21
+ import {
22
+ pullAll,
23
+ type PullAllDeps,
24
+ type PullAllVaultClient,
25
+ type SyncCallOptions,
26
+ type SyncCallResult,
27
+ } from "./cloud.js";
28
+
29
+ // ── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ interface FakeMembership {
32
+ companyUid: string;
33
+ }
34
+
35
+ interface FakeEntity {
36
+ uid: string;
37
+ type: "person" | "company";
38
+ slug: string;
39
+ bucketName?: string;
40
+ createdAt: string;
41
+ }
42
+
43
+ function makeVaultClient(opts: {
44
+ memberships?: FakeMembership[];
45
+ persons?: FakeEntity[];
46
+ entitiesBySlug?: Record<string, { slug: string; name?: string }>;
47
+ failGetEntityFor?: Set<string>;
48
+ }): PullAllVaultClient {
49
+ return {
50
+ listMyMemberships: vi
51
+ .fn<[], Promise<FakeMembership[]>>()
52
+ .mockResolvedValue(opts.memberships ?? []),
53
+ listPersonEntities: vi
54
+ .fn<[], Promise<FakeEntity[]>>()
55
+ .mockResolvedValue(opts.persons ?? []),
56
+ getEntity: vi
57
+ .fn<[string], Promise<{ slug?: string; name?: string } | null>>()
58
+ .mockImplementation(async (uid: string) => {
59
+ if (opts.failGetEntityFor?.has(uid)) {
60
+ throw new Error(`fake getEntity failure for ${uid}`);
61
+ }
62
+ return opts.entitiesBySlug?.[uid] ?? null;
63
+ }),
64
+ };
65
+ }
66
+
67
+ function makeSyncSpy(
68
+ perCallOverrides: Record<string, Partial<SyncCallResult> | Error> = {},
69
+ ): {
70
+ fn: PullAllDeps["sync"];
71
+ calls: SyncCallOptions[];
72
+ } {
73
+ const calls: SyncCallOptions[] = [];
74
+ const fn: PullAllDeps["sync"] = vi
75
+ .fn<[SyncCallOptions], Promise<SyncCallResult>>()
76
+ .mockImplementation(async (options: SyncCallOptions) => {
77
+ calls.push(options);
78
+ const override = perCallOverrides[options.company];
79
+ if (override instanceof Error) {
80
+ throw override;
81
+ }
82
+ return {
83
+ filesDownloaded: 0,
84
+ bytesDownloaded: 0,
85
+ filesSkipped: 0,
86
+ conflicts: 0,
87
+ conflictPaths: [],
88
+ aborted: false,
89
+ ...override,
90
+ };
91
+ });
92
+ return { fn, calls };
93
+ }
94
+
95
+ // ── 1. Happy path ───────────────────────────────────────────────────────────
96
+
97
+ describe("pullAll", () => {
98
+ it("syncs every membership plus the canonical person entity", async () => {
99
+ const vaultClient = makeVaultClient({
100
+ memberships: [
101
+ { companyUid: "cmp_acme" },
102
+ { companyUid: "cmp_globex" },
103
+ ],
104
+ persons: [
105
+ {
106
+ uid: "psn_alice",
107
+ type: "person",
108
+ slug: "alice",
109
+ bucketName: "hq-vault-psn-alice",
110
+ createdAt: "2026-01-01T00:00:00Z",
111
+ },
112
+ ],
113
+ entitiesBySlug: {
114
+ cmp_acme: { slug: "acme", name: "Acme" },
115
+ cmp_globex: { slug: "globex", name: "Globex" },
116
+ },
117
+ });
118
+ const sync = makeSyncSpy({
119
+ cmp_acme: { filesDownloaded: 3, bytesDownloaded: 300 },
120
+ cmp_globex: { filesDownloaded: 1, bytesDownloaded: 50 },
121
+ psn_alice: { filesDownloaded: 7, bytesDownloaded: 700 },
122
+ });
123
+
124
+ const result = await pullAll(
125
+ { hqRoot: "/tmp/hq", onConflict: "abort" },
126
+ { vaultClient, sync: sync.fn },
127
+ );
128
+
129
+ expect(sync.calls).toHaveLength(3);
130
+ expect(sync.calls[0]).toMatchObject({
131
+ company: "cmp_acme",
132
+ hqRoot: "/tmp/hq",
133
+ onConflict: "abort",
134
+ });
135
+ expect(sync.calls[1]).toMatchObject({ company: "cmp_globex" });
136
+ expect(sync.calls[2]).toMatchObject({
137
+ company: "psn_alice",
138
+ personalMode: true,
139
+ journalSlug: "personal",
140
+ });
141
+
142
+ expect(result.attempted).toBe(3);
143
+ expect(result.filesDownloaded).toBe(11);
144
+ expect(result.bytesDownloaded).toBe(1050);
145
+ expect(result.errors).toEqual([]);
146
+ expect(result.perCompany.map((row) => row.slug)).toEqual([
147
+ "acme",
148
+ "globex",
149
+ "personal",
150
+ ]);
151
+ });
152
+
153
+ // ── 2. Conflict strategy passthrough ──────────────────────────────────────
154
+
155
+ it("forwards --on-conflict to every sync() call", async () => {
156
+ const vaultClient = makeVaultClient({
157
+ memberships: [{ companyUid: "cmp_acme" }],
158
+ persons: [
159
+ {
160
+ uid: "psn_alice",
161
+ type: "person",
162
+ slug: "alice",
163
+ createdAt: "2026-01-01T00:00:00Z",
164
+ },
165
+ ],
166
+ });
167
+ const sync = makeSyncSpy();
168
+
169
+ await pullAll(
170
+ { hqRoot: "/tmp/hq", onConflict: "overwrite" },
171
+ { vaultClient, sync: sync.fn },
172
+ );
173
+
174
+ for (const call of sync.calls) {
175
+ expect(call.onConflict).toBe("overwrite");
176
+ }
177
+ });
178
+
179
+ // ── 3. Per-company error isolation ────────────────────────────────────────
180
+
181
+ it("continues past a per-company sync failure and reports it in errors[]", async () => {
182
+ const vaultClient = makeVaultClient({
183
+ memberships: [
184
+ { companyUid: "cmp_acme" },
185
+ { companyUid: "cmp_globex" },
186
+ ],
187
+ persons: [
188
+ {
189
+ uid: "psn_alice",
190
+ type: "person",
191
+ slug: "alice",
192
+ createdAt: "2026-01-01T00:00:00Z",
193
+ },
194
+ ],
195
+ entitiesBySlug: {
196
+ cmp_acme: { slug: "acme" },
197
+ cmp_globex: { slug: "globex" },
198
+ },
199
+ });
200
+ const sync = makeSyncSpy({
201
+ cmp_globex: new Error("STS denied"),
202
+ });
203
+
204
+ const result = await pullAll(
205
+ { hqRoot: "/tmp/hq" },
206
+ { vaultClient, sync: sync.fn },
207
+ );
208
+
209
+ expect(sync.calls.map((c) => c.company)).toEqual([
210
+ "cmp_acme",
211
+ "cmp_globex",
212
+ "psn_alice",
213
+ ]);
214
+ expect(result.attempted).toBe(3);
215
+ expect(result.errors).toEqual([
216
+ { company: "globex", message: "STS denied" },
217
+ ]);
218
+ const globexRow = result.perCompany.find((r) => r.slug === "globex");
219
+ expect(globexRow?.error).toBe("STS denied");
220
+ expect(globexRow?.result).toBeUndefined();
221
+ });
222
+
223
+ // ── 4. No person entity ────────────────────────────────────────────────────
224
+
225
+ it("omits the personal target when listPersonEntities returns []", async () => {
226
+ const vaultClient = makeVaultClient({
227
+ memberships: [{ companyUid: "cmp_acme" }],
228
+ persons: [],
229
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
230
+ });
231
+ const sync = makeSyncSpy();
232
+
233
+ const result = await pullAll(
234
+ { hqRoot: "/tmp/hq" },
235
+ { vaultClient, sync: sync.fn },
236
+ );
237
+
238
+ expect(sync.calls).toHaveLength(1);
239
+ expect(sync.calls[0].company).toBe("cmp_acme");
240
+ expect(sync.calls[0].personalMode).toBeUndefined();
241
+ expect(result.perCompany.map((r) => r.slug)).toEqual(["acme"]);
242
+ });
243
+
244
+ // ── 5. No memberships and no person entity ────────────────────────────────
245
+
246
+ it("returns cleanly with zero attempts when there is nothing to sync", async () => {
247
+ const vaultClient = makeVaultClient({});
248
+ const sync = makeSyncSpy();
249
+
250
+ const result = await pullAll(
251
+ { hqRoot: "/tmp/hq" },
252
+ { vaultClient, sync: sync.fn },
253
+ );
254
+
255
+ expect(sync.calls).toEqual([]);
256
+ expect(result).toEqual({
257
+ attempted: 0,
258
+ filesDownloaded: 0,
259
+ bytesDownloaded: 0,
260
+ conflicts: 0,
261
+ errors: [],
262
+ perCompany: [],
263
+ });
264
+ });
265
+
266
+ // ── 6. Canonical-person tiebreak ──────────────────────────────────────────
267
+
268
+ it("picks the oldest person entity, breaking ties by uid", async () => {
269
+ const vaultClient = makeVaultClient({
270
+ memberships: [],
271
+ persons: [
272
+ {
273
+ uid: "psn_zzz",
274
+ type: "person",
275
+ slug: "alice-zzz",
276
+ createdAt: "2026-01-01T00:00:00Z",
277
+ },
278
+ {
279
+ uid: "psn_aaa",
280
+ type: "person",
281
+ slug: "alice-aaa",
282
+ createdAt: "2026-01-01T00:00:00Z",
283
+ },
284
+ {
285
+ uid: "psn_old",
286
+ type: "person",
287
+ slug: "alice-old",
288
+ createdAt: "2025-06-01T00:00:00Z",
289
+ },
290
+ ],
291
+ });
292
+ const sync = makeSyncSpy();
293
+
294
+ await pullAll({ hqRoot: "/tmp/hq" }, { vaultClient, sync: sync.fn });
295
+
296
+ expect(sync.calls).toHaveLength(1);
297
+ expect(sync.calls[0].company).toBe("psn_old");
298
+ expect(sync.calls[0].personalMode).toBe(true);
299
+ expect(sync.calls[0].journalSlug).toBe("personal");
300
+ });
301
+
302
+ // ── 7. hqRoot passthrough ─────────────────────────────────────────────────
303
+
304
+ it("forwards hqRoot verbatim to every sync() call", async () => {
305
+ const vaultClient = makeVaultClient({
306
+ memberships: [{ companyUid: "cmp_acme" }],
307
+ persons: [
308
+ {
309
+ uid: "psn_alice",
310
+ type: "person",
311
+ slug: "alice",
312
+ createdAt: "2026-01-01T00:00:00Z",
313
+ },
314
+ ],
315
+ });
316
+ const sync = makeSyncSpy();
317
+
318
+ await pullAll(
319
+ { hqRoot: "/Users/me/scratch/hq" },
320
+ { vaultClient, sync: sync.fn },
321
+ );
322
+
323
+ for (const call of sync.calls) {
324
+ expect(call.hqRoot).toBe("/Users/me/scratch/hq");
325
+ }
326
+ });
327
+ });
@@ -24,6 +24,7 @@ import {
24
24
  readJournal,
25
25
  getJournalPath,
26
26
  loadCachedTokens,
27
+ VaultClient,
27
28
  type ConflictStrategy,
28
29
  type EntityContext,
29
30
  type SyncProgressEvent,
@@ -41,6 +42,157 @@ interface CommonSyncOptions {
41
42
  company?: string;
42
43
  }
43
44
 
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // `hq sync pull --all` orchestrator
47
+ //
48
+ // Mirrors the fanout that `hq-sync-runner --companies --direction pull` does
49
+ // in the menubar app: list every membership, append the canonical person
50
+ // entity, sync each target into <hq-root>. Extracted as a pure function with
51
+ // injectable deps so cloud.pull-all.test.ts can drive it without network.
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ export interface PullAllVaultClient {
55
+ listMyMemberships(): Promise<Array<{ companyUid: string }>>;
56
+ listPersonEntities(): Promise<
57
+ Array<{
58
+ uid: string;
59
+ type: string;
60
+ slug: string;
61
+ bucketName?: string;
62
+ createdAt: string;
63
+ }>
64
+ >;
65
+ getEntity(uid: string): Promise<{ slug?: string; name?: string } | null>;
66
+ }
67
+
68
+ export interface SyncCallOptions {
69
+ company: string;
70
+ hqRoot: string;
71
+ onConflict?: ConflictStrategy;
72
+ personalMode?: boolean;
73
+ journalSlug?: string;
74
+ }
75
+
76
+ export interface SyncCallResult {
77
+ filesDownloaded: number;
78
+ bytesDownloaded: number;
79
+ filesSkipped: number;
80
+ conflicts: number;
81
+ conflictPaths: string[];
82
+ aborted: boolean;
83
+ }
84
+
85
+ export interface PullAllDeps {
86
+ vaultClient: PullAllVaultClient;
87
+ sync: (options: SyncCallOptions) => Promise<SyncCallResult>;
88
+ }
89
+
90
+ export interface PullAllOptions {
91
+ hqRoot: string;
92
+ onConflict?: ConflictStrategy;
93
+ }
94
+
95
+ export interface PullAllRow {
96
+ slug: string;
97
+ result?: SyncCallResult;
98
+ error?: string;
99
+ }
100
+
101
+ export interface PullAllResult {
102
+ attempted: number;
103
+ filesDownloaded: number;
104
+ bytesDownloaded: number;
105
+ conflicts: number;
106
+ errors: Array<{ company: string; message: string }>;
107
+ perCompany: PullAllRow[];
108
+ }
109
+
110
+ interface PlanEntry {
111
+ slug: string;
112
+ syncOptions: SyncCallOptions;
113
+ }
114
+
115
+ // Oldest-first by createdAt, ties broken by uid lexicographic — matches
116
+ // `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
117
+ // the same person bucket that `hq-sync-runner` picks.
118
+ function pickCanonicalPerson<
119
+ E extends { uid: string; type: string; createdAt: string },
120
+ >(persons: E[]): E | null {
121
+ const onlyPersons = persons.filter((e) => e.type === "person");
122
+ if (onlyPersons.length === 0) return null;
123
+ return [...onlyPersons].sort((a, b) => {
124
+ if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
125
+ return a.uid < b.uid ? -1 : 1;
126
+ })[0];
127
+ }
128
+
129
+ export async function pullAll(
130
+ options: PullAllOptions,
131
+ deps: PullAllDeps,
132
+ ): Promise<PullAllResult> {
133
+ const memberships = await deps.vaultClient.listMyMemberships();
134
+ const persons = await deps.vaultClient.listPersonEntities();
135
+
136
+ const plan: PlanEntry[] = [];
137
+ for (const m of memberships) {
138
+ let slug = m.companyUid;
139
+ try {
140
+ const info = await deps.vaultClient.getEntity(m.companyUid);
141
+ if (info?.slug) slug = info.slug;
142
+ } catch {
143
+ // Best-effort — keep UID as the row label rather than aborting the run.
144
+ }
145
+ plan.push({
146
+ slug,
147
+ syncOptions: {
148
+ company: m.companyUid,
149
+ hqRoot: options.hqRoot,
150
+ ...(options.onConflict ? { onConflict: options.onConflict } : {}),
151
+ },
152
+ });
153
+ }
154
+
155
+ const personal = pickCanonicalPerson(persons);
156
+ if (personal) {
157
+ plan.push({
158
+ slug: "personal",
159
+ syncOptions: {
160
+ company: personal.uid,
161
+ hqRoot: options.hqRoot,
162
+ personalMode: true,
163
+ journalSlug: "personal",
164
+ ...(options.onConflict ? { onConflict: options.onConflict } : {}),
165
+ },
166
+ });
167
+ }
168
+
169
+ const result: PullAllResult = {
170
+ attempted: 0,
171
+ filesDownloaded: 0,
172
+ bytesDownloaded: 0,
173
+ conflicts: 0,
174
+ errors: [],
175
+ perCompany: [],
176
+ };
177
+
178
+ for (const entry of plan) {
179
+ result.attempted += 1;
180
+ try {
181
+ const r = await deps.sync(entry.syncOptions);
182
+ result.filesDownloaded += r.filesDownloaded;
183
+ result.bytesDownloaded += r.bytesDownloaded;
184
+ result.conflicts += r.conflicts;
185
+ result.perCompany.push({ slug: entry.slug, result: r });
186
+ } catch (err) {
187
+ const message = err instanceof Error ? err.message : String(err);
188
+ result.errors.push({ company: entry.slug, message });
189
+ result.perCompany.push({ slug: entry.slug, error: message });
190
+ }
191
+ }
192
+
193
+ return result;
194
+ }
195
+
44
196
  export function registerCloudCommands(program: Command): void {
45
197
  program
46
198
  .command("push")
@@ -228,12 +380,23 @@ export function registerCloudCommands(program: Command): void {
228
380
  "--on-conflict <strategy>",
229
381
  "Conflict strategy: overwrite | keep | abort (omit for interactive)",
230
382
  )
383
+ .option(
384
+ "--all",
385
+ "Pull every company you are a member of plus your personal vault " +
386
+ "into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
387
+ "the personal vault syncs at <hq-root>. Ignores --company.",
388
+ )
231
389
  .action(
232
390
  async (
233
391
  options: CommonSyncOptions & {
234
392
  onConflict?: ConflictStrategy;
393
+ all?: boolean;
235
394
  },
236
395
  ) => {
396
+ if (options.all) {
397
+ await runPullAll(options.hqRoot, options.onConflict);
398
+ return;
399
+ }
237
400
  try {
238
401
  console.log(chalk.bold("\nHQ Sync — Pull"));
239
402
  console.log(` HQ root: ${options.hqRoot}`);
@@ -328,6 +491,83 @@ export function registerCloudCommands(program: Command): void {
328
491
  });
329
492
  }
330
493
 
494
+ async function runPullAll(
495
+ hqRoot: string,
496
+ onConflict?: ConflictStrategy,
497
+ ): Promise<void> {
498
+ console.log(chalk.bold("\nHQ Sync — Pull (all)"));
499
+ console.log(` HQ root: ${hqRoot}`);
500
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
501
+
502
+ let result: PullAllResult;
503
+ try {
504
+ const accessToken = await ensureCognitoToken();
505
+ const vaultConfig = buildVaultConfig(accessToken);
506
+ const realClient = new VaultClient(vaultConfig);
507
+
508
+ const adapter: PullAllVaultClient = {
509
+ listMyMemberships: () => realClient.listMyMemberships(),
510
+ listPersonEntities: () => realClient.entity.listByType("person"),
511
+ getEntity: async (uid: string) => {
512
+ try {
513
+ return await realClient.entity.get(uid);
514
+ } catch {
515
+ return null;
516
+ }
517
+ },
518
+ };
519
+
520
+ result = await pullAll(
521
+ { hqRoot, ...(onConflict ? { onConflict } : {}) },
522
+ {
523
+ vaultClient: adapter,
524
+ sync: (opts) =>
525
+ sync({
526
+ company: opts.company,
527
+ hqRoot: opts.hqRoot,
528
+ vaultConfig,
529
+ ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
530
+ ...(opts.personalMode !== undefined
531
+ ? { personalMode: opts.personalMode }
532
+ : {}),
533
+ ...(opts.journalSlug !== undefined
534
+ ? { journalSlug: opts.journalSlug }
535
+ : {}),
536
+ }),
537
+ },
538
+ );
539
+ } catch (err) {
540
+ console.error(
541
+ chalk.red("\n✗ Pull-all failed:"),
542
+ err instanceof Error ? err.message : String(err),
543
+ );
544
+ process.exit(1);
545
+ }
546
+
547
+ for (const row of result.perCompany) {
548
+ if (row.error) {
549
+ console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
550
+ } else if (row.result) {
551
+ const r = row.result;
552
+ const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
553
+ console.log(
554
+ ` ${status} ${row.slug}: ${r.filesDownloaded} file(s), ` +
555
+ `${formatBytes(r.bytesDownloaded)}, ` +
556
+ `${r.filesSkipped} skipped, ${r.conflicts} conflict(s)` +
557
+ (r.aborted ? " — aborted" : ""),
558
+ );
559
+ }
560
+ }
561
+
562
+ const errored = result.errors.length;
563
+ const summary =
564
+ `\nPulled ${result.filesDownloaded} file(s) ` +
565
+ `(${formatBytes(result.bytesDownloaded)}) across ${result.attempted} ` +
566
+ `target(s); ${result.conflicts} conflict(s); ${errored} error(s)`;
567
+ console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
568
+ if (errored > 0) process.exit(1);
569
+ }
570
+
331
571
  function formatBytes(bytes: number): string {
332
572
  if (bytes === 0) return "0 B";
333
573
  const units = ["B", "KB", "MB", "GB"];