@indigoai-us/hq-cli 5.25.1 → 5.28.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/dm.d.ts +41 -0
- package/dist/commands/dm.js +148 -0
- package/dist/commands/files-browse.d.ts +49 -0
- package/dist/commands/files-browse.js +102 -2
- package/dist/commands/sync-narrow.js +15 -4
- package/dist/index.js +4 -2
- package/dist/lib/local-tree-diff.d.ts +7 -2
- package/dist/lib/local-tree-diff.js +18 -6
- package/package.json +2 -2
- package/src/commands/dm.test.ts +88 -0
- package/src/commands/dm.ts +222 -0
- package/src/commands/files-browse.test.ts +89 -0
- package/src/commands/files-browse.ts +157 -0
- package/src/commands/sync-narrow.test.ts +66 -6
- package/src/commands/sync-narrow.ts +15 -1
- package/src/index.ts +2 -0
- package/src/lib/local-tree-diff.test.ts +40 -20
- package/src/lib/local-tree-diff.ts +22 -6
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* Uses a tmpdir fixture (same pattern as cloud-demote.test.ts) — vitest +
|
|
5
5
|
* real fs — so the symmetric-diff + dirty-classification logic is exercised
|
|
6
6
|
* end-to-end against an actual journal + filesystem.
|
|
7
|
+
*
|
|
8
|
+
* NAMESPACE CONTRACT (the bug these tests previously encoded): files are
|
|
9
|
+
* written on disk at their hq-root-relative path (`companies/<slug>/...`),
|
|
10
|
+
* but `buildNarrowPlan` emits — and the journal + grants endpoint use —
|
|
11
|
+
* COMPANY-RELATIVE keys (`meetings/a.md`). The `key()` helper derives the
|
|
12
|
+
* company-relative form from the on-disk path so journals, prefix sets, and
|
|
13
|
+
* expectations all speak the same namespace the real server does.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -30,11 +37,23 @@ function writeFile(rel: string, contents: string): { abs: string; rel: string }
|
|
|
30
37
|
return { abs, rel };
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Strip the `companies/<slug>/` prefix from an on-disk hq-root-relative path
|
|
42
|
+
* to get the COMPANY-RELATIVE key — the namespace `buildNarrowPlan` emits and
|
|
43
|
+
* the journal + grants endpoint use.
|
|
44
|
+
*/
|
|
45
|
+
function key(rel: string): string {
|
|
46
|
+
return rel.replace(/^companies\/[^/]+\//, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function sha256(s: string): string {
|
|
34
50
|
return crypto.createHash("sha256").update(s).digest("hex");
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Build a journal that "knows about" the given files at their current hash +
|
|
55
|
+
* mtime. Journal keys are COMPANY-RELATIVE (via `key()`), matching the engine.
|
|
56
|
+
*/
|
|
38
57
|
function journalFromFiles(
|
|
39
58
|
files: Array<{ rel: string; contents: string; syncedAt?: string }>,
|
|
40
59
|
): SyncJournal {
|
|
@@ -45,7 +64,7 @@ function journalFromFiles(
|
|
|
45
64
|
pulls: [],
|
|
46
65
|
};
|
|
47
66
|
for (const f of files) {
|
|
48
|
-
j.files[f.rel] = {
|
|
67
|
+
j.files[key(f.rel)] = {
|
|
49
68
|
hash: sha256(f.contents),
|
|
50
69
|
size: Buffer.byteLength(f.contents),
|
|
51
70
|
// Stamp syncedAt slightly in the future so on-disk mtime <= syncedAt
|
|
@@ -97,13 +116,13 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
97
116
|
const plan = buildNarrowPlan({
|
|
98
117
|
hqRoot: tmpRoot,
|
|
99
118
|
companySlug: "acme",
|
|
100
|
-
prospectivePrefixSet: ["
|
|
119
|
+
prospectivePrefixSet: ["meetings/"],
|
|
101
120
|
journal,
|
|
102
121
|
});
|
|
103
122
|
|
|
104
|
-
expect(plan.staying.map((f) => f.relPath)).toEqual([staying.rel]);
|
|
105
|
-
expect(plan.clean.map((f) => f.relPath)).toEqual([cleanOrphan.rel]);
|
|
106
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([dirtyOrphan.rel]);
|
|
123
|
+
expect(plan.staying.map((f) => f.relPath)).toEqual([key(staying.rel)]);
|
|
124
|
+
expect(plan.clean.map((f) => f.relPath)).toEqual([key(cleanOrphan.rel)]);
|
|
125
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(dirtyOrphan.rel)]);
|
|
107
126
|
expect(plan.dirty[0].reason).toBe("hash-mismatch");
|
|
108
127
|
|
|
109
128
|
expect(plan.totalStayingCount).toBe(1);
|
|
@@ -117,10 +136,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
117
136
|
const plan = buildNarrowPlan({
|
|
118
137
|
hqRoot: tmpRoot,
|
|
119
138
|
companySlug: "acme",
|
|
120
|
-
prospectivePrefixSet: ["
|
|
139
|
+
prospectivePrefixSet: ["meetings/"],
|
|
121
140
|
journal: journalFromFiles([]),
|
|
122
141
|
});
|
|
123
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
142
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
124
143
|
expect(plan.dirty[0].reason).toBe("not-in-journal");
|
|
125
144
|
});
|
|
126
145
|
|
|
@@ -130,16 +149,16 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
130
149
|
{ rel: orphan.rel, contents: "still here" },
|
|
131
150
|
]);
|
|
132
151
|
// Mark the entry tombstoned post-hoc.
|
|
133
|
-
journal.files[orphan.rel]!.removedAt = new Date().toISOString();
|
|
134
|
-
journal.files[orphan.rel]!.removedReason = "scope_shrink";
|
|
152
|
+
journal.files[key(orphan.rel)]!.removedAt = new Date().toISOString();
|
|
153
|
+
journal.files[key(orphan.rel)]!.removedReason = "scope_shrink";
|
|
135
154
|
|
|
136
155
|
const plan = buildNarrowPlan({
|
|
137
156
|
hqRoot: tmpRoot,
|
|
138
157
|
companySlug: "acme",
|
|
139
|
-
prospectivePrefixSet: ["
|
|
158
|
+
prospectivePrefixSet: ["meetings/"],
|
|
140
159
|
journal,
|
|
141
160
|
});
|
|
142
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
161
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
143
162
|
expect(plan.dirty[0].reason).toBe("not-in-journal");
|
|
144
163
|
});
|
|
145
164
|
|
|
@@ -151,10 +170,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
151
170
|
const plan = buildNarrowPlan({
|
|
152
171
|
hqRoot: tmpRoot,
|
|
153
172
|
companySlug: "acme",
|
|
154
|
-
prospectivePrefixSet: ["
|
|
173
|
+
prospectivePrefixSet: ["meetings/"],
|
|
155
174
|
journal,
|
|
156
175
|
});
|
|
157
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
176
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
158
177
|
expect(plan.dirty[0].reason).toBe("modified-after-sync");
|
|
159
178
|
});
|
|
160
179
|
|
|
@@ -166,7 +185,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
166
185
|
const plan = buildNarrowPlan({
|
|
167
186
|
hqRoot: tmpRoot,
|
|
168
187
|
companySlug: "acme",
|
|
169
|
-
prospectivePrefixSet: ["
|
|
188
|
+
prospectivePrefixSet: ["meetings/"],
|
|
170
189
|
journal: journalFromFiles([
|
|
171
190
|
{ rel: a.rel, contents: "a" },
|
|
172
191
|
{ rel: b.rel, contents: "b" },
|
|
@@ -175,15 +194,15 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
175
194
|
});
|
|
176
195
|
|
|
177
196
|
const stayingPaths = plan.staying.map((f) => f.relPath).sort();
|
|
178
|
-
expect(stayingPaths).toEqual([a.rel, b.rel].sort());
|
|
179
|
-
expect(plan.clean.map((f) => f.relPath)).toEqual([c.rel]);
|
|
197
|
+
expect(stayingPaths).toEqual([key(a.rel), key(b.rel)].sort());
|
|
198
|
+
expect(plan.clean.map((f) => f.relPath)).toEqual([key(c.rel)]);
|
|
180
199
|
});
|
|
181
200
|
|
|
182
201
|
it("returns an empty plan when the company folder is missing", () => {
|
|
183
202
|
const plan = buildNarrowPlan({
|
|
184
203
|
hqRoot: tmpRoot,
|
|
185
204
|
companySlug: "ghost",
|
|
186
|
-
prospectivePrefixSet: ["
|
|
205
|
+
prospectivePrefixSet: [""],
|
|
187
206
|
journal: journalFromFiles([]),
|
|
188
207
|
});
|
|
189
208
|
expect(plan.totalStayingCount).toBe(0);
|
|
@@ -214,7 +233,8 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
214
233
|
const plan = buildNarrowPlan({
|
|
215
234
|
hqRoot: tmpRoot,
|
|
216
235
|
companySlug: "acme",
|
|
217
|
-
|
|
236
|
+
// Empty-string prefix = "covers everything" per isCoveredByAny.
|
|
237
|
+
prospectivePrefixSet: [""],
|
|
218
238
|
journal: journalFromFiles([]),
|
|
219
239
|
});
|
|
220
240
|
expect(plan.totalStayingCount).toBe(2);
|
|
@@ -223,7 +243,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
223
243
|
// both are staying, regardless of journal state
|
|
224
244
|
expect(
|
|
225
245
|
plan.staying.map((f) => f.relPath).sort(),
|
|
226
|
-
).toEqual([a.rel, b.rel].sort());
|
|
246
|
+
).toEqual([key(a.rel), key(b.rel)].sort());
|
|
227
247
|
});
|
|
228
248
|
});
|
|
229
249
|
|
|
@@ -43,7 +43,11 @@ export type DirtyReason =
|
|
|
43
43
|
| "stat-error";
|
|
44
44
|
|
|
45
45
|
export interface NarrowFile {
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
|
|
48
|
+
* shared by the per-company journal keys, the vault S3 keys, and the
|
|
49
|
+
* server's explicit-grant paths. NOT hq-root-relative.
|
|
50
|
+
*/
|
|
47
51
|
relPath: string;
|
|
48
52
|
/** Absolute path on disk (convenience for the CLI delete loop). */
|
|
49
53
|
absPath: string;
|
|
@@ -79,7 +83,8 @@ export interface BuildNarrowPlanInput {
|
|
|
79
83
|
/**
|
|
80
84
|
* Coalesced prospective `shared`-mode prefix set (the result of running
|
|
81
85
|
* the caller's explicit grants through `coalescePrefixes`). Prefixes are
|
|
82
|
-
*
|
|
86
|
+
* COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
|
|
87
|
+
* returns and the namespace `relPath` is now computed in.
|
|
83
88
|
*/
|
|
84
89
|
prospectivePrefixSet: readonly string[];
|
|
85
90
|
/**
|
|
@@ -120,7 +125,16 @@ export function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan {
|
|
|
120
125
|
return emptyPlan();
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
|
|
128
|
+
// Walk with `walkRoot` as the rel-root so each file's `relPath` is
|
|
129
|
+
// COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
|
|
130
|
+
// server's explicit-grant paths, the per-company journal keys, and the
|
|
131
|
+
// hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
|
|
132
|
+
// (the old behavior) produced `companies/<slug>/meetings/a.md`, which
|
|
133
|
+
// matched neither the company-relative grants nor the journal keys — so
|
|
134
|
+
// every file fell out of `prospectivePrefixSet` AND missed its journal
|
|
135
|
+
// entry, making narrow flag the entire tree as dirty orphans. See the
|
|
136
|
+
// namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
|
|
137
|
+
walkLocal(walkRoot, walkRoot, (file) => {
|
|
124
138
|
if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
|
|
125
139
|
staying.push(file);
|
|
126
140
|
return;
|
|
@@ -170,7 +184,9 @@ function emptyPlan(): NarrowPlan {
|
|
|
170
184
|
*/
|
|
171
185
|
function walkLocal(
|
|
172
186
|
dir: string,
|
|
173
|
-
hqRoot
|
|
187
|
+
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
188
|
+
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
189
|
+
relRoot: string,
|
|
174
190
|
emit: (file: NarrowFile) => void,
|
|
175
191
|
): void {
|
|
176
192
|
let entries: fs.Dirent[];
|
|
@@ -186,7 +202,7 @@ function walkLocal(
|
|
|
186
202
|
|
|
187
203
|
for (const entry of entries) {
|
|
188
204
|
const absPath = path.join(dir, entry.name);
|
|
189
|
-
const relPath = path.relative(
|
|
205
|
+
const relPath = path.relative(relRoot, absPath);
|
|
190
206
|
|
|
191
207
|
if (entry.isSymbolicLink()) {
|
|
192
208
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
@@ -204,7 +220,7 @@ function walkLocal(
|
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
if (entry.isDirectory()) {
|
|
207
|
-
walkLocal(absPath,
|
|
223
|
+
walkLocal(absPath, relRoot, emit);
|
|
208
224
|
continue;
|
|
209
225
|
}
|
|
210
226
|
|