@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
@@ -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
  }
package/src/s3.test.ts CHANGED
@@ -629,4 +629,34 @@ describe("downloadFile", () => {
629
629
  expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
630
630
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
631
631
  });
632
+
633
+ it("returns the object's user-metadata (including created-by) for a regular file", async () => {
634
+ nextGetObjectResponse = {
635
+ Body: (async function* () {
636
+ yield new Uint8Array([104, 105]); // "hi"
637
+ })(),
638
+ Metadata: { "created-by": "alice@example.com", "created-by-sub": "sub-123" },
639
+ };
640
+
641
+ const localPath = path.join(tmpRoot, "authored.md");
642
+ const result = await downloadFile(makeCtx(), "authored.md", localPath);
643
+
644
+ expect(result.metadata?.["created-by"]).toBe("alice@example.com");
645
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
646
+ });
647
+
648
+ it("returns the object's user-metadata for a symlink record", async () => {
649
+ const localPath = path.join(tmpRoot, "authored-link.md");
650
+ nextGetObjectResponse = {
651
+ Body: (async function* () {
652
+ yield new Uint8Array();
653
+ })(),
654
+ Metadata: { "hq-symlink-target": "x", "created-by": "bob@example.com" },
655
+ };
656
+
657
+ const result = await downloadFile(makeCtx(), "authored-link.md", localPath);
658
+
659
+ expect(result.metadata?.["created-by"]).toBe("bob@example.com");
660
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
661
+ });
632
662
  });
package/src/s3.ts CHANGED
@@ -279,11 +279,20 @@ export async function uploadSymlink(
279
279
  return { etag: response.ETag || "" };
280
280
  }
281
281
 
282
+ /**
283
+ * Download an object to localPath and return its S3 user-metadata.
284
+ *
285
+ * Materializes regular files and symlink records (the symlink branch
286
+ * reconstructs the link from the body/marker). The GetObject response
287
+ * already carries `response.Metadata` (S3 lowercases keys), so we
288
+ * return it to callers — e.g. the pull loop reads `created-by` to
289
+ * attribute downloaded files to their author with zero extra network.
290
+ */
282
291
  export async function downloadFile(
283
292
  ctx: EntityContext,
284
293
  key: string,
285
294
  localPath: string,
286
- ): Promise<void> {
295
+ ): Promise<{ metadata?: Record<string, string> }> {
287
296
  const client = buildClient(ctx);
288
297
 
289
298
  const response = await client.send(
@@ -362,7 +371,7 @@ export async function downloadFile(
362
371
  }
363
372
  }
364
373
  fs.symlinkSync(symlinkTarget, localPath);
365
- return;
374
+ return { metadata: response.Metadata };
366
375
  }
367
376
 
368
377
  // Symmetric to the symlink branch above: when a key was previously a
@@ -395,6 +404,7 @@ export async function downloadFile(
395
404
  chunks.push(Buffer.from(chunk));
396
405
  }
397
406
  fs.writeFileSync(localPath, Buffer.concat(chunks));
407
+ return { metadata: response.Metadata };
398
408
  }
399
409
 
400
410
  export interface RemoteFile {