@indigoai-us/hq-cloud 5.31.0 → 5.33.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 (69) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +79 -20
  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 +132 -11
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +236 -6
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +28 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +26 -5
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +82 -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/conflict-file.d.ts +7 -6
  23. package/dist/lib/conflict-file.d.ts.map +1 -1
  24. package/dist/lib/conflict-file.js +7 -27
  25. package/dist/lib/conflict-file.js.map +1 -1
  26. package/dist/lib/conflict.test.d.ts +4 -3
  27. package/dist/lib/conflict.test.d.ts.map +1 -1
  28. package/dist/lib/conflict.test.js +5 -33
  29. package/dist/lib/conflict.test.js.map +1 -1
  30. package/dist/lib/describe-error.d.ts +21 -0
  31. package/dist/lib/describe-error.d.ts.map +1 -0
  32. package/dist/lib/describe-error.js +53 -0
  33. package/dist/lib/describe-error.js.map +1 -0
  34. package/dist/lib/describe-error.test.d.ts +2 -0
  35. package/dist/lib/describe-error.test.d.ts.map +1 -0
  36. package/dist/lib/describe-error.test.js +89 -0
  37. package/dist/lib/describe-error.test.js.map +1 -0
  38. package/dist/lib/machine-id.d.ts +108 -0
  39. package/dist/lib/machine-id.d.ts.map +1 -0
  40. package/dist/lib/machine-id.js +170 -0
  41. package/dist/lib/machine-id.js.map +1 -0
  42. package/dist/lib/machine-id.test.d.ts +8 -0
  43. package/dist/lib/machine-id.test.d.ts.map +1 -0
  44. package/dist/lib/machine-id.test.js +195 -0
  45. package/dist/lib/machine-id.test.js.map +1 -0
  46. package/dist/personal-vault.d.ts +63 -7
  47. package/dist/personal-vault.d.ts.map +1 -1
  48. package/dist/personal-vault.js +112 -8
  49. package/dist/personal-vault.js.map +1 -1
  50. package/dist/personal-vault.test.d.ts +14 -0
  51. package/dist/personal-vault.test.d.ts.map +1 -0
  52. package/dist/personal-vault.test.js +191 -0
  53. package/dist/personal-vault.test.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/bin/sync-runner.test.ts +364 -0
  56. package/src/bin/sync-runner.ts +83 -18
  57. package/src/cli/share.test.ts +269 -6
  58. package/src/cli/share.ts +169 -11
  59. package/src/cli/sync.test.ts +91 -0
  60. package/src/cli/sync.ts +57 -5
  61. package/src/index.ts +3 -0
  62. package/src/lib/conflict-file.ts +7 -27
  63. package/src/lib/conflict.test.ts +4 -40
  64. package/src/lib/describe-error.test.ts +100 -0
  65. package/src/lib/describe-error.ts +58 -0
  66. package/src/lib/machine-id.test.ts +221 -0
  67. package/src/lib/machine-id.ts +175 -0
  68. package/src/personal-vault.test.ts +231 -0
  69. package/src/personal-vault.ts +134 -8
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Machine-ID provisioning — owns the per-host identity used to attribute
3
+ * conflict mirrors, telemetry rows, and `.hq-conflicts/index.json` entries.
4
+ *
5
+ * Historically the menubar app (`indigoai-us/hq-sync`) was the sole writer
6
+ * of `machineId` via `~/.hq/menubar.json`, and every other sync caller
7
+ * best-effort read from there. That arrangement is backwards: hq-cloud is
8
+ * the engine that runs on every sync host (macOS-with-menubar, macOS CLI,
9
+ * Linux HQ Pro Outposts, future Windows), while the menubar is an optional
10
+ * macOS-only UI. Linux outposts therefore had no menubar.json, so
11
+ * `readShortMachineId()` returned the literal string `"unknown"` and
12
+ * every conflict file on those hosts was tagged `-unknown` (which then
13
+ * also slipped past `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts` — see
14
+ * Fix 2 — and rode S3 round-trips as a regular file).
15
+ *
16
+ * This module flips ownership: hq-cloud provisions a UUID on first call
17
+ * and persists it to `<hqRoot>/.hq/machine-id` (one line, plain text).
18
+ * Every subsequent call hits the persisted file. Existing macOS installs
19
+ * with menubar-written IDs are migrated forward on first call: tier 3
20
+ * picks up the menubar.json value, writes it to `<hqRoot>/.hq/machine-id`,
21
+ * and returns it — so the id is stable across the migration window.
22
+ *
23
+ * Resolution order (first hit wins, every miss falls through):
24
+ * 1. `process.env.HQ_MACHINE_ID` — explicit override for CI / tests.
25
+ * 2. `<hqRoot>/.hq/machine-id` — source-of-truth on the host.
26
+ * 3. `~/.hq/menubar.json` `machineId` field — back-compat for existing
27
+ * macOS installs; migrated forward to tier 2 on first read.
28
+ * 4. Autogen — write a fresh UUID to `<hqRoot>/.hq/machine-id` and return.
29
+ *
30
+ * Concurrent autogen race is benign: two writers each pick a fresh UUID,
31
+ * last-writer-wins on disk, both processes re-read the now-stable file on
32
+ * their next call. The window is narrow (single sync run startup) and the
33
+ * downside (one extra conflict-file rename across a single race window) is
34
+ * trivial compared to the litter-ratchet bug it replaces.
35
+ */
36
+
37
+ import { createHash, randomUUID } from "node:crypto";
38
+ import * as fs from "node:fs";
39
+ import * as os from "node:os";
40
+ import * as path from "node:path";
41
+
42
+ /**
43
+ * Path to `~/.hq/menubar.json`. Evaluated lazily at call time (not module
44
+ * load) so tests overriding `HOME` post-import see the right file. Going
45
+ * through `os.homedir()` rather than `process.env.HOME` keeps the Windows
46
+ * USERPROFILE fallback intact.
47
+ */
48
+ function menubarJsonPath(): string {
49
+ return path.join(os.homedir(), ".hq", "menubar.json");
50
+ }
51
+
52
+ /**
53
+ * Path to `<hqRoot>/.hq/machine-id` — the source-of-truth file.
54
+ */
55
+ function hqRootMachineIdPath(hqRoot: string): string {
56
+ return path.join(hqRoot, ".hq", "machine-id");
57
+ }
58
+
59
+ /**
60
+ * Read the persisted id from `<hqRoot>/.hq/machine-id`, or undefined if
61
+ * absent/unreadable/empty. Trims trailing whitespace so manual edits with
62
+ * a final newline don't break attribution.
63
+ */
64
+ function readHqRootMachineId(hqRoot: string): string | undefined {
65
+ try {
66
+ const raw = fs.readFileSync(hqRootMachineIdPath(hqRoot), "utf-8").trim();
67
+ return raw.length > 0 ? raw : undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the menubar-written id from `~/.hq/menubar.json`, or undefined if
75
+ * the file is missing / unreadable / doesn't contain a string `machineId`.
76
+ */
77
+ function readMenubarMachineId(): string | undefined {
78
+ try {
79
+ const raw = fs.readFileSync(menubarJsonPath(), "utf-8");
80
+ const parsed = JSON.parse(raw) as { machineId?: unknown };
81
+ if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
82
+ return parsed.machineId;
83
+ }
84
+ return undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Persist `id` to `<hqRoot>/.hq/machine-id`. Best-effort — failures are
92
+ * silent so a read-only hqRoot (e.g. a CI mount) still gets a working id
93
+ * for the current process, even if it can't be persisted for the next run.
94
+ */
95
+ function persistMachineId(hqRoot: string, id: string): void {
96
+ try {
97
+ fs.mkdirSync(path.join(hqRoot, ".hq"), { recursive: true });
98
+ fs.writeFileSync(hqRootMachineIdPath(hqRoot), `${id}\n`);
99
+ } catch {
100
+ // Read-only filesystem or permission issue — caller gets the id back
101
+ // anyway. Next sync run will retry the persist.
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Resolve or provision the machine id for this host, persisting it to
107
+ * `<hqRoot>/.hq/machine-id` so the result is stable across sync runs.
108
+ *
109
+ * Returns the full id (UUID-shaped on first generation, free-form when
110
+ * migrated from a menubar.json that wrote something non-UUID). Use
111
+ * {@link readShortMachineId} for the 6-char prefix used in conflict
112
+ * filenames.
113
+ */
114
+ export function getOrCreateMachineId(hqRoot: string): string {
115
+ // Tier 1: env override.
116
+ const fromEnv = process.env.HQ_MACHINE_ID;
117
+ if (fromEnv && fromEnv.length > 0) return fromEnv;
118
+
119
+ // Tier 2: persisted source-of-truth.
120
+ const persisted = readHqRootMachineId(hqRoot);
121
+ if (persisted) return persisted;
122
+
123
+ // Tier 3: back-compat read of menubar.json. Migrate forward on first hit
124
+ // so subsequent calls take tier 2 and the menubar dependency drops out.
125
+ const fromMenubar = readMenubarMachineId();
126
+ if (fromMenubar) {
127
+ persistMachineId(hqRoot, fromMenubar);
128
+ return fromMenubar;
129
+ }
130
+
131
+ // Tier 4: autogen + persist.
132
+ const fresh = randomUUID();
133
+ persistMachineId(hqRoot, fresh);
134
+ return fresh;
135
+ }
136
+
137
+ /**
138
+ * Short form (six hex chars) for use in conflict filenames. The short
139
+ * token is what gets stamped into `<orig>.conflict-<ts>-<short>.<ext>` —
140
+ * see `buildConflictPath` in `./conflict-file.ts`.
141
+ *
142
+ * **Always returns `[a-f0-9]{6}`** so the resulting filename matches the
143
+ * `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts`. Tier 1 (`HQ_MACHINE_ID`)
144
+ * and tier 3 (legacy menubar values) can return arbitrary non-hex strings
145
+ * — e.g. an env override of `"ci-runner-42"` or a menubar-written
146
+ * `"menubar-legacy-id"`. Slicing those raw would produce `ci-run` or
147
+ * `menuba`, which the ephemeral filter would refuse and the push walker
148
+ * would round-trip to S3 — the exact litter-ratchet bug this module
149
+ * exists to close.
150
+ *
151
+ * Normalization: if the first 6 chars of the resolved id are all hex
152
+ * (the typical UUID / hex-id case), use them as-is so the short token
153
+ * remains an intuitive prefix of the full id. Otherwise derive a
154
+ * deterministic SHA-1 hash of the full id and take the first 6 chars —
155
+ * stable across calls, attributable to the same machine, always hex.
156
+ */
157
+ export function readShortMachineId(hqRoot: string): string {
158
+ const full = getOrCreateMachineId(hqRoot);
159
+ const head = full.slice(0, 6);
160
+ if (/^[a-f0-9]{6}$/.test(head)) return head;
161
+ return createHash("sha1").update(full).digest("hex").slice(0, 6);
162
+ }
163
+
164
+ /**
165
+ * Test-only exports. Mirrors the `_testing` namespace pattern used by
166
+ * `src/cli/share.ts` so regression-critical helpers can be pinned by
167
+ * direct unit tests without round-tripping through the public API.
168
+ */
169
+ export const _testing = {
170
+ menubarJsonPath,
171
+ hqRootMachineIdPath,
172
+ readHqRootMachineId,
173
+ readMenubarMachineId,
174
+ persistMachineId,
175
+ };
@@ -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
+ });
@@ -14,8 +14,14 @@
14
14
  * - `.git`: a git repo's own metadata is hostile to multi-machine
15
15
  * sync; .gitignore alone doesn't cover `.git/` because it's the repo
16
16
  * itself, not a tracked path.
17
- * - `companies/`: synced separately by the runner's per-membership
18
- * fanout; do not double-write into the personal vault.
17
+ * - `companies/`: the top-level `companies/` directory is never enumerated
18
+ * wholesale. Team-backed companies are synced by the runner's
19
+ * per-membership fanout (one bucket per company). Individual
20
+ * `companies/{slug}/` subdirs MAY be added back via
21
+ * `computePersonalCompanySubdirs()` for companies that explicitly
22
+ * declare `cloud: false` in `company.yaml` and are not in the operator's
23
+ * team-synced membership set — those land under the personal bucket as
24
+ * `companies/{slug}/...` keys.
19
25
  * - `repos/`, `workspace/`: per user directive — heavy local-only
20
26
  * content (cloned remotes, session threads) that has no business in
21
27
  * the personal vault.
@@ -41,23 +47,143 @@ export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
41
47
  ];
42
48
 
43
49
  /**
44
- * Compute absolute paths to share for the personal vault: every top-level
45
- * entry under `hqRoot` whose basename is NOT in
46
- * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`. Mirrors the Rust
47
- * `is_personal_vault_path` predicate (just hoisted to the top-level step).
50
+ * Company slugs that are never eligible for the personal-bucket fallback,
51
+ * regardless of their `cloud:` marker. `_template` is the scaffolding
52
+ * source for `/newcompany` copying it into the personal vault would
53
+ * pollute every machine's vault with the template tree.
54
+ */
55
+ export const PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS: readonly string[] = [
56
+ "_template",
57
+ ];
58
+
59
+ export interface PersonalVaultOptions {
60
+ /**
61
+ * Slugs of companies that already have their own team bucket (i.e. the
62
+ * operator has an active Membership row for them). These are excluded
63
+ * from the personal-bucket fallback so a single company's content never
64
+ * ends up in two buckets.
65
+ */
66
+ teamSyncedSlugs?: ReadonlySet<string>;
67
+ /**
68
+ * When true, walk `companies/` and include subdirs that explicitly opt in
69
+ * via `cloud: false` in their `company.yaml` (after applying the standard
70
+ * filter: exclude `_template`, exclude team-synced slugs). When false
71
+ * (the default), `companies/` is never enumerated — same as the legacy
72
+ * personal-vault scope. Gated behind a runtime flag so the new behavior
73
+ * stays opt-in until operators explicitly request it via
74
+ * `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`.
75
+ */
76
+ includeLocalCompanies?: boolean;
77
+ }
78
+
79
+ /**
80
+ * Compute absolute paths to share for the personal vault.
81
+ *
82
+ * Two sources are concatenated:
83
+ * 1. Every top-level entry under `hqRoot` whose basename is NOT in
84
+ * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
85
+ * 2. Every `companies/{slug}/` subdir that opts into the personal
86
+ * bucket via `company.yaml: cloud: false`, after excluding (a)
87
+ * `_template`, (b) slugs already in `opts.teamSyncedSlugs`, and
88
+ * (c) any subdir without an explicit `cloud: false` marker.
89
+ *
48
90
  * Order is whatever `fs.readdirSync` returns — share() doesn't care, and
49
91
  * the per-file walk inside share() handles recursion uniformly. Missing
50
92
  * hqRoot returns []; callers treat that as "no personal content to push"
51
93
  * rather than a hard error.
52
94
  */
53
- export function computePersonalVaultPaths(hqRoot: string): string[] {
95
+ export function computePersonalVaultPaths(
96
+ hqRoot: string,
97
+ opts: PersonalVaultOptions = {},
98
+ ): string[] {
54
99
  let entries: string[];
55
100
  try {
56
101
  entries = fs.readdirSync(hqRoot);
57
102
  } catch {
58
103
  return [];
59
104
  }
60
- return entries
105
+ const topLevel = entries
61
106
  .filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
62
107
  .map((name) => path.join(hqRoot, name));
108
+ const companySubdirs = opts.includeLocalCompanies === true
109
+ ? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
110
+ : [];
111
+ return [...topLevel, ...companySubdirs];
112
+ }
113
+
114
+ /**
115
+ * Discover `companies/{slug}/` subdirs that should sync to the personal
116
+ * bucket as a fallback for companies the operator has not designated as
117
+ * team-backed. Filter rules (all must hold):
118
+ *
119
+ * 1. The subdir is a directory (skip stray files).
120
+ * 2. The slug is NOT in `PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS`
121
+ * (currently just `_template`).
122
+ * 3. The slug is NOT in `teamSyncedSlugs` (membership-backed slugs sync
123
+ * to their own bucket and must not be double-written).
124
+ * 4. `companies/{slug}/company.yaml` exists and contains a
125
+ * `cloud: false` line. A missing file or `cloud: true` opts the
126
+ * directory OUT of the personal vault — silent inclusion would
127
+ * capture scratch/dead dirs the user never intended to ship.
128
+ *
129
+ * Returns absolute paths.
130
+ */
131
+ export function computePersonalCompanySubdirs(
132
+ hqRoot: string,
133
+ teamSyncedSlugs: ReadonlySet<string> = new Set(),
134
+ ): string[] {
135
+ const companiesRoot = path.join(hqRoot, "companies");
136
+ let slugs: string[];
137
+ try {
138
+ slugs = fs.readdirSync(companiesRoot);
139
+ } catch {
140
+ return [];
141
+ }
142
+ const eligible: string[] = [];
143
+ for (const slug of slugs) {
144
+ if (PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS.includes(slug)) continue;
145
+ if (teamSyncedSlugs.has(slug)) continue;
146
+ const subdir = path.join(companiesRoot, slug);
147
+ let isDir = false;
148
+ try {
149
+ isDir = fs.statSync(subdir).isDirectory();
150
+ } catch {
151
+ continue;
152
+ }
153
+ if (!isDir) continue;
154
+ if (!companyHasCloudFalseMarker(subdir)) continue;
155
+ eligible.push(subdir);
156
+ }
157
+ return eligible;
158
+ }
159
+
160
+ /**
161
+ * True iff `{subdir}/company.yaml` exists and contains an explicit
162
+ * top-level `cloud: false` line. Matches the canonical shape
163
+ * `/designate-team` writes — that command's awk is the only producer of
164
+ * this key, and it always emits `cloud: <bool>` at column zero with no
165
+ * indentation, no quoting, lowercase boolean, optional trailing comment.
166
+ *
167
+ * Anchored at column 0 deliberately:
168
+ * - rejects `meta:\n cloud: false` (nested key, would be `meta.cloud`
169
+ * in a real YAML parser — false positive for us if we allowed
170
+ * arbitrary leading whitespace)
171
+ * - rejects `- cloud: false` (list item, also a nested key)
172
+ * - rejects flow mappings like `{ cloud: false }`
173
+ *
174
+ * Other intentional rejections (lowercase `false` only, no YAML-alt
175
+ * boolean spellings): `cloud: False`, `cloud: FALSE`, `cloud: no`,
176
+ * `cloud: off`, `cloud: "false"`, `cloud: 'false'`. None of these are
177
+ * produced by `/designate-team`; matching them risks confusing
178
+ * round-trips with hand-edited YAML.
179
+ */
180
+ function companyHasCloudFalseMarker(subdir: string): boolean {
181
+ const yamlPath = path.join(subdir, "company.yaml");
182
+ let text: string;
183
+ try {
184
+ text = fs.readFileSync(yamlPath, "utf8");
185
+ } catch {
186
+ return false;
187
+ }
188
+ return /^cloud:\s*false\b/m.test(text);
63
189
  }