@indigoai-us/hq-cloud 5.32.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.
@@ -2454,6 +2454,23 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2454
2454
  ["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
2455
2455
  // Non-markdown extensions also valid (sh scripts, ts files, etc.).
2456
2456
  ["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
2457
+ // ── extensionless originals (regression: `path.extname('.gitignore')`
2458
+ // returns '' in Node, so `buildConflictPath` produces no trailing
2459
+ // `.<ext>` segment for hidden-but-extensionless files).
2460
+ [".gitignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2461
+ [".hqignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2462
+ [".agents/skills.conflict-2026-05-19T17-07-01Z-0a513b", true],
2463
+ // ── legacy "unknown" machine token (regression: hosts without
2464
+ // `~/.hq/menubar.json` pre-Fix-3 fell through to the literal string
2465
+ // `"unknown"`, which `[a-f0-9]+` refused. Producer side is closed in
2466
+ // `../lib/machine-id.ts`, but the regex still must filter the
2467
+ // already-on-disk legacy files so the next push removes them).
2468
+ [".gitignore.conflict-2026-05-15T15-10-35Z-unknown", true],
2469
+ [".agents/skills.conflict-2026-05-15T15-10-35Z-unknown", true],
2470
+ ["notes.md.conflict-2026-05-15T15-10-35Z-unknown.md", true],
2471
+ [".hq/install-manifest.json.conflict-2026-05-15T15-11-58Z-unknown.json", true],
2472
+ // Multi-dot extension (e.g., archive tarballs that conflicted).
2473
+ ["dump.conflict-2026-05-13T19-40-40Z-abc.tar.gz", true],
2457
2474
  ])("matches conflict mirror: %s", (p, expected) => {
2458
2475
  expect(isEphemeralPath(p)).toBe(expected);
2459
2476
  });
@@ -2467,17 +2484,20 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2467
2484
  ["conflict-resolution.md", false],
2468
2485
  ["my-conflict.md", false],
2469
2486
  ["foo.conflict-handler.md", false],
2470
- // Date-shaped but missing the trailing dot + extension (real conflicts
2471
- // always carry a file extension; the trailing `\.` in the pattern is the
2472
- // safety against bare-substring false positives).
2473
- ["foo.conflict-2026-05-13T19-40-40Z-abc", false],
2474
- // Wrong-case or non-hex machine hash.
2487
+ // Wrong-case or non-hex/non-"unknown" machine hash.
2475
2488
  ["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
2476
2489
  // Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
2477
2490
  ["foo.conflict-2026-05-13-abc123.md", false],
2478
- // Missing leading dot before "conflict" (this protects against legitimate
2491
+ // Missing leading dot before "conflict" (protects against legitimate
2479
2492
  // files that happen to contain the word "conflict" mid-name).
2480
2493
  ["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
2494
+ // Extra trailing segments after the machine hash — the `$` anchor +
2495
+ // `[^/]*` ext class ensure a conflict marker can't appear mid-path.
2496
+ ["foo.conflict-2026-05-13T19-40-40Z-abc/extra/path", false],
2497
+ ["foo.conflict-2026-05-13T19-40-40Z-abc-then-more-text", false],
2498
+ // Bare "unknown"-like tokens that aren't the literal sentinel.
2499
+ ["foo.conflict-2026-05-13T19-40-40Z-unknowing.md", false],
2500
+ ["foo.conflict-2026-05-13T19-40-40Z-UNKNOWN.md", false],
2481
2501
  ])("rejects non-mirror: %s", (p, expected) => {
2482
2502
  expect(isEphemeralPath(p)).toBe(expected);
2483
2503
  });
package/src/cli/share.ts CHANGED
@@ -32,8 +32,11 @@ import type { SyncProgressEvent } from "./sync.js";
32
32
  /**
33
33
  * Local-only ephemeral artifacts: conflict-mirror files written by the pull
34
34
  * leg whenever a 3-way merge keeps local AND wants to preserve the remote
35
- * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>.<ext>`
36
- * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`).
35
+ * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
36
+ * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
37
+ * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
38
+ * originals produce no trailing dot, see `buildConflictPath` in
39
+ * `../lib/conflict-file.ts`).
37
40
  *
38
41
  * These files MUST never round-trip to S3 — they're local-only safety backups
39
42
  * the user reviews and deletes once the merge is resolved. Pre-fix, the push
@@ -42,13 +45,34 @@ import type { SyncProgressEvent } from "./sync.js";
42
45
  * deleted them locally (because pull-confirmation had stamped them as
43
46
  * `direction: "down"`). Net effect: a permanent litter ratchet on remote.
44
47
  *
48
+ * Two known producer-shapes the regex must accommodate (both observed on
49
+ * affected user trees prior to this fix):
50
+ *
51
+ * 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
52
+ * provisioning (see `../lib/machine-id.ts`), hosts without
53
+ * `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
54
+ * install — fell through to the literal string `"unknown"` from the
55
+ * old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
56
+ * live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
57
+ * filenames. They round-tripped to S3 as ordinary files (which IS the
58
+ * "permanent litter ratchet" this module's contract was supposed to
59
+ * prevent). The new machine-id provisioning closes the producer side,
60
+ * but we still accept `unknown` here so legacy files already on disk
61
+ * are filtered out by the next push.
62
+ *
63
+ * 2. **Extensionless originals.** `path.extname('.gitignore')` returns
64
+ * `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
65
+ * segment for hidden-but-extensionless files like `.gitignore`,
66
+ * `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
67
+ * tail was mandatory, so those names slipped through.
68
+ *
45
69
  * Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
46
70
  * they never upload; (2) `computeDeletePlan` — skip these so an already-
47
71
  * journaled mirror that's been deleted locally doesn't get included in the
48
72
  * regular delete plan (the dedicated reconcile path handles existing litter).
49
73
  */
50
74
  const EPHEMERAL_PATH_PATTERN =
51
- /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]+\./;
75
+ /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
52
76
 
53
77
  /**
54
78
  * Cheap pure check — pass the relative key OR a basename; either works. Used
package/src/cli/sync.ts CHANGED
@@ -365,7 +365,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
365
365
  if (resolution !== "abort" && resolution !== "overwrite") {
366
366
  try {
367
367
  const detectedAt = new Date().toISOString();
368
- const machineId = readShortMachineId();
368
+ const machineId = readShortMachineId(hqRoot);
369
369
  const originalRelative = path.relative(hqRoot, localPath);
370
370
  const conflictRelative = buildConflictPath(
371
371
  originalRelative,
@@ -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,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
+ });
@@ -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
+ };