@indigoai-us/hq-cli 5.17.0 → 5.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.d.ts +26 -1
- package/dist/commands/cloud.js +128 -10
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.ts +195 -4
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq sync narrow` (US-007) — explicit migration ritual to flip a company's
|
|
3
|
+
* membership from `syncMode: all` to `syncMode: shared`, with a dry-run
|
|
4
|
+
* preview and a dirty-file safety gate.
|
|
5
|
+
*
|
|
6
|
+
* Two call shapes:
|
|
7
|
+
*
|
|
8
|
+
* hq sync narrow --dry-run [--company <slug>]
|
|
9
|
+
* Resolves the membership for <slug> (or the active company), fetches
|
|
10
|
+
* the caller's explicit grants, coalesces them, walks the local
|
|
11
|
+
* `companies/<slug>/` tree, and prints a table:
|
|
12
|
+
*
|
|
13
|
+
* files staying: N
|
|
14
|
+
* clean orphans: N (size)
|
|
15
|
+
* dirty orphans: N (size)
|
|
16
|
+
*
|
|
17
|
+
* Writes nothing — no journal, no local file, no server call beyond
|
|
18
|
+
* the read-only GET on grants + sync-config.
|
|
19
|
+
*
|
|
20
|
+
* hq sync narrow --apply [--yes] [--force] [--company <slug>]
|
|
21
|
+
* Same computation, then:
|
|
22
|
+
* - if `dirty > 0` and `--force` NOT set → abort with conflict report
|
|
23
|
+
* and a recovery message.
|
|
24
|
+
* - else delete clean orphans on disk, tombstone their journal
|
|
25
|
+
* entries, optionally delete dirty orphans too (with `--force`),
|
|
26
|
+
* then PUT sync-config to `shared`. The server's PUT writes the
|
|
27
|
+
* authoritative `MEMBERSHIP_SYNC_CONFIG_CHANGED` audit row; the
|
|
28
|
+
* CLI prints a local audit line as well.
|
|
29
|
+
*
|
|
30
|
+
* Refuses on already-`shared` or `custom` memberships (acceptance 5) —
|
|
31
|
+
* narrowing makes sense only as `all → shared`.
|
|
32
|
+
*
|
|
33
|
+
* Cross-package note: depends on hq-cloud helpers shipped in US-005
|
|
34
|
+
* (`coalescePrefixes`, `readJournal`/`writeJournal`, `tombstoneEntry`) and
|
|
35
|
+
* the VaultClient sync-config endpoints from US-003. While that hq-cloud
|
|
36
|
+
* release is unpublished, hq-cli pins `@indigoai-us/hq-cloud` to
|
|
37
|
+
* `file:../hq-cloud` via `pnpm.overrides`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { Command } from "commander";
|
|
41
|
+
import chalk from "chalk";
|
|
42
|
+
import * as readline from "node:readline";
|
|
43
|
+
import * as fs from "node:fs";
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
VaultClient,
|
|
47
|
+
coalescePrefixes,
|
|
48
|
+
readJournal,
|
|
49
|
+
writeJournal,
|
|
50
|
+
tombstoneEntry,
|
|
51
|
+
type ExplicitGrant,
|
|
52
|
+
type Membership,
|
|
53
|
+
type MembershipSyncConfig,
|
|
54
|
+
type SyncMode,
|
|
55
|
+
type SyncJournal,
|
|
56
|
+
} from "@indigoai-us/hq-cloud";
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
DEFAULT_HQ_ROOT,
|
|
60
|
+
ensureCognitoToken,
|
|
61
|
+
buildVaultConfig,
|
|
62
|
+
} from "../utils/cognito-session.js";
|
|
63
|
+
import { readActiveCompanySlug } from "./sync-mode.js";
|
|
64
|
+
import {
|
|
65
|
+
buildNarrowPlan,
|
|
66
|
+
formatBytes,
|
|
67
|
+
formatNarrowPlanSummary,
|
|
68
|
+
type NarrowPlan,
|
|
69
|
+
type NarrowDirtyFile,
|
|
70
|
+
} from "../lib/local-tree-diff.js";
|
|
71
|
+
|
|
72
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** Subset of VaultClient surface this command exercises (test seam). */
|
|
75
|
+
export interface SyncNarrowVaultClient {
|
|
76
|
+
listMyMemberships(): Promise<Membership[]>;
|
|
77
|
+
getMembershipSyncConfig(membershipId: string): Promise<MembershipSyncConfig>;
|
|
78
|
+
setMembershipSyncConfig(
|
|
79
|
+
membershipId: string,
|
|
80
|
+
partial: { syncMode: SyncMode; customPaths?: string[] },
|
|
81
|
+
): Promise<MembershipSyncConfig>;
|
|
82
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
83
|
+
entity: {
|
|
84
|
+
get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Journal I/O seam — defaults to the real `readJournal`/`writeJournal`. */
|
|
89
|
+
export interface JournalIO {
|
|
90
|
+
read(slug: string): SyncJournal;
|
|
91
|
+
write(slug: string, journal: SyncJournal): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const realJournalIO: JournalIO = {
|
|
95
|
+
read: readJournal,
|
|
96
|
+
write: writeJournal,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Filesystem seam — defaults to real `fs` (only `unlinkSync` is exercised). */
|
|
100
|
+
export interface FsIO {
|
|
101
|
+
unlinkSync(p: string): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const realFsIO: FsIO = {
|
|
105
|
+
unlinkSync: fs.unlinkSync,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Confirmation prompt seam — defaults to a `readline` y/N gate. */
|
|
109
|
+
export type ConfirmFn = (message: string) => Promise<boolean>;
|
|
110
|
+
|
|
111
|
+
const realConfirm: ConfirmFn = async (message) => {
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: process.stdin,
|
|
114
|
+
output: process.stdout,
|
|
115
|
+
});
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
118
|
+
rl.close();
|
|
119
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export interface ResolveTargetInput {
|
|
125
|
+
companySlug: string;
|
|
126
|
+
vaultClient: SyncNarrowVaultClient;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ResolveTargetResult {
|
|
130
|
+
membership: Membership;
|
|
131
|
+
companyUid: string;
|
|
132
|
+
companySlug: string;
|
|
133
|
+
syncConfig: MembershipSyncConfig;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the caller's membership for `companySlug` and read its current
|
|
138
|
+
* sync-config. Throws if the slug doesn't match any membership or if the
|
|
139
|
+
* current mode isn't eligible for narrowing (`all` → `shared` only).
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveNarrowTarget(
|
|
142
|
+
input: ResolveTargetInput,
|
|
143
|
+
): Promise<ResolveTargetResult> {
|
|
144
|
+
const { companySlug, vaultClient } = input;
|
|
145
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
146
|
+
const enriched = await Promise.all(
|
|
147
|
+
memberships.map(async (m) => {
|
|
148
|
+
try {
|
|
149
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
150
|
+
return { membership: m, slug: ent.slug, name: ent.name };
|
|
151
|
+
} catch {
|
|
152
|
+
return { membership: m, slug: undefined, name: undefined };
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const match = enriched.find((row) => row.slug === companySlug);
|
|
158
|
+
if (!match) {
|
|
159
|
+
const known = enriched
|
|
160
|
+
.map((r) => r.slug)
|
|
161
|
+
.filter((s): s is string => !!s)
|
|
162
|
+
.join(", ");
|
|
163
|
+
throw new Error(
|
|
164
|
+
`No membership found for company '${companySlug}'. Memberships visible to you: ${
|
|
165
|
+
known || "(none)"
|
|
166
|
+
}.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const membership = match.membership;
|
|
171
|
+
const syncConfig = await vaultClient.getMembershipSyncConfig(
|
|
172
|
+
membership.membershipKey,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (syncConfig.syncMode !== "all") {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Refusing to narrow: membership for '${companySlug}' is currently ` +
|
|
178
|
+
`syncMode='${syncConfig.syncMode}'. The narrow ritual only runs on ` +
|
|
179
|
+
`syncMode='all' memberships (shared → shared is a no-op; ` +
|
|
180
|
+
`custom requires \`hq sync mode shared\` directly).`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
membership,
|
|
186
|
+
companyUid: membership.companyUid,
|
|
187
|
+
companySlug,
|
|
188
|
+
syncConfig,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ComputePlanInput {
|
|
193
|
+
hqRoot: string;
|
|
194
|
+
companySlug: string;
|
|
195
|
+
companyUid: string;
|
|
196
|
+
vaultClient: SyncNarrowVaultClient;
|
|
197
|
+
journalIO?: JournalIO;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ComputePlanResult {
|
|
201
|
+
plan: NarrowPlan;
|
|
202
|
+
prospectivePrefixSet: string[];
|
|
203
|
+
grants: ExplicitGrant[];
|
|
204
|
+
journal: SyncJournal;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Compute the narrow plan for one company: fetch explicit grants, coalesce,
|
|
209
|
+
* read the per-company journal, walk the local tree, classify.
|
|
210
|
+
*
|
|
211
|
+
* Reads only — never mutates the journal or hits a destructive endpoint.
|
|
212
|
+
* `apply()` consumes this and performs the destructive steps.
|
|
213
|
+
*/
|
|
214
|
+
export async function computeNarrowPlan(
|
|
215
|
+
input: ComputePlanInput,
|
|
216
|
+
): Promise<ComputePlanResult> {
|
|
217
|
+
const { hqRoot, companySlug, companyUid, vaultClient } = input;
|
|
218
|
+
const io = input.journalIO ?? realJournalIO;
|
|
219
|
+
|
|
220
|
+
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
221
|
+
const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
|
|
222
|
+
const journal = io.read(companySlug);
|
|
223
|
+
|
|
224
|
+
const plan = buildNarrowPlan({
|
|
225
|
+
hqRoot,
|
|
226
|
+
companySlug,
|
|
227
|
+
prospectivePrefixSet,
|
|
228
|
+
journal,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { plan, prospectivePrefixSet, grants, journal };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface ApplyNarrowInput {
|
|
235
|
+
companySlug: string;
|
|
236
|
+
membershipId: string;
|
|
237
|
+
plan: NarrowPlan;
|
|
238
|
+
journal: SyncJournal;
|
|
239
|
+
vaultClient: SyncNarrowVaultClient;
|
|
240
|
+
/** If true, delete dirty orphans too (still tombstoned). */
|
|
241
|
+
force: boolean;
|
|
242
|
+
journalIO?: JournalIO;
|
|
243
|
+
fsIO?: FsIO;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ApplyNarrowResult {
|
|
247
|
+
cleanDeleted: number;
|
|
248
|
+
cleanBytes: number;
|
|
249
|
+
dirtyDeleted: number;
|
|
250
|
+
dirtyBytes: number;
|
|
251
|
+
tombstoned: number;
|
|
252
|
+
newConfig: MembershipSyncConfig;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Apply the destructive side of a narrow:
|
|
257
|
+
* 1. Delete clean orphans (best-effort — ENOENT is ignored).
|
|
258
|
+
* 2. Tombstone their journal entries.
|
|
259
|
+
* 3. If `force`, also delete + tombstone dirty orphans.
|
|
260
|
+
* 4. Persist the journal.
|
|
261
|
+
* 5. PUT sync-config → `shared` (server writes the audit row).
|
|
262
|
+
*
|
|
263
|
+
* Order is fail-safe: if a delete throws partway, the journal write below
|
|
264
|
+
* won't run, but the in-progress deletions are already on disk — that's the
|
|
265
|
+
* "don't roll back I/O" contract from the spec. The CLI surfaces a
|
|
266
|
+
* recovery message when this throws.
|
|
267
|
+
*
|
|
268
|
+
* Pure-ish: no prompting (the CLI wrapper handles that), no console
|
|
269
|
+
* output. Just disk + network.
|
|
270
|
+
*/
|
|
271
|
+
export async function applyNarrow(
|
|
272
|
+
input: ApplyNarrowInput,
|
|
273
|
+
): Promise<ApplyNarrowResult> {
|
|
274
|
+
const { plan, journal, force, companySlug, membershipId, vaultClient } = input;
|
|
275
|
+
const journalIO = input.journalIO ?? realJournalIO;
|
|
276
|
+
const fsIO = input.fsIO ?? realFsIO;
|
|
277
|
+
|
|
278
|
+
let cleanDeleted = 0;
|
|
279
|
+
let dirtyDeleted = 0;
|
|
280
|
+
let tombstoned = 0;
|
|
281
|
+
const cleanBytes = plan.clean.reduce((sum, f) => sum + f.bytes, 0);
|
|
282
|
+
const dirtyBytes = force
|
|
283
|
+
? plan.dirty.reduce((sum, f) => sum + f.bytes, 0)
|
|
284
|
+
: 0;
|
|
285
|
+
|
|
286
|
+
// 1. Delete clean orphans + tombstone.
|
|
287
|
+
for (const file of plan.clean) {
|
|
288
|
+
try {
|
|
289
|
+
fsIO.unlinkSync(file.absPath);
|
|
290
|
+
cleanDeleted++;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
293
|
+
if (code !== "ENOENT") throw err;
|
|
294
|
+
}
|
|
295
|
+
tombstoneEntry(journal, file.relPath, "narrow_apply");
|
|
296
|
+
tombstoned++;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 2. With --force, delete dirty orphans too.
|
|
300
|
+
if (force) {
|
|
301
|
+
for (const file of plan.dirty) {
|
|
302
|
+
try {
|
|
303
|
+
fsIO.unlinkSync(file.absPath);
|
|
304
|
+
dirtyDeleted++;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
307
|
+
if (code !== "ENOENT") throw err;
|
|
308
|
+
}
|
|
309
|
+
// tombstone only if a journal entry exists — `not-in-journal` files
|
|
310
|
+
// never had one to tombstone.
|
|
311
|
+
if (journal.files[file.relPath]) {
|
|
312
|
+
tombstoneEntry(journal, file.relPath, "narrow_apply");
|
|
313
|
+
tombstoned++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 3. Persist journal.
|
|
319
|
+
journalIO.write(companySlug, journal);
|
|
320
|
+
|
|
321
|
+
// 4. PUT sync-config → shared. Server writes the authoritative
|
|
322
|
+
// MEMBERSHIP_SYNC_CONFIG_CHANGED audit row.
|
|
323
|
+
const newConfig = await vaultClient.setMembershipSyncConfig(membershipId, {
|
|
324
|
+
syncMode: "shared",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
cleanDeleted,
|
|
329
|
+
cleanBytes,
|
|
330
|
+
dirtyDeleted,
|
|
331
|
+
dirtyBytes,
|
|
332
|
+
tombstoned,
|
|
333
|
+
newConfig,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Dirty-file conflict report (operator-facing) ───────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Format the dirty-file conflict report shown on `--apply` when dirty files
|
|
341
|
+
* are present and `--force` is NOT set. The CLI prints this and exits
|
|
342
|
+
* non-zero so the operator can resolve before retrying.
|
|
343
|
+
*/
|
|
344
|
+
export function formatDirtyConflictReport(
|
|
345
|
+
dirty: NarrowDirtyFile[],
|
|
346
|
+
companySlug: string,
|
|
347
|
+
): string {
|
|
348
|
+
const lines: string[] = [];
|
|
349
|
+
lines.push(
|
|
350
|
+
`Aborting narrow for '${companySlug}': ${dirty.length} locally-modified file(s) ` +
|
|
351
|
+
`would be removed by this narrow.`,
|
|
352
|
+
);
|
|
353
|
+
lines.push("");
|
|
354
|
+
lines.push("Dirty files:");
|
|
355
|
+
const cap = 25;
|
|
356
|
+
for (const f of dirty.slice(0, cap)) {
|
|
357
|
+
lines.push(` ${f.relPath} (${formatBytes(f.bytes)}, ${f.reason})`);
|
|
358
|
+
}
|
|
359
|
+
if (dirty.length > cap) {
|
|
360
|
+
lines.push(` ... ${dirty.length - cap} more`);
|
|
361
|
+
}
|
|
362
|
+
lines.push("");
|
|
363
|
+
lines.push("Resolve before retrying:");
|
|
364
|
+
lines.push(" - commit or push the modifications via the normal sync flow;");
|
|
365
|
+
lines.push(" - move them outside the company folder; or");
|
|
366
|
+
lines.push(" - delete them locally if they really should be gone.");
|
|
367
|
+
lines.push("");
|
|
368
|
+
lines.push(
|
|
369
|
+
"Alternatively, re-run with --force to remove the dirty files anyway " +
|
|
370
|
+
"(destructive — there is no undo).",
|
|
371
|
+
);
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── CLI registration ────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
interface SyncNarrowCliOptions {
|
|
378
|
+
dryRun?: boolean;
|
|
379
|
+
apply?: boolean;
|
|
380
|
+
yes?: boolean;
|
|
381
|
+
force?: boolean;
|
|
382
|
+
company?: string;
|
|
383
|
+
hqRoot: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Wire `hq sync narrow` onto the existing `sync` Commander group. Called
|
|
388
|
+
* from `src/index.ts` after `registerSyncModeCommand`.
|
|
389
|
+
*/
|
|
390
|
+
export function registerSyncNarrowCommand(syncCmd: Command): void {
|
|
391
|
+
syncCmd
|
|
392
|
+
.command("narrow")
|
|
393
|
+
.description(
|
|
394
|
+
"Migrate a company's membership from syncMode='all' to 'shared' — " +
|
|
395
|
+
"dry-run previews diff, --apply performs the prune + flip",
|
|
396
|
+
)
|
|
397
|
+
.option(
|
|
398
|
+
"--dry-run",
|
|
399
|
+
"Compute + print the narrow plan; write nothing (default if neither --dry-run nor --apply is passed)",
|
|
400
|
+
)
|
|
401
|
+
.option("--apply", "Execute the narrow — delete clean orphans, flip sync-mode to 'shared'")
|
|
402
|
+
.option("--yes", "Skip the interactive confirmation prompt on --apply")
|
|
403
|
+
.option(
|
|
404
|
+
"--force",
|
|
405
|
+
"On --apply, also remove locally-modified (dirty) files (destructive — no undo)",
|
|
406
|
+
)
|
|
407
|
+
.option(
|
|
408
|
+
"--company <slug>",
|
|
409
|
+
"Company slug (defaults to the active company in <hq-root>/.hq/config.json)",
|
|
410
|
+
)
|
|
411
|
+
.option(
|
|
412
|
+
"--hq-root <path>",
|
|
413
|
+
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
414
|
+
DEFAULT_HQ_ROOT,
|
|
415
|
+
)
|
|
416
|
+
.action(async (options: SyncNarrowCliOptions) => {
|
|
417
|
+
try {
|
|
418
|
+
if (options.dryRun && options.apply) {
|
|
419
|
+
throw new Error("--dry-run and --apply are mutually exclusive.");
|
|
420
|
+
}
|
|
421
|
+
// Default to dry-run when nothing is specified — safer.
|
|
422
|
+
const apply = !!options.apply;
|
|
423
|
+
const dryRun = !apply;
|
|
424
|
+
|
|
425
|
+
const companySlug =
|
|
426
|
+
options.company ?? readActiveCompanySlug(options.hqRoot);
|
|
427
|
+
if (!companySlug) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
"No company specified. Pass --company <slug> or set activeCompany in <hq-root>/.hq/config.json.",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const accessToken = await ensureCognitoToken();
|
|
434
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
435
|
+
const client = new VaultClient(vaultConfig);
|
|
436
|
+
|
|
437
|
+
const target = await resolveNarrowTarget({
|
|
438
|
+
companySlug,
|
|
439
|
+
vaultClient: client,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const { plan, prospectivePrefixSet, grants } = await computeNarrowPlan({
|
|
443
|
+
hqRoot: options.hqRoot,
|
|
444
|
+
companySlug: target.companySlug,
|
|
445
|
+
companyUid: target.companyUid,
|
|
446
|
+
vaultClient: client,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Always print the summary.
|
|
450
|
+
console.log(
|
|
451
|
+
chalk.bold(
|
|
452
|
+
`${dryRun ? "Dry-run" : "Apply"}: narrow '${target.companySlug}' (${grants.length} explicit grant${
|
|
453
|
+
grants.length === 1 ? "" : "s"
|
|
454
|
+
}, ${prospectivePrefixSet.length} coalesced prefix${
|
|
455
|
+
prospectivePrefixSet.length === 1 ? "" : "es"
|
|
456
|
+
})`,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
console.log(formatNarrowPlanSummary(plan));
|
|
460
|
+
|
|
461
|
+
if (dryRun) {
|
|
462
|
+
if (plan.totalDirtyCount > 0) {
|
|
463
|
+
console.log("");
|
|
464
|
+
console.log(
|
|
465
|
+
chalk.yellow(
|
|
466
|
+
`Heads-up: ${plan.totalDirtyCount} dirty file(s) would block --apply unless --force is passed.`,
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --apply path
|
|
474
|
+
if (plan.totalDirtyCount > 0 && !options.force) {
|
|
475
|
+
console.error(chalk.red(formatDirtyConflictReport(plan.dirty, target.companySlug)));
|
|
476
|
+
process.exit(2);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!options.yes) {
|
|
481
|
+
const force = !!options.force;
|
|
482
|
+
const dirtyTail = force
|
|
483
|
+
? ` AND remove ${plan.totalDirtyCount} dirty file(s) (--force)`
|
|
484
|
+
: "";
|
|
485
|
+
const confirmed = await realConfirm(
|
|
486
|
+
`About to remove ${plan.totalCleanCount} clean file(s)${dirtyTail} and flip '${target.companySlug}' to syncMode='shared'. Proceed?`,
|
|
487
|
+
);
|
|
488
|
+
if (!confirmed) {
|
|
489
|
+
console.log("Cancelled. No changes applied.");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Re-read the journal at apply time to avoid races with concurrent syncs.
|
|
495
|
+
const journal = readJournal(target.companySlug);
|
|
496
|
+
const result = await applyNarrow({
|
|
497
|
+
companySlug: target.companySlug,
|
|
498
|
+
membershipId: target.membership.membershipKey,
|
|
499
|
+
plan,
|
|
500
|
+
journal,
|
|
501
|
+
vaultClient: client,
|
|
502
|
+
force: !!options.force,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
console.log(
|
|
506
|
+
chalk.green("✓"),
|
|
507
|
+
`Narrow applied for ${chalk.bold(target.companySlug)}:`,
|
|
508
|
+
);
|
|
509
|
+
console.log(
|
|
510
|
+
chalk.dim(
|
|
511
|
+
` removed: ${result.cleanDeleted} clean (${formatBytes(result.cleanBytes)})${
|
|
512
|
+
result.dirtyDeleted > 0
|
|
513
|
+
? ` + ${result.dirtyDeleted} dirty (${formatBytes(result.dirtyBytes)})`
|
|
514
|
+
: ""
|
|
515
|
+
}`,
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
console.log(
|
|
519
|
+
chalk.dim(` tombstoned: ${result.tombstoned} journal entr${result.tombstoned === 1 ? "y" : "ies"}`),
|
|
520
|
+
);
|
|
521
|
+
console.log(
|
|
522
|
+
chalk.dim(
|
|
523
|
+
` syncMode: all → ${result.newConfig.syncMode}` +
|
|
524
|
+
(result.newConfig.updatedAt ? ` (updatedAt: ${result.newConfig.updatedAt})` : ""),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
console.log(
|
|
528
|
+
chalk.dim(
|
|
529
|
+
` audit: server-side MEMBERSHIP_SYNC_CONFIG_CHANGED row written by PUT /v1/memberships/${target.membership.membershipKey}/sync-config`,
|
|
530
|
+
),
|
|
531
|
+
);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.error(
|
|
534
|
+
chalk.red("✗ sync narrow failed:"),
|
|
535
|
+
err instanceof Error ? err.message : String(err),
|
|
536
|
+
);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { registerSyncCommand } from "./commands/sync.js";
|
|
|
11
11
|
import { registerListCommand } from "./commands/list.js";
|
|
12
12
|
import { registerUpdateCommand } from "./commands/update.js";
|
|
13
13
|
import { registerCloudCommands } from "./commands/cloud.js";
|
|
14
|
+
import { registerSyncModeCommand } from "./commands/sync-mode.js";
|
|
15
|
+
import { registerSyncNarrowCommand } from "./commands/sync-narrow.js";
|
|
14
16
|
import { registerCloudProvisionCommands } from "./commands/cloud-provision.js";
|
|
15
17
|
import { registerCloudDemoteCommands } from "./commands/cloud-demote.js";
|
|
16
18
|
import { registerLoginCommand } from "./commands/login.js";
|
|
@@ -27,6 +29,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
|
|
|
27
29
|
import { registerRunCommand } from "./commands/run.js";
|
|
28
30
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
29
31
|
import { registerFilesCommand } from "./commands/files.js";
|
|
32
|
+
import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
30
33
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
34
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
35
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
@@ -91,6 +94,8 @@ const syncCmd = program
|
|
|
91
94
|
.description("Cloud sync commands — sync HQ to S3 for mobile access");
|
|
92
95
|
|
|
93
96
|
registerCloudCommands(syncCmd);
|
|
97
|
+
registerSyncModeCommand(syncCmd);
|
|
98
|
+
registerSyncNarrowCommand(syncCmd);
|
|
94
99
|
|
|
95
100
|
// Cloud provisioning subcommand group (entity + bucket + initial sync)
|
|
96
101
|
// Distinct from `hq sync` which assumes provisioning has already happened.
|
|
@@ -122,7 +127,10 @@ registerRunCommand(program);
|
|
|
122
127
|
registerGroupsCommand(program);
|
|
123
128
|
|
|
124
129
|
// Files ACL management (subcommand group — hq files share|unshare|acl)
|
|
125
|
-
registerFilesCommand
|
|
130
|
+
// `registerFilesCommand` returns the `files` group so we can attach the
|
|
131
|
+
// browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
|
|
132
|
+
const filesCmd = registerFilesCommand(program);
|
|
133
|
+
registerFilesBrowseCommands(filesCmd);
|
|
126
134
|
|
|
127
135
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
128
136
|
registerMembersCommand(program);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-bump smoke for @indigoai-us/hq-cloud.
|
|
3
|
+
*
|
|
4
|
+
* After moving from `file:../hq-cloud` to the published `^5.23.0`, this
|
|
5
|
+
* test pins the contract: the SDK methods + types that hq-cli's
|
|
6
|
+
* sync-mode / sync-narrow / files-browse / narrow-hint-banner commands
|
|
7
|
+
* depend on must keep resolving from the npm-published package.
|
|
8
|
+
*
|
|
9
|
+
* If a future major bump on hq-cloud removes or renames any of these, the
|
|
10
|
+
* matching hq-cli command would only fail at runtime (or at the call site's
|
|
11
|
+
* own test). Locking it here catches the regression at install time in CI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import * as cloud from "@indigoai-us/hq-cloud";
|
|
16
|
+
|
|
17
|
+
describe("@indigoai-us/hq-cloud — pinned methods used by hq-cli", () => {
|
|
18
|
+
it("US-004 + US-008 prep — VaultClient carries the sync-browse methods", () => {
|
|
19
|
+
const proto = cloud.VaultClient.prototype as unknown as Record<
|
|
20
|
+
string,
|
|
21
|
+
unknown
|
|
22
|
+
>;
|
|
23
|
+
expect(typeof proto.listMyExplicitGrants).toBe("function");
|
|
24
|
+
expect(typeof proto.getMembershipSyncConfig).toBe("function");
|
|
25
|
+
expect(typeof proto.setMembershipSyncConfig).toBe("function");
|
|
26
|
+
expect(typeof proto.vend).toBe("function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("US-004 type aliases used by sync-mode + sync-narrow + files-browse compile-link", () => {
|
|
30
|
+
const _grant: cloud.ExplicitGrant = {
|
|
31
|
+
companyUid: "cmp_x",
|
|
32
|
+
path: "companies/x/",
|
|
33
|
+
permission: "read",
|
|
34
|
+
source: "person",
|
|
35
|
+
};
|
|
36
|
+
const _config: cloud.MembershipSyncConfig = {
|
|
37
|
+
membershipId: "mbr_x",
|
|
38
|
+
syncMode: "shared",
|
|
39
|
+
isDefault: false,
|
|
40
|
+
updatedAt: "2026-05-20T00:00:00Z",
|
|
41
|
+
updatedBy: "prs_x",
|
|
42
|
+
};
|
|
43
|
+
const _input: cloud.SetMembershipSyncConfigInput = { syncMode: "all" };
|
|
44
|
+
expect(_grant.source).toBe("person");
|
|
45
|
+
expect(_config.syncMode).toBe("shared");
|
|
46
|
+
expect(_input.syncMode).toBe("all");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("US-008 prep — VendPurpose + VendInput keep purpose='browse' typecheckable", () => {
|
|
50
|
+
const _vendInput: cloud.VendInput = {
|
|
51
|
+
paths: ["companies/x/"],
|
|
52
|
+
operations: "read-only",
|
|
53
|
+
purpose: "browse",
|
|
54
|
+
};
|
|
55
|
+
expect(_vendInput.purpose).toBe("browse");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("dep is the published @indigoai-us/hq-cloud, not a local file: link", async () => {
|
|
59
|
+
// The package.json of the loaded module must be the npm-published one
|
|
60
|
+
// (not file:../hq-cloud which was the dev wiring during US-006 onwards).
|
|
61
|
+
// We resolve the package.json path via Node's require resolver and read
|
|
62
|
+
// its `_resolved` (pnpm) or `version` field — both confirm origin.
|
|
63
|
+
const pkgPath = require.resolve(
|
|
64
|
+
"@indigoai-us/hq-cloud/package.json",
|
|
65
|
+
);
|
|
66
|
+
const pkg = await import(pkgPath, { with: { type: "json" } });
|
|
67
|
+
expect(pkg.default.name).toBe("@indigoai-us/hq-cloud");
|
|
68
|
+
// Must be ≥ 5.23.0 — the first published version carrying the sync-
|
|
69
|
+
// browse SDK + raw vend with purpose. A lower version means the bump
|
|
70
|
+
// never made it into the lockfile / pnpm-store.
|
|
71
|
+
const [maj, min] = pkg.default.version.split(".").map(Number);
|
|
72
|
+
expect(maj).toBeGreaterThanOrEqual(5);
|
|
73
|
+
if (maj === 5) expect(min).toBeGreaterThanOrEqual(23);
|
|
74
|
+
});
|
|
75
|
+
});
|