@indigoai-us/hq-cli 5.345.154 → 5.345.156

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/dist/command-catalog.generated.d.ts +59 -7
  3. package/dist/command-catalog.generated.js +75 -7
  4. package/dist/commands/agent-inbox.js +2 -2
  5. package/dist/commands/auth.d.ts +2 -0
  6. package/dist/commands/auth.js +92 -3
  7. package/dist/commands/core.js +1 -1
  8. package/dist/commands/files-browse.d.ts +16 -0
  9. package/dist/commands/files-browse.js +52 -1
  10. package/dist/commands/integrations-core.d.ts +13 -1
  11. package/dist/commands/integrations-core.js +49 -4
  12. package/dist/commands/integrations.js +4 -2
  13. package/dist/commands/library-add.d.ts +6 -2
  14. package/dist/commands/library-add.js +45 -6
  15. package/dist/commands/library-caption.d.ts +38 -1
  16. package/dist/commands/library-caption.js +304 -3
  17. package/dist/commands/library-link.d.ts +7 -3
  18. package/dist/commands/library-link.js +10 -4
  19. package/dist/commands/library-remove.d.ts +122 -0
  20. package/dist/commands/library-remove.js +681 -0
  21. package/dist/commands/library.d.ts +26 -1
  22. package/dist/commands/library.js +101 -14
  23. package/dist/commands/secrets.d.ts +16 -0
  24. package/dist/commands/secrets.js +144 -8
  25. package/dist/commands/storage-offload.d.ts +22 -4
  26. package/dist/commands/storage-offload.js +196 -2
  27. package/dist/commands/storage.js +5 -0
  28. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  29. package/dist/lib/agent-kit/inbox-state.js +5 -1
  30. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  31. package/dist/lib/daemon/machine-login.d.ts +20 -0
  32. package/dist/lib/daemon/machine-login.js +53 -8
  33. package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
  34. package/dist/lib/daemon/machine-token-identity.js +123 -0
  35. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  36. package/dist/lib/doctor/checks/sync-health.js +66 -1
  37. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  38. package/dist/lib/doctor/company-resolution-state.js +89 -0
  39. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  40. package/dist/lib/doctor/fix/apply.js +128 -12
  41. package/dist/lib/doctor/types.d.ts +1 -1
  42. package/dist/lib/doctor/types.js +1 -0
  43. package/dist/lib/hook-engine/resolve.js +6 -2
  44. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  45. package/dist/lib/index-render/company-knowledge.js +3 -2
  46. package/dist/lib/index-render/index.js +44 -3
  47. package/dist/lib/index-render/projects.d.ts +1 -1
  48. package/dist/lib/index-render/projects.js +4 -2
  49. package/dist/lib/lanes/registry-flag.d.ts +23 -2
  50. package/dist/lib/lanes/registry-flag.js +256 -36
  51. package/dist/lib/personal-secrets-audit.d.ts +31 -0
  52. package/dist/lib/personal-secrets-audit.js +45 -0
  53. package/dist/sentry-before-send.js +1 -0
  54. package/dist/utils/access-denied-hint.js +6 -0
  55. package/dist/utils/client-health.d.ts +34 -0
  56. package/dist/utils/client-health.js +96 -3
  57. package/package.json +7 -7
@@ -0,0 +1,681 @@
1
+ /**
2
+ * `hq library rm <placeholder|original> [more...]` — remove assets from the
3
+ * company media library.
4
+ *
5
+ * A library asset is three things: the `.hqcloud` placeholder in the tree,
6
+ * the same placeholder synced to the company vault at its company-relative
7
+ * path, and the content-addressed blob `.hq-offload/blobs/<sha256>` it points
8
+ * at. A fetched asset also has its original file beside the placeholder.
9
+ *
10
+ * What `rm` removes, in this order:
11
+ * 1. the local original, when one is on disk and its sha256 matches the
12
+ * placeholder. Since hq-cloud #998, sync restores a deleted placeholder
13
+ * while the original sits beside it, so the original has to go first.
14
+ * 2. the local placeholder;
15
+ * 3. the vault placeholder key, through POST /v1/files/delete (the endpoint
16
+ * `hq files delete` uses; it versions the object and writes a tombstone so
17
+ * sync does not bring it back). If sync already pushed the delete, the
18
+ * key is simply gone.
19
+ * 4. with `--purge` only, the blob, once every placeholder that uses it is
20
+ * gone.
21
+ *
22
+ * Nothing changes until every target has been checked: each must be a
23
+ * `vault: company` placeholder with an `hq-asset` block whose key is a
24
+ * `.hq-offload/blobs/<sha256>` blob. Personal placeholders, plain offload
25
+ * placeholders and synced-path keys (key = the file's own path) are refused.
26
+ * A dry-run delete of each vault key runs first, so a credential that cannot
27
+ * delete (an HQ_API_KEY without files:write) is refused before any local file
28
+ * is touched.
29
+ *
30
+ * The blob is kept by default: the same bytes added under two names share one
31
+ * blob, and teammates may hold placeholders this machine never synced.
32
+ * `--purge` deletes it only when no other placeholder references it, checked
33
+ * in the company's local tree and then in the company vault (see
34
+ * cloudBlobReferences: a quick asset search, then every `.hqcloud` key the
35
+ * caller can list). If either check finds another reference, or the vault
36
+ * check cannot run, `--purge` is refused and nothing changes.
37
+ *
38
+ * The vault bucket is versioned, so `hq files restore <key>` undoes the vault
39
+ * side.
40
+ */
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import * as readline from "node:readline";
44
+ import chalk from "chalk";
45
+ import { isCloudPlaceholderName, parseCloudPlaceholder } from "@indigoai-us/hq-cloud";
46
+ import { OFFLOAD_PREFIX, PLACEHOLDER_SUFFIX, digestFile, resolvePlaceholder } from "./storage-offload.js";
47
+ /** A company library blob key: `.hq-offload/blobs/<64 lowercase hex>`. */
48
+ const BLOB_KEY_PATTERN = /^\.hq-offload\/blobs\/([0-9a-f]{64})$/;
49
+ /** hq-pro caps an asset search at 50 hits. */
50
+ const CLOUD_REFERENCE_SEARCH_LIMIT = 50;
51
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
52
+ function posixRelative(hqRoot, abs) {
53
+ return path.relative(fs.realpathSync(path.resolve(hqRoot)), abs).split(path.sep).join("/");
54
+ }
55
+ function isCompanyUid(ref) {
56
+ return ref.startsWith("cmp_");
57
+ }
58
+ /**
59
+ * Check one target and work out what removing it means. Throws with a message
60
+ * that names the target and says what to do instead.
61
+ */
62
+ async function planTarget(target, deps, hashFile) {
63
+ const placeholderAbs = resolvePlaceholder(deps.hqRoot, target);
64
+ const placeholderPath = posixRelative(deps.hqRoot, placeholderAbs);
65
+ const slug = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
66
+ if (!slug) {
67
+ throw new Error(`'${placeholderPath}' is not inside companies/<slug>/, so it is not a company library asset.`);
68
+ }
69
+ const placeholder = parseCloudPlaceholder(await fs.promises.readFile(placeholderAbs, "utf8"));
70
+ if (!placeholder)
71
+ throw new Error(`'${placeholderPath}' is not a valid HQ cloud placeholder.`);
72
+ if (placeholder.vault !== "company") {
73
+ throw new Error(`'${placeholderPath}' is a ${placeholder.vault} placeholder. hq library rm only removes company library assets; ` +
74
+ "use `hq storage fetch` to bring a personal file back instead.");
75
+ }
76
+ if (!placeholder.asset) {
77
+ throw new Error(`'${placeholderPath}' is not a library asset (it has no hq-asset block). ` +
78
+ "It is a storage offload placeholder; use `hq storage fetch` to bring the file back.");
79
+ }
80
+ const originalAbs = placeholderAbs.slice(0, -PLACEHOLDER_SUFFIX.length);
81
+ const originalPath = placeholderPath.slice(0, -PLACEHOLDER_SUFFIX.length);
82
+ const vaultKey = placeholderPath.slice(`companies/${slug}/`.length);
83
+ const blob = BLOB_KEY_PATTERN.exec(placeholder.key);
84
+ if (!blob) {
85
+ throw new Error(`'${placeholderPath}' points at '${placeholder.key}', not a library blob (${OFFLOAD_PREFIX}<sha256>). ` +
86
+ "That key holds the file at its own synced path, and removing this entry could delete it for everyone. " +
87
+ "Use `hq storage fetch` and `hq files delete` if you mean to delete that file.");
88
+ }
89
+ if (placeholder.sha256 !== null && placeholder.sha256 !== blob[1]) {
90
+ throw new Error(`'${placeholderPath}' records sha256 ${placeholder.sha256} but points at blob ${blob[1]}. ` +
91
+ "Refusing to remove an inconsistent placeholder.");
92
+ }
93
+ let originalOnDisk = false;
94
+ const st = await fs.promises.lstat(originalAbs).catch((err) => {
95
+ if (err.code === "ENOENT")
96
+ return null;
97
+ throw err;
98
+ });
99
+ if (st) {
100
+ if (!st.isFile()) {
101
+ throw new Error(`'${originalPath}' is on disk but is not a regular file. Move it out of the library folder first.`);
102
+ }
103
+ const sha = await hashFile(originalAbs);
104
+ if (sha !== blob[1]) {
105
+ throw new Error(`'${originalPath}' is on disk and differs from the library copy (sha256 ${sha.slice(0, 12)}…, ` +
106
+ `library ${blob[1].slice(0, 12)}…). Move it out of the library folder first if you want to keep it, then re-run.`);
107
+ }
108
+ originalOnDisk = true;
109
+ }
110
+ return {
111
+ slug,
112
+ planned: {
113
+ placeholderAbs,
114
+ originalAbs,
115
+ originalOnDisk,
116
+ item: {
117
+ placeholderPath,
118
+ originalPath,
119
+ vaultKey,
120
+ blobKey: placeholder.key,
121
+ original: originalOnDisk ? "would-remove" : "absent",
122
+ placeholder: "would-remove",
123
+ vault: "not-run",
124
+ },
125
+ },
126
+ };
127
+ }
128
+ function walkPlaceholders(directory, out) {
129
+ let entries;
130
+ try {
131
+ entries = fs.readdirSync(directory, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ const child = path.join(directory, entry.name);
138
+ if (entry.isDirectory()) {
139
+ if (!SKIP_DIRS.has(entry.name))
140
+ walkPlaceholders(child, out);
141
+ }
142
+ else if (entry.isFile() && isCloudPlaceholderName(entry.name)) {
143
+ out.push(child);
144
+ }
145
+ }
146
+ }
147
+ /** Local company placeholders, other than `exclude`, that point at `blobKey`. */
148
+ export function localBlobReferences(hqRoot, slug, blobKey, exclude) {
149
+ const files = [];
150
+ walkPlaceholders(path.join(fs.realpathSync(path.resolve(hqRoot)), "companies", slug), files);
151
+ const refs = [];
152
+ for (const file of files.sort()) {
153
+ if (exclude.has(file))
154
+ continue;
155
+ let text;
156
+ try {
157
+ if (fs.statSync(file).size >= 4096)
158
+ continue;
159
+ text = fs.readFileSync(file, "utf8");
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ const placeholder = parseCloudPlaceholder(text);
165
+ if (placeholder?.vault === "company" && placeholder.key === blobKey)
166
+ refs.push(posixRelative(hqRoot, file));
167
+ }
168
+ return refs;
169
+ }
170
+ /** Company-relative form of a search hit's placeholder path. */
171
+ function companyRelative(placeholderPath, slug) {
172
+ const anchored = `companies/${slug}/`;
173
+ return placeholderPath.startsWith(anchored) ? placeholderPath.slice(anchored.length) : placeholderPath;
174
+ }
175
+ /** Add `ref` to the reference list for `blobKey`. */
176
+ function addRef(refs, blobKey, ref) {
177
+ const list = refs.get(blobKey) ?? [];
178
+ if (!list.includes(ref))
179
+ list.push(ref);
180
+ refs.set(blobKey, list);
181
+ }
182
+ function denialReason(res, body) {
183
+ const missing = Array.isArray(body.missing) ? body.missing.join(", ") : typeof body.missing === "string" ? body.missing : "";
184
+ if (missing)
185
+ return `this API key is missing ${missing}`;
186
+ return typeof body.error === "string" ? body.error : typeof body.message === "string" ? body.message : `HTTP ${res.status}`;
187
+ }
188
+ /**
189
+ * Placeholders in the company vault, other than `exclude` (company-relative
190
+ * keys), that point at any of `blobKeys`. Returns blob key → placeholder keys.
191
+ *
192
+ * Two passes:
193
+ * 1. a quick asset search (`kind: "asset"`, query "hqcloud", which every
194
+ * placeholder path carries). Each hit returns its vaultKey, so a shared
195
+ * blob is usually found in one call. This pass cannot prove a blob is
196
+ * unused: hq-pro does not index the hashes and keys in a placeholder,
197
+ * caps a search at 50 hits with no paging, and may lag a fresh upload.
198
+ * Its errors are ignored; pass 2 decides.
199
+ * 2. when pass 1 finds nothing, every `.hqcloud` key the caller can list
200
+ * (GET /v1/files/list, as `hq files search` does), read in one batched
201
+ * presign and parsed. This lists the whole company vault, so it can take
202
+ * minutes on a large one. Any listing or read failure throws: a purge
203
+ * must not go ahead on a guess.
204
+ */
205
+ export async function cloudBlobReferences(blobKeys, slug, companyUid, exclude, deps) {
206
+ const wanted = new Set(blobKeys);
207
+ const refs = new Map();
208
+ try {
209
+ const res = await deps.searchAssets({
210
+ query: "hqcloud",
211
+ kind: "asset",
212
+ company: companyUid,
213
+ limit: CLOUD_REFERENCE_SEARCH_LIMIT,
214
+ });
215
+ const body = (await res.json().catch(() => ({})));
216
+ if (res.ok) {
217
+ for (const row of Array.isArray(body.results) ? body.results : []) {
218
+ const asset = row?.metadata?.asset;
219
+ if (!asset || typeof asset.vaultKey !== "string" || !wanted.has(asset.vaultKey))
220
+ continue;
221
+ if (typeof asset.placeholderPath !== "string")
222
+ continue;
223
+ const key = companyRelative(asset.placeholderPath, slug);
224
+ if (!exclude.has(key))
225
+ addRef(refs, asset.vaultKey, key);
226
+ }
227
+ }
228
+ }
229
+ catch {
230
+ // Pass 2 is authoritative.
231
+ }
232
+ if (refs.size > 0)
233
+ return refs;
234
+ deps.log(chalk.dim("Checking every placeholder in the company vault for the same blob (a large vault can take a few minutes)..."));
235
+ let keys;
236
+ try {
237
+ keys = (await deps.listVaultKeys(companyUid)).filter((key) => key.endsWith(PLACEHOLDER_SUFFIX) && !exclude.has(key));
238
+ }
239
+ catch (err) {
240
+ throw new Error(`Could not list the company vault to check for other placeholders: ${err.message}`);
241
+ }
242
+ if (keys.length === 0)
243
+ return refs;
244
+ let texts;
245
+ try {
246
+ texts = await deps.readVaultObjects(companyUid, keys);
247
+ }
248
+ catch (err) {
249
+ throw new Error(`Could not read the company vault's placeholders to check for other references: ${err.message}`);
250
+ }
251
+ const unreadable = [];
252
+ for (const key of keys) {
253
+ const text = texts.get(key);
254
+ if (text === undefined || text === null) {
255
+ unreadable.push(key);
256
+ continue;
257
+ }
258
+ const placeholder = parseCloudPlaceholder(text);
259
+ if (placeholder?.vault === "company" && wanted.has(placeholder.key))
260
+ addRef(refs, placeholder.key, key);
261
+ }
262
+ if (unreadable.length > 0) {
263
+ throw new Error(`Could not read ${unreadable.length} placeholder(s) in the company vault (${unreadable.slice(0, 3).join(", ")}` +
264
+ `${unreadable.length > 3 ? ", ..." : ""}), so the blob may still be in use.`);
265
+ }
266
+ return refs;
267
+ }
268
+ /** GET /v1/files/list for every key in the company vault, following the cursor. */
269
+ export async function listAllVaultKeys(companyUid, list) {
270
+ const keys = [];
271
+ let cursor;
272
+ for (let page = 0; page < 10_000; page++) {
273
+ const query = { company: companyUid };
274
+ if (cursor)
275
+ query.cursor = cursor;
276
+ const res = await list(query);
277
+ const body = (await res.json().catch(() => ({})));
278
+ if (!res.ok)
279
+ throw new Error(denialReason(res, body));
280
+ for (const object of Array.isArray(body.objects) ? body.objects : []) {
281
+ const key = object?.key;
282
+ if (typeof key === "string")
283
+ keys.push(key);
284
+ }
285
+ cursor = typeof body.cursor === "string" && body.cursor ? body.cursor : undefined;
286
+ if (!cursor)
287
+ return keys;
288
+ }
289
+ throw new Error("the vault listing did not end");
290
+ }
291
+ /** hq-pro accepts up to 1000 keys per presign request. */
292
+ const PRESIGN_BATCH = 1000;
293
+ const READ_CONCURRENCY = 8;
294
+ /**
295
+ * Read small vault objects: one batched presign (op "get") per 1000 keys, then
296
+ * a GET per URL. A key that cannot be presigned or read maps to null. Errors
297
+ * never include a URL, which carries a signature.
298
+ */
299
+ export async function readSmallVaultObjects(companyUid, keys, presign, get = (url) => fetch(url)) {
300
+ const out = new Map();
301
+ for (let i = 0; i < keys.length; i += PRESIGN_BATCH) {
302
+ const batch = keys.slice(i, i + PRESIGN_BATCH);
303
+ const res = await presign({ company: companyUid, op: "get", keys: batch });
304
+ const body = (await res.json().catch(() => ({})));
305
+ if (!res.ok)
306
+ throw new Error(denialReason(res, body));
307
+ const urls = new Map();
308
+ for (const row of Array.isArray(body.results) ? body.results : []) {
309
+ const r = (row ?? {});
310
+ if (typeof r.key === "string" && typeof r.url === "string")
311
+ urls.set(r.key, r.url);
312
+ }
313
+ const queue = [...batch];
314
+ const worker = async () => {
315
+ for (let key = queue.shift(); key !== undefined; key = queue.shift()) {
316
+ const url = urls.get(key);
317
+ if (!url) {
318
+ out.set(key, null);
319
+ continue;
320
+ }
321
+ try {
322
+ const got = await get(url);
323
+ const text = got.ok ? await got.text() : null;
324
+ // A placeholder is under 4 KiB; a larger `.hqcloud` object is not one
325
+ // and cannot reference a blob, so it reads as empty text.
326
+ out.set(key, text === null ? null : Buffer.byteLength(text) < 4096 ? text : "");
327
+ }
328
+ catch {
329
+ out.set(key, null);
330
+ }
331
+ }
332
+ };
333
+ await Promise.all(Array.from({ length: READ_CONCURRENCY }, worker));
334
+ }
335
+ return out;
336
+ }
337
+ function describeItem(p, dryRun) {
338
+ const verb = dryRun ? "would remove" : "remove";
339
+ const lines = [` ${chalk.bold(p.item.placeholderPath)}`];
340
+ if (p.originalOnDisk)
341
+ lines.push(` local original: ${p.item.originalPath} (${verb}, sha256 matches the library copy)`);
342
+ lines.push(` local placeholder: ${verb}`);
343
+ lines.push(p.vaultHas
344
+ ? ` vault key: ${p.item.vaultKey} (${verb})`
345
+ : ` vault key: ${p.item.vaultKey} (not in the vault; nothing to remove there)`);
346
+ return lines;
347
+ }
348
+ /**
349
+ * Testable core. Checks every target, previews, confirms, then removes in the
350
+ * order described at the top of this file.
351
+ */
352
+ export async function runLibraryRemove(targets, options, deps) {
353
+ if (targets.length === 0)
354
+ throw new Error("Pass at least one library placeholder or original path.");
355
+ const hashFile = deps.hashFile ?? (async (file) => (await digestFile(file)).sha256);
356
+ const removeFile = deps.removeFile ?? ((file) => fs.promises.rm(file));
357
+ const dryRun = options.dryRun === true;
358
+ const purge = options.purge === true;
359
+ // 1. Check every target. Nothing changes if any of them is refused.
360
+ const planned = [];
361
+ const seen = new Set();
362
+ let slug;
363
+ for (const target of targets) {
364
+ const plan = await planTarget(target, deps, hashFile);
365
+ if (slug !== undefined && plan.slug !== slug) {
366
+ throw new Error(`All targets must be in one company; got companies/${slug}/ and companies/${plan.slug}/.`);
367
+ }
368
+ slug = plan.slug;
369
+ if (seen.has(plan.planned.placeholderAbs))
370
+ continue;
371
+ seen.add(plan.planned.placeholderAbs);
372
+ planned.push(plan.planned);
373
+ }
374
+ const company = slug;
375
+ if (options.company !== undefined && !isCompanyUid(options.company) && options.company !== company) {
376
+ throw new Error(`The targets are in companies/${company}/, but --company is ${options.company}.`);
377
+ }
378
+ const companyUid = await deps.resolveCompany(company);
379
+ if (options.company !== undefined && isCompanyUid(options.company) && options.company !== companyUid) {
380
+ throw new Error(`The targets are in companies/${company}/ (${companyUid}), but --company is ${options.company}.`);
381
+ }
382
+ // 2. Ask the vault what it holds, with the delete endpoint's own dry run.
383
+ // This is also the permission check: a credential that cannot delete is
384
+ // refused here, before any local file is touched.
385
+ const plans = [];
386
+ for (const p of planned) {
387
+ const preview = await deps.deleteKey(companyUid, p.item.vaultKey, true);
388
+ plans.push({ ...p, vaultHas: preview.matched > 0 });
389
+ }
390
+ // A local original is normally an untracked copy of the blob. If the vault
391
+ // also holds it at its own path, it is a synced file, and deleting it here
392
+ // would let sync delete that copy for everyone. Leave that to the user.
393
+ for (const p of plans) {
394
+ if (!p.originalOnDisk)
395
+ continue;
396
+ const originalKey = p.item.originalPath.slice(`companies/${company}/`.length);
397
+ const preview = await deps.deleteKey(companyUid, originalKey, true);
398
+ if (preview.matched > 0) {
399
+ throw new Error(`'${p.item.originalPath}' is also stored in the vault at its own path, so it is a synced file, not only a ` +
400
+ "fetched copy. Move it out of the library folder (or delete it with `hq files delete`) first, then re-run. " +
401
+ "Nothing was removed.");
402
+ }
403
+ }
404
+ for (const p of plans)
405
+ p.item.vault = p.vaultHas ? "would-remove" : "absent";
406
+ // 3. Blobs. Kept unless --purge, and purged only when nothing else uses them.
407
+ const blobKeys = [...new Set(plans.map((p) => p.item.blobKey))];
408
+ const blobs = [];
409
+ if (purge) {
410
+ const removingFiles = new Set(plans.map((p) => p.placeholderAbs));
411
+ const removingKeys = new Set(plans.map((p) => p.item.vaultKey));
412
+ const problems = [];
413
+ const localRefs = new Map(blobKeys.map((key) => [key, localBlobReferences(deps.hqRoot, company, key, removingFiles)]));
414
+ // Local references are enough to refuse; only ask the vault when the tree has none.
415
+ const cloudRefs = [...localRefs.values()].some((refs) => refs.length > 0)
416
+ ? new Map()
417
+ : await cloudBlobReferences(blobKeys, company, companyUid, removingKeys, deps);
418
+ for (const key of blobKeys) {
419
+ const local = localRefs.get(key) ?? [];
420
+ const cloud = (cloudRefs.get(key) ?? []).filter((ref) => !local.includes(`companies/${company}/${ref}`));
421
+ const refs = [...local, ...cloud.map((ref) => `${ref} (in the vault)`)];
422
+ if (refs.length > 0)
423
+ problems.push(`${key} is also used by: ${refs.join(", ")}`);
424
+ }
425
+ if (problems.length > 0) {
426
+ throw new Error(`Refusing --purge: other library placeholders point at the same blob.\n ${problems.join("\n ")}\n` +
427
+ "Remove those too in the same command, or re-run without --purge to keep the blob. Nothing was removed.");
428
+ }
429
+ for (const key of blobKeys) {
430
+ const preview = await deps.deleteKey(companyUid, key, true);
431
+ blobs.push({ key, state: preview.matched > 0 ? "would-remove" : "absent" });
432
+ }
433
+ }
434
+ else {
435
+ for (const key of blobKeys)
436
+ blobs.push({ key, state: "kept" });
437
+ }
438
+ const undo = [
439
+ ...plans.filter((p) => p.vaultHas).map((p) => `hq files restore ${p.item.vaultKey} --company ${company}`),
440
+ ...blobs.filter((b) => b.state === "would-remove").map((b) => `hq files restore ${b.key} --company ${company}`),
441
+ ];
442
+ const result = {
443
+ company,
444
+ companyUid,
445
+ dryRun,
446
+ purge,
447
+ confirmed: false,
448
+ items: plans.map((p) => p.item),
449
+ blobs,
450
+ undo,
451
+ };
452
+ // 4. Preview.
453
+ const noun = plans.length === 1 ? "library asset" : "library assets";
454
+ deps.log(chalk.yellow(`${dryRun ? "[dry-run] Would remove" : "About to remove"} ${plans.length} ${noun} from companies/${company}/:`));
455
+ for (const p of plans)
456
+ for (const line of describeItem(p, dryRun))
457
+ deps.log(line);
458
+ for (const blob of blobs) {
459
+ deps.log(blob.state === "kept"
460
+ ? chalk.dim(` blob ${blob.key}: kept (other placeholders may use it; pass --purge to delete it)`)
461
+ : blob.state === "absent"
462
+ ? chalk.dim(` blob ${blob.key}: not in the vault`)
463
+ : ` blob ${blob.key}: ${dryRun ? "would delete" : "delete"} (no other placeholder uses it)`);
464
+ }
465
+ if (dryRun) {
466
+ deps.log(chalk.dim("Nothing was removed (--dry-run)."));
467
+ return result;
468
+ }
469
+ // 5. Confirm.
470
+ if (!options.yes) {
471
+ const ok = await deps.confirm(`Remove ${plans.length} ${noun}? This deletes them from the shared vault for everyone` +
472
+ (plans.some((p) => p.originalOnDisk) ? " and deletes the local originals listed above" : "") +
473
+ ".");
474
+ if (!ok) {
475
+ for (const p of plans) {
476
+ p.item.original = p.originalOnDisk ? "kept" : "absent";
477
+ p.item.placeholder = "kept";
478
+ p.item.vault = p.vaultHas ? "kept" : "absent";
479
+ }
480
+ for (const blob of blobs)
481
+ if (blob.state === "would-remove")
482
+ blob.state = "kept";
483
+ deps.log(chalk.dim("Aborted. Nothing was removed."));
484
+ return result;
485
+ }
486
+ }
487
+ result.confirmed = true;
488
+ // 6. Remove: local original, local placeholder, vault key. A failed step
489
+ // stops that asset; the others still run.
490
+ const failedBlobs = new Set();
491
+ for (const p of plans) {
492
+ const { item } = p;
493
+ try {
494
+ if (p.originalOnDisk) {
495
+ item.original = "failed";
496
+ await removeFile(p.originalAbs);
497
+ item.original = "removed";
498
+ }
499
+ item.placeholder = "failed";
500
+ await removeFile(p.placeholderAbs);
501
+ item.placeholder = "removed";
502
+ if (p.vaultHas) {
503
+ item.vault = "failed";
504
+ await deps.deleteKey(companyUid, item.vaultKey, false);
505
+ // matched 0 here means sync pushed the delete first: the key is gone either way.
506
+ item.vault = "removed";
507
+ }
508
+ }
509
+ catch (err) {
510
+ item.error = err.message;
511
+ if (item.placeholder === "would-remove")
512
+ item.placeholder = "not-run";
513
+ if (item.vault === "would-remove")
514
+ item.vault = "not-run";
515
+ failedBlobs.add(item.blobKey);
516
+ }
517
+ }
518
+ // 7. Purge blobs whose placeholders were all removed.
519
+ for (const blob of blobs) {
520
+ if (blob.state !== "would-remove")
521
+ continue;
522
+ if (failedBlobs.has(blob.key)) {
523
+ blob.state = "not-run";
524
+ blob.error = "kept because removing a placeholder that uses it failed";
525
+ continue;
526
+ }
527
+ try {
528
+ blob.state = "failed";
529
+ await deps.deleteKey(companyUid, blob.key, false);
530
+ blob.state = "removed";
531
+ }
532
+ catch (err) {
533
+ blob.error = err.message;
534
+ }
535
+ }
536
+ return result;
537
+ }
538
+ export function formatLibraryRemoveResult(result) {
539
+ if (result.dryRun || !result.confirmed)
540
+ return "";
541
+ const lines = [];
542
+ for (const item of result.items) {
543
+ if (item.error) {
544
+ lines.push(`${chalk.red("Not removed")} ${item.placeholderPath}: ${item.error}`);
545
+ const done = [
546
+ item.original === "removed" ? "local original" : "",
547
+ item.placeholder === "removed" ? "local placeholder" : "",
548
+ ].filter(Boolean);
549
+ if (done.length > 0)
550
+ lines.push(chalk.yellow(` Already removed: ${done.join(", ")}.`));
551
+ continue;
552
+ }
553
+ const parts = [
554
+ item.original === "removed" ? "local original" : "",
555
+ "local placeholder",
556
+ item.vault === "removed" ? `vault key ${item.vaultKey}` : "",
557
+ ].filter(Boolean);
558
+ lines.push(`${chalk.green("Removed")} ${item.placeholderPath} (${parts.join(", ")})`);
559
+ }
560
+ for (const blob of result.blobs) {
561
+ if (blob.state === "removed")
562
+ lines.push(`${chalk.green("Deleted")} blob ${blob.key}`);
563
+ else if (blob.state === "kept")
564
+ lines.push(chalk.dim(`Kept blob ${blob.key}.`));
565
+ else if (blob.error)
566
+ lines.push(`${chalk.red("Blob not deleted")} ${blob.key}: ${blob.error}`);
567
+ }
568
+ if (result.undo.length > 0) {
569
+ lines.push(chalk.dim("The vault keeps prior versions. To undo the vault side, run:"));
570
+ for (const cmd of result.undo)
571
+ lines.push(chalk.dim(` ${cmd}`));
572
+ lines.push(chalk.dim("then `hq sync` to bring the placeholder back, and `hq library fetch` for the original."));
573
+ }
574
+ return lines.join("\n");
575
+ }
576
+ /**
577
+ * User-facing message for a failed POST /v1/files/delete. `err` is duck-typed
578
+ * on `status` (files.ts's FilesDeleteHttpError) so this module does not load
579
+ * files.ts at startup.
580
+ */
581
+ export function vaultDeleteErrorMessage(err, key, apiKey, format) {
582
+ const status = err?.status;
583
+ if (typeof status !== "number")
584
+ return err.message;
585
+ if (status === 403 && apiKey) {
586
+ return (`This API key cannot remove library assets: deleting '${key}' needs files:write ` +
587
+ `(${err.message}). Use a key with files:write, or unset HQ_API_KEY and run signed in.`);
588
+ }
589
+ return format(err, key);
590
+ }
591
+ /** Yes/no prompt on stderr, so `--json` stdout stays machine-readable. */
592
+ function confirmOnStderr(message) {
593
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
594
+ return new Promise((resolve) => {
595
+ rl.question(`${message} [y/N] `, (answer) => {
596
+ rl.close();
597
+ resolve(/^y(es)?$/i.test(answer.trim()));
598
+ });
599
+ });
600
+ }
601
+ /**
602
+ * Company resolver for rm. A session resolves slugs through the API. An
603
+ * HQ_API_KEY cannot (hq-pro serves no keyed membership lookup), so it maps the
604
+ * placeholder folder's slug, or an explicit `--company` slug or uid, through
605
+ * companies/manifest.yaml with library.ts's libraryCompanyForApiKey; hq-pro
606
+ * still checks the uid against the key on every delete.
607
+ */
608
+ export async function removeCompanyResolver(credential, hqRoot, explicit) {
609
+ const { getCompanyUid } = await import("../utils/vault-api.js");
610
+ if (credential.kind === "cognito")
611
+ return (slug) => getCompanyUid(credential.token, slug);
612
+ // The same manifest mapping `add`, `describe` and `link` use on a key
613
+ // (#2080). Imported lazily: library.ts registers this module.
614
+ const [{ readCompaniesManifestMap }, { libraryCompanyForApiKey }] = await Promise.all([
615
+ import("../lib/work-context/repo-remote.js"),
616
+ import("./library.js"),
617
+ ]);
618
+ return async (slug) => libraryCompanyForApiKey(readCompaniesManifestMap(hqRoot), explicit ?? slug, slug).uid;
619
+ }
620
+ export function registerLibraryRemoveCommand(library) {
621
+ library
622
+ .command("rm")
623
+ .alias("remove")
624
+ .description("Remove library assets: the local original (if fetched), the placeholder, and its vault copy. " +
625
+ "Keeps the shared blob unless --purge. Previews and asks first unless --yes")
626
+ .argument("<targets...>", "Library .hqcloud placeholders (or their original paths)")
627
+ .option("--company <company>", "Company slug (or cmp_ uid); default: the targets' companies/<slug>/")
628
+ .option("--purge", "Also delete the blob, after checking no other placeholder (local or in the vault) uses it")
629
+ .option("--dry-run", "Show what would be removed without changing anything")
630
+ .option("-y, --yes", "Skip the confirmation prompt")
631
+ .option("--json", "Print machine-readable JSON (the preview goes to stderr)")
632
+ .action(async (targets, options) => {
633
+ try {
634
+ const [{ resolveVaultCredentialForCapability }, { vaultApiFetch }, { findHqRoot }] = await Promise.all([
635
+ import("../utils/resolve-vault-credential.js"),
636
+ import("../utils/vault-api.js"),
637
+ import("../utils/manifest.js"),
638
+ ]);
639
+ const credential = await resolveVaultCredentialForCapability("files:write", "library rm", {
640
+ interactive: true,
641
+ });
642
+ const { token } = credential;
643
+ const apiKey = credential.kind === "api-key";
644
+ const hqRoot = findHqRoot();
645
+ const log = options.json ? (line) => console.error(line) : (line) => console.log(line);
646
+ const result = await runLibraryRemove(targets, { company: options.company, purge: options.purge, dryRun: options.dryRun, yes: options.yes }, {
647
+ hqRoot,
648
+ resolveCompany: await removeCompanyResolver(credential, hqRoot, options.company),
649
+ deleteKey: async (companyUid, key, dryRun) => {
650
+ const { callDeleteEndpoint, formatFilesDeleteError } = await import("./files.js");
651
+ try {
652
+ return await callDeleteEndpoint({ token, companyUid, prefix: key, dryRun });
653
+ }
654
+ catch (err) {
655
+ throw new Error(vaultDeleteErrorMessage(err, key, apiKey, formatFilesDeleteError));
656
+ }
657
+ },
658
+ searchAssets: (body) => vaultApiFetch({ token, path: "/v1/search", method: "POST", body }),
659
+ listVaultKeys: (companyUid) => listAllVaultKeys(companyUid, (query) => vaultApiFetch({ token, path: "/v1/files/list", query })),
660
+ readVaultObjects: (companyUid, keys) => readSmallVaultObjects(companyUid, keys, (body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body })),
661
+ confirm: confirmOnStderr,
662
+ log,
663
+ });
664
+ if (options.json)
665
+ console.log(JSON.stringify(result, null, 2));
666
+ else {
667
+ const text = formatLibraryRemoveResult(result);
668
+ if (text)
669
+ console.log(text);
670
+ }
671
+ if (result.items.some((item) => item.error) || result.blobs.some((blob) => blob.error && blob.state === "failed")) {
672
+ process.exitCode = 1;
673
+ }
674
+ }
675
+ catch (err) {
676
+ console.error(chalk.red(err.message));
677
+ process.exitCode = 1;
678
+ }
679
+ });
680
+ }
681
+ //# sourceMappingURL=library-remove.js.map