@indigoai-us/hq-cli 5.345.153 → 5.345.155

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 (33) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/command-catalog.generated.d.ts +54 -6
  3. package/dist/command-catalog.generated.js +69 -6
  4. package/dist/commands/auth.d.ts +2 -0
  5. package/dist/commands/auth.js +92 -3
  6. package/dist/commands/cloud.d.ts +17 -0
  7. package/dist/commands/cloud.js +48 -1
  8. package/dist/commands/lanes.js +7 -1
  9. package/dist/commands/library-add.d.ts +6 -2
  10. package/dist/commands/library-add.js +45 -6
  11. package/dist/commands/library-caption.d.ts +38 -1
  12. package/dist/commands/library-caption.js +304 -3
  13. package/dist/commands/library-link.d.ts +7 -3
  14. package/dist/commands/library-link.js +10 -4
  15. package/dist/commands/library-remove.d.ts +122 -0
  16. package/dist/commands/library-remove.js +681 -0
  17. package/dist/commands/library.d.ts +26 -1
  18. package/dist/commands/library.js +101 -14
  19. package/dist/commands/secrets.js +72 -5
  20. package/dist/commands/storage-offload.d.ts +22 -4
  21. package/dist/commands/storage-offload.js +196 -2
  22. package/dist/commands/storage.js +5 -0
  23. package/dist/lib/daemon/machine-login.d.ts +20 -0
  24. package/dist/lib/daemon/machine-login.js +53 -8
  25. package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
  26. package/dist/lib/daemon/machine-token-identity.js +123 -0
  27. package/dist/lib/lanes/registry-flag.d.ts +23 -2
  28. package/dist/lib/lanes/registry-flag.js +256 -36
  29. package/dist/lib/personal-secrets-audit.d.ts +31 -0
  30. package/dist/lib/personal-secrets-audit.js +45 -0
  31. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  32. package/dist/lib/sync/read-only-local-only.js +122 -0
  33. package/package.json +7 -7
@@ -29,10 +29,10 @@ import https from "node:https";
29
29
  import path from "node:path";
30
30
  import { Readable } from "node:stream";
31
31
  import { formatCloudAssetBlock, formatCloudPlaceholder, isCoveredByAny, parseCloudPlaceholder, resolvePullScope, } from "@indigoai-us/hq-cloud";
32
- import { PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
32
+ import { CONTENT_SHA256_METADATA_KEY, PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
33
33
  import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
34
34
  /** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
35
- const CONTENT_SHA256_META_KEY = "hq-content-sha256";
35
+ const CONTENT_SHA256_META_KEY = CONTENT_SHA256_METADATA_KEY;
36
36
  const MEDIA_TYPES = {
37
37
  ".png": "image/png",
38
38
  ".jpg": "image/jpeg",
@@ -42,6 +42,7 @@ const MEDIA_TYPES = {
42
42
  ".svg": "image/svg+xml",
43
43
  ".avif": "image/avif",
44
44
  ".heic": "image/heic",
45
+ ".heif": "image/heif",
45
46
  ".mp4": "video/mp4",
46
47
  ".mov": "video/quicktime",
47
48
  ".webm": "video/webm",
@@ -136,6 +137,41 @@ export function resolveLibraryDir(hqRoot, options) {
136
137
  }
137
138
  return { slug, dirRel, dirAbs };
138
139
  }
140
+ /**
141
+ * The company and library folder for `add`. `--company` may be a slug or a
142
+ * cmp_ uid; the folder always uses the company's slug, so a uid never becomes
143
+ * a companies/cmp_…/ folder. A slug that disagrees with `--to` is refused
144
+ * before any lookup, as it always was.
145
+ */
146
+ async function resolveLibraryTarget(options, deps) {
147
+ const companyIsUid = options.company !== undefined && isCompanyUid(options.company);
148
+ if (!companyIsUid)
149
+ resolveLibraryDir(deps.hqRoot, options);
150
+ const folderSlug = options.to === undefined ? undefined : resolveLibraryDir(deps.hqRoot, { to: options.to }).slug;
151
+ const company = await deps.resolveCompany((options.company ?? folderSlug), folderSlug);
152
+ if (isCompanyUid(company.slug)) {
153
+ throw new Error(`No company folder is known for ${company.uid}; library folders are companies/<slug>/. ` +
154
+ "Pass --company <slug> or --to companies/<slug>/<dir>.");
155
+ }
156
+ if (folderSlug !== undefined && company.slug !== folderSlug) {
157
+ throw new Error(`--to is inside companies/${folderSlug}/, but --company ${options.company} is ${company.slug}.`);
158
+ }
159
+ return { company, ...resolveLibraryDir(deps.hqRoot, { company: company.slug, to: options.to }) };
160
+ }
161
+ function isCompanyUid(ref) {
162
+ return ref.startsWith("cmp_");
163
+ }
164
+ /**
165
+ * Capabilities an API key lacks, from hq-pro's keyed-route 403
166
+ * (`{ error, granted, missing }`). Empty for any other response.
167
+ */
168
+ function missingKeyCapabilities(status, body) {
169
+ if (status !== 403)
170
+ return [];
171
+ if (typeof body.missing === "string" && body.missing)
172
+ return [body.missing];
173
+ return Array.isArray(body.missing) ? body.missing.filter((c) => typeof c === "string") : [];
174
+ }
139
175
  async function hashStream(stream) {
140
176
  const hash = crypto.createHash("sha256");
141
177
  let size = 0;
@@ -212,8 +248,7 @@ async function uploadVerified(source, digest, grant, deps) {
212
248
  export async function runLibraryAdd(inputs, options, deps) {
213
249
  if (inputs.length === 0)
214
250
  throw new Error("Pass at least one file or URL.");
215
- const { slug, dirRel, dirAbs } = resolveLibraryDir(deps.hqRoot, options);
216
- const company = await deps.resolveCompany(slug);
251
+ const { company, slug, dirRel, dirAbs } = await resolveLibraryTarget(options, deps);
217
252
  const items = [];
218
253
  const planned = [];
219
254
  const claimed = new Set();
@@ -268,9 +303,13 @@ export async function runLibraryAdd(inputs, options, deps) {
268
303
  }
269
304
  const body = (await response.json().catch(() => ({})));
270
305
  if (!response.ok) {
271
- const msg = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
306
+ const missing = missingKeyCapabilities(response.status, body);
307
+ const msg = missing.length > 0
308
+ ? `This API key cannot upload (missing ${missing.join(", ")}). Use a key made with ` +
309
+ "`hq api-keys create --preset library-agent --allow-upload`, or unset HQ_API_KEY and run signed in."
310
+ : `Could not get an upload URL: ${typeof body.error === "string" ? body.error : `HTTP ${response.status}`}`;
272
311
  for (const p of planned)
273
- p.item.error = `Could not get an upload URL: ${msg}`;
312
+ p.item.error = msg;
274
313
  return { company: slug, items };
275
314
  }
276
315
  const rows = Array.isArray(body.results) ? body.results : [];
@@ -105,8 +105,45 @@ export declare function runLibraryDescribe(target: string, deps: LibraryDescribe
105
105
  export type RunTool = (command: string, args: string[]) => Promise<{
106
106
  stdout: string;
107
107
  }>;
108
+ /**
109
+ * HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
110
+ * bytes are not a format Claude Code reads directly. AVIF (also a HEIF
111
+ * container) is not included.
112
+ */
113
+ export declare function isHeifImage(head: Buffer, name: string): boolean;
114
+ /** Width and height from a JPEG's start-of-frame marker, or null. */
115
+ export declare function jpegDimensions(bytes: Buffer): {
116
+ width: number;
117
+ height: number;
118
+ } | null;
119
+ /** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
120
+ export declare function isUsableJpegFrame(bytes: Buffer): boolean;
121
+ /** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
122
+ export declare function jpegExifOrientation(bytes: Buffer): number;
123
+ /** The JPEG without its APP1 (EXIF and XMP) segments. */
124
+ export declare function stripJpegApp1(bytes: Buffer): Buffer;
125
+ interface TileGrid {
126
+ width: number;
127
+ height: number;
128
+ left: number;
129
+ top: number;
130
+ tiles: Array<{
131
+ stream: number;
132
+ x: number;
133
+ y: number;
134
+ }>;
135
+ }
136
+ /**
137
+ * The primary tile grid from `ffprobe -show_stream_groups -of json`: the
138
+ * "Tile Grid" group marked default, else the first one. Null when the output
139
+ * does not have the expected shape (older ffmpeg has no tile grid groups).
140
+ */
141
+ export declare function primaryTileGrid(ffprobeJson: string): TileGrid | null;
142
+ /** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
143
+ export declare function tileGridFilter(grid: TileGrid): string;
108
144
  /** Seek points at 10%, 35%, 60% and 85% of a known duration. */
109
145
  export declare function videoSeekPoints(durationSeconds: number | null): number[];
110
146
  /** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
111
- export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool): Promise<Buffer[]>;
147
+ export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool, platform?: NodeJS.Platform): Promise<Buffer[]>;
148
+ export {};
112
149
  //# sourceMappingURL=library-caption.d.ts.map
@@ -5,8 +5,11 @@
5
5
  * Captioning runs on the user's machine through the local Claude Code CLI
6
6
  * (`claude -p`, via the provider-launcher seam). No cloud caption service is
7
7
  * called. Up to 4 frames are written to a private temp dir: an image is used
8
- * as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise ffmpeg
9
- * re-encodes it to a 1024px JPEG. A video is sampled at up to 4 seek points,
8
+ * as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise it is
9
+ * re-encoded to a 1024px JPEG. A HEIC/HEIF is decoded to its primary image
10
+ * with sips (macOS), ImageMagick, or ffmpeg's tile grid; any other image, and
11
+ * a HEIC none of those can decode, goes through ffmpeg's default stream. A
12
+ * video is sampled at up to 4 seek points,
10
13
  * falling back to the first frame. Claude Code reads the frames with its Read
11
14
  * tool only and replies with JSON { description, tags }.
12
15
  *
@@ -339,6 +342,297 @@ async function jpegFrame(run, file, out, seconds) {
339
342
  return null;
340
343
  }
341
344
  }
345
+ // ── HEIC / HEIF primary image ───────────────────────────────────────────────
346
+ /** ftyp brands of HEVC-coded HEIF images (HEIC) and generic HEIF image files. */
347
+ const HEIF_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs", "mif1", "msf1", "mif2"]);
348
+ /** HEVC-specific brands: these mark a file as HEIC even when it also lists AVIF brands. */
349
+ const HEVC_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs"]);
350
+ const AVIF_BRANDS = new Set(["avif", "avis"]);
351
+ const HEIF_EXTENSIONS = new Set([".heic", ".heif", ".hif"]);
352
+ /** Bytes read to sniff the ftyp box. */
353
+ const HEIF_SNIFF_BYTES = 64;
354
+ /** A decoded frame smaller than this on either edge is not a usable picture. */
355
+ const MIN_FRAME_EDGE_PX = 16;
356
+ /** The brands in a leading ISO BMFF `ftyp` box, or null when there is none. */
357
+ function ftypBrands(head) {
358
+ if (head.length < 16 || head.subarray(4, 8).toString("latin1") !== "ftyp")
359
+ return null;
360
+ const boxSize = head.readUInt32BE(0);
361
+ const end = Math.min(head.length, boxSize >= 16 ? boxSize : head.length);
362
+ const brands = [head.subarray(8, 12).toString("latin1")];
363
+ for (let at = 16; at + 4 <= end; at += 4)
364
+ brands.push(head.subarray(at, at + 4).toString("latin1"));
365
+ return brands;
366
+ }
367
+ /**
368
+ * HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
369
+ * bytes are not a format Claude Code reads directly. AVIF (also a HEIF
370
+ * container) is not included.
371
+ */
372
+ export function isHeifImage(head, name) {
373
+ const brands = ftypBrands(head);
374
+ if (brands) {
375
+ if (brands.some((brand) => HEVC_BRANDS.has(brand)))
376
+ return true;
377
+ if (brands.some((brand) => AVIF_BRANDS.has(brand)))
378
+ return false;
379
+ if (brands.some((brand) => HEIF_BRANDS.has(brand)))
380
+ return true;
381
+ }
382
+ return HEIF_EXTENSIONS.has(path.extname(name).toLowerCase()) && !isCaptionableImage(head);
383
+ }
384
+ /** Width and height from a JPEG's start-of-frame marker, or null. */
385
+ export function jpegDimensions(bytes) {
386
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
387
+ return null;
388
+ let at = 2;
389
+ while (at + 4 <= bytes.length) {
390
+ if (bytes[at] !== 0xff)
391
+ return null;
392
+ const marker = bytes[at + 1];
393
+ if (marker === 0xff) {
394
+ at += 1;
395
+ continue;
396
+ }
397
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
398
+ at += 2;
399
+ continue;
400
+ }
401
+ if (marker === 0xd9 || marker === 0xda)
402
+ return null;
403
+ const length = bytes.readUInt16BE(at + 2);
404
+ if (length < 2)
405
+ return null;
406
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
407
+ if (isSof) {
408
+ if (at + 9 > bytes.length)
409
+ return null;
410
+ return { height: bytes.readUInt16BE(at + 5), width: bytes.readUInt16BE(at + 7) };
411
+ }
412
+ at += 2 + length;
413
+ }
414
+ return null;
415
+ }
416
+ /** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
417
+ export function isUsableJpegFrame(bytes) {
418
+ if (bytes.length === 0 || bytes.length > CAPTION_MAX_FRAME_BYTES)
419
+ return false;
420
+ if (!(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff))
421
+ return false;
422
+ const size = jpegDimensions(bytes);
423
+ if (!size)
424
+ return false;
425
+ const edges = [size.width, size.height];
426
+ return edges.every((edge) => edge >= MIN_FRAME_EDGE_PX && edge <= FRAME_EDGE_PX);
427
+ }
428
+ /** Run one decoder that writes `out`, and keep its output only when it is a usable JPEG. */
429
+ async function decodedFrame(run, command, args, out) {
430
+ try {
431
+ await fs.promises.rm(out, { force: true });
432
+ await run(command, args);
433
+ const bytes = await fs.promises.readFile(out);
434
+ return isUsableJpegFrame(bytes) ? bytes : null;
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ }
440
+ /** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
441
+ export function jpegExifOrientation(bytes) {
442
+ for (const segment of jpegSegments(bytes)) {
443
+ if (segment.marker !== 0xe1)
444
+ continue;
445
+ const body = bytes.subarray(segment.start + 4, segment.end);
446
+ if (body.length < 14 || body.subarray(0, 6).toString("latin1") !== "Exif\0\0")
447
+ continue;
448
+ const tiff = body.subarray(6);
449
+ const order = tiff.subarray(0, 2).toString("latin1");
450
+ if (order !== "II" && order !== "MM")
451
+ continue;
452
+ const u16 = (at) => (order === "II" ? tiff.readUInt16LE(at) : tiff.readUInt16BE(at));
453
+ const u32 = (at) => (order === "II" ? tiff.readUInt32LE(at) : tiff.readUInt32BE(at));
454
+ const ifd = u32(4);
455
+ if (ifd + 2 > tiff.length)
456
+ continue;
457
+ const count = u16(ifd);
458
+ for (let i = 0; i < count; i++) {
459
+ const entry = ifd + 2 + i * 12;
460
+ if (entry + 12 > tiff.length)
461
+ break;
462
+ if (u16(entry) === 0x0112) {
463
+ const value = u16(entry + 8);
464
+ return value >= 1 && value <= 8 ? value : 1;
465
+ }
466
+ }
467
+ }
468
+ return 1;
469
+ }
470
+ /** Marker segments before the scan: `start` is the 0xFF byte, `end` is past the segment. */
471
+ function jpegSegments(bytes) {
472
+ const segments = [];
473
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
474
+ return segments;
475
+ let at = 2;
476
+ while (at + 4 <= bytes.length && bytes[at] === 0xff) {
477
+ const marker = bytes[at + 1];
478
+ if (marker === 0xda || marker === 0xd9)
479
+ break;
480
+ const length = bytes.readUInt16BE(at + 2);
481
+ if (length < 2 || at + 2 + length > bytes.length)
482
+ break;
483
+ segments.push({ marker, start: at, end: at + 2 + length });
484
+ at += 2 + length;
485
+ }
486
+ return segments;
487
+ }
488
+ /** The JPEG without its APP1 (EXIF and XMP) segments. */
489
+ export function stripJpegApp1(bytes) {
490
+ const drop = jpegSegments(bytes).filter((segment) => segment.marker === 0xe1);
491
+ if (drop.length === 0)
492
+ return bytes;
493
+ const parts = [];
494
+ let at = 0;
495
+ for (const segment of drop) {
496
+ parts.push(bytes.subarray(at, segment.start));
497
+ at = segment.end;
498
+ }
499
+ parts.push(bytes.subarray(at));
500
+ return Buffer.concat(parts);
501
+ }
502
+ /** sips steps that turn stored pixels upright for each EXIF orientation (rotations are clockwise). */
503
+ const SIPS_UPRIGHT = {
504
+ 2: [["-f", "horizontal"]],
505
+ 3: [["-r", "180"]],
506
+ 4: [["-f", "vertical"]],
507
+ 5: [["-r", "90"], ["-f", "horizontal"]],
508
+ 6: [["-r", "90"]],
509
+ 7: [["-r", "90"], ["-f", "vertical"]],
510
+ 8: [["-r", "270"]],
511
+ };
512
+ /**
513
+ * sips keeps the stored pixel order and copies the EXIF orientation, so a
514
+ * HEIC shot upside down or sideways decodes rotated. Rotate the pixels
515
+ * upright with sips and drop the EXIF/XMP segments so nothing rotates them
516
+ * again. When a rotation step fails, the frame is kept with its EXIF intact.
517
+ */
518
+ async function uprightSipsFrame(run, frame, out) {
519
+ const steps = SIPS_UPRIGHT[jpegExifOrientation(frame)];
520
+ if (!steps)
521
+ return stripJpegApp1(frame);
522
+ try {
523
+ for (const step of steps)
524
+ await run("sips", [...step, out]);
525
+ const rotated = await fs.promises.readFile(out);
526
+ const upright = stripJpegApp1(rotated);
527
+ return isUsableJpegFrame(upright) ? upright : frame;
528
+ }
529
+ catch {
530
+ return frame;
531
+ }
532
+ }
533
+ function nonNegativeInt(value) {
534
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
535
+ }
536
+ /**
537
+ * The primary tile grid from `ffprobe -show_stream_groups -of json`: the
538
+ * "Tile Grid" group marked default, else the first one. Null when the output
539
+ * does not have the expected shape (older ffmpeg has no tile grid groups).
540
+ */
541
+ export function primaryTileGrid(ffprobeJson) {
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(ffprobeJson);
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ const groups = parsed.stream_groups;
550
+ if (!Array.isArray(groups))
551
+ return null;
552
+ const grids = groups.filter((g) => !!g && g.type === "Tile Grid");
553
+ const group = grids.find((g) => g.disposition?.default === 1) ?? grids[0];
554
+ const component = Array.isArray(group?.components) ? group.components[0] : undefined;
555
+ if (!component || !Array.isArray(component.subcomponents))
556
+ return null;
557
+ const width = nonNegativeInt(component.width);
558
+ const height = nonNegativeInt(component.height);
559
+ const left = nonNegativeInt(component.horizontal_offset) ?? 0;
560
+ const top = nonNegativeInt(component.vertical_offset) ?? 0;
561
+ if (!width || !height)
562
+ return null;
563
+ const tiles = [];
564
+ for (const sub of component.subcomponents) {
565
+ const stream = nonNegativeInt(sub?.stream_index);
566
+ const x = nonNegativeInt(sub?.tile_horizontal_offset);
567
+ const y = nonNegativeInt(sub?.tile_vertical_offset);
568
+ if (stream === null || x === null || y === null)
569
+ return null;
570
+ tiles.push({ stream, x, y });
571
+ }
572
+ return tiles.length > 0 ? { width, height, left, top, tiles } : null;
573
+ }
574
+ /** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
575
+ export function tileGridFilter(grid) {
576
+ const inputs = grid.tiles.map((tile) => `[0:${tile.stream}]`).join("");
577
+ const crop = `crop=${grid.width}:${grid.height}:${grid.left}:${grid.top}`;
578
+ if (grid.tiles.length === 1)
579
+ return `${inputs}${crop},${SCALE_FILTER}[frame]`;
580
+ const layout = grid.tiles.map((tile) => `${tile.x}_${tile.y}`).join("|");
581
+ return `${inputs}xstack=inputs=${grid.tiles.length}:layout=${layout}:fill=black,${crop},${SCALE_FILTER}[frame]`;
582
+ }
583
+ /**
584
+ * The primary image of a HEIC / HEIF as a 1024px JPEG. ffmpeg's default video
585
+ * stream in an iPhone HEIC is a single tile or, for Portrait photos, the
586
+ * depth/segmentation matte, so the primary image is decoded with sips
587
+ * (macOS), then ImageMagick (libheif), then by assembling ffmpeg's primary
588
+ * tile grid. Null when none of them produced a usable frame.
589
+ */
590
+ async function heifPrimaryFrame(run, file, dir, platform) {
591
+ const out = path.join(dir, "primary.jpg");
592
+ const edge = String(FRAME_EDGE_PX);
593
+ if (platform === "darwin") {
594
+ const frame = await decodedFrame(run, "sips", ["-s", "format", "jpeg", "-s", "formatOptions", "80", "-Z", edge, file, "--out", out], out);
595
+ if (frame)
596
+ return uprightSipsFrame(run, frame, out);
597
+ }
598
+ // The explicit heic: coder and [0] make ImageMagick decode only the primary image as HEIF.
599
+ const imArgs = [`heic:${file}[0]`, "-auto-orient", "-resize", `${edge}x${edge}>`, "-quality", "80", out];
600
+ const magick = await decodedFrame(run, "magick", imArgs, out);
601
+ if (magick)
602
+ return magick;
603
+ // On Windows `convert` is the system disk-conversion tool, not ImageMagick.
604
+ if (platform !== "win32") {
605
+ const convert = await decodedFrame(run, "convert", imArgs, out);
606
+ if (convert)
607
+ return convert;
608
+ }
609
+ try {
610
+ const { stdout } = await run("ffprobe", ["-v", "error", "-show_stream_groups", "-of", "json", file]);
611
+ const grid = primaryTileGrid(stdout);
612
+ if (grid) {
613
+ const args = ["-v", "error", "-y", "-i", file, "-filter_complex", tileGridFilter(grid), "-map", "[frame]",
614
+ "-frames:v", "1", "-q:v", "4", "-update", "1", out];
615
+ const frame = await decodedFrame(run, "ffmpeg", args, out);
616
+ if (frame)
617
+ return frame;
618
+ }
619
+ }
620
+ catch {
621
+ // No ffprobe, or one without stream groups: fall back to ffmpeg's default stream.
622
+ }
623
+ return null;
624
+ }
625
+ async function readHead(file) {
626
+ const handle = await fs.promises.open(file, "r");
627
+ try {
628
+ const head = Buffer.alloc(HEIF_SNIFF_BYTES);
629
+ const { bytesRead } = await handle.read(head, 0, HEIF_SNIFF_BYTES, 0);
630
+ return head.subarray(0, bytesRead);
631
+ }
632
+ finally {
633
+ await handle.close();
634
+ }
635
+ }
342
636
  /** Seek points at 10%, 35%, 60% and 85% of a known duration. */
343
637
  export function videoSeekPoints(durationSeconds) {
344
638
  if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0)
@@ -346,7 +640,8 @@ export function videoSeekPoints(durationSeconds) {
346
640
  return [0.1, 0.35, 0.6, 0.85].map((share) => durationSeconds * share);
347
641
  }
348
642
  /** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
349
- export async function extractCaptionFrames(file, mediaKind, run = runTool) {
643
+ export async function extractCaptionFrames(file, mediaKind, run = runTool, platform = process.platform) {
644
+ let heif = false;
350
645
  if (mediaKind === "image") {
351
646
  const stat = await fs.promises.stat(file);
352
647
  if (stat.size <= CAPTION_MAX_FRAME_BYTES) {
@@ -354,9 +649,15 @@ export async function extractCaptionFrames(file, mediaKind, run = runTool) {
354
649
  if (isCaptionableImage(bytes))
355
650
  return [bytes];
356
651
  }
652
+ heif = isHeifImage(await readHead(file), file);
357
653
  }
358
654
  const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-frames-"));
359
655
  try {
656
+ if (heif) {
657
+ const primary = await heifPrimaryFrame(run, file, dir, platform);
658
+ if (primary)
659
+ return [primary];
660
+ }
360
661
  if (mediaKind === "image") {
361
662
  const frame = await jpegFrame(run, file, path.join(dir, "image.jpg"));
362
663
  return frame ? [frame] : [];
@@ -10,10 +10,14 @@ export interface LibraryLinkOptions {
10
10
  export interface LibraryLinkDeps {
11
11
  /** HQ root; only called for a placeholder target. */
12
12
  hqRoot: () => string;
13
- /** True when the call runs under HQ_API_KEY (slugs cannot be resolved). */
13
+ /** True when the call runs under HQ_API_KEY (no membership lookup). */
14
14
  apiKey: boolean;
15
- /** Company ref (slug or uid, or undefined for the default) → cmp_ uid. */
16
- resolveCompany: (ref: string | undefined) => Promise<string>;
15
+ /**
16
+ * Company ref (slug or uid, or undefined for the default) → cmp_ uid. Under
17
+ * a key, `placeholderSlug` is the placeholder's companies/<slug>/, a
18
+ * fallback when neither --company nor HQ_COMPANY_UID names the company.
19
+ */
20
+ resolveCompany: (ref: string | undefined, placeholderSlug?: string) => Promise<string>;
17
21
  /** POST /v1/files/presign. */
18
22
  presign: (body: Record<string, unknown>) => Promise<Response>;
19
23
  }
@@ -6,7 +6,8 @@
6
6
  * Runs on a Cognito session or on an `HQ_API_KEY` with `files:read` (the
7
7
  * library-agent preset). Under a key, vaultApiFetch rewrites /v1/files/presign
8
8
  * to /v1/keys/files/presign; the key is company-bound, so the company comes
9
- * from --company <cmp_uid>, HQ_COMPANY_UID, or the key's own membership.
9
+ * from --company (a cmp_ uid, or a slug mapped through companies/manifest.yaml),
10
+ * HQ_COMPANY_UID, or the placeholder's companies/<slug>/ via the manifest.
10
11
  *
11
12
  * Wire contract (hq-pro files-presign):
12
13
  * POST /v1/files/presign
@@ -44,6 +45,7 @@ export async function runLibraryLink(target, options, deps) {
44
45
  let key;
45
46
  let placeholderPath;
46
47
  let companyRef = options.company;
48
+ let placeholderSlug;
47
49
  if (isVaultKey(target)) {
48
50
  key = target;
49
51
  }
@@ -63,14 +65,18 @@ export async function runLibraryLink(target, options, deps) {
63
65
  }
64
66
  key = placeholder.key;
65
67
  // On a session the placeholder's companies/<slug>/ names the company. A
66
- // key is company-bound and cannot resolve slugs, so it uses its own.
68
+ // key is company-bound: its resolver prefers HQ_COMPANY_UID and gets the
69
+ // slug only as a fallback.
70
+ placeholderSlug = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
67
71
  if (companyRef === undefined && !deps.apiKey) {
68
- companyRef = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
72
+ companyRef = placeholderSlug;
69
73
  if (!companyRef)
70
74
  throw new Error(`'${placeholderPath}' is not inside companies/<slug>/; pass --company.`);
71
75
  }
72
76
  }
73
- const company = await deps.resolveCompany(companyRef);
77
+ const company = deps.apiKey && placeholderSlug !== undefined
78
+ ? await deps.resolveCompany(companyRef, placeholderSlug)
79
+ : await deps.resolveCompany(companyRef);
74
80
  const res = await deps.presign({ company, key, op: "get", expiresIn });
75
81
  const body = (await res.json().catch(() => ({})));
76
82
  if (!res.ok) {
@@ -0,0 +1,122 @@
1
+ import { Command } from "commander";
2
+ export interface LibraryRemoveOptions {
3
+ /** Company slug (or cmp_ uid); every target must be in this company. */
4
+ company?: string;
5
+ purge?: boolean;
6
+ dryRun?: boolean;
7
+ yes?: boolean;
8
+ }
9
+ /** Server reply for POST /v1/files/delete (the fields rm reads). */
10
+ export interface VaultDeleteReply {
11
+ matched: number;
12
+ deleted: number;
13
+ }
14
+ export interface LibraryRemoveDeps {
15
+ hqRoot: string;
16
+ /** Company slug → cmp_ uid. */
17
+ resolveCompany: (slug: string) => Promise<string>;
18
+ /**
19
+ * POST /v1/files/delete for one exact key. Throws an Error whose message is
20
+ * ready to show (permission and auth errors included).
21
+ */
22
+ deleteKey: (companyUid: string, key: string, dryRun: boolean) => Promise<VaultDeleteReply>;
23
+ /** POST /v1/search. Only called for `--purge`. */
24
+ searchAssets: (body: Record<string, unknown>) => Promise<Response>;
25
+ /** Every key in the company vault (company-relative). Only called for `--purge`. */
26
+ listVaultKeys: (companyUid: string) => Promise<string[]>;
27
+ /** Text of small vault objects; null for one that cannot be read. Only called for `--purge`. */
28
+ readVaultObjects: (companyUid: string, keys: readonly string[]) => Promise<Map<string, string | null>>;
29
+ /** Ask a yes/no question. Only called without --yes and --dry-run. */
30
+ confirm: (message: string) => Promise<boolean>;
31
+ /** Preview and progress lines. */
32
+ log: (line: string) => void;
33
+ /** sha256 of a local file. */
34
+ hashFile?: (file: string) => Promise<string>;
35
+ /** Delete one local file. */
36
+ removeFile?: (file: string) => Promise<void>;
37
+ }
38
+ export type StepState = "removed" | "would-remove" | "absent" | "kept" | "failed" | "not-run";
39
+ export interface LibraryRemoveItem {
40
+ /** HQ-relative placeholder path. */
41
+ placeholderPath: string;
42
+ /** HQ-relative original path (the file the placeholder stands in for). */
43
+ originalPath: string;
44
+ /** Company-relative vault key of the synced placeholder. */
45
+ vaultKey: string;
46
+ /** The blob the placeholder points at. */
47
+ blobKey: string;
48
+ original: StepState;
49
+ placeholder: StepState;
50
+ vault: StepState;
51
+ error?: string;
52
+ }
53
+ export interface LibraryRemoveBlob {
54
+ key: string;
55
+ state: StepState;
56
+ error?: string;
57
+ }
58
+ export interface LibraryRemoveResult {
59
+ company: string;
60
+ companyUid: string;
61
+ dryRun: boolean;
62
+ purge: boolean;
63
+ /** False when the confirmation was declined. */
64
+ confirmed: boolean;
65
+ items: LibraryRemoveItem[];
66
+ blobs: LibraryRemoveBlob[];
67
+ /** Commands that undo the vault side. */
68
+ undo: string[];
69
+ }
70
+ /** Local company placeholders, other than `exclude`, that point at `blobKey`. */
71
+ export declare function localBlobReferences(hqRoot: string, slug: string, blobKey: string, exclude: ReadonlySet<string>): string[];
72
+ /**
73
+ * Placeholders in the company vault, other than `exclude` (company-relative
74
+ * keys), that point at any of `blobKeys`. Returns blob key → placeholder keys.
75
+ *
76
+ * Two passes:
77
+ * 1. a quick asset search (`kind: "asset"`, query "hqcloud", which every
78
+ * placeholder path carries). Each hit returns its vaultKey, so a shared
79
+ * blob is usually found in one call. This pass cannot prove a blob is
80
+ * unused: hq-pro does not index the hashes and keys in a placeholder,
81
+ * caps a search at 50 hits with no paging, and may lag a fresh upload.
82
+ * Its errors are ignored; pass 2 decides.
83
+ * 2. when pass 1 finds nothing, every `.hqcloud` key the caller can list
84
+ * (GET /v1/files/list, as `hq files search` does), read in one batched
85
+ * presign and parsed. This lists the whole company vault, so it can take
86
+ * minutes on a large one. Any listing or read failure throws: a purge
87
+ * must not go ahead on a guess.
88
+ */
89
+ export declare function cloudBlobReferences(blobKeys: readonly string[], slug: string, companyUid: string, exclude: ReadonlySet<string>, deps: Pick<LibraryRemoveDeps, "searchAssets" | "listVaultKeys" | "readVaultObjects" | "log">): Promise<Map<string, string[]>>;
90
+ /** GET /v1/files/list for every key in the company vault, following the cursor. */
91
+ export declare function listAllVaultKeys(companyUid: string, list: (query: Record<string, string>) => Promise<Response>): Promise<string[]>;
92
+ /**
93
+ * Read small vault objects: one batched presign (op "get") per 1000 keys, then
94
+ * a GET per URL. A key that cannot be presigned or read maps to null. Errors
95
+ * never include a URL, which carries a signature.
96
+ */
97
+ export declare function readSmallVaultObjects(companyUid: string, keys: readonly string[], presign: (body: Record<string, unknown>) => Promise<Response>, get?: (url: string) => Promise<Response>): Promise<Map<string, string | null>>;
98
+ /**
99
+ * Testable core. Checks every target, previews, confirms, then removes in the
100
+ * order described at the top of this file.
101
+ */
102
+ export declare function runLibraryRemove(targets: readonly string[], options: LibraryRemoveOptions, deps: LibraryRemoveDeps): Promise<LibraryRemoveResult>;
103
+ export declare function formatLibraryRemoveResult(result: LibraryRemoveResult): string;
104
+ /**
105
+ * User-facing message for a failed POST /v1/files/delete. `err` is duck-typed
106
+ * on `status` (files.ts's FilesDeleteHttpError) so this module does not load
107
+ * files.ts at startup.
108
+ */
109
+ export declare function vaultDeleteErrorMessage(err: unknown, key: string, apiKey: boolean, format: (err: never, key: string) => string): string;
110
+ /**
111
+ * Company resolver for rm. A session resolves slugs through the API. An
112
+ * HQ_API_KEY cannot (hq-pro serves no keyed membership lookup), so it maps the
113
+ * placeholder folder's slug, or an explicit `--company` slug or uid, through
114
+ * companies/manifest.yaml with library.ts's libraryCompanyForApiKey; hq-pro
115
+ * still checks the uid against the key on every delete.
116
+ */
117
+ export declare function removeCompanyResolver(credential: {
118
+ kind: "api-key" | "cognito";
119
+ token: string;
120
+ }, hqRoot: string, explicit: string | undefined): Promise<(slug: string) => Promise<string>>;
121
+ export declare function registerLibraryRemoveCommand(library: Command): void;
122
+ //# sourceMappingURL=library-remove.d.ts.map