@indigoai-us/hq-cloud 5.30.0 → 5.32.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.
Files changed (57) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +69 -2
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +292 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +26 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +105 -8
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +210 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +37 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +137 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/describe-error.d.ts +21 -0
  23. package/dist/lib/describe-error.d.ts.map +1 -0
  24. package/dist/lib/describe-error.js +53 -0
  25. package/dist/lib/describe-error.js.map +1 -0
  26. package/dist/lib/describe-error.test.d.ts +2 -0
  27. package/dist/lib/describe-error.test.d.ts.map +1 -0
  28. package/dist/lib/describe-error.test.js +89 -0
  29. package/dist/lib/describe-error.test.js.map +1 -0
  30. package/dist/personal-vault.d.ts +63 -7
  31. package/dist/personal-vault.d.ts.map +1 -1
  32. package/dist/personal-vault.js +112 -8
  33. package/dist/personal-vault.js.map +1 -1
  34. package/dist/personal-vault.test.d.ts +14 -0
  35. package/dist/personal-vault.test.d.ts.map +1 -0
  36. package/dist/personal-vault.test.js +191 -0
  37. package/dist/personal-vault.test.js.map +1 -0
  38. package/dist/s3.d.ts +12 -1
  39. package/dist/s3.d.ts.map +1 -1
  40. package/dist/s3.js +11 -1
  41. package/dist/s3.js.map +1 -1
  42. package/dist/s3.test.js +24 -0
  43. package/dist/s3.test.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/bin/sync-runner.test.ts +364 -0
  46. package/src/bin/sync-runner.ts +73 -2
  47. package/src/cli/share.test.ts +243 -0
  48. package/src/cli/share.ts +142 -8
  49. package/src/cli/sync.test.ts +152 -0
  50. package/src/cli/sync.ts +68 -5
  51. package/src/index.ts +3 -0
  52. package/src/lib/describe-error.test.ts +100 -0
  53. package/src/lib/describe-error.ts +58 -0
  54. package/src/personal-vault.test.ts +231 -0
  55. package/src/personal-vault.ts +134 -8
  56. package/src/s3.test.ts +30 -0
  57. package/src/s3.ts +12 -2
package/src/cli/sync.ts CHANGED
@@ -91,6 +91,15 @@ export type SyncProgressEvent =
91
91
  * uploaded vs downloaded.
92
92
  */
93
93
  direction?: "up" | "down";
94
+ /**
95
+ * Email of the file's author, read from the S3 object's `created-by`
96
+ * user-metadata. Only set on the download/pull path (a downloaded file
97
+ * was authored by whoever uploaded it); push-side progress has no author
98
+ * because the uploader is the local user. `null`/absent when the object
99
+ * carries no `created-by`. The menubar activity log shows this so the
100
+ * user sees who authored each file they received.
101
+ */
102
+ author?: string | null;
94
103
  }
95
104
  | { type: "error"; path: string; message: string }
96
105
  | {
@@ -173,10 +182,36 @@ export interface SyncOptions {
173
182
  /**
174
183
  * When true, the caller is syncing against the caller's person-entity
175
184
  * bucket. Pulled keys whose path starts with `companies/` are dropped
176
- * (belt-and-braces — the person bucket should never contain those,
177
- * but the runner must not write them into the user's company folders).
185
+ * by default (belt-and-braces — the person bucket should never contain
186
+ * those, and the runner must not write them into the user's company
187
+ * folders). See `includeLocalCompanies` for the opt-in escape hatch.
178
188
  */
179
189
  personalMode?: boolean;
190
+ /**
191
+ * Opt-in: when `personalMode === true`, allow `companies/{slug}/...` keys
192
+ * through the pull filter EXCEPT for slugs in `teamSyncedSlugs` (which
193
+ * are orphan remnants from a company that was promoted to its own team
194
+ * bucket and remain pre-decommission). Mirrors the push-side
195
+ * `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` gate: when an operator opts
196
+ * into the cloud:false → personal-bucket fallback on their machine,
197
+ * pull must also know about the new key shape on the OTHER machine that
198
+ * subscribes to those keys. Without this, the feature is push-only —
199
+ * the destination machine never sees the keys.
200
+ *
201
+ * Default false preserves the pre-5.20 behavior (drop all
202
+ * `companies/...` keys in personalMode).
203
+ */
204
+ includeLocalCompanies?: boolean;
205
+ /**
206
+ * Slugs of companies the operator has an active team-bucket Membership
207
+ * for. Only consulted when `personalMode === true` AND
208
+ * `includeLocalCompanies === true`: keys under `companies/{slug}/...`
209
+ * for any slug in this set are dropped as orphans from a pre-promotion
210
+ * personal-bucket fallback. The push-side decommission cycle eventually
211
+ * removes these from the bucket; this filter prevents them from
212
+ * re-downloading into the same disk paths the team-bucket pull manages.
213
+ */
214
+ teamSyncedSlugs?: ReadonlySet<string>;
180
215
  /**
181
216
  * Override for the per-slug journal file name. Defaults to `ctx.slug`.
182
217
  * sync-runner passes `journalSlug: "personal"` for the personal slot so
@@ -258,6 +293,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
258
293
  companyRoot,
259
294
  shouldSync,
260
295
  options.personalMode === true,
296
+ options.includeLocalCompanies === true,
297
+ options.teamSyncedSlugs ?? null,
261
298
  );
262
299
 
263
300
  emit({
@@ -403,7 +440,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
403
440
 
404
441
  // Download (action === "download" or conflict resolved to "overwrite")
405
442
  try {
406
- await downloadFile(ctx, remoteFile.key, localPath);
443
+ const { metadata } = await downloadFile(ctx, remoteFile.key, localPath);
444
+ const author = metadata?.["created-by"] ?? null;
407
445
 
408
446
  // Symlink records materialize as real symlinks on disk. lstat
409
447
  // (does not follow) lets us detect that case so the journal stamp
@@ -433,6 +471,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
433
471
  path: remoteFile.key,
434
472
  bytes: size,
435
473
  ...(remoteJournalMessage ? { message: remoteJournalMessage } : {}),
474
+ ...(author ? { author } : {}),
436
475
  });
437
476
 
438
477
  filesDownloaded++;
@@ -602,6 +641,8 @@ function computePullPlan(
602
641
  companyRoot: string,
603
642
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
604
643
  personalMode: boolean,
644
+ includeLocalCompanies: boolean,
645
+ teamSyncedSlugs: ReadonlySet<string> | null,
605
646
  ): PullPlan {
606
647
  const items: PullPlanItem[] = [];
607
648
 
@@ -609,8 +650,30 @@ function computePullPlan(
609
650
  const localPath = path.join(companyRoot, remoteFile.key);
610
651
 
611
652
  if (personalMode && remoteFile.key.startsWith("companies/")) {
612
- items.push({ action: "skip-personal-mode", remoteFile, localPath });
613
- continue;
653
+ // Default: drop every `companies/...` key — the legacy contract
654
+ // is that the personal bucket should never contain them.
655
+ //
656
+ // EXCEPTION: when the operator has opted into the cloud:false →
657
+ // personal-bucket fallback (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1
658
+ // on this machine), keys under `companies/{slug}/...` are
659
+ // legitimate — they're content a peer machine pushed for a
660
+ // company whose `company.yaml` declares `cloud: false`. Allow
661
+ // them through EXCEPT for slugs the operator has an active team-
662
+ // bucket Membership for: those are orphan remnants from before
663
+ // the company was promoted, and downloading them would clash
664
+ // with the team-bucket pull at the same disk path.
665
+ //
666
+ // Symmetric to the push-side `decommissionPrefixes` logic in
667
+ // share.ts — both target the same orphan class, both honor the
668
+ // same `teamSyncedSlugs` set derived from the operator's live
669
+ // membership at runtime.
670
+ const slug = remoteFile.key.split("/")[1] ?? "";
671
+ const isTeamSyncedOrphan =
672
+ teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
673
+ if (!includeLocalCompanies || isTeamSyncedOrphan) {
674
+ items.push({ action: "skip-personal-mode", remoteFile, localPath });
675
+ continue;
676
+ }
614
677
  }
615
678
 
616
679
  // LIST gives us no kind signal for the remote object — we don't
package/src/index.ts CHANGED
@@ -105,8 +105,11 @@ export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
105
105
  // Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
106
106
  export {
107
107
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
108
+ PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
108
109
  computePersonalVaultPaths,
110
+ computePersonalCompanySubdirs,
109
111
  } from "./personal-vault.js";
112
+ export type { PersonalVaultOptions } from "./personal-vault.js";
110
113
 
111
114
  // Personal-vault default-exclusions (5.25+) — second-tier deep-walk filter
112
115
  // for secrets, machine-local state, scratch dirs, OS/build cruft.
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { describeError } from "./describe-error.js";
3
+
4
+ describe("describeError", () => {
5
+ it("returns String(value) for non-Error throws", () => {
6
+ expect(describeError("plain string")).toBe("plain string");
7
+ expect(describeError(42)).toBe("42");
8
+ expect(describeError(null)).toBe("null");
9
+ expect(describeError(undefined)).toBe("undefined");
10
+ });
11
+
12
+ it("returns just the message for a plain Error (skips generic 'Error' name)", () => {
13
+ expect(describeError(new Error("acme blew up"))).toBe("acme blew up");
14
+ });
15
+
16
+ it("includes named subclasses (TypeError, RangeError, …)", () => {
17
+ expect(describeError(new TypeError("nope"))).toBe("TypeError nope");
18
+ });
19
+
20
+ it("surfaces AWS SDK v3 UnknownError with cause chain (the bug this fixes)", () => {
21
+ const sdkErr = new Error("UnknownError");
22
+ sdkErr.name = "UnknownError";
23
+ const dnsErr = new Error(
24
+ "getaddrinfo ENOTFOUND privy.s3.us-east-1.amazonaws.com",
25
+ );
26
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).code =
27
+ "ENOTFOUND";
28
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).syscall =
29
+ "getaddrinfo";
30
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).hostname =
31
+ "privy.s3.us-east-1.amazonaws.com";
32
+ (sdkErr as Error & { cause?: unknown }).cause = dnsErr;
33
+
34
+ const msg = describeError(sdkErr);
35
+ expect(msg).toContain("UnknownError");
36
+ expect(msg).toContain("cause=ENOTFOUND");
37
+ expect(msg).toContain("syscall=getaddrinfo");
38
+ expect(msg).toContain("host=privy.s3.us-east-1.amazonaws.com");
39
+ });
40
+
41
+ it("surfaces $metadata.httpStatusCode when present (AccessDenied, etc.)", () => {
42
+ const err = new Error("The provided credentials were not authorized.");
43
+ err.name = "AccessDenied";
44
+ (err as Error & { $metadata?: { httpStatusCode?: number } }).$metadata = {
45
+ httpStatusCode: 403,
46
+ };
47
+ const msg = describeError(err);
48
+ expect(msg).toContain("AccessDenied");
49
+ expect(msg).toContain("http=403");
50
+ expect(msg).toContain("The provided credentials");
51
+ });
52
+
53
+ it("includes own .code attribute on the top-level error", () => {
54
+ const err = new Error("connect timeout") as Error & { code?: string };
55
+ err.code = "ETIMEDOUT";
56
+ expect(describeError(err)).toContain("code=ETIMEDOUT");
57
+ });
58
+
59
+ it("walks up to 3 cause levels, no further", () => {
60
+ // top → c1 → c2 → c3 → c4 (5 errors, 4 cause hops). The walk should
61
+ // surface c1/c2/c3 codes but stop before c4.
62
+ const c4 = new Error("c4");
63
+ (c4 as Error & { code?: string }).code = "C4_CODE";
64
+ const c3 = new Error("c3");
65
+ (c3 as Error & { code?: string; cause?: unknown }).code = "C3_CODE";
66
+ (c3 as Error & { cause?: unknown }).cause = c4;
67
+ const c2 = new Error("c2");
68
+ (c2 as Error & { code?: string; cause?: unknown }).code = "C2_CODE";
69
+ (c2 as Error & { cause?: unknown }).cause = c3;
70
+ const c1 = new Error("c1");
71
+ (c1 as Error & { code?: string; cause?: unknown }).code = "C1_CODE";
72
+ (c1 as Error & { cause?: unknown }).cause = c2;
73
+ const top = new Error("top");
74
+ (top as Error & { cause?: unknown }).cause = c1;
75
+ const msg = describeError(top);
76
+ expect(msg).toContain("cause=C1_CODE");
77
+ expect(msg).toContain("cause=C2_CODE");
78
+ expect(msg).toContain("cause=C3_CODE");
79
+ // c4 is the 4th cause level — outside the 3-level walk
80
+ expect(msg).not.toContain("C4_CODE");
81
+ });
82
+
83
+ it("breaks on a cycle in the cause chain", () => {
84
+ const a = new Error("a");
85
+ const b = new Error("b");
86
+ (a as Error & { cause?: unknown }).cause = b;
87
+ (b as Error & { cause?: unknown }).cause = a;
88
+ // Should not infinite-loop; should return something containing "a" and "b".
89
+ const msg = describeError(a);
90
+ expect(msg).toContain("a");
91
+ expect(msg).toContain("b");
92
+ });
93
+
94
+ it("de-dups consecutive identical fragments (the 'UnknownError UnknownError' case)", () => {
95
+ const err = new Error("UnknownError");
96
+ err.name = "UnknownError";
97
+ // Without de-dup the output would start "UnknownError UnknownError".
98
+ expect(describeError(err)).toBe("UnknownError");
99
+ });
100
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Surface AWS SDK v3's opaque `UnknownError` wrapper.
3
+ *
4
+ * When the SDK can't match a response to a modeled exception (which includes
5
+ * every Node-side networking failure — `getaddrinfo ENOTFOUND`, `ECONNRESET`,
6
+ * `EHOSTUNREACH`, expired-DNS-cache hits, etc.) it raises an error whose
7
+ * `name` and `message` are both literally "UnknownError" and stashes the
8
+ * underlying Node error on `.cause`. Plain `err.message` extraction at the
9
+ * catch site loses the cause chain entirely, leaving operators with an
10
+ * unactionable "UnknownError" in the event stream.
11
+ *
12
+ * This walks the cause chain (capped at 3 levels) and stitches together a
13
+ * single diagnostic line that preserves the original failure mode.
14
+ *
15
+ * Example outputs:
16
+ * "UnknownError cause=ENOTFOUND syscall=getaddrinfo host=hq-vault-cmp-…s3.us-east-1.amazonaws.com"
17
+ * "AccessDenied http=403 The provided credentials …"
18
+ * "Error code=ETIMEDOUT cause=ETIMEDOUT host=api.example.com"
19
+ */
20
+ export function describeError(err: unknown): string {
21
+ if (!(err instanceof Error)) return String(err);
22
+
23
+ const parts: string[] = [];
24
+ const e = err as Error & {
25
+ code?: string;
26
+ $metadata?: { httpStatusCode?: number; requestId?: string };
27
+ cause?: unknown;
28
+ };
29
+
30
+ if (e.name && e.name !== "Error") parts.push(e.name);
31
+ if (e.code) parts.push(`code=${e.code}`);
32
+ if (e.$metadata?.httpStatusCode) parts.push(`http=${e.$metadata.httpStatusCode}`);
33
+
34
+ // Walk cause chain to capture the underlying syscall / hostname / code
35
+ // that the SDK swallowed. Cap depth at 3 and de-cycle with a Set.
36
+ const seen = new Set<unknown>([err]);
37
+ let cur: unknown = e.cause;
38
+ for (let i = 0; i < 3 && cur && !seen.has(cur); i++) {
39
+ seen.add(cur);
40
+ const c = cur as {
41
+ code?: string;
42
+ syscall?: string;
43
+ hostname?: string;
44
+ message?: string;
45
+ cause?: unknown;
46
+ };
47
+ if (c.code) parts.push(`cause=${c.code}`);
48
+ if (c.syscall) parts.push(`syscall=${c.syscall}`);
49
+ if (c.hostname) parts.push(`host=${c.hostname}`);
50
+ if (c.message && c.message !== e.message) parts.push(c.message);
51
+ cur = c.cause;
52
+ }
53
+
54
+ if (e.message) parts.push(e.message);
55
+
56
+ // De-dup consecutive repeats (SDK's "UnknownError UnknownError" case)
57
+ return parts.filter((p, i) => i === 0 || p !== parts[i - 1]).join(" ");
58
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Unit tests for the personal-vault path-discovery helpers.
3
+ *
4
+ * These cover the column-anchored `cloud: false` marker regex and the
5
+ * `companies/{slug}/` enumeration filter — both are pure functions over
6
+ * disk + caller-supplied option sets, so we exercise them against tmp
7
+ * directories rather than mocking fs.
8
+ *
9
+ * The sync-runner-level integration (env-var gate, team-synced slug
10
+ * derivation, end-to-end share() invocation) lives in
11
+ * `bin/sync-runner.test.ts` — tests G, H, I.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import * as fs from "fs";
16
+ import * as os from "os";
17
+ import * as path from "path";
18
+ import {
19
+ computePersonalCompanySubdirs,
20
+ computePersonalVaultPaths,
21
+ PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
22
+ PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
23
+ } from "./personal-vault.js";
24
+
25
+ describe("personal-vault helpers", () => {
26
+ let hqRoot: string;
27
+
28
+ beforeEach(() => {
29
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pv-test-"));
30
+ });
31
+
32
+ afterEach(() => {
33
+ fs.rmSync(hqRoot, { recursive: true, force: true });
34
+ });
35
+
36
+ /** Helper: write `companies/{slug}/company.yaml` with arbitrary content. */
37
+ function writeCompany(slug: string, yaml: string | null): void {
38
+ const dir = path.join(hqRoot, "companies", slug);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ if (yaml !== null) fs.writeFileSync(path.join(dir, "company.yaml"), yaml);
41
+ }
42
+
43
+ /** Helper: relative-paths-sorted projection of an absolute-path list. */
44
+ function rel(abs: string[]): string[] {
45
+ return abs.map((p) => path.relative(hqRoot, p)).sort();
46
+ }
47
+
48
+ // ── Exported constants ─────────────────────────────────────────────────
49
+ it("constants: PERSONAL_VAULT_EXCLUDED_TOP_LEVEL contains the canonical four names", () => {
50
+ expect([...PERSONAL_VAULT_EXCLUDED_TOP_LEVEL].sort()).toEqual([
51
+ ".git",
52
+ "companies",
53
+ "repos",
54
+ "workspace",
55
+ ]);
56
+ });
57
+
58
+ it("constants: PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS hard-lists `_template`", () => {
59
+ expect(PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS).toContain("_template");
60
+ });
61
+
62
+ // ── computePersonalCompanySubdirs: marker-regex acceptance ─────────────
63
+ it("marker: accepts canonical `cloud: false` at column 0", () => {
64
+ writeCompany("foo", "cloud: false\nname: Foo\n");
65
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
66
+ path.join("companies", "foo"),
67
+ ]);
68
+ });
69
+
70
+ it("marker: accepts `cloud:false` (no space after colon)", () => {
71
+ writeCompany("foo", "cloud:false\n");
72
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
73
+ path.join("companies", "foo"),
74
+ ]);
75
+ });
76
+
77
+ it("marker: accepts trailing comment after `cloud: false`", () => {
78
+ writeCompany("foo", "cloud: false # local-only, never upload to team\n");
79
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
80
+ path.join("companies", "foo"),
81
+ ]);
82
+ });
83
+
84
+ it("marker: accepts CRLF line endings", () => {
85
+ writeCompany("foo", "cloud: false\r\nname: Foo\r\n");
86
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
87
+ path.join("companies", "foo"),
88
+ ]);
89
+ });
90
+
91
+ it("marker: accepts `cloud: false` even when it is NOT the first line", () => {
92
+ writeCompany("foo", "name: Foo\nslug: foo\ncloud: false\n");
93
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
94
+ path.join("companies", "foo"),
95
+ ]);
96
+ });
97
+
98
+ // ── computePersonalCompanySubdirs: marker-regex rejection ──────────────
99
+ it("marker: rejects `cloud: true`", () => {
100
+ writeCompany("foo", "cloud: true\n");
101
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
102
+ });
103
+
104
+ it("marker: rejects missing company.yaml entirely", () => {
105
+ writeCompany("foo", null);
106
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
107
+ });
108
+
109
+ it("marker: rejects empty company.yaml", () => {
110
+ writeCompany("foo", "");
111
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
112
+ });
113
+
114
+ it("marker: rejects nested `cloud: false` (column-0 anchor — false-positive guard)", () => {
115
+ // Regression for an audit finding: an earlier `^[ \t]*cloud:` regex
116
+ // allowed arbitrary leading whitespace, which silently matched
117
+ // nested keys like `meta.cloud = false`. The tightened `^cloud:` regex
118
+ // rejects this, matching the canonical column-0 shape that
119
+ // `/designate-team`'s awk emits.
120
+ writeCompany("foo", "meta:\n cloud: false\n");
121
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
122
+ });
123
+
124
+ it("marker: rejects list-item style `- cloud: false`", () => {
125
+ writeCompany("foo", "tags:\n- cloud: false\n");
126
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
127
+ });
128
+
129
+ it("marker: rejects flow-mapping `{ cloud: false }`", () => {
130
+ writeCompany("foo", "{ cloud: false }\n");
131
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
132
+ });
133
+
134
+ it("marker: rejects YAML-alt boolean spellings (False, FALSE, no, off)", () => {
135
+ for (const variant of ["cloud: False\n", "cloud: FALSE\n", "cloud: no\n", "cloud: off\n"]) {
136
+ writeCompany("foo", variant);
137
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
138
+ fs.rmSync(path.join(hqRoot, "companies", "foo"), { recursive: true });
139
+ }
140
+ });
141
+
142
+ it("marker: rejects quoted `cloud: \"false\"` (the quote breaks the literal-false match)", () => {
143
+ writeCompany("foo", 'cloud: "false"\n');
144
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
145
+ });
146
+
147
+ it("marker: rejects `cloud: falsehood` (word-boundary guard)", () => {
148
+ writeCompany("foo", "cloud: falsehood\n");
149
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
150
+ });
151
+
152
+ // ── Subdir-level filters ───────────────────────────────────────────────
153
+ it("filter: skips _template even with cloud:false marker", () => {
154
+ writeCompany("_template", "cloud: false\n");
155
+ writeCompany("real", "cloud: false\n");
156
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
157
+ path.join("companies", "real"),
158
+ ]);
159
+ });
160
+
161
+ it("filter: skips slugs in the teamSyncedSlugs set", () => {
162
+ writeCompany("free", "cloud: false\n");
163
+ writeCompany("team-acme", "cloud: false\n");
164
+ expect(
165
+ rel(computePersonalCompanySubdirs(hqRoot, new Set(["team-acme"]))),
166
+ ).toEqual([path.join("companies", "free")]);
167
+ });
168
+
169
+ it("filter: skips stray files under companies/ (only directories considered)", () => {
170
+ // Hypothetical: someone drops a README.md at companies/. Should not
171
+ // crash, should not be enumerated as a company subdir.
172
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
173
+ fs.writeFileSync(path.join(hqRoot, "companies", "README.md"), "# README");
174
+ writeCompany("real", "cloud: false\n");
175
+ expect(rel(computePersonalCompanySubdirs(hqRoot))).toEqual([
176
+ path.join("companies", "real"),
177
+ ]);
178
+ });
179
+
180
+ it("filter: missing hqRoot/companies/ returns []", () => {
181
+ // No `companies/` dir at all (fresh install or atypical layout).
182
+ expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
183
+ });
184
+
185
+ // ── computePersonalVaultPaths: top-level + company-subdir composition ──
186
+ it("composition: includeLocalCompanies=false skips companies/ entirely", () => {
187
+ fs.mkdirSync(path.join(hqRoot, ".claude"));
188
+ fs.mkdirSync(path.join(hqRoot, "knowledge"));
189
+ writeCompany("foo", "cloud: false\n");
190
+
191
+ const out = rel(computePersonalVaultPaths(hqRoot));
192
+ // Top-level entries present, no companies/* subdir.
193
+ expect(out).toContain(".claude");
194
+ expect(out).toContain("knowledge");
195
+ expect(out.some((p) => p.startsWith("companies"))).toBe(false);
196
+ });
197
+
198
+ it("composition: includeLocalCompanies=true adds opt-in company subdirs", () => {
199
+ fs.mkdirSync(path.join(hqRoot, "knowledge"));
200
+ writeCompany("foo", "cloud: false\n");
201
+ writeCompany("bar", "cloud: true\n");
202
+
203
+ const out = rel(computePersonalVaultPaths(hqRoot, { includeLocalCompanies: true }));
204
+ expect(out).toContain("knowledge");
205
+ expect(out).toContain(path.join("companies", "foo"));
206
+ expect(out).not.toContain(path.join("companies", "bar"));
207
+ // The companies/ top-level entry itself is never present — only specific
208
+ // opted-in subdirs. Preserves the invariant that share() never walks
209
+ // the whole companies/ tree under personalMode.
210
+ expect(out).not.toContain("companies");
211
+ });
212
+
213
+ it("composition: includeLocalCompanies=true + teamSyncedSlugs filter combine correctly", () => {
214
+ writeCompany("foo", "cloud: false\n");
215
+ writeCompany("synced", "cloud: false\n");
216
+
217
+ const out = rel(
218
+ computePersonalVaultPaths(hqRoot, {
219
+ includeLocalCompanies: true,
220
+ teamSyncedSlugs: new Set(["synced"]),
221
+ }),
222
+ );
223
+ expect(out).toContain(path.join("companies", "foo"));
224
+ expect(out).not.toContain(path.join("companies", "synced"));
225
+ });
226
+
227
+ it("composition: missing hqRoot returns []", () => {
228
+ fs.rmSync(hqRoot, { recursive: true });
229
+ expect(computePersonalVaultPaths(hqRoot, { includeLocalCompanies: true })).toEqual([]);
230
+ });
231
+ });