@indigoai-us/hq-cloud 5.32.0 → 5.34.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.
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +53 -27
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +69 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +60 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +129 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +104 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +260 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +469 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +7 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +19 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/lib/conflict-file.d.ts +7 -6
- package/dist/lib/conflict-file.d.ts.map +1 -1
- package/dist/lib/conflict-file.js +7 -27
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/lib/conflict.test.d.ts +4 -3
- package/dist/lib/conflict.test.d.ts.map +1 -1
- package/dist/lib/conflict.test.js +5 -33
- package/dist/lib/conflict.test.js.map +1 -1
- package/dist/lib/machine-id.d.ts +108 -0
- package/dist/lib/machine-id.d.ts.map +1 -0
- package/dist/lib/machine-id.js +170 -0
- package/dist/lib/machine-id.js.map +1 -0
- package/dist/lib/machine-id.test.d.ts +8 -0
- package/dist/lib/machine-id.test.d.ts.map +1 -0
- package/dist/lib/machine-id.test.js +195 -0
- package/dist/lib/machine-id.test.js.map +1 -0
- package/dist/s3.d.ts +21 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +69 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +129 -2
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +85 -0
- package/src/bin/sync-runner.ts +62 -25
- package/src/cli/share.test.ts +115 -6
- package/src/cli/share.ts +149 -9
- package/src/cli/sync.test.ts +529 -0
- package/src/cli/sync.ts +295 -8
- package/src/ignore.test.ts +20 -3
- package/src/ignore.ts +7 -1
- package/src/lib/conflict-file.ts +7 -27
- package/src/lib/conflict.test.ts +4 -40
- package/src/lib/machine-id.test.ts +221 -0
- package/src/lib/machine-id.ts +175 -0
- package/src/s3.test.ts +142 -2
- package/src/s3.ts +71 -2
|
@@ -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
|
+
};
|
package/src/s3.test.ts
CHANGED
|
@@ -115,12 +115,21 @@ describe("uploadFile", () => {
|
|
|
115
115
|
fs.writeFileSync(tmpFile, "hello");
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it("omits Metadata when no author is provided (back-compat)", async () => {
|
|
118
|
+
it("omits author Metadata fields when no author is provided (back-compat)", async () => {
|
|
119
|
+
// Pre-Bug-#5: this test asserted Metadata was undefined entirely. Now
|
|
120
|
+
// \`hq-mode\` is stamped on every upload to preserve source-side
|
|
121
|
+
// permissions, so the assertion is narrower: author fields stay absent
|
|
122
|
+
// when no author is passed, but \`hq-mode\` is present.
|
|
119
123
|
await uploadFile(makeCtx(), tmpFile, "attribution-test.md");
|
|
120
124
|
|
|
121
125
|
const put = sentCommands.find((c) => c.name === "PutObjectCommand");
|
|
122
126
|
expect(put).toBeDefined();
|
|
123
|
-
|
|
127
|
+
const meta = put!.input.Metadata as Record<string, string> | undefined;
|
|
128
|
+
expect(meta?.["created-by"]).toBeUndefined();
|
|
129
|
+
expect(meta?.["created-by-sub"]).toBeUndefined();
|
|
130
|
+
expect(meta?.["created-at"]).toBeUndefined();
|
|
131
|
+
// \`hq-mode\` IS present — that's the new Bug #5 contract.
|
|
132
|
+
expect(meta?.["hq-mode"]).toMatch(/^[0-7]{3}$/);
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
it("stamps created-by + created-by-sub + created-at when author is provided", async () => {
|
|
@@ -179,6 +188,37 @@ describe("uploadFile", () => {
|
|
|
179
188
|
expect(Date.now() - stamped).toBeLessThan(60 * 1000);
|
|
180
189
|
});
|
|
181
190
|
|
|
191
|
+
it("stamps source file mode as hq-mode metadata (Bug #5 — preserve permissions)", async () => {
|
|
192
|
+
// Bug #5 (broader than originally reported): every uploaded file's mode
|
|
193
|
+
// collapsed to the receiver's umask default (0644 on the verification
|
|
194
|
+
// hosts). 0755 scripts arrived non-executable, breaking every shell-tool
|
|
195
|
+
// workflow. Fix: stamp the source-side \`mode & 0o777\` into S3 user
|
|
196
|
+
// metadata as \`hq-mode\` (octal string), then chmod on download.
|
|
197
|
+
fs.chmodSync(tmpFile, 0o755);
|
|
198
|
+
|
|
199
|
+
await uploadFile(makeCtx(), tmpFile, "exec.sh");
|
|
200
|
+
|
|
201
|
+
const put = sentCommands.find((c) => c.name === "PutObjectCommand");
|
|
202
|
+
expect(put).toBeDefined();
|
|
203
|
+
const meta = put!.input.Metadata as Record<string, string> | undefined;
|
|
204
|
+
expect(meta?.["hq-mode"]).toBe("755");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("stamps various modes correctly (0600, 0640, 0700, 0750)", async () => {
|
|
208
|
+
// Verification report V5: all four modes collapsed to 0644 receiver-side
|
|
209
|
+
// because the upload carried no mode at all. Pin each mode round-trips
|
|
210
|
+
// through the metadata header in canonical octal-string form (no leading
|
|
211
|
+
// zero — the parser uses parseInt(..., 8)).
|
|
212
|
+
for (const mode of [0o600, 0o640, 0o700, 0o750]) {
|
|
213
|
+
sentCommands.length = 0;
|
|
214
|
+
fs.chmodSync(tmpFile, mode);
|
|
215
|
+
await uploadFile(makeCtx(), tmpFile, "f.bin");
|
|
216
|
+
const put = sentCommands.find((c) => c.name === "PutObjectCommand");
|
|
217
|
+
const meta = put!.input.Metadata as Record<string, string> | undefined;
|
|
218
|
+
expect(meta?.["hq-mode"]).toBe(mode.toString(8));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
182
222
|
it("elides non-ASCII or empty author fields rather than throwing", async () => {
|
|
183
223
|
// S3 user-defined metadata must be ASCII-only and total ≤ 2KB. Partial
|
|
184
224
|
// attribution beats hard failure — values that fail the printable check
|
|
@@ -630,6 +670,106 @@ describe("downloadFile", () => {
|
|
|
630
670
|
expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
|
|
631
671
|
});
|
|
632
672
|
|
|
673
|
+
it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
|
|
674
|
+
// Round-trip pair to the s3.upload test: source-side mode lives in
|
|
675
|
+
// \`Metadata['hq-mode']\` as an octal string; the receiver must chmod
|
|
676
|
+
// the file to that exact mode after writing the bytes. Pre-fix the
|
|
677
|
+
// receiver took the umask default and 0755 scripts arrived 0644.
|
|
678
|
+
nextGetObjectResponse = {
|
|
679
|
+
Body: (async function* () {
|
|
680
|
+
yield new Uint8Array([35, 33, 47, 98, 105, 110]); // "#!/bin"
|
|
681
|
+
})(),
|
|
682
|
+
Metadata: { "hq-mode": "755" },
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const localPath = path.join(tmpRoot, "exec.sh");
|
|
686
|
+
await downloadFile(makeCtx(), "exec.sh", localPath);
|
|
687
|
+
|
|
688
|
+
// mask to permission bits — the upper bits encode file-type (S_IFREG)
|
|
689
|
+
// and are not preserved on chmod.
|
|
690
|
+
const stat = fs.statSync(localPath);
|
|
691
|
+
expect((stat.mode & 0o777).toString(8)).toBe("755");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("rounds-trips multiple modes (0600/0640/0700/0750/0755) via hq-mode", async () => {
|
|
695
|
+
// Verification report V5 multi-mode pin: every mode collapsed to 0644
|
|
696
|
+
// because the receiver had no mode signal at all. With \`hq-mode\` in
|
|
697
|
+
// metadata, all five modes must arrive at their exact source value.
|
|
698
|
+
for (const octal of ["600", "640", "700", "750", "755"]) {
|
|
699
|
+
nextGetObjectResponse = {
|
|
700
|
+
Body: (async function* () {
|
|
701
|
+
yield new Uint8Array([97]); // "a"
|
|
702
|
+
})(),
|
|
703
|
+
Metadata: { "hq-mode": octal },
|
|
704
|
+
};
|
|
705
|
+
const localPath = path.join(tmpRoot, `mode-${octal}.bin`);
|
|
706
|
+
await downloadFile(makeCtx(), `mode-${octal}.bin`, localPath);
|
|
707
|
+
expect((fs.statSync(localPath).mode & 0o777).toString(8)).toBe(octal);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("rejects malformed hq-mode metadata via strict-octal regex (Codex P2)", async () => {
|
|
712
|
+
// Codex review on PR #24 caught: parseInt(modeOctal, 8) accepts
|
|
713
|
+
// partial-prefix garbage — "755junk" parses to 0o755 instead of
|
|
714
|
+
// NaN — so tampered or malformed metadata could still chmod the
|
|
715
|
+
// local file. Strict regex MUST reject anything that isn't pure
|
|
716
|
+
// octal digits before parseInt sees it; the file then arrives at
|
|
717
|
+
// umask default like the legacy back-compat path.
|
|
718
|
+
const malformed = [
|
|
719
|
+
"755junk", // trailing garbage — parseInt parses 0o755 pre-fix
|
|
720
|
+
"0x755", // hex-looking prefix
|
|
721
|
+
"8", // out-of-octal-range digit
|
|
722
|
+
"9", // ditto
|
|
723
|
+
"-755", // signed
|
|
724
|
+
"abc", // non-numeric
|
|
725
|
+
"", // empty
|
|
726
|
+
"7777", // mode > 0o777 after parse
|
|
727
|
+
"12345", // too long (more than 4 octal digits)
|
|
728
|
+
" 755 ", // whitespace
|
|
729
|
+
];
|
|
730
|
+
for (const bad of malformed) {
|
|
731
|
+
nextGetObjectResponse = {
|
|
732
|
+
Body: (async function* () {
|
|
733
|
+
yield new Uint8Array([116]); // "t"
|
|
734
|
+
})(),
|
|
735
|
+
Metadata: { "hq-mode": bad },
|
|
736
|
+
};
|
|
737
|
+
const localPath = path.join(tmpRoot, `bad-${malformed.indexOf(bad)}.bin`);
|
|
738
|
+
await downloadFile(makeCtx(), `bad-${malformed.indexOf(bad)}.bin`, localPath);
|
|
739
|
+
// Mode MUST be the umask default (whatever the test process inherits),
|
|
740
|
+
// NOT a partial-parse of the malformed string. Specifically, even if
|
|
741
|
+
// the malformed string would partial-parse to 0755, the file must NOT
|
|
742
|
+
// arrive at 0755 — it must take the umask default. We can't pin the
|
|
743
|
+
// exact default value (test runner umask varies), but we can pin that
|
|
744
|
+
// a partial-parse value (0o755) does NOT match for "755junk" cases.
|
|
745
|
+
const mode = (fs.statSync(localPath).mode & 0o777);
|
|
746
|
+
// Heuristic: if the parsed mode would have been 0o755, fail loudly.
|
|
747
|
+
// (umask default on most CI runners is 0o644 or 0o664, never 0o755.)
|
|
748
|
+
if (bad.startsWith("755") || bad === "0x755") {
|
|
749
|
+
expect(mode).not.toBe(0o755);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("downloads with default umask permissions when hq-mode metadata is absent (back-compat)", async () => {
|
|
755
|
+
// Legacy uploads from pre-fix engines have no \`hq-mode\` metadata.
|
|
756
|
+
// The receiver must NOT crash and must NOT change the mode — let
|
|
757
|
+
// the OS default apply, mirroring the pre-fix behavior.
|
|
758
|
+
nextGetObjectResponse = {
|
|
759
|
+
Body: (async function* () {
|
|
760
|
+
yield new Uint8Array([108, 101, 103, 97, 99, 121]); // "legacy"
|
|
761
|
+
})(),
|
|
762
|
+
Metadata: {},
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const localPath = path.join(tmpRoot, "legacy.bin");
|
|
766
|
+
await expect(
|
|
767
|
+
downloadFile(makeCtx(), "legacy.bin", localPath),
|
|
768
|
+
).resolves.toBeDefined();
|
|
769
|
+
// No assertion on mode — receiver default is whatever umask set.
|
|
770
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("legacy");
|
|
771
|
+
});
|
|
772
|
+
|
|
633
773
|
it("returns the object's user-metadata (including created-by) for a regular file", async () => {
|
|
634
774
|
nextGetObjectResponse = {
|
|
635
775
|
Body: (async function* () {
|
package/src/s3.ts
CHANGED
|
@@ -160,6 +160,28 @@ export function decodeSymlinkMetadataValue(value: string): string {
|
|
|
160
160
|
*/
|
|
161
161
|
export const SYMLINK_BODY_PREFIX = "hq-symlink:";
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* S3 user-metadata key carrying the source-side file mode (permission bits
|
|
165
|
+
* only — \`mode & 0o777\`) as an octal string ("755", "640", etc.). On
|
|
166
|
+
* download, downloadFile parses this with \`parseInt(value, 8)\` and chmods
|
|
167
|
+
* the file to the exact source mode after the byte write.
|
|
168
|
+
*
|
|
169
|
+
* Bug #5 in the 5.33.0 deep-test was originally reported as "exec bit lost
|
|
170
|
+
* on sync" but the verification report broadened it: ALL modes (0600 / 0640
|
|
171
|
+
* / 0700 / 0750 / 0755) collapsed to the receiver's umask default (0644)
|
|
172
|
+
* because no mode signal crossed the wire at all. Stamping the mode in
|
|
173
|
+
* metadata is the smallest schema change that preserves the full
|
|
174
|
+
* permission bitfield without a per-host umask negotiation.
|
|
175
|
+
*
|
|
176
|
+
* Symlinks: skipped at upload time (symlink mode is OS-controlled
|
|
177
|
+
* lrwxrwxrwx) and skipped on download (\`fs.chmodSync\` follows symlinks
|
|
178
|
+
* and would mutate the target's mode instead).
|
|
179
|
+
*
|
|
180
|
+
* Back-compat: legacy uploads have no \`hq-mode\` header — the receiver
|
|
181
|
+
* leaves the umask default in place, matching pre-fix behavior.
|
|
182
|
+
*/
|
|
183
|
+
export const FILE_MODE_META_KEY = "hq-mode";
|
|
184
|
+
|
|
163
185
|
/**
|
|
164
186
|
* Encode/decode the symlink wire body. Kept as exported helpers so the
|
|
165
187
|
* format is centrally defined and tests can probe both sides without
|
|
@@ -178,6 +200,20 @@ export async function uploadFile(
|
|
|
178
200
|
const client = buildClient(ctx);
|
|
179
201
|
const body = fs.readFileSync(localPath);
|
|
180
202
|
|
|
203
|
+
// Capture source-side file mode (permission bits only) for Bug #5 — see
|
|
204
|
+
// FILE_MODE_META_KEY doc. Best-effort: lstat failure (raced rm, EPERM)
|
|
205
|
+
// falls through to "no mode header" and the receiver keeps its umask
|
|
206
|
+
// default — same as the legacy back-compat path.
|
|
207
|
+
let modeOctal: string | undefined;
|
|
208
|
+
try {
|
|
209
|
+
const lstat = fs.lstatSync(localPath);
|
|
210
|
+
if (!lstat.isSymbolicLink()) {
|
|
211
|
+
modeOctal = (lstat.mode & 0o777).toString(8);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// Leave modeOctal undefined; receiver applies its umask default.
|
|
215
|
+
}
|
|
216
|
+
|
|
181
217
|
// Preserve the original `created-at` across re-uploads when the object
|
|
182
218
|
// already exists with author metadata — same convention the hq-console
|
|
183
219
|
// upload route uses, so the NEW-pill ageing window doesn't reset on every
|
|
@@ -198,7 +234,10 @@ export async function uploadFile(
|
|
|
198
234
|
}
|
|
199
235
|
}
|
|
200
236
|
|
|
201
|
-
const Metadata
|
|
237
|
+
const Metadata: Record<string, string> = {
|
|
238
|
+
...(author ? buildAuthorMetadata(author, createdAt) : {}),
|
|
239
|
+
...(modeOctal ? { [FILE_MODE_META_KEY]: modeOctal } : {}),
|
|
240
|
+
};
|
|
202
241
|
|
|
203
242
|
const response = await client.send(
|
|
204
243
|
new PutObjectCommand({
|
|
@@ -206,7 +245,7 @@ export async function uploadFile(
|
|
|
206
245
|
Key: key,
|
|
207
246
|
Body: body,
|
|
208
247
|
ContentType: getMimeType(key),
|
|
209
|
-
...(
|
|
248
|
+
...(Object.keys(Metadata).length > 0 ? { Metadata } : {}),
|
|
210
249
|
}),
|
|
211
250
|
);
|
|
212
251
|
|
|
@@ -404,6 +443,36 @@ export async function downloadFile(
|
|
|
404
443
|
chunks.push(Buffer.from(chunk));
|
|
405
444
|
}
|
|
406
445
|
fs.writeFileSync(localPath, Buffer.concat(chunks));
|
|
446
|
+
|
|
447
|
+
// Bug #5 — apply source-side mode after the byte write. See
|
|
448
|
+
// FILE_MODE_META_KEY for the metadata contract. Parses defensively:
|
|
449
|
+
// a malformed value falls through with no chmod so the umask default
|
|
450
|
+
// applies, matching the legacy back-compat path. fs.chmodSync
|
|
451
|
+
// follows symlinks — that's fine here because we're on the regular-
|
|
452
|
+
// file branch (the symlink branch above already returned).
|
|
453
|
+
//
|
|
454
|
+
// Codex P2 (PR #24 round 3): strict octal-only regex BEFORE parseInt.
|
|
455
|
+
// parseInt(modeOctal, 8) accepts partial-prefix garbage — "755junk"
|
|
456
|
+
// parses to 0o755 instead of NaN — so tampered or malformed metadata
|
|
457
|
+
// could still change local permissions unexpectedly. The regex
|
|
458
|
+
// requires 1–4 pure octal digits (`[0-7]{1,4}$`), which matches what
|
|
459
|
+
// the upload side stamps (`(mode & 0o777).toString(8)` → at most
|
|
460
|
+
// three digits, all 0–7) and rejects everything else.
|
|
461
|
+
const modeOctal = response.Metadata?.[FILE_MODE_META_KEY];
|
|
462
|
+
if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
|
|
463
|
+
const parsed = parseInt(modeOctal, 8);
|
|
464
|
+
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
|
|
465
|
+
try {
|
|
466
|
+
fs.chmodSync(localPath, parsed);
|
|
467
|
+
} catch {
|
|
468
|
+
// chmod failure (read-only FS, EPERM) is non-fatal — the file
|
|
469
|
+
// is materialized, just with the umask default. Surface via
|
|
470
|
+
// S3-side metadata being present but the file not matching;
|
|
471
|
+
// a future operator-side audit can reconcile.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
407
476
|
return { metadata: response.Metadata };
|
|
408
477
|
}
|
|
409
478
|
|