@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
package/src/cli/sync.ts CHANGED
@@ -182,10 +182,36 @@ export interface SyncOptions {
182
182
  /**
183
183
  * When true, the caller is syncing against the caller's person-entity
184
184
  * bucket. Pulled keys whose path starts with `companies/` are dropped
185
- * (belt-and-braces — the person bucket should never contain those,
186
- * 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.
187
188
  */
188
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>;
189
215
  /**
190
216
  * Override for the per-slug journal file name. Defaults to `ctx.slug`.
191
217
  * sync-runner passes `journalSlug: "personal"` for the personal slot so
@@ -267,6 +293,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
267
293
  companyRoot,
268
294
  shouldSync,
269
295
  options.personalMode === true,
296
+ options.includeLocalCompanies === true,
297
+ options.teamSyncedSlugs ?? null,
270
298
  );
271
299
 
272
300
  emit({
@@ -337,7 +365,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
337
365
  if (resolution !== "abort" && resolution !== "overwrite") {
338
366
  try {
339
367
  const detectedAt = new Date().toISOString();
340
- const machineId = readShortMachineId();
368
+ const machineId = readShortMachineId(hqRoot);
341
369
  const originalRelative = path.relative(hqRoot, localPath);
342
370
  const conflictRelative = buildConflictPath(
343
371
  originalRelative,
@@ -613,6 +641,8 @@ function computePullPlan(
613
641
  companyRoot: string,
614
642
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
615
643
  personalMode: boolean,
644
+ includeLocalCompanies: boolean,
645
+ teamSyncedSlugs: ReadonlySet<string> | null,
616
646
  ): PullPlan {
617
647
  const items: PullPlanItem[] = [];
618
648
 
@@ -620,8 +650,30 @@ function computePullPlan(
620
650
  const localPath = path.join(companyRoot, remoteFile.key);
621
651
 
622
652
  if (personalMode && remoteFile.key.startsWith("companies/")) {
623
- items.push({ action: "skip-personal-mode", remoteFile, localPath });
624
- 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
+ }
625
677
  }
626
678
 
627
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.
@@ -7,38 +7,18 @@
7
7
  * surface their own conflicts without name collisions, and lets the user
8
8
  * (or the `/resolve-conflicts` HQ skill) see local + cloud side-by-side
9
9
  * in their file browser.
10
+ *
11
+ * Machine-id provisioning lives in `./machine-id.ts` — hq-cloud owns the
12
+ * source-of-truth file `<hqRoot>/.hq/machine-id` so every sync host
13
+ * (including Linux outposts with no menubar app) gets a stable id. This
14
+ * module re-exports `readShortMachineId` for back-compat with existing
15
+ * callers; new callers should import directly from `./machine-id.js`.
10
16
  */
11
17
 
12
18
  import * as fs from "fs";
13
- import * as os from "os";
14
19
  import * as path from "path";
15
20
 
16
- /**
17
- * Path to `~/.hq/menubar.json`. Evaluated lazily at call time (not module
18
- * load) so that tests overriding `HOME` after import — and any future code
19
- * that changes the user's effective home dir at runtime — see the right
20
- * file. Going through `os.homedir()` rather than `process.env.HOME` keeps
21
- * the Windows USERPROFILE fallback intact.
22
- */
23
- function menubarJsonPath(): string {
24
- return path.join(os.homedir(), ".hq", "menubar.json");
25
- }
26
-
27
- /**
28
- * Read the short machine ID (first 6 chars) from `~/.hq/menubar.json`.
29
- * Falls back to "unknown" if the file is missing/unreadable — conflict
30
- * files should still be written even when machine identity is unclear.
31
- */
32
- export function readShortMachineId(): string {
33
- try {
34
- const raw = fs.readFileSync(menubarJsonPath(), "utf-8");
35
- const parsed = JSON.parse(raw);
36
- const id = typeof parsed.machineId === "string" ? parsed.machineId : "";
37
- return id.slice(0, 6) || "unknown";
38
- } catch {
39
- return "unknown";
40
- }
41
- }
21
+ export { readShortMachineId, getOrCreateMachineId } from "./machine-id.js";
42
22
 
43
23
  /**
44
24
  * Build the conflict file path for an original. ISO uses `-` instead of
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Tests for the pure conflict primitives — path building, machine-id
3
- * fallback, atomic index writes, dedup. Kept in one file so the related
4
- * helpers stay co-located.
2
+ * Tests for the pure conflict primitives — path building, atomic index
3
+ * writes, dedup. The machine-id resolver moved to `./machine-id.ts` (see
4
+ * `./machine-id.test.ts`) when hq-cloud took ownership of the provisioning
5
+ * step from the menubar app.
5
6
  */
6
7
 
7
8
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
@@ -11,7 +12,6 @@ import * as path from "path";
11
12
  import {
12
13
  buildConflictPath,
13
14
  buildConflictId,
14
- readShortMachineId,
15
15
  } from "./conflict-file.js";
16
16
  import {
17
17
  appendConflictEntry,
@@ -56,42 +56,6 @@ describe("buildConflictId", () => {
56
56
  });
57
57
  });
58
58
 
59
- describe("readShortMachineId", () => {
60
- let originalHome: string | undefined;
61
- let tmpHome: string;
62
-
63
- beforeEach(() => {
64
- originalHome = process.env.HOME;
65
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-machineid-"));
66
- process.env.HOME = tmpHome;
67
- });
68
-
69
- afterEach(() => {
70
- if (originalHome) process.env.HOME = originalHome;
71
- else delete process.env.HOME;
72
- fs.rmSync(tmpHome, { recursive: true, force: true });
73
- });
74
-
75
- it("returns the first 6 chars when menubar.json has a machineId", () => {
76
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
77
- fs.writeFileSync(
78
- path.join(tmpHome, ".hq", "menubar.json"),
79
- JSON.stringify({ machineId: "deadbeefcafe1234567890" }),
80
- );
81
- expect(readShortMachineId()).toBe("deadbe");
82
- });
83
-
84
- it("falls back to 'unknown' when menubar.json is missing", () => {
85
- expect(readShortMachineId()).toBe("unknown");
86
- });
87
-
88
- it("falls back to 'unknown' when menubar.json is malformed", () => {
89
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
90
- fs.writeFileSync(path.join(tmpHome, ".hq", "menubar.json"), "{not-json");
91
- expect(readShortMachineId()).toBe("unknown");
92
- });
93
- });
94
-
95
59
  describe("conflict index", () => {
96
60
  let tmpHq: string;
97
61
 
@@ -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,221 @@
1
+ /**
2
+ * Machine-ID resolver tests. Pins the four-tier fallback contract so a
3
+ * regression in tier ordering, the migration-forward behavior, or the
4
+ * "unknown sentinel is no longer reachable" invariant is caught at build
5
+ * time rather than re-litigating it on a user's Lightsail outpost.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ import { getOrCreateMachineId, readShortMachineId } from "./machine-id.js";
13
+
14
+ function freshTmp(prefix: string): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
16
+ }
17
+
18
+ describe("getOrCreateMachineId (four-tier resolver)", () => {
19
+ let originalHome: string | undefined;
20
+ let originalEnvId: string | undefined;
21
+ let tmpHome: string;
22
+ let tmpHqRoot: string;
23
+
24
+ beforeEach(() => {
25
+ originalHome = process.env.HOME;
26
+ originalEnvId = process.env.HQ_MACHINE_ID;
27
+ delete process.env.HQ_MACHINE_ID;
28
+ tmpHome = freshTmp("hq-machineid-home-");
29
+ tmpHqRoot = freshTmp("hq-machineid-root-");
30
+ process.env.HOME = tmpHome;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (originalHome) process.env.HOME = originalHome;
35
+ else delete process.env.HOME;
36
+ if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
37
+ else delete process.env.HQ_MACHINE_ID;
38
+ fs.rmSync(tmpHome, { recursive: true, force: true });
39
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
40
+ });
41
+
42
+ // ── tier 1: HQ_MACHINE_ID env override ────────────────────────────────
43
+ it("tier 1: returns HQ_MACHINE_ID env when set, ignoring lower tiers", () => {
44
+ process.env.HQ_MACHINE_ID = "env-override-id";
45
+ // Even if a persisted file exists, env wins.
46
+ fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
47
+ fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "persisted\n");
48
+ expect(getOrCreateMachineId(tmpHqRoot)).toBe("env-override-id");
49
+ // Env-only resolution must not clobber the on-disk source-of-truth.
50
+ expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(
51
+ "persisted",
52
+ );
53
+ });
54
+
55
+ // ── tier 2: <hqRoot>/.hq/machine-id ───────────────────────────────────
56
+ it("tier 2: returns the trimmed contents of <hqRoot>/.hq/machine-id", () => {
57
+ fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
58
+ fs.writeFileSync(
59
+ path.join(tmpHqRoot, ".hq", "machine-id"),
60
+ " abc-123-persisted \n\n",
61
+ );
62
+ expect(getOrCreateMachineId(tmpHqRoot)).toBe("abc-123-persisted");
63
+ });
64
+
65
+ it("tier 2: empty machine-id file falls through to autogen", () => {
66
+ fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
67
+ fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "");
68
+ const id = getOrCreateMachineId(tmpHqRoot);
69
+ // UUID v4 shape.
70
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
71
+ // Persisted on disk for next call.
72
+ expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(id);
73
+ });
74
+
75
+ // ── tier 3: ~/.hq/menubar.json (legacy, migrated forward) ─────────────
76
+ it("tier 3: reads menubar.json AND migrates the value into <hqRoot>/.hq/machine-id", () => {
77
+ fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
78
+ fs.writeFileSync(
79
+ path.join(tmpHome, ".hq", "menubar.json"),
80
+ JSON.stringify({ machineId: "menubar-legacy-id" }),
81
+ );
82
+ expect(getOrCreateMachineId(tmpHqRoot)).toBe("menubar-legacy-id");
83
+ // Migrated forward — subsequent calls now hit tier 2.
84
+ expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(
85
+ "menubar-legacy-id",
86
+ );
87
+ });
88
+
89
+ it("tier 3: malformed menubar.json falls through to autogen", () => {
90
+ fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
91
+ fs.writeFileSync(path.join(tmpHome, ".hq", "menubar.json"), "{not-json");
92
+ const id = getOrCreateMachineId(tmpHqRoot);
93
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
94
+ });
95
+
96
+ it("tier 3: menubar.json without a machineId field falls through to autogen", () => {
97
+ fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
98
+ fs.writeFileSync(
99
+ path.join(tmpHome, ".hq", "menubar.json"),
100
+ JSON.stringify({ telemetryEnabled: true }),
101
+ );
102
+ const id = getOrCreateMachineId(tmpHqRoot);
103
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
104
+ });
105
+
106
+ // ── tier 4: autogen + persist ─────────────────────────────────────────
107
+ it("tier 4: generates a UUID and persists it for the next call", () => {
108
+ const id = getOrCreateMachineId(tmpHqRoot);
109
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
110
+ expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(id);
111
+ // Stable across calls.
112
+ expect(getOrCreateMachineId(tmpHqRoot)).toBe(id);
113
+ });
114
+
115
+ it("tier 4: stays in-process even if hqRoot is read-only (best-effort persist)", () => {
116
+ // Pre-create .hq as a regular dir, then strip write perms.
117
+ fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
118
+ fs.chmodSync(path.join(tmpHqRoot, ".hq"), 0o500); // r-x only
119
+ try {
120
+ const id = getOrCreateMachineId(tmpHqRoot);
121
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
122
+ } finally {
123
+ fs.chmodSync(path.join(tmpHqRoot, ".hq"), 0o700);
124
+ }
125
+ });
126
+
127
+ // ── invariant: "unknown" sentinel is unreachable ──────────────────────
128
+ it("never returns the legacy 'unknown' sentinel — every host gets a real id", () => {
129
+ // No env, no persisted file, no menubar.json — pure tier-4 path.
130
+ const id = getOrCreateMachineId(tmpHqRoot);
131
+ expect(id).not.toBe("unknown");
132
+ expect(id.length).toBeGreaterThan(6);
133
+ });
134
+ });
135
+
136
+ describe("readShortMachineId", () => {
137
+ let originalHome: string | undefined;
138
+ let originalEnvId: string | undefined;
139
+ let tmpHome: string;
140
+ let tmpHqRoot: string;
141
+
142
+ beforeEach(() => {
143
+ originalHome = process.env.HOME;
144
+ originalEnvId = process.env.HQ_MACHINE_ID;
145
+ delete process.env.HQ_MACHINE_ID;
146
+ tmpHome = freshTmp("hq-machineid-short-home-");
147
+ tmpHqRoot = freshTmp("hq-machineid-short-root-");
148
+ process.env.HOME = tmpHome;
149
+ });
150
+
151
+ afterEach(() => {
152
+ if (originalHome) process.env.HOME = originalHome;
153
+ else delete process.env.HOME;
154
+ if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
155
+ else delete process.env.HQ_MACHINE_ID;
156
+ fs.rmSync(tmpHome, { recursive: true, force: true });
157
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
158
+ });
159
+
160
+ it("returns the first 6 chars when the resolved id has a hex prefix", () => {
161
+ process.env.HQ_MACHINE_ID = "deadbeefcafe1234567890";
162
+ expect(readShortMachineId(tmpHqRoot)).toBe("deadbe");
163
+ });
164
+
165
+ it("returns the first 6 chars of an autogenerated UUID", () => {
166
+ const short = readShortMachineId(tmpHqRoot);
167
+ expect(short).toHaveLength(6);
168
+ expect(short).toMatch(/^[0-9a-f]{6}$/);
169
+ expect(short).not.toBe("unknow"); // legacy "unknown" prefix — must not reappear
170
+ });
171
+
172
+ it("reads the same hex prefix from <hqRoot>/.hq/machine-id when persisted", () => {
173
+ fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
174
+ fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "abcdef-rest-can-be-anything");
175
+ expect(readShortMachineId(tmpHqRoot)).toBe("abcdef");
176
+ });
177
+
178
+ // ── normalization invariant: short token is ALWAYS [a-f0-9]{6} ────────
179
+ //
180
+ // Regression coverage for the Codex-flagged P2 — without this, a
181
+ // non-hex `HQ_MACHINE_ID` or legacy menubar value (e.g. "ci-runner-42",
182
+ // "menubar-legacy-id") would slice to a non-hex 6-char prefix, the
183
+ // conflict filename would carry that non-hex token, and the
184
+ // `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts` (which only accepts
185
+ // `[a-f0-9]+` or the literal `unknown`) would refuse it, restoring the
186
+ // exact litter-ratchet loop this module exists to close.
187
+ it.each([
188
+ // Tier-1 env override with non-hex characters.
189
+ ["ci-runner-42"],
190
+ ["env-override-id"],
191
+ // Tier-3 legacy menubar value with non-hex characters.
192
+ ["menubar-legacy-id"],
193
+ // Mixed-case that contains non-hex letters in the first 6 chars.
194
+ ["Gabc12-rest"],
195
+ // First 6 chars are hex but contain uppercase (regex is case-sensitive).
196
+ ["ABCDEF-rest"],
197
+ ])("normalizes non-hex source ids to a hex token: %s", (sourceId) => {
198
+ process.env.HQ_MACHINE_ID = sourceId;
199
+ const short = readShortMachineId(tmpHqRoot);
200
+ expect(short).toMatch(/^[a-f0-9]{6}$/);
201
+ expect(short).toHaveLength(6);
202
+ });
203
+
204
+ it("normalization is deterministic — same source id always yields same short token", () => {
205
+ process.env.HQ_MACHINE_ID = "menubar-legacy-id";
206
+ const a = readShortMachineId(tmpHqRoot);
207
+ const b = readShortMachineId(tmpHqRoot);
208
+ expect(a).toBe(b);
209
+ expect(a).toMatch(/^[a-f0-9]{6}$/);
210
+ });
211
+
212
+ it("normalization distinguishes different source ids", () => {
213
+ process.env.HQ_MACHINE_ID = "menubar-legacy-id";
214
+ const a = readShortMachineId(tmpHqRoot);
215
+ process.env.HQ_MACHINE_ID = "ci-runner-42";
216
+ const b = readShortMachineId(tmpHqRoot);
217
+ expect(a).not.toBe(b);
218
+ expect(a).toMatch(/^[a-f0-9]{6}$/);
219
+ expect(b).toMatch(/^[a-f0-9]{6}$/);
220
+ });
221
+ });