@indigoai-us/hq-cli 5.12.0 → 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 +22 -0
- package/dist/cli-version.d.ts +1 -1
- package/dist/cli-version.js +1 -1
- package/dist/commands/cloud.d.ts +57 -0
- package/dist/commands/cloud.js +146 -3
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli-version.ts +1 -1
- package/src/commands/cloud.pull-all.test.ts +327 -0
- package/src/commands/cloud.ts +240 -0
- package/src/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
14
|
+
## [5.12.1] — 2026-05-09
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **CREATED BY column in HQ Console Vault now populates for cloud-synced files.**
|
|
19
|
+
Pulls in `@indigoai-us/hq-cloud@5.11.3`, which stamps `created-by`,
|
|
20
|
+
`created-by-sub`, and `created-at` user metadata on every `PutObject` so the
|
|
21
|
+
vault listing's HEAD fan-out has identity to attribute. Files synced via
|
|
22
|
+
`hq cloud provision`, `hq files share`, and the watcher previously rendered
|
|
23
|
+
as `—` because only the Next.js upload route stamped author metadata.
|
|
24
|
+
|
|
3
25
|
## [5.12.0] — 2026-05-08
|
|
4
26
|
|
|
5
27
|
### Added
|
package/dist/cli-version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "5.12.
|
|
1
|
+
export declare const CLI_VERSION = "5.12.1";
|
|
2
2
|
//# sourceMappingURL=cli-version.d.ts.map
|
package/dist/cli-version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
2
|
!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]="f65738b2-fa67-5b68-a0c8-c73fba12c6c5")}catch(e){}}();
|
|
3
|
-
export const CLI_VERSION = "5.12.
|
|
3
|
+
export const CLI_VERSION = "5.12.1";
|
|
4
4
|
//# sourceMappingURL=cli-version.js.map
|
|
5
5
|
//# debugId=f65738b2-fa67-5b68-a0c8-c73fba12c6c5
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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]="
|
|
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=
|
|
412
|
+
//# debugId=e7f5f66e-78d5-5787-acb4-fe35424f69c9
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const program = new Command();
|
|
|
35
35
|
program
|
|
36
36
|
.name("hq")
|
|
37
37
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
38
|
-
.version("5.12.
|
|
38
|
+
.version("5.12.1");
|
|
39
39
|
// Module management subcommand group
|
|
40
40
|
const modulesCmd = program
|
|
41
41
|
.command("modules")
|
package/package.json
CHANGED
package/src/cli-version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "5.12.
|
|
1
|
+
export const CLI_VERSION = "5.12.1";
|
|
@@ -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
|
+
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -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"];
|
package/src/index.ts
CHANGED