@indigoai-us/hq-cli 5.12.4 → 5.13.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/CHANGELOG.md +51 -0
- package/dist/commands/cloud.d.ts +68 -0
- package/dist/commands/cloud.js +459 -8
- package/dist/commands/secrets.js +2 -3
- package/dist/index.js +11 -2
- package/package.json +1 -1
- package/src/commands/cloud.push-all.test.ts +381 -0
- package/src/commands/cloud.selectors.test.ts +155 -0
- package/src/commands/cloud.ts +674 -8
- package/src/commands/secrets.test.ts +96 -0
- package/src/commands/secrets.ts +0 -2
- package/src/index.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [5.13.0] — 2026-05-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq sync now` — bidirectional sync command.** Mirrors the AppBar HQ Sync
|
|
10
|
+
"Sync Now" button: pushes local changes to the target vault, then pulls
|
|
11
|
+
remote updates. Push runs first so the subsequent pull doesn't redownload
|
|
12
|
+
files that were about to be broadcast (matches `hq-sync-runner` ordering).
|
|
13
|
+
Supports `--company <slug>`, `--personal`, `--all`, `--message <msg>`,
|
|
14
|
+
`--on-conflict <strategy>`, and `--hq-root <path>`. The `--all` form
|
|
15
|
+
composes `pushAll` then `pullAll`, fanning out across every membership and
|
|
16
|
+
the canonical personal vault in a single invocation.
|
|
17
|
+
|
|
18
|
+
- **`--personal` flag on `hq sync pull`, `hq sync push`, and `hq sync now`.**
|
|
19
|
+
Targets the caller's canonical person-entity vault without needing the
|
|
20
|
+
person UID — resolves it from the cached Cognito session, then routes
|
|
21
|
+
through `personalMode` (no `companies/<slug>/` prefix) with `journalSlug:
|
|
22
|
+
"personal"` for idempotency-state parity with the Rust personal first-push.
|
|
23
|
+
For `push --personal` with no explicit `[paths]`, defaults to the same
|
|
24
|
+
top-level scope as `--all`'s personal slot (every top-level entry under
|
|
25
|
+
`<hq-root>` minus `.git`, `companies`, `repos`, `workspace`). Mutually
|
|
26
|
+
exclusive with `--all` and `--company`.
|
|
27
|
+
|
|
28
|
+
- **`--all` flag on `hq sync push`.** Symmetric with `pull --all`: pushes
|
|
29
|
+
every company you are a member of plus the canonical personal vault in
|
|
30
|
+
a single invocation. Company targets push `<hq-root>/companies/<slug>/`;
|
|
31
|
+
personal pushes the canonical personal-vault scope. Each leg uses the
|
|
32
|
+
menubar runner's bulk defaults (`skipUnchanged: true`,
|
|
33
|
+
`propagateDeletes: true`) so re-runs are cheap and on-disk deletes
|
|
34
|
+
propagate to the vault. Cannot be combined with explicit `[paths]` or
|
|
35
|
+
`--creds-from-stdin` — the fanout needs to vend per-target credentials.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Selector flags on `hq sync {pull,push,now}` are mutually exclusive.**
|
|
40
|
+
`--all`, `--personal`, and `--company` are now validated as a single
|
|
41
|
+
selector: at most one may be set per invocation; zero means "use the
|
|
42
|
+
active company from `.hq/config.json`". Conflicting combinations are
|
|
43
|
+
rejected with a clear error before any network calls.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Swallow `EPIPE` on stdout/stderr to stop Sentry fatals.** When a
|
|
48
|
+
downstream consumer (`| head`, closed pager, broken shell pipe) shut the
|
|
49
|
+
read end of the CLI's stdout or stderr, Node raised `EPIPE` that Sentry
|
|
50
|
+
then captured as a fatal. Both streams now ignore `EPIPE` quietly so the
|
|
51
|
+
CLI exits cleanly when piped into truncating consumers, and the Sentry
|
|
52
|
+
dashboard stops filling up with non-actionable pipe errors.
|
|
53
|
+
|
|
3
54
|
## [5.12.4] — 2026-05-12
|
|
4
55
|
|
|
5
56
|
### Changed
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -58,6 +58,50 @@ export interface PullAllRow {
|
|
|
58
58
|
result?: SyncCallResult;
|
|
59
59
|
error?: string;
|
|
60
60
|
}
|
|
61
|
+
export interface ShareCallOptions {
|
|
62
|
+
company: string;
|
|
63
|
+
hqRoot: string;
|
|
64
|
+
paths: string[];
|
|
65
|
+
onConflict?: ConflictStrategy;
|
|
66
|
+
personalMode?: boolean;
|
|
67
|
+
journalSlug?: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
skipUnchanged?: boolean;
|
|
70
|
+
propagateDeletes?: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface ShareCallResult {
|
|
73
|
+
filesUploaded: number;
|
|
74
|
+
bytesUploaded: number;
|
|
75
|
+
filesSkipped: number;
|
|
76
|
+
filesDeleted: number;
|
|
77
|
+
conflictPaths: string[];
|
|
78
|
+
aborted: boolean;
|
|
79
|
+
}
|
|
80
|
+
export interface PushAllDeps {
|
|
81
|
+
vaultClient: PullAllVaultClient;
|
|
82
|
+
share: (options: ShareCallOptions) => Promise<ShareCallResult>;
|
|
83
|
+
}
|
|
84
|
+
export interface PushAllOptions {
|
|
85
|
+
hqRoot: string;
|
|
86
|
+
onConflict?: ConflictStrategy;
|
|
87
|
+
message?: string;
|
|
88
|
+
}
|
|
89
|
+
export interface PushAllRow {
|
|
90
|
+
slug: string;
|
|
91
|
+
result?: ShareCallResult;
|
|
92
|
+
error?: string;
|
|
93
|
+
}
|
|
94
|
+
export interface PushAllResult {
|
|
95
|
+
attempted: number;
|
|
96
|
+
filesUploaded: number;
|
|
97
|
+
bytesUploaded: number;
|
|
98
|
+
filesDeleted: number;
|
|
99
|
+
errors: Array<{
|
|
100
|
+
company: string;
|
|
101
|
+
message: string;
|
|
102
|
+
}>;
|
|
103
|
+
perCompany: PushAllRow[];
|
|
104
|
+
}
|
|
61
105
|
export interface PullAllResult {
|
|
62
106
|
attempted: number;
|
|
63
107
|
filesDownloaded: number;
|
|
@@ -70,5 +114,29 @@ export interface PullAllResult {
|
|
|
70
114
|
perCompany: PullAllRow[];
|
|
71
115
|
}
|
|
72
116
|
export declare function pullAll(options: PullAllOptions, deps: PullAllDeps): Promise<PullAllResult>;
|
|
117
|
+
/**
|
|
118
|
+
* Drives `hq sync push --all`: same membership + canonical-person fanout as
|
|
119
|
+
* pullAll, but each leg calls share() with the runner's bulk defaults
|
|
120
|
+
* (skipUnchanged: true, propagateDeletes: true). Pure function with injected
|
|
121
|
+
* deps so tests can drive it without network or filesystem.
|
|
122
|
+
*/
|
|
123
|
+
export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Promise<PushAllResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the canonical person entity UID for the logged-in user. Used by
|
|
126
|
+
* `hq sync {push,pull,now} --personal` to target the personal vault without
|
|
127
|
+
* the caller needing to know the UID. Throws a clean error if the user has
|
|
128
|
+
* no person entity (typically means they haven't run `hq onboard`).
|
|
129
|
+
*/
|
|
130
|
+
export declare function resolveCanonicalPersonUid(vaultClient: PullAllVaultClient): Promise<string>;
|
|
131
|
+
/**
|
|
132
|
+
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
133
|
+
* `--company` are mutually exclusive — at most one may be set per
|
|
134
|
+
* invocation; zero means "use the active company from .hq/config.json".
|
|
135
|
+
*/
|
|
136
|
+
export declare function assertSingleSelector(opts: {
|
|
137
|
+
all?: boolean;
|
|
138
|
+
personal?: boolean;
|
|
139
|
+
company?: string;
|
|
140
|
+
}, command: string): void;
|
|
73
141
|
export declare function registerCloudCommands(program: Command): void;
|
|
74
142
|
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
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]="7bc55d66-7b02-5b45-9444-710bb6d8dfd6")}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, VaultClient, } from "@indigoai-us/hq-cloud";
|
|
20
|
+
import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, } from "@indigoai-us/hq-cloud";
|
|
21
21
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
22
22
|
// Oldest-first by createdAt, ties broken by uid lexicographic — matches
|
|
23
23
|
// `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
|
|
@@ -93,6 +93,115 @@ export async function pullAll(options, deps) {
|
|
|
93
93
|
}
|
|
94
94
|
return result;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Drives `hq sync push --all`: same membership + canonical-person fanout as
|
|
98
|
+
* pullAll, but each leg calls share() with the runner's bulk defaults
|
|
99
|
+
* (skipUnchanged: true, propagateDeletes: true). Pure function with injected
|
|
100
|
+
* deps so tests can drive it without network or filesystem.
|
|
101
|
+
*/
|
|
102
|
+
export async function pushAll(options, deps) {
|
|
103
|
+
const memberships = await deps.vaultClient.listMyMemberships();
|
|
104
|
+
const persons = await deps.vaultClient.listPersonEntities();
|
|
105
|
+
const plan = [];
|
|
106
|
+
for (const m of memberships) {
|
|
107
|
+
let slug = m.companyUid;
|
|
108
|
+
try {
|
|
109
|
+
const info = await deps.vaultClient.getEntity(m.companyUid);
|
|
110
|
+
if (info?.slug)
|
|
111
|
+
slug = info.slug;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Best-effort — keep UID as the row label.
|
|
115
|
+
}
|
|
116
|
+
plan.push({
|
|
117
|
+
slug,
|
|
118
|
+
shareOptions: {
|
|
119
|
+
company: m.companyUid,
|
|
120
|
+
hqRoot: options.hqRoot,
|
|
121
|
+
paths: [path.join(options.hqRoot, "companies", slug)],
|
|
122
|
+
skipUnchanged: true,
|
|
123
|
+
propagateDeletes: true,
|
|
124
|
+
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
125
|
+
...(options.message ? { message: options.message } : {}),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const personal = pickCanonicalPerson(persons);
|
|
130
|
+
if (personal) {
|
|
131
|
+
plan.push({
|
|
132
|
+
slug: "personal",
|
|
133
|
+
shareOptions: {
|
|
134
|
+
company: personal.uid,
|
|
135
|
+
hqRoot: options.hqRoot,
|
|
136
|
+
paths: computePersonalVaultPaths(options.hqRoot),
|
|
137
|
+
personalMode: true,
|
|
138
|
+
journalSlug: "personal",
|
|
139
|
+
skipUnchanged: true,
|
|
140
|
+
propagateDeletes: true,
|
|
141
|
+
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
142
|
+
...(options.message ? { message: options.message } : {}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const result = {
|
|
147
|
+
attempted: 0,
|
|
148
|
+
filesUploaded: 0,
|
|
149
|
+
bytesUploaded: 0,
|
|
150
|
+
filesDeleted: 0,
|
|
151
|
+
errors: [],
|
|
152
|
+
perCompany: [],
|
|
153
|
+
};
|
|
154
|
+
for (const entry of plan) {
|
|
155
|
+
result.attempted += 1;
|
|
156
|
+
try {
|
|
157
|
+
const r = await deps.share(entry.shareOptions);
|
|
158
|
+
result.filesUploaded += r.filesUploaded;
|
|
159
|
+
result.bytesUploaded += r.bytesUploaded;
|
|
160
|
+
result.filesDeleted += r.filesDeleted;
|
|
161
|
+
result.perCompany.push({ slug: entry.slug, result: r });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
165
|
+
result.errors.push({ company: entry.slug, message });
|
|
166
|
+
result.perCompany.push({ slug: entry.slug, error: message });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the canonical person entity UID for the logged-in user. Used by
|
|
173
|
+
* `hq sync {push,pull,now} --personal` to target the personal vault without
|
|
174
|
+
* the caller needing to know the UID. Throws a clean error if the user has
|
|
175
|
+
* no person entity (typically means they haven't run `hq onboard`).
|
|
176
|
+
*/
|
|
177
|
+
export async function resolveCanonicalPersonUid(vaultClient) {
|
|
178
|
+
const persons = await vaultClient.listPersonEntities();
|
|
179
|
+
const pick = pickCanonicalPerson(persons);
|
|
180
|
+
if (!pick) {
|
|
181
|
+
throw new Error("No personal vault found for the logged-in user. Run `hq onboard` " +
|
|
182
|
+
"first, or check `hq whoami` to confirm you're signed in to the " +
|
|
183
|
+
"right account.");
|
|
184
|
+
}
|
|
185
|
+
return pick.uid;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
189
|
+
* `--company` are mutually exclusive — at most one may be set per
|
|
190
|
+
* invocation; zero means "use the active company from .hq/config.json".
|
|
191
|
+
*/
|
|
192
|
+
export function assertSingleSelector(opts, command) {
|
|
193
|
+
const selectors = [];
|
|
194
|
+
if (opts.all)
|
|
195
|
+
selectors.push("--all");
|
|
196
|
+
if (opts.personal)
|
|
197
|
+
selectors.push("--personal");
|
|
198
|
+
if (opts.company)
|
|
199
|
+
selectors.push(`--company ${opts.company}`);
|
|
200
|
+
if (selectors.length > 1) {
|
|
201
|
+
throw new Error(`\`hq sync ${command}\` accepts at most one of --all, --personal, ` +
|
|
202
|
+
`--company; got: ${selectors.join(", ")}.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
96
205
|
export function registerCloudCommands(program) {
|
|
97
206
|
program
|
|
98
207
|
.command("push")
|
|
@@ -112,7 +221,42 @@ export function registerCloudCommands(program) {
|
|
|
112
221
|
"synthetic `{type:\"complete\",...}` line is appended at the end with " +
|
|
113
222
|
"the final ShareResult. Subprocess callers parse these to render their " +
|
|
114
223
|
"own UI (e.g. AppBar Tauri events).")
|
|
224
|
+
.option("--all", "Push every company you are a member of plus your personal vault. " +
|
|
225
|
+
"Company targets push `<hq-root>/companies/<slug>/`; personal pushes " +
|
|
226
|
+
"every top-level entry under <hq-root> minus the excluded set " +
|
|
227
|
+
"(.git, companies, repos, workspace). Implies --skip-unchanged + " +
|
|
228
|
+
"--propagate-deletes. Mutually exclusive with --company, --personal, " +
|
|
229
|
+
"and any explicit [paths].")
|
|
230
|
+
.option("--personal", "Push to the caller's canonical personal vault (resolved from the " +
|
|
231
|
+
"cached Cognito session). When no [paths] are given, defaults to " +
|
|
232
|
+
"every top-level entry under <hq-root> minus the excluded set " +
|
|
233
|
+
"(.git, companies, repos, workspace) — same scope as `--all`'s " +
|
|
234
|
+
"personal slot. Mutually exclusive with --company and --all.")
|
|
115
235
|
.action(async (paths, options) => {
|
|
236
|
+
try {
|
|
237
|
+
assertSingleSelector(options, "push");
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
console.error(chalk.red("\n✗ Push failed:"), err instanceof Error ? err.message : String(err));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
if (options.all) {
|
|
244
|
+
if (paths && paths.length > 0) {
|
|
245
|
+
console.error(chalk.red("\n✗ Push failed:"), "`--all` cannot be combined with explicit [paths]. " +
|
|
246
|
+
"Drop the paths to fan out to every membership + personal, " +
|
|
247
|
+
"or drop --all to push specific paths to a single target.");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
if (options.credsFromStdin) {
|
|
251
|
+
console.error(chalk.red("\n✗ Push failed:"), "`--all` cannot be combined with --creds-from-stdin (fanout " +
|
|
252
|
+
"needs to vend per-target credentials via the cached Cognito " +
|
|
253
|
+
"session). Run separate `--creds-from-stdin` invocations per " +
|
|
254
|
+
"target instead.");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
await runPushAll(options.hqRoot, options.message, options.onConflict);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
116
260
|
const jsonMode = options.json === true;
|
|
117
261
|
// Suppress the human banner/result output in JSON mode — the parent
|
|
118
262
|
// process renders its own UI from the stderr ndjson stream.
|
|
@@ -124,11 +268,14 @@ export function registerCloudCommands(program) {
|
|
|
124
268
|
process.stderr.write(JSON.stringify(event) + "\n");
|
|
125
269
|
};
|
|
126
270
|
try {
|
|
127
|
-
|
|
271
|
+
if (options.personal && options.credsFromStdin) {
|
|
272
|
+
throw new Error("`--personal` cannot be combined with --creds-from-stdin: " +
|
|
273
|
+
"--personal resolves the canonical person UID via the cached " +
|
|
274
|
+
"Cognito session, while --creds-from-stdin expects the caller " +
|
|
275
|
+
"to have already resolved entity + credentials. Pick one.");
|
|
276
|
+
}
|
|
128
277
|
log(chalk.bold("\nHQ Sync — Push"));
|
|
129
278
|
log(` HQ root: ${options.hqRoot}`);
|
|
130
|
-
log(` Company: ${options.company ?? "(from .hq/config.json or stdin)"}`);
|
|
131
|
-
log(` Paths: ${targetPaths.join(", ")}\n`);
|
|
132
279
|
// Resolve credentials. Two paths:
|
|
133
280
|
// 1. --creds-from-stdin: parse JSON EntityContext from stdin (the
|
|
134
281
|
// AppBar shell-out contract — vend-child upstream, pipe in here).
|
|
@@ -153,6 +300,39 @@ export function registerCloudCommands(program) {
|
|
|
153
300
|
const accessToken = await ensureCognitoToken();
|
|
154
301
|
vaultConfig = buildVaultConfig(accessToken);
|
|
155
302
|
}
|
|
303
|
+
// Resolve the target. For `--personal`, look up the caller's
|
|
304
|
+
// canonical person entity and force personalMode + journalSlug so
|
|
305
|
+
// share() lands files at hqRoot directly (no companies/<slug>/
|
|
306
|
+
// prefix). For everything else, the company is whatever the user
|
|
307
|
+
// passed or the active company from .hq/config.json.
|
|
308
|
+
let targetCompany = options.company;
|
|
309
|
+
let personalMode = false;
|
|
310
|
+
let journalSlug;
|
|
311
|
+
if (options.personal) {
|
|
312
|
+
const client = new VaultClient(vaultConfig);
|
|
313
|
+
targetCompany = await resolveCanonicalPersonUid({
|
|
314
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
315
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
316
|
+
getEntity: async () => null,
|
|
317
|
+
});
|
|
318
|
+
personalMode = true;
|
|
319
|
+
journalSlug = "personal";
|
|
320
|
+
}
|
|
321
|
+
// Default paths. For `--personal` with no explicit paths, default to
|
|
322
|
+
// the canonical personal-vault top-level scope so re-runs from a
|
|
323
|
+
// user shell push the same surface as `--all`'s personal slot.
|
|
324
|
+
// Otherwise preserve the historical "default to cwd" semantics so
|
|
325
|
+
// `hq sync push <file>` and bare `hq sync push` from inside the
|
|
326
|
+
// company tree both work as before.
|
|
327
|
+
const targetPaths = paths && paths.length > 0
|
|
328
|
+
? paths
|
|
329
|
+
: options.personal
|
|
330
|
+
? computePersonalVaultPaths(options.hqRoot)
|
|
331
|
+
: [process.cwd()];
|
|
332
|
+
log(` Company: ${options.personal
|
|
333
|
+
? `(personal: ${targetCompany})`
|
|
334
|
+
: (options.company ?? "(from .hq/config.json or stdin)")}`);
|
|
335
|
+
log(` Paths: ${targetPaths.join(", ")}\n`);
|
|
156
336
|
// In JSON mode, forward every share() event verbatim to stderr as
|
|
157
337
|
// ndjson. In human mode, share()'s defaultConsoleLogger handles the
|
|
158
338
|
// rendering (no onEvent → falls through to stdout/stderr printing).
|
|
@@ -168,13 +348,15 @@ export function registerCloudCommands(program) {
|
|
|
168
348
|
const author = resolveUploadAuthorFromCache();
|
|
169
349
|
const result = await share({
|
|
170
350
|
paths: targetPaths,
|
|
171
|
-
company:
|
|
351
|
+
company: targetCompany,
|
|
172
352
|
message: options.message,
|
|
173
353
|
onConflict: options.onConflict,
|
|
174
354
|
vaultConfig,
|
|
175
355
|
entityContext,
|
|
176
356
|
hqRoot: options.hqRoot,
|
|
177
357
|
onEvent,
|
|
358
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
359
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
178
360
|
...(author ? { author } : {}),
|
|
179
361
|
});
|
|
180
362
|
if (jsonMode) {
|
|
@@ -219,12 +401,28 @@ export function registerCloudCommands(program) {
|
|
|
219
401
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
220
402
|
.option("--all", "Pull every company you are a member of plus your personal vault " +
|
|
221
403
|
"into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
|
|
222
|
-
"the personal vault syncs at <hq-root>.
|
|
404
|
+
"the personal vault syncs at <hq-root>. Mutually exclusive with " +
|
|
405
|
+
"--company and --personal.")
|
|
406
|
+
.option("--personal", "Pull the caller's canonical personal vault into <hq-root> directly " +
|
|
407
|
+
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
408
|
+
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
409
|
+
"and --all.")
|
|
223
410
|
.action(async (options) => {
|
|
411
|
+
try {
|
|
412
|
+
assertSingleSelector(options, "pull");
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
224
418
|
if (options.all) {
|
|
225
419
|
await runPullAll(options.hqRoot, options.onConflict);
|
|
226
420
|
return;
|
|
227
421
|
}
|
|
422
|
+
if (options.personal) {
|
|
423
|
+
await runPullPersonal(options.hqRoot, options.onConflict);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
228
426
|
try {
|
|
229
427
|
console.log(chalk.bold("\nHQ Sync — Pull"));
|
|
230
428
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
@@ -291,6 +489,33 @@ export function registerCloudCommands(program) {
|
|
|
291
489
|
process.exit(1);
|
|
292
490
|
}
|
|
293
491
|
});
|
|
492
|
+
program
|
|
493
|
+
.command("now")
|
|
494
|
+
.description("Bidirectional sync: push local changes, then pull remote updates " +
|
|
495
|
+
"(mirrors AppBar HQ Sync's \"Sync Now\" button)")
|
|
496
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
497
|
+
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
498
|
+
.option("--message <msg>", "Optional message attached to journal entries for the push leg")
|
|
499
|
+
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
500
|
+
.option("--all", "Sync every company you are a member of plus your personal vault " +
|
|
501
|
+
"(pushAll then pullAll). Mutually exclusive with --company and " +
|
|
502
|
+
"--personal.")
|
|
503
|
+
.option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
|
|
504
|
+
"Mutually exclusive with --company and --all.")
|
|
505
|
+
.action(async (options) => {
|
|
506
|
+
try {
|
|
507
|
+
assertSingleSelector(options, "now");
|
|
508
|
+
if (options.all) {
|
|
509
|
+
await runNowAll(options.hqRoot, options.message, options.onConflict);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
294
519
|
}
|
|
295
520
|
async function runPullAll(hqRoot, onConflict) {
|
|
296
521
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
@@ -354,6 +579,232 @@ async function runPullAll(hqRoot, onConflict) {
|
|
|
354
579
|
if (errored > 0)
|
|
355
580
|
process.exit(1);
|
|
356
581
|
}
|
|
582
|
+
async function runPullPersonal(hqRoot, onConflict) {
|
|
583
|
+
console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
|
|
584
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
585
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
586
|
+
try {
|
|
587
|
+
const accessToken = await ensureCognitoToken();
|
|
588
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
589
|
+
const client = new VaultClient(vaultConfig);
|
|
590
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
591
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
592
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
593
|
+
getEntity: async () => null,
|
|
594
|
+
});
|
|
595
|
+
const result = await sync({
|
|
596
|
+
company: personalUid,
|
|
597
|
+
...(onConflict ? { onConflict } : {}),
|
|
598
|
+
vaultConfig,
|
|
599
|
+
hqRoot,
|
|
600
|
+
personalMode: true,
|
|
601
|
+
journalSlug: "personal",
|
|
602
|
+
});
|
|
603
|
+
if (result.aborted) {
|
|
604
|
+
console.log(chalk.yellow(`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
console.error(chalk.red("\n✗ Pull (personal) failed:"), err instanceof Error ? err.message : String(err));
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async function runPushAll(hqRoot, message, onConflict) {
|
|
615
|
+
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
616
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
617
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
618
|
+
let result;
|
|
619
|
+
try {
|
|
620
|
+
const accessToken = await ensureCognitoToken();
|
|
621
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
622
|
+
const realClient = new VaultClient(vaultConfig);
|
|
623
|
+
const author = resolveUploadAuthorFromCache();
|
|
624
|
+
const adapter = {
|
|
625
|
+
listMyMemberships: () => realClient.listMyMemberships(),
|
|
626
|
+
listPersonEntities: () => realClient.entity.listByType("person"),
|
|
627
|
+
getEntity: async (uid) => {
|
|
628
|
+
try {
|
|
629
|
+
return await realClient.entity.get(uid);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
result = await pushAll({
|
|
637
|
+
hqRoot,
|
|
638
|
+
...(onConflict ? { onConflict } : {}),
|
|
639
|
+
...(message ? { message } : {}),
|
|
640
|
+
}, {
|
|
641
|
+
vaultClient: adapter,
|
|
642
|
+
share: (opts) => share({
|
|
643
|
+
paths: opts.paths,
|
|
644
|
+
company: opts.company,
|
|
645
|
+
vaultConfig,
|
|
646
|
+
hqRoot: opts.hqRoot,
|
|
647
|
+
...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
|
|
648
|
+
...(opts.personalMode !== undefined
|
|
649
|
+
? { personalMode: opts.personalMode }
|
|
650
|
+
: {}),
|
|
651
|
+
...(opts.journalSlug !== undefined
|
|
652
|
+
? { journalSlug: opts.journalSlug }
|
|
653
|
+
: {}),
|
|
654
|
+
...(opts.message !== undefined ? { message: opts.message } : {}),
|
|
655
|
+
...(opts.skipUnchanged !== undefined
|
|
656
|
+
? { skipUnchanged: opts.skipUnchanged }
|
|
657
|
+
: {}),
|
|
658
|
+
...(opts.propagateDeletes !== undefined
|
|
659
|
+
? { propagateDeletes: opts.propagateDeletes }
|
|
660
|
+
: {}),
|
|
661
|
+
...(author ? { author } : {}),
|
|
662
|
+
}),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
console.error(chalk.red("\n✗ Push-all failed:"), err instanceof Error ? err.message : String(err));
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
for (const row of result.perCompany) {
|
|
670
|
+
if (row.error) {
|
|
671
|
+
console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
|
|
672
|
+
}
|
|
673
|
+
else if (row.result) {
|
|
674
|
+
const r = row.result;
|
|
675
|
+
const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
|
|
676
|
+
console.log(` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
|
|
677
|
+
`${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
|
|
678
|
+
`${r.filesDeleted} deleted, ${r.conflictPaths.length} conflict(s)` +
|
|
679
|
+
(r.aborted ? " — aborted" : ""));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const errored = result.errors.length;
|
|
683
|
+
const summary = `\nPushed ${result.filesUploaded} file(s) ` +
|
|
684
|
+
`(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
|
|
685
|
+
`target(s); ${result.filesDeleted} deleted; ${errored} error(s)`;
|
|
686
|
+
console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
|
|
687
|
+
if (errored > 0)
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
691
|
+
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
692
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
693
|
+
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
694
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
695
|
+
try {
|
|
696
|
+
const accessToken = await ensureCognitoToken();
|
|
697
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
698
|
+
const author = resolveUploadAuthorFromCache();
|
|
699
|
+
// Resolve the target. For --personal, look up the canonical person and
|
|
700
|
+
// route paths/journal through personalMode. Otherwise use the company arg
|
|
701
|
+
// (or fall back to the active company inside share()/sync()).
|
|
702
|
+
let targetCompany = company;
|
|
703
|
+
let personalMode = false;
|
|
704
|
+
let journalSlug;
|
|
705
|
+
let pushPaths;
|
|
706
|
+
if (personal) {
|
|
707
|
+
const client = new VaultClient(vaultConfig);
|
|
708
|
+
targetCompany = await resolveCanonicalPersonUid({
|
|
709
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
710
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
711
|
+
getEntity: async () => null,
|
|
712
|
+
});
|
|
713
|
+
personalMode = true;
|
|
714
|
+
journalSlug = "personal";
|
|
715
|
+
pushPaths = computePersonalVaultPaths(hqRoot);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
// For company targets we need a concrete slug to compute the push path.
|
|
719
|
+
// share() can resolve `company` itself for the upload, but the path
|
|
720
|
+
// computation must happen here. Use the explicit company if given;
|
|
721
|
+
// otherwise fall back to the active-company resolution inside the
|
|
722
|
+
// engine and compute paths from `hqRoot/companies` (share() will refuse
|
|
723
|
+
// anything outside that subtree anyway).
|
|
724
|
+
const slug = company ?? readActiveCompanySlug(hqRoot);
|
|
725
|
+
if (!slug) {
|
|
726
|
+
throw new Error("No company specified and no active company found. " +
|
|
727
|
+
"Use --company <slug>, --personal, or set up .hq/config.json.");
|
|
728
|
+
}
|
|
729
|
+
pushPaths = [path.join(hqRoot, "companies", slug)];
|
|
730
|
+
}
|
|
731
|
+
// Push first so the subsequent pull doesn't redownload files we were
|
|
732
|
+
// about to broadcast (matches hq-sync-runner ordering).
|
|
733
|
+
console.log(chalk.dim(" → push leg"));
|
|
734
|
+
const pushResult = await share({
|
|
735
|
+
paths: pushPaths,
|
|
736
|
+
company: targetCompany,
|
|
737
|
+
vaultConfig,
|
|
738
|
+
hqRoot,
|
|
739
|
+
skipUnchanged: true,
|
|
740
|
+
propagateDeletes: true,
|
|
741
|
+
...(onConflict ? { onConflict } : {}),
|
|
742
|
+
...(message ? { message } : {}),
|
|
743
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
744
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
745
|
+
...(author ? { author } : {}),
|
|
746
|
+
});
|
|
747
|
+
console.log(` ${pushResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
|
|
748
|
+
`${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
|
|
749
|
+
`${pushResult.filesDeleted} deleted` +
|
|
750
|
+
(pushResult.aborted ? " — aborted" : ""));
|
|
751
|
+
if (pushResult.aborted) {
|
|
752
|
+
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
755
|
+
console.log(chalk.dim(" → pull leg"));
|
|
756
|
+
const pullResult = await sync({
|
|
757
|
+
company: targetCompany,
|
|
758
|
+
vaultConfig,
|
|
759
|
+
hqRoot,
|
|
760
|
+
...(onConflict ? { onConflict } : {}),
|
|
761
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
762
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
763
|
+
});
|
|
764
|
+
console.log(` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
|
|
765
|
+
`${pullResult.filesDownloaded} downloaded, ${pullResult.filesSkipped} skipped, ` +
|
|
766
|
+
`${pullResult.conflicts} conflict(s)` +
|
|
767
|
+
(pullResult.aborted ? " — aborted" : ""));
|
|
768
|
+
if (pullResult.aborted) {
|
|
769
|
+
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
console.log(chalk.green("\n✓ Sync now complete"));
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
776
|
+
process.exit(1);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async function runNowAll(hqRoot, message, onConflict) {
|
|
780
|
+
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
781
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
782
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
783
|
+
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
784
|
+
// so the per-target rendering, error isolation, and exit codes are
|
|
785
|
+
// identical to running `push --all` then `pull --all` back-to-back.
|
|
786
|
+
console.log(chalk.dim("→ push --all"));
|
|
787
|
+
await runPushAll(hqRoot, message, onConflict);
|
|
788
|
+
console.log(chalk.dim("\n→ pull --all"));
|
|
789
|
+
await runPullAll(hqRoot, onConflict);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
793
|
+
* Returns undefined when the file is missing, malformed, or has no
|
|
794
|
+
* `activeCompany` field — `runNowSingle` surfaces a clean error in that case.
|
|
795
|
+
*/
|
|
796
|
+
function readActiveCompanySlug(hqRoot) {
|
|
797
|
+
const configPath = path.join(hqRoot, ".hq", "config.json");
|
|
798
|
+
if (!fs.existsSync(configPath))
|
|
799
|
+
return undefined;
|
|
800
|
+
try {
|
|
801
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
802
|
+
return cfg.activeCompany;
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
357
808
|
function formatBytes(bytes) {
|
|
358
809
|
if (bytes === 0)
|
|
359
810
|
return "0 B";
|
|
@@ -409,4 +860,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
409
860
|
}
|
|
410
861
|
}
|
|
411
862
|
//# sourceMappingURL=cloud.js.map
|
|
412
|
-
//# debugId=
|
|
863
|
+
//# debugId=7bc55d66-7b02-5b45-9444-710bb6d8dfd6
|