@indigoai-us/hq-cli 5.345.80 → 5.345.81
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/CHANGELOG.md +7 -0
- package/dist/command-catalog.generated.d.ts +142 -0
- package/dist/command-catalog.generated.js +173 -0
- package/dist/command-help.js +2 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/storage-core.d.ts +98 -0
- package/dist/commands/storage-core.js +223 -0
- package/dist/commands/storage-gate.d.ts +35 -0
- package/dist/commands/storage-gate.js +85 -0
- package/dist/commands/storage-offload.d.ts +342 -0
- package/dist/commands/storage-offload.js +943 -0
- package/dist/commands/storage.d.ts +199 -0
- package/dist/commands/storage.js +931 -0
- package/dist/fast-scaffold.js +5 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +5140 -3412
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/cli-flag-keys.d.ts +6 -0
- package/dist/lib/cli-flag-keys.js +6 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/storage-feature-flag.d.ts +56 -0
- package/dist/lib/storage-feature-flag.js +104 -0
- package/dist/main.js +12 -1
- package/dist/utils/resident-package-preload.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq storage offload` / `hq storage fetch` — move big files to the caller's
|
|
3
|
+
* PERSONAL vault (S3 Standard, content-addressed) and leave small linked
|
|
4
|
+
* pointers behind. Shared contract: owner-approved offload design, 2026-10-09.
|
|
5
|
+
*
|
|
6
|
+
* Two kinds of big file:
|
|
7
|
+
* - History copies (old versions inside the HQ root git history). The CLI
|
|
8
|
+
* uploads each blob, verifies size + sha256 + git blob id, then hands only
|
|
9
|
+
* verified blobs to core `hq-storage.sh strip-large`, which rewrites history
|
|
10
|
+
* to the pointer text. Working-tree files are never touched.
|
|
11
|
+
* - Current files (idle big files in the working tree). NOT offloaded yet:
|
|
12
|
+
* the sync delete planner lives in @indigoai-us/hq-cloud (`computePullPlan`
|
|
13
|
+
* and the push delete planner in dist/cli/sync.js), so replacing `x.mp4`
|
|
14
|
+
* with `x.mp4.hqcloud` would still propagate as a remote delete of `x.mp4`.
|
|
15
|
+
* `--current` reports `{ available: false }` until hq-cloud learns about
|
|
16
|
+
* placeholders (CURRENT_OFFLOAD_ENABLED). `offloadCurrentFile` is built
|
|
17
|
+
* and tested but not called.
|
|
18
|
+
*
|
|
19
|
+
* Fetch also reads placeholders that HQ sync rewrote after a teammate changed
|
|
20
|
+
* the file (`vault: company`, the company vault key, `sha256: unknown`,
|
|
21
|
+
* `remote-etag`). Those download from the company vault, only for an active
|
|
22
|
+
* member, and verify by size and ETag. A placeholder marked `orphaned: true`
|
|
23
|
+
* (a teammate deleted the file) is refused unless `--force-orphan` is given,
|
|
24
|
+
* which writes the caller's own personal-vault copy to a file outside HQ so
|
|
25
|
+
* it can never sync back.
|
|
26
|
+
*
|
|
27
|
+
* Tenant isolation: bytes only ever go to the caller's personal vault. The
|
|
28
|
+
* personal store refuses a company vault bucket, and nothing here lists or
|
|
29
|
+
* opens company memberships.
|
|
30
|
+
*/
|
|
31
|
+
import * as crypto from "node:crypto";
|
|
32
|
+
import * as fs from "node:fs";
|
|
33
|
+
import * as os from "node:os";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import { spawn, execFile } from "node:child_process";
|
|
36
|
+
import { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
|
|
37
|
+
import { checkPreconditions, coreHasFlag, coreSupports, dropExpiredBackups, PRECONDITION_FALLBACK, coreKeepsUndo, reclaimFields, reclaimWindowHours, runCoreScript, } from "./storage-core.js";
|
|
38
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
39
|
+
export const PLACEHOLDER_SUFFIX = ".hqcloud";
|
|
40
|
+
export const OFFLOAD_PREFIX = ".hq-offload/blobs/";
|
|
41
|
+
export const POINTER_HEADER = "HQ-CLOUD-FILE v1";
|
|
42
|
+
export const POINTER_FOOTER = "This file is stored in HQ cloud storage. Open it in HQ, or run the command above to download it.";
|
|
43
|
+
export const DEFAULT_MIN_BYTES = 50 * 1024 * 1024;
|
|
44
|
+
export const DEFAULT_IDLE_DAYS = 14;
|
|
45
|
+
export const CURRENT_BLOCKED_REASON = "Offloading current files is not available yet: HQ sync would treat the replaced file as deleted.";
|
|
46
|
+
/**
|
|
47
|
+
* Gate for `--current`. Stays false until the owner enables current-file
|
|
48
|
+
* offload on top of hq-cloud's cloud-only placeholders (PR #919, 6.18.61).
|
|
49
|
+
*/
|
|
50
|
+
export const CURRENT_OFFLOAD_ENABLED = false;
|
|
51
|
+
export const ORPHANED_MESSAGE = "This file was deleted by a teammate. It's no longer in the cloud for your team.";
|
|
52
|
+
const UPDATE_HQ = "update HQ";
|
|
53
|
+
export const UPDATE_SYNC = "update HQ sync";
|
|
54
|
+
const PERSONAL_BUCKET_PREFIX = "hq-vault-prs-";
|
|
55
|
+
/** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
|
|
56
|
+
export const OFFLOAD_TOP_LEVEL = ".hq-offload";
|
|
57
|
+
/**
|
|
58
|
+
* Whether personal-vault sync skips `.hq-offload/`. Without that exclusion a
|
|
59
|
+
* later `hq sync` pulls every offloaded blob back to disk (and to every other
|
|
60
|
+
* machine), undoing the savings. Pass hq-cloud's
|
|
61
|
+
* `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
|
|
62
|
+
*
|
|
63
|
+
* @indigoai-us/hq-cloud 6.18.61 (PR #919) lists ".hq-offload" in
|
|
64
|
+
* PERSONAL_VAULT_EXCLUDED_TOP_LEVEL; storage.ts passes that export here. The
|
|
65
|
+
* check stays at runtime so an older bundled HQ sync still reports
|
|
66
|
+
* "update HQ sync". CURRENT_OFFLOAD_ENABLED is a separate owner decision.
|
|
67
|
+
*/
|
|
68
|
+
export function syncExcludesOffload(excludedTopLevel) {
|
|
69
|
+
return Array.isArray(excludedTopLevel) && excludedTopLevel.includes(OFFLOAD_TOP_LEVEL);
|
|
70
|
+
}
|
|
71
|
+
export function offloadKey(gitBlobSha) {
|
|
72
|
+
return `${OFFLOAD_PREFIX}${gitBlobSha}`;
|
|
73
|
+
}
|
|
74
|
+
// ── Size parsing ────────────────────────────────────────────────────────────
|
|
75
|
+
const UNITS = {
|
|
76
|
+
"": 1,
|
|
77
|
+
b: 1,
|
|
78
|
+
kb: 1024,
|
|
79
|
+
k: 1024,
|
|
80
|
+
mb: 1024 ** 2,
|
|
81
|
+
m: 1024 ** 2,
|
|
82
|
+
gb: 1024 ** 3,
|
|
83
|
+
g: 1024 ** 3,
|
|
84
|
+
};
|
|
85
|
+
export function parseSize(value) {
|
|
86
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value);
|
|
87
|
+
const unit = m ? UNITS[m[2].toLowerCase()] : undefined;
|
|
88
|
+
if (!m || unit === undefined) {
|
|
89
|
+
throw new Error(`Size must look like 50MB, 1GB or a byte count, got '${value}'.`);
|
|
90
|
+
}
|
|
91
|
+
return Math.round(Number(m[1]) * unit);
|
|
92
|
+
}
|
|
93
|
+
export function resolveMinBytes(flag, env) {
|
|
94
|
+
if (flag !== undefined)
|
|
95
|
+
return parseSize(flag);
|
|
96
|
+
const fromEnv = env.HQ_OFFLOAD_MIN_BYTES;
|
|
97
|
+
if (fromEnv !== undefined && fromEnv.trim() !== "")
|
|
98
|
+
return parseSize(fromEnv);
|
|
99
|
+
return DEFAULT_MIN_BYTES;
|
|
100
|
+
}
|
|
101
|
+
export function renderPointer(p) {
|
|
102
|
+
const lines = [POINTER_HEADER, `path: ${p.path}`, `size: ${p.size}`, `sha256: ${p.sha256 ?? "unknown"}`];
|
|
103
|
+
if (p.gitBlob)
|
|
104
|
+
lines.push(`git-blob: ${p.gitBlob}`);
|
|
105
|
+
lines.push(`vault: ${p.vault}`, `key: ${p.key}`);
|
|
106
|
+
if (p.offloadedAt)
|
|
107
|
+
lines.push(`offloaded-at: ${p.offloadedAt}`);
|
|
108
|
+
if (p.remoteEtag)
|
|
109
|
+
lines.push(`remote-etag: ${p.remoteEtag}`);
|
|
110
|
+
if (p.orphaned)
|
|
111
|
+
lines.push("orphaned: true");
|
|
112
|
+
lines.push(`retrieve: hq storage fetch "${p.path}"`, POINTER_FOOTER, "");
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
/** ETags compare without the surrounding quotes S3 returns. */
|
|
116
|
+
export function normalizeEtag(etag) {
|
|
117
|
+
return (etag ?? "").trim().replace(/^W\//, "").replace(/^"|"$/g, "");
|
|
118
|
+
}
|
|
119
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
120
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
121
|
+
/**
|
|
122
|
+
* Parse pointer text. Returns null for anything that is not a valid v1 pointer.
|
|
123
|
+
*
|
|
124
|
+
* Two shapes are valid:
|
|
125
|
+
* - an offload copy: `vault: personal`, key = offloadKey(git-blob), with a
|
|
126
|
+
* git blob id to verify against;
|
|
127
|
+
* - a vault object recorded by sync: either vault, a non-offload key, and a
|
|
128
|
+
* `remote-etag` to verify against (sha256 may be `unknown`).
|
|
129
|
+
*/
|
|
130
|
+
export function parsePointer(text) {
|
|
131
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
132
|
+
if (lines[0]?.trim() !== POINTER_HEADER)
|
|
133
|
+
return null;
|
|
134
|
+
const fields = new Map();
|
|
135
|
+
for (const line of lines.slice(1)) {
|
|
136
|
+
const m = /^([a-z0-9-]+): ?(.*)$/.exec(line);
|
|
137
|
+
if (m && !fields.has(m[1]))
|
|
138
|
+
fields.set(m[1], m[2].trim());
|
|
139
|
+
}
|
|
140
|
+
const sizeText = fields.get("size") ?? "";
|
|
141
|
+
const shaText = (fields.get("sha256") ?? "").toLowerCase();
|
|
142
|
+
const vault = fields.get("vault");
|
|
143
|
+
const gitBlob = fields.get("git-blob");
|
|
144
|
+
const remoteEtag = normalizeEtag(fields.get("remote-etag"));
|
|
145
|
+
const p = {
|
|
146
|
+
path: fields.get("path") ?? "",
|
|
147
|
+
size: Number(sizeText),
|
|
148
|
+
sha256: shaText === "unknown" ? null : shaText,
|
|
149
|
+
vault: vault === "company" ? "company" : "personal",
|
|
150
|
+
key: fields.get("key") ?? "",
|
|
151
|
+
};
|
|
152
|
+
if (gitBlob)
|
|
153
|
+
p.gitBlob = gitBlob;
|
|
154
|
+
const offloadedAt = fields.get("offloaded-at");
|
|
155
|
+
if (offloadedAt)
|
|
156
|
+
p.offloadedAt = offloadedAt;
|
|
157
|
+
if (remoteEtag)
|
|
158
|
+
p.remoteEtag = remoteEtag;
|
|
159
|
+
if (fields.get("orphaned") === "true")
|
|
160
|
+
p.orphaned = true;
|
|
161
|
+
if (!p.path ||
|
|
162
|
+
!p.key ||
|
|
163
|
+
!/^\d+$/.test(sizeText) ||
|
|
164
|
+
!Number.isSafeInteger(p.size) ||
|
|
165
|
+
(p.sha256 !== null && !HEX64.test(p.sha256)) ||
|
|
166
|
+
(vault !== "personal" && vault !== "company") ||
|
|
167
|
+
(p.gitBlob !== undefined && !HEX40.test(p.gitBlob))) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const isOffloadCopy = p.gitBlob !== undefined && p.key === offloadKey(p.gitBlob);
|
|
171
|
+
if (isOffloadCopy)
|
|
172
|
+
return p.vault === "personal" ? p : null;
|
|
173
|
+
if (p.key.startsWith(OFFLOAD_PREFIX) || !p.remoteEtag)
|
|
174
|
+
return null;
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
177
|
+
/** sha256 and git blob id (sha1 of "blob <size>\0" + bytes) in one pass. */
|
|
178
|
+
export async function digestFile(file) {
|
|
179
|
+
const size = (await fs.promises.stat(file)).size;
|
|
180
|
+
const sha256 = crypto.createHash("sha256");
|
|
181
|
+
const sha1 = crypto.createHash("sha1");
|
|
182
|
+
sha1.update(`blob ${size}\0`);
|
|
183
|
+
for await (const chunk of fs.createReadStream(file)) {
|
|
184
|
+
sha256.update(chunk);
|
|
185
|
+
sha1.update(chunk);
|
|
186
|
+
}
|
|
187
|
+
return { size, sha256: sha256.digest("hex"), gitBlob: sha1.digest("hex") };
|
|
188
|
+
}
|
|
189
|
+
function isNotFound(err) {
|
|
190
|
+
const e = err;
|
|
191
|
+
return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* S3-backed store over the caller's personal vault bucket. Accepts only
|
|
195
|
+
* personal vault buckets (`hq-vault-prs-*`) so a mis-resolved entity can never
|
|
196
|
+
* receive offloaded bytes.
|
|
197
|
+
*/
|
|
198
|
+
export function s3BlobStore(bucket, s3) {
|
|
199
|
+
if (!bucket || !bucket.startsWith(PERSONAL_BUCKET_PREFIX)) {
|
|
200
|
+
throw new Error(`Refusing to offload into '${bucket}': offload only writes to your personal vault.`);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
vault: "personal",
|
|
204
|
+
async head(key) {
|
|
205
|
+
try {
|
|
206
|
+
const res = (await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })));
|
|
207
|
+
const fromChecksum = res.ChecksumSHA256 && !res.ChecksumSHA256.includes("-")
|
|
208
|
+
? Buffer.from(res.ChecksumSHA256, "base64").toString("hex")
|
|
209
|
+
: undefined;
|
|
210
|
+
// The server-computed checksum wins; client metadata is a fallback for
|
|
211
|
+
// objects stored without one.
|
|
212
|
+
return { size: res.ContentLength ?? -1, sha256: fromChecksum ?? res.Metadata?.sha256 };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (isNotFound(err))
|
|
216
|
+
return null;
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
async put(key, file, digest) {
|
|
221
|
+
await s3.send(new PutObjectCommand({
|
|
222
|
+
Bucket: bucket,
|
|
223
|
+
Key: key,
|
|
224
|
+
Body: fs.createReadStream(file),
|
|
225
|
+
ContentLength: digest.size,
|
|
226
|
+
ChecksumSHA256: Buffer.from(digest.sha256, "hex").toString("base64"),
|
|
227
|
+
StorageClass: "STANDARD",
|
|
228
|
+
Metadata: { sha256: digest.sha256, "git-blob": digest.gitBlob },
|
|
229
|
+
}));
|
|
230
|
+
},
|
|
231
|
+
async download(key, dest) {
|
|
232
|
+
const res = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
|
|
233
|
+
const body = res.Body;
|
|
234
|
+
if (!body)
|
|
235
|
+
throw new Error(`Empty download for ${key}.`);
|
|
236
|
+
await new Promise((resolve, reject) => {
|
|
237
|
+
const out = fs.createWriteStream(dest, { flags: "wx" });
|
|
238
|
+
body.on("error", reject);
|
|
239
|
+
out.on("error", reject);
|
|
240
|
+
out.on("finish", () => resolve());
|
|
241
|
+
body.pipe(out);
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
export function s3VaultObjectStore(bucket, s3) {
|
|
247
|
+
return {
|
|
248
|
+
async download(key, dest, etag) {
|
|
249
|
+
let res;
|
|
250
|
+
try {
|
|
251
|
+
res = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key, IfMatch: `"${normalizeEtag(etag)}"` })));
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
const e = err;
|
|
255
|
+
if (e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412) {
|
|
256
|
+
throw new Error("The cloud copy of this file changed since HQ sync last saw it. Run `hq sync`, then try again.");
|
|
257
|
+
}
|
|
258
|
+
if (isNotFound(err))
|
|
259
|
+
throw new Error(`This file is no longer in the cloud (${key}).`);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
const body = res.Body;
|
|
263
|
+
if (!body)
|
|
264
|
+
throw new Error(`Empty download for ${key}.`);
|
|
265
|
+
await new Promise((resolve, reject) => {
|
|
266
|
+
const out = fs.createWriteStream(dest, { flags: "wx" });
|
|
267
|
+
body.on("error", reject);
|
|
268
|
+
out.on("error", reject);
|
|
269
|
+
out.on("finish", () => resolve());
|
|
270
|
+
body.pipe(out);
|
|
271
|
+
});
|
|
272
|
+
return normalizeEtag(res.ETag);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// ── Core script capability ──────────────────────────────────────────────────
|
|
277
|
+
export function storageScriptPath(hqRoot) {
|
|
278
|
+
return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
|
|
279
|
+
}
|
|
280
|
+
/** Whether the installed core script has a `case` arm for `sub`. */
|
|
281
|
+
export function scriptSupports(hqRoot, sub) {
|
|
282
|
+
return coreSupports(storageScriptPath(hqRoot), sub);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Run a core subcommand. Callers check scriptSupports first, so a missing
|
|
286
|
+
* subcommand is already reported as "update HQ"; any failure here passes the
|
|
287
|
+
* script's own stderr through instead of guessing.
|
|
288
|
+
*/
|
|
289
|
+
function runCore(deps, args) {
|
|
290
|
+
return runCoreScript(deps.runScript, storageScriptPath(deps.hqRoot), args);
|
|
291
|
+
}
|
|
292
|
+
export async function getLarge(deps, minBytes) {
|
|
293
|
+
if (!scriptSupports(deps.hqRoot, "large")) {
|
|
294
|
+
return { available: false, reason: UPDATE_HQ, blobs: [], current: [], worktree_holders: [] };
|
|
295
|
+
}
|
|
296
|
+
const raw = await runCore(deps, ["large", "--json", "--min-bytes", String(minBytes)]);
|
|
297
|
+
if (raw.available === false) {
|
|
298
|
+
return { available: false, reason: String(raw.reason), blobs: [], current: [], worktree_holders: [] };
|
|
299
|
+
}
|
|
300
|
+
const history = (raw.history ?? {});
|
|
301
|
+
const current = (raw.current ?? {});
|
|
302
|
+
const blobs = (Array.isArray(history.blobs) ? history.blobs : [])
|
|
303
|
+
.map((b) => b)
|
|
304
|
+
.filter((b) => typeof b.sha === "string" && HEX40.test(b.sha) && typeof b.bytes === "number")
|
|
305
|
+
.map((b) => ({
|
|
306
|
+
...b,
|
|
307
|
+
paths: Array.isArray(b.paths) ? b.paths : [],
|
|
308
|
+
in_head: b.in_head === true,
|
|
309
|
+
held_by: Array.isArray(b.held_by) ? b.held_by.filter((p) => typeof p === "string") : [],
|
|
310
|
+
}));
|
|
311
|
+
const holders = (Array.isArray(history.worktree_holders) ? history.worktree_holders : [])
|
|
312
|
+
.map((h) => h)
|
|
313
|
+
.filter((h) => typeof h.path === "string")
|
|
314
|
+
.map((h) => ({ path: h.path, branch: typeof h.branch === "string" ? h.branch : null }));
|
|
315
|
+
const files = (Array.isArray(current.files) ? current.files : [])
|
|
316
|
+
.map((f) => f)
|
|
317
|
+
.filter((f) => typeof f.path === "string" && typeof f.bytes === "number")
|
|
318
|
+
.map((f) => ({ ...f, idle_days: typeof f.idle_days === "number" ? f.idle_days : 0 }));
|
|
319
|
+
return { available: true, blobs, current: files, worktree_holders: holders };
|
|
320
|
+
}
|
|
321
|
+
/** Eligible history blobs split into ones offload can free and ones a linked worktree keeps. */
|
|
322
|
+
export function splitHeld(blobs) {
|
|
323
|
+
return { free: blobs.filter((b) => b.held_by.length === 0), held: blobs.filter((b) => b.held_by.length > 0) };
|
|
324
|
+
}
|
|
325
|
+
/** Distinct worktree paths that hold any of `held`, in first-seen order. */
|
|
326
|
+
export function heldWorktrees(held) {
|
|
327
|
+
return [...new Set(held.flatMap((b) => b.held_by))];
|
|
328
|
+
}
|
|
329
|
+
/** Plain refusal when every eligible old file is still used by a linked worktree. */
|
|
330
|
+
export function worktreeHoldsAllCopy(held) {
|
|
331
|
+
const paths = heldWorktrees(held);
|
|
332
|
+
const names = paths.slice(0, 5).map((p) => `worktree ${p}`).join(", ") + (paths.length > 5 ? `, and ${paths.length - 5} more` : "");
|
|
333
|
+
return (`Nothing was uploaded or changed. ${held.length} old big file version(s) are still used by ` +
|
|
334
|
+
`${paths.length === 1 ? "another worktree" : `${paths.length} other worktrees`} (${names}), so moving them would free no space. ` +
|
|
335
|
+
"HQ never changes other worktrees; the space can be freed once they no longer use this history.");
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* History blobs eligible for offload: at least `minBytes`, and not part of the
|
|
339
|
+
* current checkout. Blobs in HEAD are current files; stripping them would
|
|
340
|
+
* leave the working tree out of step with HEAD.
|
|
341
|
+
*/
|
|
342
|
+
export function selectHistoryBlobs(blobs, minBytes) {
|
|
343
|
+
return blobs.filter((b) => !b.in_head && b.bytes >= minBytes && b.paths.length > 0);
|
|
344
|
+
}
|
|
345
|
+
// ── Upload + verify ─────────────────────────────────────────────────────────
|
|
346
|
+
export function matchesDigest(obj, digest) {
|
|
347
|
+
return obj !== null && obj.size === digest.size && obj.sha256 === digest.sha256;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Export, hash, upload and verify one history blob. Resolves only when the
|
|
351
|
+
* stored object matches size and sha256. Dedupes on the content-addressed key:
|
|
352
|
+
* an already-verified object is not re-uploaded.
|
|
353
|
+
*/
|
|
354
|
+
export async function uploadBlob(blob, store, deps) {
|
|
355
|
+
const tmp = path.join(deps.tmpDir(), `hq-offload-${blob.sha}-${process.pid}`);
|
|
356
|
+
try {
|
|
357
|
+
await deps.exportBlob(blob.sha, tmp);
|
|
358
|
+
const digest = await digestFile(tmp);
|
|
359
|
+
if (digest.gitBlob !== blob.sha) {
|
|
360
|
+
throw new Error(`git blob ${blob.sha} exported with id ${digest.gitBlob}`);
|
|
361
|
+
}
|
|
362
|
+
return { digest, uploaded: await uploadVerified(store, tmp, digest) };
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await fs.promises.rm(tmp, { force: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Upload `file` to its content-addressed key and verify size + sha256.
|
|
370
|
+
* Returns false when a verified copy was already there.
|
|
371
|
+
*/
|
|
372
|
+
async function uploadVerified(store, file, digest) {
|
|
373
|
+
const key = offloadKey(digest.gitBlob);
|
|
374
|
+
if (matchesDigest(await store.head(key), digest))
|
|
375
|
+
return false;
|
|
376
|
+
await store.put(key, file, digest);
|
|
377
|
+
const stored = await store.head(key);
|
|
378
|
+
if (!matchesDigest(stored, digest)) {
|
|
379
|
+
throw new Error(`upload verification failed for ${digest.gitBlob} (stored size ${stored?.size ?? "missing"}, expected ${digest.size})`);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
export const durablePlaceholderFs = {
|
|
384
|
+
async writePlaceholder(file, text) {
|
|
385
|
+
const fh = await fs.promises.open(file, "wx");
|
|
386
|
+
try {
|
|
387
|
+
await fh.writeFile(text, "utf8");
|
|
388
|
+
await fh.sync();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await fh.close();
|
|
392
|
+
}
|
|
393
|
+
// Persist the directory entry too, so a crash cannot keep the delete of
|
|
394
|
+
// the original while losing the placeholder. Windows cannot fsync a
|
|
395
|
+
// directory handle.
|
|
396
|
+
if (process.platform !== "win32") {
|
|
397
|
+
const dir = await fs.promises.open(path.dirname(file), "r");
|
|
398
|
+
try {
|
|
399
|
+
await dir.sync();
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
await dir.close();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
async removeOriginal(file) {
|
|
407
|
+
await fs.promises.rm(file);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Offload one current file: upload and verify, write `x.hqcloud` (fsynced),
|
|
412
|
+
* and only then remove `x`. With the placeholder written first, a sync
|
|
413
|
+
* watcher never sees `x` missing without its placeholder, which it would
|
|
414
|
+
* push as a delete.
|
|
415
|
+
*
|
|
416
|
+
* Not called while CURRENT_OFFLOAD_ENABLED is false.
|
|
417
|
+
*/
|
|
418
|
+
export async function offloadCurrentFile(rel, store, deps, fsOps = durablePlaceholderFs) {
|
|
419
|
+
if (store.vault !== "personal")
|
|
420
|
+
throw new Error("Offload only writes to your personal vault.");
|
|
421
|
+
assertRestorable(rel);
|
|
422
|
+
const abs = containedPath(deps.hqRoot, rel);
|
|
423
|
+
const before = await fs.promises.stat(abs);
|
|
424
|
+
if (!before.isFile())
|
|
425
|
+
throw new Error(`'${rel}' is not a regular file.`);
|
|
426
|
+
const digest = await digestFile(abs);
|
|
427
|
+
await uploadVerified(store, abs, digest);
|
|
428
|
+
const unchanged = async () => {
|
|
429
|
+
const st = await fs.promises.stat(abs);
|
|
430
|
+
return st.size === before.size && st.mtimeMs === before.mtimeMs && st.size === digest.size;
|
|
431
|
+
};
|
|
432
|
+
const changed = new Error(`'${rel}' changed while uploading. Nothing was replaced.`);
|
|
433
|
+
if (!(await unchanged()))
|
|
434
|
+
throw changed;
|
|
435
|
+
const placeholder = `${abs}${PLACEHOLDER_SUFFIX}`;
|
|
436
|
+
await fsOps.writePlaceholder(placeholder, renderPointer({
|
|
437
|
+
path: toPosix(rel),
|
|
438
|
+
size: digest.size,
|
|
439
|
+
sha256: digest.sha256,
|
|
440
|
+
gitBlob: digest.gitBlob,
|
|
441
|
+
vault: "personal",
|
|
442
|
+
key: offloadKey(digest.gitBlob),
|
|
443
|
+
offloadedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
444
|
+
}));
|
|
445
|
+
if (!(await unchanged())) {
|
|
446
|
+
await fs.promises.rm(placeholder, { force: true });
|
|
447
|
+
throw changed;
|
|
448
|
+
}
|
|
449
|
+
await fsOps.removeOriginal(abs);
|
|
450
|
+
return { path: toPosix(rel), bytes: digest.size };
|
|
451
|
+
}
|
|
452
|
+
export async function runOffload(opts, deps) {
|
|
453
|
+
const both = !opts.history && !opts.current;
|
|
454
|
+
const doHistory = both || opts.history;
|
|
455
|
+
const doCurrent = both || opts.current;
|
|
456
|
+
const result = {
|
|
457
|
+
history: { uploaded: 0, bytes: 0, freed_bytes: 0 },
|
|
458
|
+
current: { offloaded: [], freed_bytes: 0 },
|
|
459
|
+
errors: [],
|
|
460
|
+
dry_run: opts.dryRun,
|
|
461
|
+
};
|
|
462
|
+
if (doCurrent) {
|
|
463
|
+
result.current.available = false;
|
|
464
|
+
result.current.reason = CURRENT_BLOCKED_REASON;
|
|
465
|
+
}
|
|
466
|
+
const script = storageScriptPath(deps.hqRoot);
|
|
467
|
+
if (!doHistory)
|
|
468
|
+
return result;
|
|
469
|
+
if (!deps.syncExcludesOffload()) {
|
|
470
|
+
result.history.available = false;
|
|
471
|
+
result.history.reason = UPDATE_SYNC;
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
const large = await getLarge(deps, opts.minBytes);
|
|
475
|
+
if (!large.available) {
|
|
476
|
+
result.history.available = false;
|
|
477
|
+
result.history.reason = large.reason;
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
const eligible = selectHistoryBlobs(large.blobs, opts.minBytes);
|
|
481
|
+
// Old versions a linked worktree still reaches stay on disk whatever offload
|
|
482
|
+
// does, so they are never uploaded, never counted, and the worktrees are named.
|
|
483
|
+
const { free: blobs, held } = splitHeld(eligible);
|
|
484
|
+
result.history.candidates = blobs.length;
|
|
485
|
+
result.history.candidate_bytes = blobs.reduce((s, b) => s + b.bytes, 0);
|
|
486
|
+
if (held.length > 0) {
|
|
487
|
+
result.history.held_by_worktrees = { count: held.length, bytes: held.reduce((s, b) => s + b.bytes, 0) };
|
|
488
|
+
result.history.worktree_holders = heldWorktrees(held);
|
|
489
|
+
}
|
|
490
|
+
// The rewrite's preconditions are checked before anything is uploaded, so a
|
|
491
|
+
// refusal never leaves uploads behind, and a dry run predicts it.
|
|
492
|
+
const refusal = blobs.length > 0 && fs.existsSync(script) ? await checkPreconditions(deps.runScript, script, "strip-large") : undefined;
|
|
493
|
+
if (refusal) {
|
|
494
|
+
result.history.refused = refusal.refused;
|
|
495
|
+
result.history.message = refusal.message;
|
|
496
|
+
if (!opts.dryRun)
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
else if (blobs.length === 0 && held.length > 0) {
|
|
500
|
+
// Same contract as prune: nothing to free is a refusal (exit 4), decided
|
|
501
|
+
// before anything is uploaded; the dry run predicts it.
|
|
502
|
+
result.history.refused = "nothing_to_free";
|
|
503
|
+
result.history.message = worktreeHoldsAllCopy(held);
|
|
504
|
+
if (!opts.dryRun)
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
const noBackup = coreHasFlag(script, "--no-backup");
|
|
508
|
+
if (opts.dryRun) {
|
|
509
|
+
// Dry run fills the real-run fields with estimates so a preview reads the
|
|
510
|
+
// same keys: every candidate uploaded, and history shrinks by its size.
|
|
511
|
+
const planned = refusal ? 0 : result.history.candidate_bytes;
|
|
512
|
+
result.history.uploaded = refusal ? 0 : blobs.length;
|
|
513
|
+
result.history.bytes = planned;
|
|
514
|
+
// When the space comes back:
|
|
515
|
+
// - core that keeps undo history: nothing at once; all of it once git's
|
|
516
|
+
// 30-day undo entries age out (reclaim_after);
|
|
517
|
+
// - older core with --no-backup: at once;
|
|
518
|
+
// - older core without it: after the 24 h backup.
|
|
519
|
+
if (refusal || planned === 0) {
|
|
520
|
+
result.history.freed_bytes = 0;
|
|
521
|
+
Object.assign(result.history, { backup_ref: null, reclaim_after: null, pending_reclaim_bytes: 0 });
|
|
522
|
+
}
|
|
523
|
+
else if (noBackup && !coreKeepsUndo(script)) {
|
|
524
|
+
result.history.freed_bytes = planned;
|
|
525
|
+
Object.assign(result.history, { backup_ref: null, reclaim_after: null, pending_reclaim_bytes: 0 });
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
result.history.freed_bytes = 0;
|
|
529
|
+
Object.assign(result.history, reclaimFields({}, {
|
|
530
|
+
dryRun: true,
|
|
531
|
+
now: deps.now(),
|
|
532
|
+
expectedBytes: planned,
|
|
533
|
+
freedBytes: 0,
|
|
534
|
+
windowHours: reclaimWindowHours(script),
|
|
535
|
+
}));
|
|
536
|
+
}
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
if (blobs.length === 0)
|
|
540
|
+
return result;
|
|
541
|
+
if (!scriptSupports(deps.hqRoot, "strip-large")) {
|
|
542
|
+
result.history.available = false;
|
|
543
|
+
result.history.reason = UPDATE_HQ;
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
if (!opts.yes) {
|
|
547
|
+
const ok = await deps.confirm(`Upload ${blobs.length} old file version(s) to your personal HQ cloud storage and replace them in local history with links?`);
|
|
548
|
+
if (!ok) {
|
|
549
|
+
deps.log("Aborted - nothing was uploaded or changed.");
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const dropped = await dropExpiredBackups(deps.runScript, script);
|
|
554
|
+
if (dropped)
|
|
555
|
+
result.reclaim = dropped;
|
|
556
|
+
const store = await deps.openPersonal();
|
|
557
|
+
if (store.vault !== "personal")
|
|
558
|
+
throw new Error("Offload only writes to your personal vault.");
|
|
559
|
+
const offloadedAt = deps.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
560
|
+
const manifest = [];
|
|
561
|
+
for (const blob of blobs) {
|
|
562
|
+
try {
|
|
563
|
+
const { digest, uploaded } = await uploadBlob(blob, store, deps);
|
|
564
|
+
if (uploaded) {
|
|
565
|
+
result.history.uploaded += 1;
|
|
566
|
+
result.history.bytes += digest.size;
|
|
567
|
+
}
|
|
568
|
+
// One pointer per blob, not per path: strip-large swaps the blob by id,
|
|
569
|
+
// so every path that held it gets the same pointer text. `path:` names
|
|
570
|
+
// the first path; `hq storage fetch --git-blob <sha>` works for all.
|
|
571
|
+
manifest.push({
|
|
572
|
+
sha: blob.sha,
|
|
573
|
+
pointer_text: renderPointer({
|
|
574
|
+
path: blob.paths[0],
|
|
575
|
+
size: digest.size,
|
|
576
|
+
sha256: digest.sha256,
|
|
577
|
+
gitBlob: blob.sha,
|
|
578
|
+
vault: "personal",
|
|
579
|
+
key: offloadKey(blob.sha),
|
|
580
|
+
offloadedAt,
|
|
581
|
+
}),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
result.errors.push(`${blob.paths[0] ?? blob.sha}: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (manifest.length === 0)
|
|
589
|
+
return result;
|
|
590
|
+
const manifestFile = path.join(deps.tmpDir(), `hq-offload-manifest-${process.pid}.json`);
|
|
591
|
+
await fs.promises.writeFile(manifestFile, JSON.stringify({ blobs: manifest }));
|
|
592
|
+
try {
|
|
593
|
+
// strip-large always prints JSON; it takes no --json flag. Every listed
|
|
594
|
+
// blob is already verified in the vault (size, sha256, git id), so no
|
|
595
|
+
// local backup is kept and the disk frees now. Core that predates
|
|
596
|
+
// --no-backup is called without it.
|
|
597
|
+
const args = ["strip-large", "--manifest", manifestFile, "--yes"];
|
|
598
|
+
if (noBackup)
|
|
599
|
+
args.push("--no-backup");
|
|
600
|
+
const strip = await runCore(deps, args);
|
|
601
|
+
if (typeof strip.refused === "string") {
|
|
602
|
+
// Precondition changed between the check and the rewrite. The uploads
|
|
603
|
+
// stay in the vault; local history is unchanged.
|
|
604
|
+
result.history.refused = strip.refused;
|
|
605
|
+
result.history.message = typeof strip.message === "string" ? strip.message : PRECONDITION_FALLBACK;
|
|
606
|
+
}
|
|
607
|
+
else if (strip.available === false) {
|
|
608
|
+
result.errors.push(`history rewrite: ${plainCoreError(String(strip.reason))}`);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
result.history.freed_bytes = typeof strip.freed_bytes === "number" ? strip.freed_bytes : 0;
|
|
612
|
+
const replaced = new Set(manifest.map((m) => m.sha));
|
|
613
|
+
const expected = blobs.filter((b) => replaced.has(b.sha)).reduce((s, b) => s + b.bytes, 0);
|
|
614
|
+
Object.assign(result.history, reclaimFields(strip, {
|
|
615
|
+
dryRun: false,
|
|
616
|
+
now: deps.now(),
|
|
617
|
+
expectedBytes: expected,
|
|
618
|
+
freedBytes: result.history.freed_bytes,
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
await fs.promises.rm(manifestFile, { force: true });
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
/** Core stderr without the script's "hq-storage: " prefix. */
|
|
628
|
+
function plainCoreError(reason) {
|
|
629
|
+
return reason.replace(/^hq-storage:\s*/gm, "").trim();
|
|
630
|
+
}
|
|
631
|
+
// ── fetch ───────────────────────────────────────────────────────────────────
|
|
632
|
+
function toPosix(p) {
|
|
633
|
+
return p.split(path.sep).join("/");
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Resolve `rel` inside HQ, following symlinks: the deepest existing ancestor
|
|
637
|
+
* is realpath'd and must still be inside the realpath'd HQ root, so a
|
|
638
|
+
* symlinked folder cannot redirect a write outside HQ.
|
|
639
|
+
*/
|
|
640
|
+
export function containedPath(hqRoot, rel) {
|
|
641
|
+
const root = path.resolve(hqRoot);
|
|
642
|
+
const abs = path.resolve(root, rel);
|
|
643
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
644
|
+
throw new Error(`Path '${rel}' is outside HQ.`);
|
|
645
|
+
}
|
|
646
|
+
const realRoot = fs.realpathSync(root);
|
|
647
|
+
let existing = abs;
|
|
648
|
+
const rest = [];
|
|
649
|
+
while (!fs.existsSync(existing)) {
|
|
650
|
+
rest.unshift(path.basename(existing));
|
|
651
|
+
existing = path.dirname(existing);
|
|
652
|
+
}
|
|
653
|
+
const real = path.join(fs.realpathSync(existing), ...rest);
|
|
654
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
|
655
|
+
throw new Error(`Path '${rel}' is outside HQ.`);
|
|
656
|
+
}
|
|
657
|
+
return real;
|
|
658
|
+
}
|
|
659
|
+
/** Restore targets that fetch must never write: git internals and agent hooks. */
|
|
660
|
+
export function assertRestorable(rel) {
|
|
661
|
+
const parts = toPosix(path.normalize(rel)).toLowerCase().split("/");
|
|
662
|
+
if (parts.includes(".git") || (parts[0] === ".claude" && parts[1] === "hooks")) {
|
|
663
|
+
throw new Error(`Refusing to restore '${rel}': HQ never writes cloud files into .git/ or .claude/hooks/.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function readPointerFile(file) {
|
|
667
|
+
if (!fs.existsSync(file))
|
|
668
|
+
return null;
|
|
669
|
+
const st = fs.statSync(file);
|
|
670
|
+
if (!st.isFile() || st.size >= 4096)
|
|
671
|
+
return null;
|
|
672
|
+
return parsePointer(fs.readFileSync(file, "utf8"));
|
|
673
|
+
}
|
|
674
|
+
/** Resolve a fetch argument (original path or placeholder path) to its placeholder file. */
|
|
675
|
+
export function resolvePlaceholder(hqRoot, arg) {
|
|
676
|
+
const abs = containedPath(hqRoot, arg);
|
|
677
|
+
const candidate = abs.endsWith(PLACEHOLDER_SUFFIX) ? abs : `${abs}${PLACEHOLDER_SUFFIX}`;
|
|
678
|
+
if (readPointerFile(candidate))
|
|
679
|
+
return candidate;
|
|
680
|
+
throw new Error(`No HQ cloud placeholder found for '${arg}'. For an old version from history, use --git-blob <sha> --out <file>.`);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Download to a fresh partial file, verify, then hard-link it to `dest`.
|
|
684
|
+
* `link` fails if `dest` exists, so a file created between the caller's
|
|
685
|
+
* existence check and the restore is never overwritten. `download` returns
|
|
686
|
+
* the served ETag when the source is pinned to one.
|
|
687
|
+
*/
|
|
688
|
+
async function downloadVerified(download, key, dest, expect) {
|
|
689
|
+
const partial = `${dest}.${crypto.randomBytes(6).toString("hex")}.hqcloud-download`;
|
|
690
|
+
try {
|
|
691
|
+
const servedEtag = await download(partial);
|
|
692
|
+
const digest = await digestFile(partial);
|
|
693
|
+
if ((expect.gitBlob !== undefined && digest.gitBlob !== expect.gitBlob) ||
|
|
694
|
+
(expect.sha256 != null && digest.sha256 !== expect.sha256) ||
|
|
695
|
+
(expect.size !== undefined && digest.size !== expect.size) ||
|
|
696
|
+
(expect.etag !== undefined && normalizeEtag(servedEtag ?? "") !== normalizeEtag(expect.etag))) {
|
|
697
|
+
throw new Error(`Downloaded file does not match its checksum (${key}). Nothing was changed.`);
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
await fs.promises.link(partial, dest);
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
if (err.code === "EEXIST") {
|
|
704
|
+
throw new Error(`'${dest}' already exists. Move it aside first.`);
|
|
705
|
+
}
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
return digest;
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
await fs.promises.rm(partial, { force: true });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/** Company slug for a company-vault placeholder, checked against its key. */
|
|
715
|
+
export function companyForPointer(pointer) {
|
|
716
|
+
const m = /^companies\/([^/]+)\/(.+)$/.exec(pointer.path);
|
|
717
|
+
if (!m || m[2] !== pointer.key) {
|
|
718
|
+
throw new Error(`Placeholder for '${pointer.path}' names company vault key '${pointer.key}', which does not match its location. Nothing was changed.`);
|
|
719
|
+
}
|
|
720
|
+
return m[1];
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Teammate-deleted file: write the caller's own offloaded copy to a file
|
|
724
|
+
* outside HQ. Outside HQ means sync never sees it, so it is never pushed back
|
|
725
|
+
* to the team. The placeholder stays as is.
|
|
726
|
+
*/
|
|
727
|
+
async function fetchOrphan(pointer, opts, deps) {
|
|
728
|
+
if (!opts.out)
|
|
729
|
+
throw new Error("--force-orphan needs --out <file> outside your HQ folder.");
|
|
730
|
+
if (!pointer.gitBlob) {
|
|
731
|
+
throw new Error(`${ORPHANED_MESSAGE} You have no personal copy of it to restore.`);
|
|
732
|
+
}
|
|
733
|
+
const dest = path.resolve(opts.out);
|
|
734
|
+
const realRoot = fs.realpathSync(path.resolve(deps.hqRoot));
|
|
735
|
+
let realParent;
|
|
736
|
+
try {
|
|
737
|
+
realParent = fs.realpathSync(path.dirname(dest));
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
throw new Error(`The folder for '${opts.out}' does not exist.`);
|
|
741
|
+
}
|
|
742
|
+
if (realParent === realRoot || realParent.startsWith(realRoot + path.sep)) {
|
|
743
|
+
throw new Error("--force-orphan writes outside your HQ folder, so the restored copy is never synced to your team. Pick an --out path outside HQ.");
|
|
744
|
+
}
|
|
745
|
+
return fetchGitBlob(pointer.gitBlob, path.join(realParent, path.basename(dest)), deps);
|
|
746
|
+
}
|
|
747
|
+
export async function runFetch(arg, deps, opts = {}) {
|
|
748
|
+
return fetchPlaceholder(arg, deps, opts);
|
|
749
|
+
}
|
|
750
|
+
async function fetchPlaceholder(arg, deps, opts) {
|
|
751
|
+
const placeholder = resolvePlaceholder(deps.hqRoot, arg);
|
|
752
|
+
const pointer = readPointerFile(placeholder);
|
|
753
|
+
if (!pointer)
|
|
754
|
+
throw new Error(`'${arg}' is not a valid HQ cloud placeholder.`);
|
|
755
|
+
if (pointer.orphaned) {
|
|
756
|
+
if (!opts.forceOrphan) {
|
|
757
|
+
throw new Error(`${ORPHANED_MESSAGE} To get your own copy back outside HQ, run: hq storage fetch "${pointer.path}" --force-orphan --out <file>`);
|
|
758
|
+
}
|
|
759
|
+
return fetchOrphan(pointer, opts, deps);
|
|
760
|
+
}
|
|
761
|
+
if (opts.forceOrphan)
|
|
762
|
+
throw new Error("--force-orphan is only for files a teammate deleted.");
|
|
763
|
+
const target = containedPath(deps.hqRoot, pointer.path);
|
|
764
|
+
const realRoot = fs.realpathSync(path.resolve(deps.hqRoot));
|
|
765
|
+
const placeholderRel = toPosix(path.relative(realRoot, placeholder));
|
|
766
|
+
if (`${toPosix(path.relative(realRoot, target))}${PLACEHOLDER_SUFFIX}` !== placeholderRel) {
|
|
767
|
+
throw new Error(`Placeholder '${placeholderRel}' names '${pointer.path}', which is not its own location. Nothing was changed.`);
|
|
768
|
+
}
|
|
769
|
+
assertRestorable(pointer.path);
|
|
770
|
+
if (fs.existsSync(target)) {
|
|
771
|
+
throw new Error(`'${pointer.path}' already exists locally. Move it aside first.`);
|
|
772
|
+
}
|
|
773
|
+
let digest;
|
|
774
|
+
if (pointer.remoteEtag === undefined) {
|
|
775
|
+
// Offload copy in the personal vault, verified by git blob id and sha256.
|
|
776
|
+
const store = await deps.openPersonal();
|
|
777
|
+
digest = await downloadVerified((partial) => store.download(pointer.key, partial), pointer.key, target, {
|
|
778
|
+
gitBlob: pointer.gitBlob,
|
|
779
|
+
sha256: pointer.sha256,
|
|
780
|
+
size: pointer.size,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
digest = await fetchVaultObject(pointer, target, deps);
|
|
785
|
+
}
|
|
786
|
+
await fs.promises.rm(placeholder, { force: true });
|
|
787
|
+
return { path: pointer.path, bytes: digest.size, sha256: digest.sha256 };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Placeholder that sync rewrote to a vault object: download pinned to the
|
|
791
|
+
* recorded ETag and verify size (and sha256 when known). A company vault is
|
|
792
|
+
* opened only for a company the caller is an active member of.
|
|
793
|
+
*/
|
|
794
|
+
async function fetchVaultObject(pointer, target, deps) {
|
|
795
|
+
if (!deps.openVaultObjects)
|
|
796
|
+
throw new Error("This HQ build cannot fetch files recorded by HQ sync.");
|
|
797
|
+
let store;
|
|
798
|
+
if (pointer.vault === "company") {
|
|
799
|
+
const slug = companyForPointer(pointer);
|
|
800
|
+
const active = deps.activeCompanySlugs ? await deps.activeCompanySlugs() : [];
|
|
801
|
+
if (!active.includes(slug)) {
|
|
802
|
+
throw new Error(`You are not an active member of '${slug}', so HQ cannot download this file for you.`);
|
|
803
|
+
}
|
|
804
|
+
store = await deps.openVaultObjects({ vault: "company", slug });
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
if (pointer.key !== pointer.path) {
|
|
808
|
+
throw new Error(`Placeholder for '${pointer.path}' names vault key '${pointer.key}', which does not match its location. Nothing was changed.`);
|
|
809
|
+
}
|
|
810
|
+
store = await deps.openVaultObjects({ vault: "personal" });
|
|
811
|
+
}
|
|
812
|
+
const etag = pointer.remoteEtag;
|
|
813
|
+
return downloadVerified((partial) => store.download(pointer.key, partial, etag), pointer.key, target, {
|
|
814
|
+
sha256: pointer.sha256,
|
|
815
|
+
size: pointer.size,
|
|
816
|
+
etag,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
export async function runFetchGitBlob(sha, out, deps) {
|
|
820
|
+
return fetchGitBlob(sha, out, deps);
|
|
821
|
+
}
|
|
822
|
+
async function fetchGitBlob(sha, out, deps) {
|
|
823
|
+
if (!HEX40.test(sha))
|
|
824
|
+
throw new Error(`--git-blob must be a 40-character git blob id, got '${sha}'.`);
|
|
825
|
+
const dest = path.resolve(out);
|
|
826
|
+
if (fs.existsSync(dest))
|
|
827
|
+
throw new Error(`'${out}' already exists.`);
|
|
828
|
+
const store = await deps.openPersonal();
|
|
829
|
+
const key = offloadKey(sha);
|
|
830
|
+
const head = await store.head(key);
|
|
831
|
+
if (!head)
|
|
832
|
+
throw new Error(`No offloaded copy of git blob ${sha} in your personal vault.`);
|
|
833
|
+
const digest = await downloadVerified((partial) => store.download(key, partial), key, dest, {
|
|
834
|
+
gitBlob: sha,
|
|
835
|
+
sha256: head.sha256,
|
|
836
|
+
size: head.size,
|
|
837
|
+
});
|
|
838
|
+
return { path: dest, bytes: digest.size, sha256: digest.sha256 };
|
|
839
|
+
}
|
|
840
|
+
/** Current-file offload availability, shared by status and its error fallback. */
|
|
841
|
+
export function currentAvailability(syncReady) {
|
|
842
|
+
if (!CURRENT_OFFLOAD_ENABLED)
|
|
843
|
+
return { current_available: false, current_reason: CURRENT_BLOCKED_REASON };
|
|
844
|
+
if (!syncReady)
|
|
845
|
+
return { current_available: false, current_reason: UPDATE_SYNC };
|
|
846
|
+
return { current_available: true };
|
|
847
|
+
}
|
|
848
|
+
export async function getOffloadStatus(deps, opts) {
|
|
849
|
+
const status = {
|
|
850
|
+
history_candidates: { count: 0, bytes: 0 },
|
|
851
|
+
current_candidates: { count: 0, bytes: 0 },
|
|
852
|
+
placeholders: { count: 0, bytes: 0 },
|
|
853
|
+
...currentAvailability(deps.syncExcludesOffload()),
|
|
854
|
+
};
|
|
855
|
+
for (const rel of await deps.listPlaceholders()) {
|
|
856
|
+
const pointer = readPointerFile(containedPath(deps.hqRoot, rel));
|
|
857
|
+
if (!pointer)
|
|
858
|
+
continue;
|
|
859
|
+
status.placeholders.count += 1;
|
|
860
|
+
status.placeholders.bytes += pointer.size;
|
|
861
|
+
}
|
|
862
|
+
const large = await getLarge(deps, opts.minBytes);
|
|
863
|
+
if (!large.available) {
|
|
864
|
+
status.available = false;
|
|
865
|
+
status.reason = large.reason;
|
|
866
|
+
return status;
|
|
867
|
+
}
|
|
868
|
+
if (!deps.syncExcludesOffload()) {
|
|
869
|
+
status.available = false;
|
|
870
|
+
status.reason = UPDATE_SYNC;
|
|
871
|
+
}
|
|
872
|
+
const { free, held } = splitHeld(selectHistoryBlobs(large.blobs, opts.minBytes));
|
|
873
|
+
for (const b of free) {
|
|
874
|
+
status.history_candidates.count += 1;
|
|
875
|
+
status.history_candidates.bytes += b.bytes;
|
|
876
|
+
}
|
|
877
|
+
if (held.length > 0) {
|
|
878
|
+
status.history_held = {
|
|
879
|
+
count: held.length,
|
|
880
|
+
bytes: held.reduce((s, b) => s + b.bytes, 0),
|
|
881
|
+
worktrees: heldWorktrees(held),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
for (const f of large.current) {
|
|
885
|
+
if (f.bytes < opts.minBytes || f.idle_days < opts.idleDays)
|
|
886
|
+
continue;
|
|
887
|
+
status.current_candidates.count += 1;
|
|
888
|
+
status.current_candidates.bytes += f.bytes;
|
|
889
|
+
}
|
|
890
|
+
return status;
|
|
891
|
+
}
|
|
892
|
+
// ── Production deps ─────────────────────────────────────────────────────────
|
|
893
|
+
export function gitExportBlob(hqRoot) {
|
|
894
|
+
return (sha, dest) => new Promise((resolve, reject) => {
|
|
895
|
+
const out = fs.createWriteStream(dest);
|
|
896
|
+
const child = spawn("git", ["-C", hqRoot, "cat-file", "blob", sha], {
|
|
897
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
898
|
+
});
|
|
899
|
+
let stderr = "";
|
|
900
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
901
|
+
child.stdout.pipe(out);
|
|
902
|
+
child.on("error", reject);
|
|
903
|
+
child.on("close", (code) => {
|
|
904
|
+
if (code !== 0) {
|
|
905
|
+
out.destroy();
|
|
906
|
+
reject(new Error(`git cat-file ${sha} failed: ${stderr.trim()}`));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (out.writableFinished)
|
|
910
|
+
resolve();
|
|
911
|
+
else
|
|
912
|
+
out.on("finish", () => resolve());
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
export function gitListPlaceholders(hqRoot) {
|
|
917
|
+
return () => new Promise((resolve, reject) => {
|
|
918
|
+
execFile("git", [
|
|
919
|
+
"-C",
|
|
920
|
+
hqRoot,
|
|
921
|
+
"ls-files",
|
|
922
|
+
"-z",
|
|
923
|
+
"--cached",
|
|
924
|
+
"--others",
|
|
925
|
+
"--exclude-standard",
|
|
926
|
+
"--",
|
|
927
|
+
`*${PLACEHOLDER_SUFFIX}`,
|
|
928
|
+
], { maxBuffer: 64 * 1024 * 1024 }, (err, stdout) => {
|
|
929
|
+
if (err) {
|
|
930
|
+
reject(err);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
resolve(String(stdout).split("\0").filter(Boolean));
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
export function defaultTmpDir() {
|
|
938
|
+
return os.tmpdir();
|
|
939
|
+
}
|
|
940
|
+
export function personalS3Store(input) {
|
|
941
|
+
return s3BlobStore(input.bucket, new S3Client({ region: input.region, credentials: input.credentials }));
|
|
942
|
+
}
|
|
943
|
+
//# sourceMappingURL=storage-offload.js.map
|