@indigoai-us/hq-cloud 6.14.45 → 6.14.47
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/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +32 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +32 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +65 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +8 -4
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +38 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +55 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +50 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/s3.test.js +6 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +48 -0
- package/src/cli/share.test.ts +72 -0
- package/src/cli/share.ts +37 -3
- package/src/cli/sync.ts +8 -4
- package/src/ignore.test.ts +55 -0
- package/src/ignore.ts +57 -0
- package/src/s3.test.ts +6 -0
- package/test/joiner-manifest-reconcile.integration.test.ts +43 -4
package/src/cli/sync.ts
CHANGED
|
@@ -398,10 +398,14 @@ export type SyncProgressEvent =
|
|
|
398
398
|
}
|
|
399
399
|
| {
|
|
400
400
|
/**
|
|
401
|
-
* Emitted
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
401
|
+
* Emitted once per key skipped because it is permanently unstorable.
|
|
402
|
+
*
|
|
403
|
+
* PULL leg: a remote key the server will never presign (for example, a
|
|
404
|
+
* `companies/…`-prefixed key in a company-scoped vault or a key with
|
|
405
|
+
* control characters) — bucket poisoning from an outdated direct-S3
|
|
406
|
+
* client. PUSH leg: a local key the upload validator would reject, so
|
|
407
|
+
* the upload can only ever throw — a doubled-tree `companies/…` key, or
|
|
408
|
+
* a control-character path such as a macOS Finder `Icon\r` file. Like
|
|
405
409
|
* `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
|
|
406
410
|
* object must never error a whole company sync (erroring gated
|
|
407
411
|
* heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
|
package/src/ignore.test.ts
CHANGED
|
@@ -33,6 +33,24 @@ describe("isExpectedIgnore", () => {
|
|
|
33
33
|
expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it("classifies macOS Finder Icon\\r files as expected noise", () => {
|
|
37
|
+
// Finder scatters one into every directory it renders a custom icon for,
|
|
38
|
+
// and a folder-level cloud-sync agent propagates them tree-wide. Treated
|
|
39
|
+
// exactly like .DS_Store so a seeded tree stays silent rather than firing
|
|
40
|
+
// the "EXCLUDED from sync — review in case this is unintended" warning
|
|
41
|
+
// once per file.
|
|
42
|
+
expect(isExpectedIgnore("Icon\r")).toBe(true);
|
|
43
|
+
expect(isExpectedIgnore("companies/indigo/Icon\r")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps a real file named Icons noteworthy (no `Icon?` over-match)", () => {
|
|
47
|
+
// The `Icon?` gitignore workaround would swallow these. Dropping real
|
|
48
|
+
// user content is a worse failure than the cruft being excluded.
|
|
49
|
+
expect(isExpectedIgnore("companies/indigo/Icons")).toBe(false);
|
|
50
|
+
expect(isExpectedIgnore("companies/indigo/Icon")).toBe(false);
|
|
51
|
+
expect(isExpectedIgnore("companies/indigo/Icons.md")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
36
54
|
it("keeps nested repos/workspace and ordinary content noteworthy", () => {
|
|
37
55
|
expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
|
|
38
56
|
expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
|
|
@@ -85,6 +103,43 @@ describe("createIgnoreFilter", () => {
|
|
|
85
103
|
expect(shouldSync(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
|
|
86
104
|
});
|
|
87
105
|
|
|
106
|
+
it("permissive mode: macOS Finder Icon\\r files never enter the walk", () => {
|
|
107
|
+
// A CR is a control character, so the vault key validator rejects the key
|
|
108
|
+
// (INVALID_KEY_CONTROL_CHARS) and the upload can only ever throw. Dropping
|
|
109
|
+
// them during the walk is what keeps them off the upload path entirely.
|
|
110
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
111
|
+
expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
|
|
112
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon\r"))).toBe(false);
|
|
113
|
+
expect(shouldSync(path.join(hqRoot, ".agents/Icon\r"))).toBe(false);
|
|
114
|
+
// Caught at any depth, like the other OS-cruft exclusions.
|
|
115
|
+
expect(shouldSync(path.join(hqRoot, "a/b/c/d/Icon\r"))).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("permissive mode: a real file named Icons still syncs", () => {
|
|
119
|
+
// Regression guard for the `Icon?` gitignore workaround, whose `?`
|
|
120
|
+
// single-char wildcard also matches Icons / Icon1 / IconX. This exclusion
|
|
121
|
+
// matches the exact Finder byte sequence instead, so real content survives.
|
|
122
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
123
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons"))).toBe(true);
|
|
124
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon"))).toBe(true);
|
|
125
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon1"))).toBe(true);
|
|
126
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons.md"))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a literal Icon+CR line in .gitignore does NOT reach the ignore lib", () => {
|
|
130
|
+
// This is why the exclusion is a predicate and not a DEFAULT_IGNORES
|
|
131
|
+
// entry. ignore@5 strips a trailing CR from a pattern line, so the
|
|
132
|
+
// spelling git itself requires silently never matches. Pinning the
|
|
133
|
+
// upstream behavior here means a future ignore-lib bump that changes it
|
|
134
|
+
// surfaces as a failing test rather than a silent double-exclusion.
|
|
135
|
+
fs.writeFileSync(path.join(hqRoot, ".gitignore"), "Icon\r\n");
|
|
136
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
137
|
+
// Still excluded — by the predicate, not by the pattern.
|
|
138
|
+
expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
|
|
139
|
+
// Proof the pattern itself is inert: it did not take `Icons` with it.
|
|
140
|
+
expect(shouldSync(path.join(hqRoot, "Icons"))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
88
143
|
it("repos/ exclusion is root-anchored to hqRoot (DEV-1791)", () => {
|
|
89
144
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
90
145
|
expect(shouldSync(path.join(hqRoot, "repos/private/hq-cloud/src/x.ts"))).toBe(false);
|
package/src/ignore.ts
CHANGED
|
@@ -244,6 +244,52 @@ function segmentToRegex(seg: string): RegExp {
|
|
|
244
244
|
return new RegExp(`^${body}$`);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Match a path whose basename is the macOS Finder custom-icon file: the
|
|
249
|
+
* literal `Icon` followed by a carriage return (U+000D).
|
|
250
|
+
*
|
|
251
|
+
* This CANNOT be expressed as a `DEFAULT_IGNORES` entry, which is why it is a
|
|
252
|
+
* predicate instead of a pattern string. Two independent reasons:
|
|
253
|
+
*
|
|
254
|
+
* 1. A pattern line ending in a literal CR is inert. The `ignore` package
|
|
255
|
+
* strips the trailing CR, so the line degrades to `Icon` and never
|
|
256
|
+
* matches — verified against ignore@5.3.2:
|
|
257
|
+
* `ignore().add("Icon\r\n").ignores("Icon\r") === false`. Git does the
|
|
258
|
+
* same thing (it reads the CR as part of the line ending), which is why
|
|
259
|
+
* the GitHub macOS template has to end that line with TWO CRs.
|
|
260
|
+
* 2. The usual workaround pattern `Icon?` DOES match, but `?` is a
|
|
261
|
+
* single-character wildcard in gitignore syntax, so it also swallows any
|
|
262
|
+
* real file named `Icons`, `Icon1`, `IconX`. Silently dropping user
|
|
263
|
+
* content is worse than the cruft we are excluding.
|
|
264
|
+
*
|
|
265
|
+
* Matching the exact byte sequence keeps the exclusion precise. Finder writes
|
|
266
|
+
* these into every directory it renders a custom icon for, and a folder-level
|
|
267
|
+
* cloud-sync agent (iCloud Desktop, backup tooling) propagates them across an
|
|
268
|
+
* entire tree — including into `.git/`. They carry no user content.
|
|
269
|
+
*
|
|
270
|
+
* They are also unsyncable by construction: a CR is a control character, so
|
|
271
|
+
* the vault key validator rejects the key outright
|
|
272
|
+
* (INVALID_KEY_CONTROL_CHARS). Excluding them at the walk is what keeps them
|
|
273
|
+
* from reaching the upload path at all.
|
|
274
|
+
*/
|
|
275
|
+
export function isMacFinderIconFile(relPath: string): boolean {
|
|
276
|
+
const basename = relPath.replace(/\/$/, "").split("/").pop() ?? "";
|
|
277
|
+
return basename === "Icon\r";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* True when any segment of `relPath` contains a character the vault key
|
|
282
|
+
* validator rejects (see `KEY_CONTROL_CHARS` in s3.ts — C0 controls plus DEL).
|
|
283
|
+
*
|
|
284
|
+
* Kept in lock-step with that validator on purpose: a key this returns `true`
|
|
285
|
+
* for can never be stored, so planning an upload for it only produces a
|
|
286
|
+
* guaranteed per-file failure.
|
|
287
|
+
*/
|
|
288
|
+
export function hasControlCharacters(relPath: string): boolean {
|
|
289
|
+
// eslint-disable-next-line no-control-regex
|
|
290
|
+
return /[\x00-\x1F\x7F]/.test(relPath);
|
|
291
|
+
}
|
|
292
|
+
|
|
247
293
|
export const EXPECTED_IGNORE_SEGMENTS = new Set([
|
|
248
294
|
".git",
|
|
249
295
|
"node_modules",
|
|
@@ -307,6 +353,11 @@ export function isExpectedIgnore(relPath: string): boolean {
|
|
|
307
353
|
return (
|
|
308
354
|
basename === ".DS_Store" ||
|
|
309
355
|
basename === "Thumbs.db" ||
|
|
356
|
+
// Same class as .DS_Store: OS-generated, zero user content. Classified
|
|
357
|
+
// expected so a tree full of them stays silent instead of firing the
|
|
358
|
+
// "EXCLUDED from sync — review in case this is unintended" warning once
|
|
359
|
+
// per file (Finder scatters one into every directory it touches).
|
|
360
|
+
basename === "Icon\r" ||
|
|
310
361
|
basename === "company.yaml" ||
|
|
311
362
|
basename === "INDEX.md" ||
|
|
312
363
|
basename === "modules.lock" ||
|
|
@@ -373,6 +424,12 @@ export function createIgnoreFilter(
|
|
|
373
424
|
const relative = path.relative(hqRoot, filePath);
|
|
374
425
|
if (!relative || relative.startsWith("..")) return true; // outside HQ root
|
|
375
426
|
|
|
427
|
+
// macOS Finder custom-icon files. Excluded here rather than as a
|
|
428
|
+
// DEFAULT_IGNORES pattern because neither available spelling works —
|
|
429
|
+
// see isMacFinderIconFile for the ignore@5 CR-stripping proof and the
|
|
430
|
+
// `Icon?` over-match hazard.
|
|
431
|
+
if (isMacFinderIconFile(relative)) return false;
|
|
432
|
+
|
|
376
433
|
// Gitignore dir-only patterns (`foo/`) only match candidate paths that
|
|
377
434
|
// end with `/`. The `ignore` lib has no stat awareness, so when the
|
|
378
435
|
// caller knows the entry is a directory we hand the matcher the
|
package/src/s3.test.ts
CHANGED
|
@@ -1679,6 +1679,12 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
|
|
|
1679
1679
|
["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
|
|
1680
1680
|
["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
|
|
1681
1681
|
["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
|
|
1682
|
+
// macOS Finder custom-icon file. Named explicitly because this is the
|
|
1683
|
+
// spelling that reaches the validator in the wild: Finder writes one into
|
|
1684
|
+
// every directory it renders a custom icon for, and the push walker now
|
|
1685
|
+
// excludes them precisely because this throw is unavoidable.
|
|
1686
|
+
["Icon\r", "INVALID_KEY_CONTROL_CHARS"],
|
|
1687
|
+
["docs/Icon\r", "INVALID_KEY_CONTROL_CHARS"],
|
|
1682
1688
|
])(
|
|
1683
1689
|
"validateVaultUploadKey mirrors the server rule set: %j → %s",
|
|
1684
1690
|
(key, code) => {
|
|
@@ -50,13 +50,21 @@ interface CompanyFixture {
|
|
|
50
50
|
name: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function makeJoinerRoot(
|
|
53
|
+
function makeJoinerRoot(
|
|
54
|
+
companies: readonly CompanyFixture[],
|
|
55
|
+
opts: { createCompanyDirs?: boolean } = {},
|
|
56
|
+
): string {
|
|
57
|
+
const createCompanyDirs = opts.createCompanyDirs ?? true;
|
|
54
58
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-joiner-"));
|
|
55
59
|
fs.mkdirSync(path.join(root, "companies"), { recursive: true });
|
|
56
60
|
// The pull materializes the company directory; the manifest entry is the
|
|
57
|
-
// thing that was missing.
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
// thing that was missing. When `createCompanyDirs` is false we DON'T
|
|
62
|
+
// pre-create them — that models a company with zero shared files, where the
|
|
63
|
+
// runner itself must materialize the directory (see the zero-file test).
|
|
64
|
+
if (createCompanyDirs) {
|
|
65
|
+
for (const c of companies) {
|
|
66
|
+
fs.mkdirSync(path.join(root, "companies", c.slug), { recursive: true });
|
|
67
|
+
}
|
|
60
68
|
}
|
|
61
69
|
fs.writeFileSync(
|
|
62
70
|
path.join(root, "companies", "manifest.yaml"),
|
|
@@ -202,6 +210,37 @@ describe("joiner manifest reconciliation (end to end)", () => {
|
|
|
202
210
|
expect(readConfig(hqRoot).activeCompany).toBe("boring-ecom");
|
|
203
211
|
});
|
|
204
212
|
|
|
213
|
+
it("materializes the company directory and writes the manifest entry even when zero files are shared", async () => {
|
|
214
|
+
// The agency/client-sharing case: a member genuinely belongs to a company,
|
|
215
|
+
// but nothing has been shared into their scope yet, so the pull downloads
|
|
216
|
+
// zero files and never creates `companies/<slug>/`. Before the fix the
|
|
217
|
+
// reconciler (which gates on that directory) skipped the slug and HQ never
|
|
218
|
+
// learned about the company. The runner now materializes the directory on a
|
|
219
|
+
// completed pull leg regardless of file count, so the manifest entry lands.
|
|
220
|
+
hqRoot = makeJoinerRoot([BORING_ECOM], { createCompanyDirs: false });
|
|
221
|
+
const deps = makeDeps([BORING_ECOM]);
|
|
222
|
+
|
|
223
|
+
// Precondition: the directory does NOT exist yet.
|
|
224
|
+
expect(fs.existsSync(path.join(hqRoot, "companies", "boring-ecom"))).toBe(
|
|
225
|
+
false,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(await runRunner(["--companies", "--hq-root", hqRoot], deps)).toBe(0);
|
|
229
|
+
|
|
230
|
+
// The runner created the directory even though the pull shared nothing...
|
|
231
|
+
expect(fs.existsSync(path.join(hqRoot, "companies", "boring-ecom"))).toBe(
|
|
232
|
+
true,
|
|
233
|
+
);
|
|
234
|
+
// ...and the manifest entry is present, so HQ now knows about the company.
|
|
235
|
+
const companies = readManifest(hqRoot);
|
|
236
|
+
expect(companies["boring-ecom"]).toEqual({
|
|
237
|
+
name: "Boring Ecom",
|
|
238
|
+
cloud_uid: "cmp_01KR1QTBSATVKFRTZ5CFNEYA0Q",
|
|
239
|
+
bucket_name: "hq-vault-boring-ecom",
|
|
240
|
+
});
|
|
241
|
+
expect(readConfig(hqRoot).activeCompany).toBe("boring-ecom");
|
|
242
|
+
});
|
|
243
|
+
|
|
205
244
|
it("writes both entries but refuses to guess activeCompany for a two-company member", async () => {
|
|
206
245
|
const acme: CompanyFixture = { uid: "cmp_acme", slug: "acme", name: "Acme" };
|
|
207
246
|
hqRoot = makeJoinerRoot([BORING_ECOM, acme]);
|