@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,931 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq storage` — how much space HQ backups use, and pruning of old ones.
|
|
3
|
+
*
|
|
4
|
+
* Two stores:
|
|
5
|
+
* - Local autosave history (the HQ root git repo). Measured and pruned by the
|
|
6
|
+
* hq-core script `core/scripts/hq-storage.sh`; older HQ installs lack it and
|
|
7
|
+
* report `{ available: false, reason: "update HQ" }`.
|
|
8
|
+
* - Cloud file history: S3 object versioning on company vault buckets. Read
|
|
9
|
+
* with ListObjectVersions and pruned with batched DeleteObjects, using
|
|
10
|
+
* membership-scoped credentials from `/sts/vend`.
|
|
11
|
+
*
|
|
12
|
+
* Tenant isolation: cloud work only ever targets companies in the caller's own
|
|
13
|
+
* active memberships (`/membership/me`). `--company` narrows that set; a slug
|
|
14
|
+
* outside it is rejected before any credentials are vended. Deleting cloud
|
|
15
|
+
* history is for owners and admins only: prune checks the membership role
|
|
16
|
+
* before vending, and `status` marks each company with `can_delete`.
|
|
17
|
+
*
|
|
18
|
+
* `offload` and `fetch` (big files to the personal vault) live in
|
|
19
|
+
* `storage-offload.ts`; this file registers them and adds the offload section
|
|
20
|
+
* to `status`.
|
|
21
|
+
*/
|
|
22
|
+
import chalk from "chalk";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import * as readline from "node:readline";
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { dropExpiredBackups, reclaimFields, reclaimWindowHours, runCoreScript, } from "./storage-core.js";
|
|
28
|
+
import { S3Client, ListObjectVersionsCommand, DeleteObjectsCommand, } from "@aws-sdk/client-s3";
|
|
29
|
+
import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, VaultClient } from "@indigoai-us/hq-cloud";
|
|
30
|
+
import { buildVaultConfig, DEFAULT_COGNITO } from "../utils/cognito-session.js";
|
|
31
|
+
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
32
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
33
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
34
|
+
import { maxLength } from "../utils/table-width.js";
|
|
35
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
36
|
+
import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, personalS3Store, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
|
|
37
|
+
export const TRANCHES = [
|
|
38
|
+
{ id: "7d", label: "Last 7 days", maxAgeDays: 7 },
|
|
39
|
+
{ id: "30d", label: "8-30 days", maxAgeDays: 30 },
|
|
40
|
+
{ id: "90d", label: "31-90 days", maxAgeDays: 90 },
|
|
41
|
+
{ id: "365d", label: "91-365 days", maxAgeDays: 365 },
|
|
42
|
+
{ id: "older", label: "Older than a year", maxAgeDays: Number.POSITIVE_INFINITY },
|
|
43
|
+
];
|
|
44
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
export function trancheFor(lastModified, now) {
|
|
46
|
+
const ageDays = (now.getTime() - lastModified.getTime()) / DAY_MS;
|
|
47
|
+
for (const t of TRANCHES) {
|
|
48
|
+
if (ageDays <= t.maxAgeDays)
|
|
49
|
+
return t.id;
|
|
50
|
+
}
|
|
51
|
+
return "older";
|
|
52
|
+
}
|
|
53
|
+
const DELETE_ROLES = new Set(["owner", "admin"]);
|
|
54
|
+
/** Cloud history delete is owners and admins only. A missing role is not enough. */
|
|
55
|
+
export function canDeleteCloudHistory(company) {
|
|
56
|
+
return company.role !== undefined && DELETE_ROLES.has(company.role);
|
|
57
|
+
}
|
|
58
|
+
export const DELETE_ROLE_REASON = "Only company owners and admins can delete cloud file history.";
|
|
59
|
+
// ── Local (hq-core script) ──────────────────────────────────────────────────
|
|
60
|
+
export function localScriptPath(hqRoot) {
|
|
61
|
+
return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
|
|
62
|
+
}
|
|
63
|
+
async function runLocal(deps, args) {
|
|
64
|
+
const script = localScriptPath(deps.hqRoot);
|
|
65
|
+
if (!fs.existsSync(script))
|
|
66
|
+
return { available: false, reason: "update HQ" };
|
|
67
|
+
return runCoreScript(deps.runScript, script, args);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Core `drop-backups --expired` right before a real local prune; undefined
|
|
71
|
+
* when HQ lacks it. Status, dry runs and fetch never call it.
|
|
72
|
+
*/
|
|
73
|
+
export async function dropExpiredLocal(deps) {
|
|
74
|
+
const script = localScriptPath(deps.hqRoot);
|
|
75
|
+
return fs.existsSync(script) ? dropExpiredBackups(deps.runScript, script) : undefined;
|
|
76
|
+
}
|
|
77
|
+
export async function getLocalReport(deps) {
|
|
78
|
+
const report = await runLocal(deps, ["report", "--json"]);
|
|
79
|
+
if (report.available === false)
|
|
80
|
+
return report;
|
|
81
|
+
// Bytes still held by backups during the grace period, and when they go.
|
|
82
|
+
return {
|
|
83
|
+
...report,
|
|
84
|
+
pending_reclaim_bytes: num(report.pending_reclaim_bytes),
|
|
85
|
+
reclaim_after: typeof report.reclaim_after === "string" ? report.reclaim_after : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ── Company selection (tenant boundary) ─────────────────────────────────────
|
|
89
|
+
export function selectCompanies(memberships, requested) {
|
|
90
|
+
if (requested === undefined)
|
|
91
|
+
return memberships;
|
|
92
|
+
const match = memberships.find((c) => c.slug === requested || c.uid === requested);
|
|
93
|
+
if (!match) {
|
|
94
|
+
throw new Error(`You are not an active member of '${requested}'. hq storage only works on your own companies.`);
|
|
95
|
+
}
|
|
96
|
+
return [match];
|
|
97
|
+
}
|
|
98
|
+
const ROLE_RANK = { owner: 3, admin: 2, member: 1, guest: 0 };
|
|
99
|
+
/**
|
|
100
|
+
* Active memberships from a `/membership/me` body, one per company. A company
|
|
101
|
+
* listed more than once keeps its highest-privilege role.
|
|
102
|
+
*/
|
|
103
|
+
export function companiesFromMemberships(body) {
|
|
104
|
+
const rows = body?.memberships;
|
|
105
|
+
const byUid = new Map();
|
|
106
|
+
for (const raw of Array.isArray(rows) ? rows : []) {
|
|
107
|
+
const m = raw;
|
|
108
|
+
if (m.status !== "active" || typeof m.companyUid !== "string")
|
|
109
|
+
continue;
|
|
110
|
+
const ref = {
|
|
111
|
+
uid: m.companyUid,
|
|
112
|
+
slug: m.companySlug ?? m.slug ?? m.companyUid,
|
|
113
|
+
...(typeof m.role === "string" ? { role: m.role } : {}),
|
|
114
|
+
};
|
|
115
|
+
const prev = byUid.get(ref.uid);
|
|
116
|
+
if (!prev || (ROLE_RANK[ref.role ?? ""] ?? -1) > (ROLE_RANK[prev.role ?? ""] ?? -1)) {
|
|
117
|
+
byUid.set(ref.uid, ref);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [...byUid.values()];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Fill `noncurrentSince` on every noncurrent entry. A noncurrent version's
|
|
124
|
+
* age is how long it has been a backup, not when it was written: a file
|
|
125
|
+
* written two years ago and overwritten yesterday became an undo copy
|
|
126
|
+
* yesterday. Within a key the current entry comes first, then newest to
|
|
127
|
+
* oldest. A noncurrent entry with no newer entry in the listing falls back
|
|
128
|
+
* to its own LastModified.
|
|
129
|
+
*/
|
|
130
|
+
export function annotateNoncurrentSince(entries) {
|
|
131
|
+
const byKey = new Map();
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
const list = byKey.get(e.key);
|
|
134
|
+
if (list)
|
|
135
|
+
list.push(e);
|
|
136
|
+
else
|
|
137
|
+
byKey.set(e.key, [e]);
|
|
138
|
+
}
|
|
139
|
+
for (const list of byKey.values()) {
|
|
140
|
+
list.sort((a, b) => Number(b.isLatest) - Number(a.isLatest) || b.lastModified.getTime() - a.lastModified.getTime());
|
|
141
|
+
for (let i = 0; i < list.length; i++) {
|
|
142
|
+
if (list[i].isLatest)
|
|
143
|
+
continue;
|
|
144
|
+
list[i].noncurrentSince = i > 0 ? list[i - 1].lastModified : list[i].lastModified;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return entries;
|
|
148
|
+
}
|
|
149
|
+
function noncurrentAge(v) {
|
|
150
|
+
return v.noncurrentSince ?? v.lastModified;
|
|
151
|
+
}
|
|
152
|
+
async function* listAllVersions(store) {
|
|
153
|
+
let keyMarker;
|
|
154
|
+
let versionIdMarker;
|
|
155
|
+
do {
|
|
156
|
+
const page = await store.s3.send(new ListObjectVersionsCommand({
|
|
157
|
+
Bucket: store.bucket,
|
|
158
|
+
KeyMarker: keyMarker,
|
|
159
|
+
VersionIdMarker: versionIdMarker,
|
|
160
|
+
}));
|
|
161
|
+
for (const v of page.Versions ?? []) {
|
|
162
|
+
if (!v.Key || !v.VersionId)
|
|
163
|
+
continue;
|
|
164
|
+
yield {
|
|
165
|
+
key: v.Key,
|
|
166
|
+
versionId: v.VersionId,
|
|
167
|
+
isLatest: Boolean(v.IsLatest),
|
|
168
|
+
lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
|
|
169
|
+
size: v.Size ?? 0,
|
|
170
|
+
isDeleteMarker: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
for (const m of page.DeleteMarkers ?? []) {
|
|
174
|
+
if (!m.Key || !m.VersionId)
|
|
175
|
+
continue;
|
|
176
|
+
yield {
|
|
177
|
+
key: m.Key,
|
|
178
|
+
versionId: m.VersionId,
|
|
179
|
+
isLatest: Boolean(m.IsLatest),
|
|
180
|
+
lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
|
|
181
|
+
size: 0,
|
|
182
|
+
isDeleteMarker: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (page.IsTruncated && !page.NextKeyMarker) {
|
|
186
|
+
throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
|
|
187
|
+
}
|
|
188
|
+
keyMarker = page.IsTruncated ? page.NextKeyMarker : undefined;
|
|
189
|
+
versionIdMarker = page.IsTruncated ? page.NextVersionIdMarker : undefined;
|
|
190
|
+
} while (keyMarker !== undefined);
|
|
191
|
+
}
|
|
192
|
+
/** Every version and delete marker in the bucket, with `noncurrentSince` set. */
|
|
193
|
+
async function listVersions(store) {
|
|
194
|
+
const all = [];
|
|
195
|
+
for await (const v of listAllVersions(store))
|
|
196
|
+
all.push(v);
|
|
197
|
+
return annotateNoncurrentSince(all);
|
|
198
|
+
}
|
|
199
|
+
function emptyTranches() {
|
|
200
|
+
return TRANCHES.map((t) => ({ id: t.id, label: t.label, count: 0, bytes: 0 }));
|
|
201
|
+
}
|
|
202
|
+
function s3ErrorName(err) {
|
|
203
|
+
const e = err;
|
|
204
|
+
return e?.name ?? e?.Code ?? e?.code ?? "";
|
|
205
|
+
}
|
|
206
|
+
function isAccessDenied(err) {
|
|
207
|
+
const name = s3ErrorName(err);
|
|
208
|
+
return name === "AccessDenied" || name === "AccessDeniedException";
|
|
209
|
+
}
|
|
210
|
+
/** Plain message when S3 refuses a version delete for one company. */
|
|
211
|
+
export function deleteDeniedMessage(slug) {
|
|
212
|
+
return `${DELETE_ROLE_REASON} Your access to ${slug} does not allow deleting old versions yet.`;
|
|
213
|
+
}
|
|
214
|
+
export function describeCloudError(err, action, slug) {
|
|
215
|
+
if (isAccessDenied(err)) {
|
|
216
|
+
if (action === "delete")
|
|
217
|
+
return deleteDeniedMessage(slug ?? "this company");
|
|
218
|
+
return "Access denied listing file history (needs s3:ListBucketVersions on the vault bucket).";
|
|
219
|
+
}
|
|
220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
221
|
+
return msg || s3ErrorName(err) || "Unknown error";
|
|
222
|
+
}
|
|
223
|
+
export async function getCloudStatus(company, deps) {
|
|
224
|
+
const status = {
|
|
225
|
+
company: company.slug,
|
|
226
|
+
available: true,
|
|
227
|
+
can_delete: canDeleteCloudHistory(company),
|
|
228
|
+
current_bytes: 0,
|
|
229
|
+
noncurrent_bytes: 0,
|
|
230
|
+
noncurrent_count: 0,
|
|
231
|
+
delete_markers: 0,
|
|
232
|
+
tranches: emptyTranches(),
|
|
233
|
+
};
|
|
234
|
+
const now = deps.now();
|
|
235
|
+
try {
|
|
236
|
+
const store = await deps.openCompany(company);
|
|
237
|
+
for (const v of await listVersions(store)) {
|
|
238
|
+
if (v.isDeleteMarker) {
|
|
239
|
+
status.delete_markers += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (v.isLatest) {
|
|
243
|
+
status.current_bytes += v.size;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
status.noncurrent_bytes += v.size;
|
|
247
|
+
status.noncurrent_count += 1;
|
|
248
|
+
const tranche = status.tranches.find((x) => x.id === trancheFor(noncurrentAge(v), now));
|
|
249
|
+
if (tranche) {
|
|
250
|
+
tranche.count += 1;
|
|
251
|
+
tranche.bytes += v.size;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return {
|
|
257
|
+
...status,
|
|
258
|
+
available: false,
|
|
259
|
+
tranches: emptyTranches(),
|
|
260
|
+
current_bytes: 0,
|
|
261
|
+
noncurrent_bytes: 0,
|
|
262
|
+
noncurrent_count: 0,
|
|
263
|
+
delete_markers: 0,
|
|
264
|
+
error: describeCloudError(err, "list"),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return status;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Versions eligible for prune: noncurrent content versions and noncurrent
|
|
271
|
+
* delete markers that stopped being current before the cutoff (see
|
|
272
|
+
* annotateNoncurrentSince). The current version of a key (content or delete
|
|
273
|
+
* marker) is never selected.
|
|
274
|
+
*/
|
|
275
|
+
export function selectPrunable(versions, cutoff) {
|
|
276
|
+
annotateNoncurrentSince(versions);
|
|
277
|
+
return versions.filter((v) => !v.isLatest && noncurrentAge(v).getTime() < cutoff.getTime());
|
|
278
|
+
}
|
|
279
|
+
const DELETE_BATCH = 1000;
|
|
280
|
+
export async function pruneCloud(company, cutoff, dryRun, deps) {
|
|
281
|
+
const result = {
|
|
282
|
+
company: company.slug,
|
|
283
|
+
deleted_count: 0,
|
|
284
|
+
deleted_bytes: 0,
|
|
285
|
+
errors: [],
|
|
286
|
+
};
|
|
287
|
+
let store;
|
|
288
|
+
let candidates;
|
|
289
|
+
try {
|
|
290
|
+
store = await deps.openCompany(company);
|
|
291
|
+
candidates = selectPrunable(await listVersions(store), cutoff);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
result.errors.push(describeCloudError(err, "list"));
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
result.deleted_count = candidates.length;
|
|
299
|
+
result.deleted_bytes = candidates.reduce((sum, v) => sum + v.size, 0);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
const byId = new Map(candidates.map((v) => [`${v.key}\u0000${v.versionId}`, v]));
|
|
303
|
+
for (let i = 0; i < candidates.length; i += DELETE_BATCH) {
|
|
304
|
+
const batch = candidates.slice(i, i + DELETE_BATCH);
|
|
305
|
+
try {
|
|
306
|
+
const res = await store.s3.send(new DeleteObjectsCommand({
|
|
307
|
+
Bucket: store.bucket,
|
|
308
|
+
Delete: {
|
|
309
|
+
Objects: batch.map((v) => ({ Key: v.key, VersionId: v.versionId })),
|
|
310
|
+
Quiet: false,
|
|
311
|
+
},
|
|
312
|
+
}));
|
|
313
|
+
for (const d of res.Deleted ?? []) {
|
|
314
|
+
const v = byId.get(`${d.Key}\u0000${d.VersionId}`);
|
|
315
|
+
if (!v)
|
|
316
|
+
continue;
|
|
317
|
+
result.deleted_count += 1;
|
|
318
|
+
result.deleted_bytes += v.size;
|
|
319
|
+
}
|
|
320
|
+
const errors = res.Errors ?? [];
|
|
321
|
+
const denied = errors.filter((e) => e.Code === "AccessDenied");
|
|
322
|
+
if (denied.length > 0) {
|
|
323
|
+
result.access_denied = true;
|
|
324
|
+
if (!result.errors.includes(deleteDeniedMessage(company.slug))) {
|
|
325
|
+
result.errors.push(deleteDeniedMessage(company.slug));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const e of errors.filter((x) => x.Code !== "AccessDenied")) {
|
|
329
|
+
result.errors.push(`${e.Key ?? "?"}: ${e.Code ?? "Error"} ${e.Message ?? ""}`.trim());
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
if (isAccessDenied(err)) {
|
|
334
|
+
result.access_denied = true;
|
|
335
|
+
if (!result.errors.includes(deleteDeniedMessage(company.slug))) {
|
|
336
|
+
result.errors.push(deleteDeniedMessage(company.slug));
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
result.errors.push(describeCloudError(err, "delete", company.slug));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
// ── Orchestrators ───────────────────────────────────────────────────────────
|
|
346
|
+
export async function runStorageStatus(opts, deps) {
|
|
347
|
+
// Read-only: no backup drop, no gc. Pending backup bytes come from report.
|
|
348
|
+
const local = await getLocalReport(deps);
|
|
349
|
+
const offload = deps.getOffload ? await deps.getOffload().catch(offloadUnavailable) : undefined;
|
|
350
|
+
let memberships;
|
|
351
|
+
try {
|
|
352
|
+
memberships = await deps.listCompanies();
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// Local sections still report when cloud is unreachable (signed out, API key).
|
|
356
|
+
return {
|
|
357
|
+
local,
|
|
358
|
+
cloud: [],
|
|
359
|
+
cloud_error: err instanceof Error ? err.message : String(err),
|
|
360
|
+
...(offload ? { offload } : {}),
|
|
361
|
+
generated_at: deps.now().toISOString(),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const companies = selectCompanies(memberships, opts.company);
|
|
365
|
+
const cloud = [];
|
|
366
|
+
for (const c of companies)
|
|
367
|
+
cloud.push(await getCloudStatus(c, deps));
|
|
368
|
+
return {
|
|
369
|
+
local,
|
|
370
|
+
cloud,
|
|
371
|
+
...(offload ? { offload } : {}),
|
|
372
|
+
generated_at: deps.now().toISOString(),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
376
|
+
export function parseCutoff(value, flag) {
|
|
377
|
+
const d = new Date(`${value}T00:00:00Z`);
|
|
378
|
+
if (!DATE_RE.test(value) || Number.isNaN(d.getTime())) {
|
|
379
|
+
throw new Error(`${flag} must be a date in YYYY-MM-DD form, got '${value}'.`);
|
|
380
|
+
}
|
|
381
|
+
return d;
|
|
382
|
+
}
|
|
383
|
+
export async function runStoragePrune(opts, deps) {
|
|
384
|
+
if (!opts.localBefore && !opts.cloudBefore) {
|
|
385
|
+
throw new Error("Pass --local-before and/or --cloud-before to choose what to prune.");
|
|
386
|
+
}
|
|
387
|
+
if (opts.localBefore)
|
|
388
|
+
parseCutoff(opts.localBefore, "--local-before");
|
|
389
|
+
const cloudCutoff = opts.cloudBefore ? parseCutoff(opts.cloudBefore, "--cloud-before") : undefined;
|
|
390
|
+
const selected = cloudCutoff
|
|
391
|
+
? selectCompanies(await deps.listCompanies(), opts.company)
|
|
392
|
+
: [];
|
|
393
|
+
// Role gate before any credentials are vended. Naming a company you cannot
|
|
394
|
+
// delete in is an error; otherwise those companies are skipped and reported.
|
|
395
|
+
if (opts.company !== undefined && selected.some((c) => !canDeleteCloudHistory(c))) {
|
|
396
|
+
throw new Error(`${DELETE_ROLE_REASON} You are not an owner or admin of '${opts.company}'.`);
|
|
397
|
+
}
|
|
398
|
+
const companies = selected.filter(canDeleteCloudHistory);
|
|
399
|
+
const skipped = selected.filter((c) => !canDeleteCloudHistory(c));
|
|
400
|
+
if (!opts.dryRun && !opts.yes) {
|
|
401
|
+
const parts = [];
|
|
402
|
+
if (opts.localBefore)
|
|
403
|
+
parts.push(`local autosave history before ${opts.localBefore}`);
|
|
404
|
+
if (cloudCutoff) {
|
|
405
|
+
const names = companies.map((c) => c.slug).join(", ") || "no companies";
|
|
406
|
+
parts.push(`old cloud file versions before ${opts.cloudBefore} in ${names}`);
|
|
407
|
+
}
|
|
408
|
+
const ok = await deps.confirm(`Permanently delete ${parts.join(" and ")}? This cannot be undone.`);
|
|
409
|
+
if (!ok) {
|
|
410
|
+
deps.log(chalk.dim("Aborted - nothing was deleted."));
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const reclaim = opts.localBefore && !opts.dryRun ? await dropExpiredLocal(deps) : undefined;
|
|
415
|
+
let local = null;
|
|
416
|
+
if (opts.localBefore) {
|
|
417
|
+
// prune always prints JSON; it takes no --json flag.
|
|
418
|
+
const args = ["prune", "--before", opts.localBefore];
|
|
419
|
+
args.push(opts.dryRun ? "--dry-run" : "--yes");
|
|
420
|
+
const windowHours = reclaimWindowHours(localScriptPath(deps.hqRoot));
|
|
421
|
+
local = withLocalReclaim(await runLocal(deps, args), opts.dryRun, deps.now(), windowHours);
|
|
422
|
+
}
|
|
423
|
+
const cloud = [];
|
|
424
|
+
if (cloudCutoff) {
|
|
425
|
+
for (const c of companies)
|
|
426
|
+
cloud.push(await pruneCloud(c, cloudCutoff, opts.dryRun, deps));
|
|
427
|
+
for (const c of skipped) {
|
|
428
|
+
cloud.push({
|
|
429
|
+
company: c.slug,
|
|
430
|
+
deleted_count: 0,
|
|
431
|
+
deleted_bytes: 0,
|
|
432
|
+
errors: [],
|
|
433
|
+
skipped_reason: DELETE_ROLE_REASON,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { local, cloud, dry_run: opts.dryRun, ...(reclaim ? { reclaim } : {}) };
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Add freed / pending space to a local prune result. Space held by the
|
|
441
|
+
* backup ref is freed after `reclaim_after`; a dry run estimates that time.
|
|
442
|
+
*/
|
|
443
|
+
function withLocalReclaim(local, dryRun, now, windowHours) {
|
|
444
|
+
if (local.available === false || local.refused)
|
|
445
|
+
return local;
|
|
446
|
+
const freed = dryRun
|
|
447
|
+
? 0
|
|
448
|
+
: typeof local.freed_bytes === "number"
|
|
449
|
+
? local.freed_bytes
|
|
450
|
+
: Math.max(0, num(local.git_dir_bytes_before) - num(local.git_dir_bytes_after));
|
|
451
|
+
const fields = reclaimFields(local, { dryRun, now, expectedBytes: num(local.est_bytes), freedBytes: freed, windowHours });
|
|
452
|
+
return dryRun ? { ...local, ...fields } : { ...local, freed_bytes: freed, ...fields };
|
|
453
|
+
}
|
|
454
|
+
// ── Formatting ──────────────────────────────────────────────────────────────
|
|
455
|
+
export function formatBytes(bytes) {
|
|
456
|
+
if (bytes < 1024)
|
|
457
|
+
return `${bytes} B`;
|
|
458
|
+
if (bytes < 1024 ** 2)
|
|
459
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
460
|
+
if (bytes < 1024 ** 3)
|
|
461
|
+
return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
|
462
|
+
return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
|
463
|
+
}
|
|
464
|
+
function table(cols, data) {
|
|
465
|
+
const widths = cols.map((col, i) => maxLength(data, (row) => row[i].length, col.length));
|
|
466
|
+
const render = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
467
|
+
return [
|
|
468
|
+
chalk.bold(render(cols)),
|
|
469
|
+
chalk.dim(render(widths.map((w) => "─".repeat(w)))),
|
|
470
|
+
...data.map(render),
|
|
471
|
+
].join("\n");
|
|
472
|
+
}
|
|
473
|
+
function num(v) {
|
|
474
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
475
|
+
}
|
|
476
|
+
export function formatStorageStatus(status) {
|
|
477
|
+
const lines = [chalk.bold("Local autosave history")];
|
|
478
|
+
const local = status.local;
|
|
479
|
+
if (local.available === false) {
|
|
480
|
+
lines.push(` Not available: ${String(local.reason ?? "unknown")}`);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const pending = reclaimLine(local);
|
|
484
|
+
if (pending)
|
|
485
|
+
lines.push(` ${pending}`);
|
|
486
|
+
lines.push(` History size: ${formatBytes(num(local.git_dir_bytes))}, ` +
|
|
487
|
+
`${num(local.commit_count)} saves, oldest ${String(local.oldest_commit_at ?? "-")}`);
|
|
488
|
+
const tranches = Array.isArray(local.tranches) ? local.tranches : [];
|
|
489
|
+
if (tranches.length > 0) {
|
|
490
|
+
lines.push(table(["AGE", "SAVES", "EST. SIZE"], tranches.map((t) => [
|
|
491
|
+
String(t.label ?? t.id),
|
|
492
|
+
String(num(t.commit_count)),
|
|
493
|
+
formatBytes(num(t.est_bytes)),
|
|
494
|
+
])));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
lines.push("", chalk.bold("Cloud file history"));
|
|
498
|
+
if (status.cloud_error)
|
|
499
|
+
lines.push(` Not available: ${status.cloud_error}`);
|
|
500
|
+
else if (status.cloud.length === 0)
|
|
501
|
+
lines.push(" No companies.");
|
|
502
|
+
for (const c of status.cloud) {
|
|
503
|
+
if (!c.available) {
|
|
504
|
+
lines.push(` ${c.company}: not available - ${c.error ?? "unknown error"}`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
lines.push(` ${c.company}: current ${formatBytes(c.current_bytes)}, old versions ` +
|
|
508
|
+
`${formatBytes(c.noncurrent_bytes)} (${c.noncurrent_count}), delete markers ${c.delete_markers}` +
|
|
509
|
+
(c.can_delete ? "" : " (view only: owners and admins can delete)"));
|
|
510
|
+
lines.push(table(["AGE", "OLD VERSIONS", "SIZE"], c.tranches.map((t) => [t.label, String(t.count), formatBytes(t.bytes)])));
|
|
511
|
+
}
|
|
512
|
+
if (status.offload)
|
|
513
|
+
lines.push("", ...formatOffloadStatus(status.offload));
|
|
514
|
+
return lines.join("\n");
|
|
515
|
+
}
|
|
516
|
+
function offloadUnavailable(err) {
|
|
517
|
+
const zero = { count: 0, bytes: 0 };
|
|
518
|
+
return {
|
|
519
|
+
history_candidates: zero,
|
|
520
|
+
current_candidates: { ...zero },
|
|
521
|
+
placeholders: { ...zero },
|
|
522
|
+
...currentAvailability(false),
|
|
523
|
+
available: false,
|
|
524
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
export function formatOffloadStatus(o) {
|
|
528
|
+
const lines = [chalk.bold("Big files")];
|
|
529
|
+
lines.push(` Already in the cloud: ${o.placeholders.count} file(s), ${formatBytes(o.placeholders.bytes)}`);
|
|
530
|
+
if (o.available === false) {
|
|
531
|
+
lines.push(` Not available: ${o.reason ?? "unknown"}`);
|
|
532
|
+
return lines;
|
|
533
|
+
}
|
|
534
|
+
if (o.history_held && o.history_held.count > 0) {
|
|
535
|
+
lines.push(` Kept by other worktrees: ${o.history_held.count}, ${formatBytes(o.history_held.bytes)} (${o.history_held.worktrees
|
|
536
|
+
.slice(0, 5)
|
|
537
|
+
.map((p) => `worktree ${p}`)
|
|
538
|
+
.join(", ")})`);
|
|
539
|
+
}
|
|
540
|
+
lines.push(` Old versions in history: ${o.history_candidates.count}, ${formatBytes(o.history_candidates.bytes)}`, ` Idle current files: ${o.current_candidates.count}, ${formatBytes(o.current_candidates.bytes)}`);
|
|
541
|
+
return lines;
|
|
542
|
+
}
|
|
543
|
+
export function formatOffload(r) {
|
|
544
|
+
const lines = [];
|
|
545
|
+
const h = r.history;
|
|
546
|
+
if (h.refused) {
|
|
547
|
+
lines.push(r.dry_run
|
|
548
|
+
? `History: a real run would stop before uploading: ${h.message ?? ""}`.trimEnd()
|
|
549
|
+
: `History: nothing was uploaded or changed. ${h.message ?? ""}`.trimEnd());
|
|
550
|
+
}
|
|
551
|
+
else if (h.available === false) {
|
|
552
|
+
lines.push(`History: not available - ${h.reason ?? "unknown"}`);
|
|
553
|
+
}
|
|
554
|
+
else if (r.dry_run) {
|
|
555
|
+
lines.push(`History: would move ${h.candidates ?? 0} old version(s), ${formatBytes(h.candidate_bytes ?? 0)}`);
|
|
556
|
+
if (num(h.freed_bytes) > 0)
|
|
557
|
+
lines.push(` ${formatBytes(num(h.freed_bytes))} is freed right away.`);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
lines.push(`History: uploaded ${h.uploaded} old version(s), ${formatBytes(h.bytes)}; freed ${formatBytes(h.freed_bytes)}`);
|
|
561
|
+
}
|
|
562
|
+
if (h.available !== false) {
|
|
563
|
+
const pending = reclaimLine(h);
|
|
564
|
+
if (pending)
|
|
565
|
+
lines.push(` ${pending}`);
|
|
566
|
+
if (h.held_by_worktrees && h.held_by_worktrees.count > 0 && h.refused !== "nothing_to_free") {
|
|
567
|
+
const names = (h.worktree_holders ?? []).slice(0, 5).map((p) => `worktree ${p}`).join(", ");
|
|
568
|
+
lines.push(` ${h.held_by_worktrees.count} more old version(s) (${formatBytes(h.held_by_worktrees.bytes)}) stay: ${names} still uses this history.`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (r.current.available === false) {
|
|
572
|
+
lines.push(`Current files: not available - ${r.current.reason ?? "unknown"}`);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
lines.push(`Current files: moved ${r.current.offloaded.length}, freed ${formatBytes(r.current.freed_bytes)}`);
|
|
576
|
+
}
|
|
577
|
+
for (const e of r.errors)
|
|
578
|
+
lines.push(chalk.red(` ${e}`));
|
|
579
|
+
return lines.join("\n");
|
|
580
|
+
}
|
|
581
|
+
/** One line on when backed-up space comes back, or nothing. */
|
|
582
|
+
export function reclaimLine(r) {
|
|
583
|
+
const pending = num(r.pending_reclaim_bytes);
|
|
584
|
+
if (pending <= 0 || typeof r.reclaim_after !== "string")
|
|
585
|
+
return undefined;
|
|
586
|
+
return `${formatBytes(pending)} stays on disk as undo history and is freed by ${r.reclaim_after}`;
|
|
587
|
+
}
|
|
588
|
+
/** Readable name for a ref that keeps old history: "branch wip", "tag v1". */
|
|
589
|
+
export function holderName(ref) {
|
|
590
|
+
if (ref === "refs/stash")
|
|
591
|
+
return "changes set aside with git stash";
|
|
592
|
+
if (ref.startsWith("refs/heads/"))
|
|
593
|
+
return `branch ${ref.slice("refs/heads/".length)}`;
|
|
594
|
+
if (ref.startsWith("refs/tags/"))
|
|
595
|
+
return `tag ${ref.slice("refs/tags/".length)}`;
|
|
596
|
+
if (ref.startsWith("worktree:"))
|
|
597
|
+
return `worktree ${ref.slice("worktree:".length)}`;
|
|
598
|
+
return ref.replace(/^refs\//, "");
|
|
599
|
+
}
|
|
600
|
+
/** Plain refusal text when branches or tags keep the history prune would remove. */
|
|
601
|
+
export function formatHoldingRefusal(local) {
|
|
602
|
+
const refs = Array.isArray(local.holding_refs) ? local.holding_refs : [];
|
|
603
|
+
const n = num(local.holding_refs_count) || refs.length;
|
|
604
|
+
const lines = [
|
|
605
|
+
`Local: nothing was changed. ${n} ${n === 1 ? "branch, saved version or worktree still uses" : "branches, saved versions or worktrees still use"} this history, so pruning it would free no space:`,
|
|
606
|
+
...refs.slice(0, 10).map((r) => ` - ${holderName(r.name)}`),
|
|
607
|
+
];
|
|
608
|
+
if (n > 10)
|
|
609
|
+
lines.push(` - and ${n - 10} more`);
|
|
610
|
+
lines.push("They may hold work that exists nowhere else, so HQ leaves them alone.", "Next step: run `hq storage offload --dry-run` to see big old files that can move to the cloud instead.");
|
|
611
|
+
return lines;
|
|
612
|
+
}
|
|
613
|
+
/** Exit code for a prune result: 4 for nothing to free, 5 for a precondition, else 0/1. */
|
|
614
|
+
export function pruneExitCode(result) {
|
|
615
|
+
const local = result.local;
|
|
616
|
+
if (local?.refused === "nothing_to_free")
|
|
617
|
+
return 4;
|
|
618
|
+
if (local && typeof local.refused === "string")
|
|
619
|
+
return 5;
|
|
620
|
+
if (result.cloud.some((c) => c.errors.length > 0))
|
|
621
|
+
return 1;
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
/** Plain lines for a local prune that was not refused (dry or real). */
|
|
625
|
+
export function formatLocalPrune(local, dryRun) {
|
|
626
|
+
const w = local.would_refuse && typeof local.would_refuse === "object" ? local.would_refuse : null;
|
|
627
|
+
if (dryRun && w) {
|
|
628
|
+
if (w.code === "nothing_to_free") {
|
|
629
|
+
const lines = formatHoldingRefusal({ ...local, holding_refs: w.holding_refs ?? local.holding_refs, holding_refs_count: w.holding_refs_count ?? local.holding_refs_count });
|
|
630
|
+
lines[0] = lines[0].replace("Local: nothing was changed.", "Local: a real run would stop and change nothing.");
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
633
|
+
return [`Local: a real run would stop: ${String(w.message ?? "HQ history cannot be rewritten right now.")}`];
|
|
634
|
+
}
|
|
635
|
+
const lines = [];
|
|
636
|
+
if (dryRun) {
|
|
637
|
+
const n = num(local.would_remove_commits);
|
|
638
|
+
if (n === 0)
|
|
639
|
+
return ["Local: nothing older than that date to remove."];
|
|
640
|
+
lines.push(`Local: would combine ${n} old save(s) into one and free about ${formatBytes(num(local.est_bytes))}.`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const n = num(local.removed_commits);
|
|
644
|
+
if (n === 0)
|
|
645
|
+
return ["Local: nothing older than that date to remove."];
|
|
646
|
+
lines.push(`Local: combined ${n} old save(s) into one. Freed ${formatBytes(num(local.freed_bytes))} now.`);
|
|
647
|
+
}
|
|
648
|
+
const pending = reclaimLine(local);
|
|
649
|
+
if (pending)
|
|
650
|
+
lines.push(` ${pending}`);
|
|
651
|
+
return lines;
|
|
652
|
+
}
|
|
653
|
+
export function formatStoragePrune(result) {
|
|
654
|
+
const verb = result.dry_run ? "Would delete" : "Deleted";
|
|
655
|
+
const lines = [];
|
|
656
|
+
const local = result.local;
|
|
657
|
+
if (local) {
|
|
658
|
+
if (local.available === false) {
|
|
659
|
+
lines.push(`Local: not available - ${String(local.reason ?? "unknown")}`);
|
|
660
|
+
}
|
|
661
|
+
else if (local.refused === "nothing_to_free") {
|
|
662
|
+
lines.push(...formatHoldingRefusal(local));
|
|
663
|
+
}
|
|
664
|
+
else if (typeof local.refused === "string") {
|
|
665
|
+
lines.push(`Local: ${String(local.message ?? "HQ history cannot be rewritten right now. Nothing was changed.")}`);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
lines.push(...formatLocalPrune(local, result.dry_run));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const c of result.cloud) {
|
|
672
|
+
if (c.skipped_reason) {
|
|
673
|
+
lines.push(`${c.company}: skipped - ${c.skipped_reason}`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
lines.push(`${c.company}: ${verb} ${c.deleted_count} old version(s), ${formatBytes(c.deleted_bytes)}`);
|
|
677
|
+
for (const e of c.errors)
|
|
678
|
+
lines.push(chalk.red(` ${e}`));
|
|
679
|
+
}
|
|
680
|
+
return lines.join("\n");
|
|
681
|
+
}
|
|
682
|
+
// ── Production deps ─────────────────────────────────────────────────────────
|
|
683
|
+
export const defaultRunScript = (scriptPath, args) => new Promise((resolve) => {
|
|
684
|
+
execFile("bash", [scriptPath, ...args], { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
685
|
+
const rawCode = err?.code;
|
|
686
|
+
const code = err ? (typeof rawCode === "number" ? rawCode : 1) : 0;
|
|
687
|
+
resolve({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
export const NEEDS_YES = "No terminal to confirm in. Re-run with --yes to go ahead, or --dry-run to preview.";
|
|
691
|
+
/**
|
|
692
|
+
* Ask y/N on a terminal. Without a TTY the question could never be answered,
|
|
693
|
+
* so it throws and the command exits non-zero instead of hanging or exiting 0.
|
|
694
|
+
*/
|
|
695
|
+
export function confirmOnTty(message, input = process.stdin, output = process.stdout) {
|
|
696
|
+
if (!input.isTTY)
|
|
697
|
+
return Promise.reject(new Error(NEEDS_YES));
|
|
698
|
+
const rl = readline.createInterface({ input, output });
|
|
699
|
+
return new Promise((resolve) => {
|
|
700
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
701
|
+
rl.close();
|
|
702
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
async function signedInVault() {
|
|
707
|
+
// Non-interactive: a signed-out run fails fast instead of waiting on a browser login.
|
|
708
|
+
const cred = await resolveVaultCredential({ interactive: false });
|
|
709
|
+
if (cred.kind === "api-key") {
|
|
710
|
+
throw new Error("hq storage needs a signed-in session. Run `hq login`.");
|
|
711
|
+
}
|
|
712
|
+
return { token: cred.token, vault: new VaultClient(buildVaultConfig(cred.token)) };
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Deps for offload/fetch. The only store is the caller's personal vault,
|
|
716
|
+
* resolved from their canonical person entity and vended with `/sts/vend-self`.
|
|
717
|
+
*/
|
|
718
|
+
async function buildOffloadDeps() {
|
|
719
|
+
const hqRoot = findHqRoot();
|
|
720
|
+
const personalVault = async () => {
|
|
721
|
+
const { vault } = await signedInVault();
|
|
722
|
+
const personUid = await resolveCanonicalPersonUid({
|
|
723
|
+
listMyMemberships: () => vault.listMyMemberships(),
|
|
724
|
+
listPersonEntities: () => vault.entity.listByType("person"),
|
|
725
|
+
getEntity: async () => null,
|
|
726
|
+
});
|
|
727
|
+
const entity = await vault.entity.get(personUid);
|
|
728
|
+
if (entity.type !== "person") {
|
|
729
|
+
throw new Error("Offload only writes to your personal vault, and the resolved entity is not a person.");
|
|
730
|
+
}
|
|
731
|
+
if (!entity.bucketName)
|
|
732
|
+
throw new Error("Your personal vault has no storage yet. Run `hq onboard`.");
|
|
733
|
+
const vend = await vault.sts.vendSelf({ personUid: entity.uid });
|
|
734
|
+
return { bucket: entity.bucketName, credentials: vend.credentials };
|
|
735
|
+
};
|
|
736
|
+
const activeCompanies = async () => {
|
|
737
|
+
const { token } = await signedInVault();
|
|
738
|
+
const res = await vaultApiFetch({ token, path: "/membership/me" });
|
|
739
|
+
if (!res.ok) {
|
|
740
|
+
throw new Error(`Could not load your companies (HTTP ${res.status}). Run \`hq login\`.`);
|
|
741
|
+
}
|
|
742
|
+
return companiesFromMemberships(await res.json());
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
hqRoot,
|
|
746
|
+
now: () => new Date(),
|
|
747
|
+
runScript: defaultRunScript,
|
|
748
|
+
confirm: (message) => confirmOnTty(message),
|
|
749
|
+
log: (line) => console.log(line),
|
|
750
|
+
exportBlob: gitExportBlob(hqRoot),
|
|
751
|
+
listPlaceholders: gitListPlaceholders(hqRoot),
|
|
752
|
+
tmpDir: defaultTmpDir,
|
|
753
|
+
syncExcludesOffload: () => syncExcludesOffload(PERSONAL_VAULT_EXCLUDED_TOP_LEVEL),
|
|
754
|
+
openPersonal: async () => {
|
|
755
|
+
const { bucket, credentials } = await personalVault();
|
|
756
|
+
return personalS3Store({ bucket, region: DEFAULT_COGNITO.region, credentials });
|
|
757
|
+
},
|
|
758
|
+
activeCompanySlugs: async () => (await activeCompanies()).map((c) => c.slug),
|
|
759
|
+
// Read-only, for placeholders that HQ sync rewrote. Company credentials
|
|
760
|
+
// come from the existing membership-scoped /sts/vend path.
|
|
761
|
+
openVaultObjects: async (target) => {
|
|
762
|
+
if (target.vault === "personal") {
|
|
763
|
+
const { bucket, credentials } = await personalVault();
|
|
764
|
+
return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials }));
|
|
765
|
+
}
|
|
766
|
+
const company = (await activeCompanies()).find((c) => c.slug === target.slug);
|
|
767
|
+
if (!company)
|
|
768
|
+
throw new Error(`You are not an active member of '${target.slug}'.`);
|
|
769
|
+
const { vault } = await signedInVault();
|
|
770
|
+
const vend = await vault.sts.vend({ companyUid: company.uid });
|
|
771
|
+
const entity = await vault.entity.get(company.uid);
|
|
772
|
+
const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
|
|
773
|
+
return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials }));
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
async function buildDeps() {
|
|
778
|
+
// Sign-in is lazy so `status` can still report local sections when signed out.
|
|
779
|
+
let session;
|
|
780
|
+
const signedIn = () => (session ??= signedInVault());
|
|
781
|
+
const offloadDeps = await buildOffloadDeps();
|
|
782
|
+
return {
|
|
783
|
+
getOffload: () => getOffloadStatus(offloadDeps, {
|
|
784
|
+
minBytes: resolveMinBytes(undefined, process.env),
|
|
785
|
+
idleDays: DEFAULT_IDLE_DAYS,
|
|
786
|
+
}),
|
|
787
|
+
hqRoot: findHqRoot(),
|
|
788
|
+
now: () => new Date(),
|
|
789
|
+
runScript: defaultRunScript,
|
|
790
|
+
confirm: (message) => confirmOnTty(message),
|
|
791
|
+
log: (line) => console.log(line),
|
|
792
|
+
listCompanies: async () => {
|
|
793
|
+
const { token } = await signedIn();
|
|
794
|
+
const res = await vaultApiFetch({ token, path: "/membership/me" });
|
|
795
|
+
if (!res.ok) {
|
|
796
|
+
throw new Error(`Could not load your companies (HTTP ${res.status}). Run \`hq login\`.`);
|
|
797
|
+
}
|
|
798
|
+
return companiesFromMemberships(await res.json());
|
|
799
|
+
},
|
|
800
|
+
openCompany: async (company) => {
|
|
801
|
+
const { vault } = await signedIn();
|
|
802
|
+
const vend = await vault.sts.vend({ companyUid: company.uid });
|
|
803
|
+
const entity = await vault.entity.get(company.uid);
|
|
804
|
+
const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
|
|
805
|
+
const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials });
|
|
806
|
+
return { bucket, s3 };
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
// ── CLI registration ────────────────────────────────────────────────────────
|
|
811
|
+
export function registerStorageCommand(program) {
|
|
812
|
+
const storage = program
|
|
813
|
+
.command("storage")
|
|
814
|
+
.description("See how much space HQ backups use and delete old ones");
|
|
815
|
+
storage
|
|
816
|
+
.command("status")
|
|
817
|
+
.description("Show local autosave history and cloud file history sizes")
|
|
818
|
+
.option("--json", "Print JSON")
|
|
819
|
+
.option("--company <slug>", "Only this company (must be one of yours)")
|
|
820
|
+
.action(async (opts) => {
|
|
821
|
+
try {
|
|
822
|
+
const status = await runStorageStatus({ company: opts.company }, await buildDeps());
|
|
823
|
+
console.log(opts.json ? JSON.stringify(status, null, 2) : formatStorageStatus(status));
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
console.error(chalk.red(err.message));
|
|
827
|
+
process.exitCode = 1;
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
storage
|
|
831
|
+
.command("prune")
|
|
832
|
+
.description("Delete local autosave history and old cloud file versions before a date")
|
|
833
|
+
.option("--local-before <date>", "Prune local autosave history before YYYY-MM-DD")
|
|
834
|
+
.option("--cloud-before <date>", "Delete old (noncurrent) cloud file versions before YYYY-MM-DD")
|
|
835
|
+
.option("--company <slug>", "Only this company (must be one of yours)")
|
|
836
|
+
.option("--dry-run", "Show what would be deleted without deleting")
|
|
837
|
+
.option("--yes", "Skip the confirmation prompt")
|
|
838
|
+
.option("--json", "Print JSON")
|
|
839
|
+
.action(async (opts) => {
|
|
840
|
+
try {
|
|
841
|
+
const result = await runStoragePrune({
|
|
842
|
+
localBefore: opts.localBefore,
|
|
843
|
+
cloudBefore: opts.cloudBefore,
|
|
844
|
+
company: opts.company,
|
|
845
|
+
dryRun: Boolean(opts.dryRun),
|
|
846
|
+
yes: Boolean(opts.yes),
|
|
847
|
+
}, await buildDeps());
|
|
848
|
+
if (!result)
|
|
849
|
+
return;
|
|
850
|
+
console.log(opts.json ? JSON.stringify(result, null, 2) : formatStoragePrune(result));
|
|
851
|
+
// Same contract as core: 4 = nothing to free, 5 = precondition
|
|
852
|
+
// refusal; the JSON on stdout still describes it.
|
|
853
|
+
const code = pruneExitCode(result);
|
|
854
|
+
if (code !== 0)
|
|
855
|
+
process.exitCode = code;
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
console.error(chalk.red(err.message));
|
|
859
|
+
process.exitCode = 1;
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
storage
|
|
863
|
+
.command("offload")
|
|
864
|
+
.description("Move big files to your personal HQ cloud storage and leave links behind (old versions in history; idle current files)")
|
|
865
|
+
.option("--min-size <size>", "Only files at least this big, e.g. 50MB (env HQ_OFFLOAD_MIN_BYTES)")
|
|
866
|
+
.option("--idle-days <days>", "Current files must be untouched this many days", String(DEFAULT_IDLE_DAYS))
|
|
867
|
+
.option("--history", "Only old versions in local autosave history")
|
|
868
|
+
.option("--current", "Only idle files in the working tree")
|
|
869
|
+
.option("--dry-run", "Show what would move without uploading or changing anything")
|
|
870
|
+
.option("--yes", "Skip the confirmation prompt")
|
|
871
|
+
.option("--json", "Print JSON")
|
|
872
|
+
.action(async (opts) => {
|
|
873
|
+
try {
|
|
874
|
+
const idleDays = Number(opts.idleDays);
|
|
875
|
+
if (!Number.isInteger(idleDays) || idleDays < 0) {
|
|
876
|
+
throw new Error(`--idle-days must be a whole number, got '${opts.idleDays}'.`);
|
|
877
|
+
}
|
|
878
|
+
const result = await runOffload({
|
|
879
|
+
minBytes: resolveMinBytes(opts.minSize, process.env),
|
|
880
|
+
idleDays,
|
|
881
|
+
history: Boolean(opts.history),
|
|
882
|
+
current: Boolean(opts.current),
|
|
883
|
+
dryRun: Boolean(opts.dryRun),
|
|
884
|
+
yes: Boolean(opts.yes),
|
|
885
|
+
}, await buildOffloadDeps());
|
|
886
|
+
if (!result)
|
|
887
|
+
return;
|
|
888
|
+
console.log(opts.json ? JSON.stringify(result, null, 2) : formatOffload(result));
|
|
889
|
+
if (result.history.refused)
|
|
890
|
+
process.exitCode = result.history.refused === "nothing_to_free" ? 4 : 5;
|
|
891
|
+
else if (result.errors.length > 0)
|
|
892
|
+
process.exitCode = 1;
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
console.error(chalk.red(err.message));
|
|
896
|
+
process.exitCode = 1;
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
storage
|
|
900
|
+
.command("fetch [path]")
|
|
901
|
+
.description("Download a file moved to HQ cloud storage, check it, and put it back in place")
|
|
902
|
+
.option("--git-blob <sha>", "Download an old version from history by its git blob id")
|
|
903
|
+
.option("--out <file>", "Where to write the --git-blob or --force-orphan download")
|
|
904
|
+
.option("--force-orphan", "For a file a teammate deleted: write your own personal-vault copy to --out (outside HQ). It is never synced back to your team.")
|
|
905
|
+
.option("--json", "Print JSON")
|
|
906
|
+
.action(async (target, opts) => {
|
|
907
|
+
try {
|
|
908
|
+
const deps = await buildOffloadDeps();
|
|
909
|
+
let result;
|
|
910
|
+
if (opts.gitBlob !== undefined) {
|
|
911
|
+
if (!opts.out)
|
|
912
|
+
throw new Error("--git-blob needs --out <file>.");
|
|
913
|
+
result = await runFetchGitBlob(opts.gitBlob, opts.out, deps);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
if (!target)
|
|
917
|
+
throw new Error("Pass a path, a .hqcloud placeholder, or --git-blob <sha> --out <file>.");
|
|
918
|
+
result = await runFetch(target, deps, { forceOrphan: opts.forceOrphan, out: opts.out });
|
|
919
|
+
}
|
|
920
|
+
console.log(opts.json
|
|
921
|
+
? JSON.stringify(result, null, 2)
|
|
922
|
+
: `Restored ${result.path} (${formatBytes(result.bytes)}), checksum verified.`);
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
console.error(chalk.red(err.message));
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
return storage;
|
|
930
|
+
}
|
|
931
|
+
//# sourceMappingURL=storage.js.map
|