@indigoai-us/hq-cli 5.17.0 → 5.18.1
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 +63 -1
- package/dist/commands/cloud.js +214 -11
- 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.pull-per-company.test.ts +188 -0
- package/src/commands/cloud.ts +327 -5
- 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,327 @@
|
|
|
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
|
+
!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]="a6c1e2b9-5d9a-57b2-a80b-adfdbce88206")}catch(e){}}();
|
|
41
|
+
import chalk from "chalk";
|
|
42
|
+
import * as readline from "node:readline";
|
|
43
|
+
import * as fs from "node:fs";
|
|
44
|
+
import { VaultClient, coalescePrefixes, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
|
|
45
|
+
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
46
|
+
import { readActiveCompanySlug } from "./sync-mode.js";
|
|
47
|
+
import { buildNarrowPlan, formatBytes, formatNarrowPlanSummary, } from "../lib/local-tree-diff.js";
|
|
48
|
+
const realJournalIO = {
|
|
49
|
+
read: readJournal,
|
|
50
|
+
write: writeJournal,
|
|
51
|
+
};
|
|
52
|
+
const realFsIO = {
|
|
53
|
+
unlinkSync: fs.unlinkSync,
|
|
54
|
+
};
|
|
55
|
+
const realConfirm = async (message) => {
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
});
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the caller's membership for `companySlug` and read its current
|
|
69
|
+
* sync-config. Throws if the slug doesn't match any membership or if the
|
|
70
|
+
* current mode isn't eligible for narrowing (`all` → `shared` only).
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveNarrowTarget(input) {
|
|
73
|
+
const { companySlug, vaultClient } = input;
|
|
74
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
75
|
+
const enriched = await Promise.all(memberships.map(async (m) => {
|
|
76
|
+
try {
|
|
77
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
78
|
+
return { membership: m, slug: ent.slug, name: ent.name };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { membership: m, slug: undefined, name: undefined };
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
const match = enriched.find((row) => row.slug === companySlug);
|
|
85
|
+
if (!match) {
|
|
86
|
+
const known = enriched
|
|
87
|
+
.map((r) => r.slug)
|
|
88
|
+
.filter((s) => !!s)
|
|
89
|
+
.join(", ");
|
|
90
|
+
throw new Error(`No membership found for company '${companySlug}'. Memberships visible to you: ${known || "(none)"}.`);
|
|
91
|
+
}
|
|
92
|
+
const membership = match.membership;
|
|
93
|
+
const syncConfig = await vaultClient.getMembershipSyncConfig(membership.membershipKey);
|
|
94
|
+
if (syncConfig.syncMode !== "all") {
|
|
95
|
+
throw new Error(`Refusing to narrow: membership for '${companySlug}' is currently ` +
|
|
96
|
+
`syncMode='${syncConfig.syncMode}'. The narrow ritual only runs on ` +
|
|
97
|
+
`syncMode='all' memberships (shared → shared is a no-op; ` +
|
|
98
|
+
`custom requires \`hq sync mode shared\` directly).`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
membership,
|
|
102
|
+
companyUid: membership.companyUid,
|
|
103
|
+
companySlug,
|
|
104
|
+
syncConfig,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Compute the narrow plan for one company: fetch explicit grants, coalesce,
|
|
109
|
+
* read the per-company journal, walk the local tree, classify.
|
|
110
|
+
*
|
|
111
|
+
* Reads only — never mutates the journal or hits a destructive endpoint.
|
|
112
|
+
* `apply()` consumes this and performs the destructive steps.
|
|
113
|
+
*/
|
|
114
|
+
export async function computeNarrowPlan(input) {
|
|
115
|
+
const { hqRoot, companySlug, companyUid, vaultClient } = input;
|
|
116
|
+
const io = input.journalIO ?? realJournalIO;
|
|
117
|
+
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
118
|
+
const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
|
|
119
|
+
const journal = io.read(companySlug);
|
|
120
|
+
const plan = buildNarrowPlan({
|
|
121
|
+
hqRoot,
|
|
122
|
+
companySlug,
|
|
123
|
+
prospectivePrefixSet,
|
|
124
|
+
journal,
|
|
125
|
+
});
|
|
126
|
+
return { plan, prospectivePrefixSet, grants, journal };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Apply the destructive side of a narrow:
|
|
130
|
+
* 1. Delete clean orphans (best-effort — ENOENT is ignored).
|
|
131
|
+
* 2. Tombstone their journal entries.
|
|
132
|
+
* 3. If `force`, also delete + tombstone dirty orphans.
|
|
133
|
+
* 4. Persist the journal.
|
|
134
|
+
* 5. PUT sync-config → `shared` (server writes the audit row).
|
|
135
|
+
*
|
|
136
|
+
* Order is fail-safe: if a delete throws partway, the journal write below
|
|
137
|
+
* won't run, but the in-progress deletions are already on disk — that's the
|
|
138
|
+
* "don't roll back I/O" contract from the spec. The CLI surfaces a
|
|
139
|
+
* recovery message when this throws.
|
|
140
|
+
*
|
|
141
|
+
* Pure-ish: no prompting (the CLI wrapper handles that), no console
|
|
142
|
+
* output. Just disk + network.
|
|
143
|
+
*/
|
|
144
|
+
export async function applyNarrow(input) {
|
|
145
|
+
const { plan, journal, force, companySlug, membershipId, vaultClient } = input;
|
|
146
|
+
const journalIO = input.journalIO ?? realJournalIO;
|
|
147
|
+
const fsIO = input.fsIO ?? realFsIO;
|
|
148
|
+
let cleanDeleted = 0;
|
|
149
|
+
let dirtyDeleted = 0;
|
|
150
|
+
let tombstoned = 0;
|
|
151
|
+
const cleanBytes = plan.clean.reduce((sum, f) => sum + f.bytes, 0);
|
|
152
|
+
const dirtyBytes = force
|
|
153
|
+
? plan.dirty.reduce((sum, f) => sum + f.bytes, 0)
|
|
154
|
+
: 0;
|
|
155
|
+
// 1. Delete clean orphans + tombstone.
|
|
156
|
+
for (const file of plan.clean) {
|
|
157
|
+
try {
|
|
158
|
+
fsIO.unlinkSync(file.absPath);
|
|
159
|
+
cleanDeleted++;
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
const code = err.code;
|
|
163
|
+
if (code !== "ENOENT")
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
tombstoneEntry(journal, file.relPath, "narrow_apply");
|
|
167
|
+
tombstoned++;
|
|
168
|
+
}
|
|
169
|
+
// 2. With --force, delete dirty orphans too.
|
|
170
|
+
if (force) {
|
|
171
|
+
for (const file of plan.dirty) {
|
|
172
|
+
try {
|
|
173
|
+
fsIO.unlinkSync(file.absPath);
|
|
174
|
+
dirtyDeleted++;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const code = err.code;
|
|
178
|
+
if (code !== "ENOENT")
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
// tombstone only if a journal entry exists — `not-in-journal` files
|
|
182
|
+
// never had one to tombstone.
|
|
183
|
+
if (journal.files[file.relPath]) {
|
|
184
|
+
tombstoneEntry(journal, file.relPath, "narrow_apply");
|
|
185
|
+
tombstoned++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// 3. Persist journal.
|
|
190
|
+
journalIO.write(companySlug, journal);
|
|
191
|
+
// 4. PUT sync-config → shared. Server writes the authoritative
|
|
192
|
+
// MEMBERSHIP_SYNC_CONFIG_CHANGED audit row.
|
|
193
|
+
const newConfig = await vaultClient.setMembershipSyncConfig(membershipId, {
|
|
194
|
+
syncMode: "shared",
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
cleanDeleted,
|
|
198
|
+
cleanBytes,
|
|
199
|
+
dirtyDeleted,
|
|
200
|
+
dirtyBytes,
|
|
201
|
+
tombstoned,
|
|
202
|
+
newConfig,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// ── Dirty-file conflict report (operator-facing) ───────────────────────────
|
|
206
|
+
/**
|
|
207
|
+
* Format the dirty-file conflict report shown on `--apply` when dirty files
|
|
208
|
+
* are present and `--force` is NOT set. The CLI prints this and exits
|
|
209
|
+
* non-zero so the operator can resolve before retrying.
|
|
210
|
+
*/
|
|
211
|
+
export function formatDirtyConflictReport(dirty, companySlug) {
|
|
212
|
+
const lines = [];
|
|
213
|
+
lines.push(`Aborting narrow for '${companySlug}': ${dirty.length} locally-modified file(s) ` +
|
|
214
|
+
`would be removed by this narrow.`);
|
|
215
|
+
lines.push("");
|
|
216
|
+
lines.push("Dirty files:");
|
|
217
|
+
const cap = 25;
|
|
218
|
+
for (const f of dirty.slice(0, cap)) {
|
|
219
|
+
lines.push(` ${f.relPath} (${formatBytes(f.bytes)}, ${f.reason})`);
|
|
220
|
+
}
|
|
221
|
+
if (dirty.length > cap) {
|
|
222
|
+
lines.push(` ... ${dirty.length - cap} more`);
|
|
223
|
+
}
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("Resolve before retrying:");
|
|
226
|
+
lines.push(" - commit or push the modifications via the normal sync flow;");
|
|
227
|
+
lines.push(" - move them outside the company folder; or");
|
|
228
|
+
lines.push(" - delete them locally if they really should be gone.");
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push("Alternatively, re-run with --force to remove the dirty files anyway " +
|
|
231
|
+
"(destructive — there is no undo).");
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Wire `hq sync narrow` onto the existing `sync` Commander group. Called
|
|
236
|
+
* from `src/index.ts` after `registerSyncModeCommand`.
|
|
237
|
+
*/
|
|
238
|
+
export function registerSyncNarrowCommand(syncCmd) {
|
|
239
|
+
syncCmd
|
|
240
|
+
.command("narrow")
|
|
241
|
+
.description("Migrate a company's membership from syncMode='all' to 'shared' — " +
|
|
242
|
+
"dry-run previews diff, --apply performs the prune + flip")
|
|
243
|
+
.option("--dry-run", "Compute + print the narrow plan; write nothing (default if neither --dry-run nor --apply is passed)")
|
|
244
|
+
.option("--apply", "Execute the narrow — delete clean orphans, flip sync-mode to 'shared'")
|
|
245
|
+
.option("--yes", "Skip the interactive confirmation prompt on --apply")
|
|
246
|
+
.option("--force", "On --apply, also remove locally-modified (dirty) files (destructive — no undo)")
|
|
247
|
+
.option("--company <slug>", "Company slug (defaults to the active company in <hq-root>/.hq/config.json)")
|
|
248
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
249
|
+
.action(async (options) => {
|
|
250
|
+
try {
|
|
251
|
+
if (options.dryRun && options.apply) {
|
|
252
|
+
throw new Error("--dry-run and --apply are mutually exclusive.");
|
|
253
|
+
}
|
|
254
|
+
// Default to dry-run when nothing is specified — safer.
|
|
255
|
+
const apply = !!options.apply;
|
|
256
|
+
const dryRun = !apply;
|
|
257
|
+
const companySlug = options.company ?? readActiveCompanySlug(options.hqRoot);
|
|
258
|
+
if (!companySlug) {
|
|
259
|
+
throw new Error("No company specified. Pass --company <slug> or set activeCompany in <hq-root>/.hq/config.json.");
|
|
260
|
+
}
|
|
261
|
+
const accessToken = await ensureCognitoToken();
|
|
262
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
263
|
+
const client = new VaultClient(vaultConfig);
|
|
264
|
+
const target = await resolveNarrowTarget({
|
|
265
|
+
companySlug,
|
|
266
|
+
vaultClient: client,
|
|
267
|
+
});
|
|
268
|
+
const { plan, prospectivePrefixSet, grants } = await computeNarrowPlan({
|
|
269
|
+
hqRoot: options.hqRoot,
|
|
270
|
+
companySlug: target.companySlug,
|
|
271
|
+
companyUid: target.companyUid,
|
|
272
|
+
vaultClient: client,
|
|
273
|
+
});
|
|
274
|
+
// Always print the summary.
|
|
275
|
+
console.log(chalk.bold(`${dryRun ? "Dry-run" : "Apply"}: narrow '${target.companySlug}' (${grants.length} explicit grant${grants.length === 1 ? "" : "s"}, ${prospectivePrefixSet.length} coalesced prefix${prospectivePrefixSet.length === 1 ? "" : "es"})`));
|
|
276
|
+
console.log(formatNarrowPlanSummary(plan));
|
|
277
|
+
if (dryRun) {
|
|
278
|
+
if (plan.totalDirtyCount > 0) {
|
|
279
|
+
console.log("");
|
|
280
|
+
console.log(chalk.yellow(`Heads-up: ${plan.totalDirtyCount} dirty file(s) would block --apply unless --force is passed.`));
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// --apply path
|
|
285
|
+
if (plan.totalDirtyCount > 0 && !options.force) {
|
|
286
|
+
console.error(chalk.red(formatDirtyConflictReport(plan.dirty, target.companySlug)));
|
|
287
|
+
process.exit(2);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!options.yes) {
|
|
291
|
+
const force = !!options.force;
|
|
292
|
+
const dirtyTail = force
|
|
293
|
+
? ` AND remove ${plan.totalDirtyCount} dirty file(s) (--force)`
|
|
294
|
+
: "";
|
|
295
|
+
const confirmed = await realConfirm(`About to remove ${plan.totalCleanCount} clean file(s)${dirtyTail} and flip '${target.companySlug}' to syncMode='shared'. Proceed?`);
|
|
296
|
+
if (!confirmed) {
|
|
297
|
+
console.log("Cancelled. No changes applied.");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Re-read the journal at apply time to avoid races with concurrent syncs.
|
|
302
|
+
const journal = readJournal(target.companySlug);
|
|
303
|
+
const result = await applyNarrow({
|
|
304
|
+
companySlug: target.companySlug,
|
|
305
|
+
membershipId: target.membership.membershipKey,
|
|
306
|
+
plan,
|
|
307
|
+
journal,
|
|
308
|
+
vaultClient: client,
|
|
309
|
+
force: !!options.force,
|
|
310
|
+
});
|
|
311
|
+
console.log(chalk.green("✓"), `Narrow applied for ${chalk.bold(target.companySlug)}:`);
|
|
312
|
+
console.log(chalk.dim(` removed: ${result.cleanDeleted} clean (${formatBytes(result.cleanBytes)})${result.dirtyDeleted > 0
|
|
313
|
+
? ` + ${result.dirtyDeleted} dirty (${formatBytes(result.dirtyBytes)})`
|
|
314
|
+
: ""}`));
|
|
315
|
+
console.log(chalk.dim(` tombstoned: ${result.tombstoned} journal entr${result.tombstoned === 1 ? "y" : "ies"}`));
|
|
316
|
+
console.log(chalk.dim(` syncMode: all → ${result.newConfig.syncMode}` +
|
|
317
|
+
(result.newConfig.updatedAt ? ` (updatedAt: ${result.newConfig.updatedAt})` : "")));
|
|
318
|
+
console.log(chalk.dim(` audit: server-side MEMBERSHIP_SYNC_CONFIG_CHANGED row written by PUT /v1/memberships/${target.membership.membershipKey}/sync-config`));
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.error(chalk.red("✗ sync narrow failed:"), err instanceof Error ? err.message : String(err));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
//# sourceMappingURL=sync-narrow.js.map
|
|
327
|
+
//# debugId=a6c1e2b9-5d9a-57b2-a80b-adfdbce88206
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!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]="
|
|
6
|
+
!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]="a9b69c08-39a6-58c3-a4b6-a309d0ab8254")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -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";
|
|
@@ -77,6 +80,8 @@ const syncCmd = program
|
|
|
77
80
|
.command("sync")
|
|
78
81
|
.description("Cloud sync commands — sync HQ to S3 for mobile access");
|
|
79
82
|
registerCloudCommands(syncCmd);
|
|
83
|
+
registerSyncModeCommand(syncCmd);
|
|
84
|
+
registerSyncNarrowCommand(syncCmd);
|
|
80
85
|
// Cloud provisioning subcommand group (entity + bucket + initial sync)
|
|
81
86
|
// Distinct from `hq sync` which assumes provisioning has already happened.
|
|
82
87
|
const cloudCmd = program
|
|
@@ -98,7 +103,10 @@ registerRunCommand(program);
|
|
|
98
103
|
// Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
|
|
99
104
|
registerGroupsCommand(program);
|
|
100
105
|
// Files ACL management (subcommand group — hq files share|unshare|acl)
|
|
101
|
-
registerFilesCommand
|
|
106
|
+
// `registerFilesCommand` returns the `files` group so we can attach the
|
|
107
|
+
// browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
|
|
108
|
+
const filesCmd = registerFilesCommand(program);
|
|
109
|
+
registerFilesBrowseCommands(filesCmd);
|
|
102
110
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
103
111
|
registerMembersCommand(program);
|
|
104
112
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
@@ -129,4 +137,4 @@ registerSignalsCommand(program);
|
|
|
129
137
|
}
|
|
130
138
|
})();
|
|
131
139
|
//# sourceMappingURL=index.js.map
|
|
132
|
-
//# debugId=
|
|
140
|
+
//# debugId=a9b69c08-39a6-58c3-a4b6-a309d0ab8254
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper for `hq sync narrow` (US-007).
|
|
3
|
+
*
|
|
4
|
+
* Walks a local directory tree under `<hqRoot>/companies/{slug}/`, classifies
|
|
5
|
+
* each file against a prospective `shared`-mode prefix set, and groups the
|
|
6
|
+
* results into three buckets:
|
|
7
|
+
*
|
|
8
|
+
* - **staying** — file is covered by the prospective prefix set; survives.
|
|
9
|
+
* - **clean** — file is NOT covered (orphan) AND is provably unchanged
|
|
10
|
+
* since the last journal sync (safe to delete).
|
|
11
|
+
* - **dirty** — file is NOT covered (orphan) AND has been locally
|
|
12
|
+
* modified since the last sync (sacred — abort without
|
|
13
|
+
* `--force`).
|
|
14
|
+
*
|
|
15
|
+
* The clean-vs-dirty rule mirrors hq-cloud's implicit-shrink path
|
|
16
|
+
* (`scope-shrink.ts::classifyOrphan`) verbatim so the two narrowing paths —
|
|
17
|
+
* implicit (next pull after sync-mode flip) and explicit (`hq sync narrow
|
|
18
|
+
* --apply`) — agree on what "dirty" means. See US-000 Task 3 in
|
|
19
|
+
* `companies/indigo/projects/hq-sync-browse-vs-sync/references.md`.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no network. Reads the journal off `SyncJournal` (caller passes it
|
|
22
|
+
* in), stats + hashes files on disk — no journal mutations and no remote
|
|
23
|
+
* calls happen here. The CLI orchestrator (`sync-narrow.ts`) is responsible
|
|
24
|
+
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
|
+
*/
|
|
26
|
+
import { type SyncJournal } from "@indigoai-us/hq-cloud";
|
|
27
|
+
export type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
|
|
28
|
+
export interface NarrowFile {
|
|
29
|
+
/** Path relative to `hqRoot` (matches journal key + S3 key naming). */
|
|
30
|
+
relPath: string;
|
|
31
|
+
/** Absolute path on disk (convenience for the CLI delete loop). */
|
|
32
|
+
absPath: string;
|
|
33
|
+
/** Size in bytes from the local stat. 0 for symlinks (lstat size is the link, not target). */
|
|
34
|
+
bytes: number;
|
|
35
|
+
}
|
|
36
|
+
export interface NarrowDirtyFile extends NarrowFile {
|
|
37
|
+
reason: DirtyReason;
|
|
38
|
+
}
|
|
39
|
+
export interface NarrowPlan {
|
|
40
|
+
/** Files covered by `prospectivePrefixSet` — survive the narrow. */
|
|
41
|
+
staying: NarrowFile[];
|
|
42
|
+
/** Orphan files that are journal-clean — safe to delete on `--apply`. */
|
|
43
|
+
clean: NarrowFile[];
|
|
44
|
+
/** Orphan files that are locally dirty — block `--apply` unless `--force`. */
|
|
45
|
+
dirty: NarrowDirtyFile[];
|
|
46
|
+
totalStayingCount: number;
|
|
47
|
+
totalCleanCount: number;
|
|
48
|
+
totalCleanBytes: number;
|
|
49
|
+
totalDirtyCount: number;
|
|
50
|
+
totalDirtyBytes: number;
|
|
51
|
+
}
|
|
52
|
+
export interface BuildNarrowPlanInput {
|
|
53
|
+
/** Absolute HQ root path (the dir containing `companies/`). */
|
|
54
|
+
hqRoot: string;
|
|
55
|
+
/** Company slug — used to derive the walk root `<hqRoot>/companies/<slug>/`. */
|
|
56
|
+
companySlug: string;
|
|
57
|
+
/**
|
|
58
|
+
* Coalesced prospective `shared`-mode prefix set (the result of running
|
|
59
|
+
* the caller's explicit grants through `coalescePrefixes`). Prefixes are
|
|
60
|
+
* hq-root-relative (e.g. `companies/indigo/meetings/`).
|
|
61
|
+
*/
|
|
62
|
+
prospectivePrefixSet: readonly string[];
|
|
63
|
+
/**
|
|
64
|
+
* Active per-company journal. Hash + mtime comparisons key off
|
|
65
|
+
* `journal.files[relPath]`.
|
|
66
|
+
*/
|
|
67
|
+
journal: SyncJournal;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a NarrowPlan by walking `<hqRoot>/companies/<slug>/` and classifying
|
|
71
|
+
* each regular file against `prospectivePrefixSet` + `journal`.
|
|
72
|
+
*
|
|
73
|
+
* Walk semantics:
|
|
74
|
+
* - Recursive `fs.readdirSync(... withFileTypes)`.
|
|
75
|
+
* - Symlinks are RECORDED but never followed (`lstat`, not `stat`), matching
|
|
76
|
+
* the `share()`/sync engine's contract.
|
|
77
|
+
* - Hidden files + `.DS_Store` + `node_modules` are walked as-is — the
|
|
78
|
+
* caller is expected to point this at HQ content, where `.hqignore`
|
|
79
|
+
* filtering happens upstream of the journal. The journal already
|
|
80
|
+
* reflects what's been pushed/pulled, so `not-in-journal` is the
|
|
81
|
+
* classifier signal for "we didn't put this here".
|
|
82
|
+
* - If the walk root is missing entirely, returns an empty plan (rather
|
|
83
|
+
* than throwing) — a freshly-flipped membership may not have synced any
|
|
84
|
+
* files yet.
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan;
|
|
87
|
+
/**
|
|
88
|
+
* Render the dry-run summary as plain text (no chalk — keep it pure). The
|
|
89
|
+
* CLI wrapper can colorize lines afterwards if desired.
|
|
90
|
+
*/
|
|
91
|
+
export declare function formatNarrowPlanSummary(plan: NarrowPlan): string;
|
|
92
|
+
/** Human-readable byte counts with a fixed unit ladder. */
|
|
93
|
+
export declare function formatBytes(n: number): string;
|
|
94
|
+
//# sourceMappingURL=local-tree-diff.d.ts.map
|