@indigoai-us/hq-cli 5.345.142 → 5.345.144
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 +74 -0
- package/dist/command-catalog.generated.d.ts +278 -6
- package/dist/command-catalog.generated.js +341 -6
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +1 -0
- package/dist/commands/agent-enroll.js +7 -0
- package/dist/commands/agents.d.ts +25 -3
- package/dist/commands/agents.js +57 -4
- package/dist/commands/api-keys.js +66 -8
- package/dist/commands/billing-sponsor.d.ts +4 -0
- package/dist/commands/billing-sponsor.js +48 -5
- package/dist/commands/billing-usage.d.ts +59 -0
- package/dist/commands/billing-usage.js +162 -0
- package/dist/commands/billing.d.ts +9 -0
- package/dist/commands/billing.js +29 -2
- package/dist/commands/core-policy.js +76 -13
- package/dist/commands/library-add.d.ts +120 -0
- package/dist/commands/library-add.js +434 -0
- package/dist/commands/library-caption.d.ts +112 -0
- package/dist/commands/library-caption.js +393 -0
- package/dist/commands/library-link.d.ts +32 -0
- package/dist/commands/library-link.js +95 -0
- package/dist/commands/library.d.ts +72 -0
- package/dist/commands/library.js +527 -0
- package/dist/commands/meetings.d.ts +8 -0
- package/dist/commands/meetings.js +25 -2
- package/dist/commands/storage-offload.d.ts +16 -2
- package/dist/commands/storage-offload.js +55 -4
- package/dist/commands/storage.d.ts +8 -1
- package/dist/commands/storage.js +6 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2410 -2063
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/billing/plan-lock.d.ts +15 -1
- package/dist/lib/billing/plan-lock.js +45 -5
- package/dist/lib/billing/usage-card.d.ts +82 -0
- package/dist/lib/billing/usage-card.js +198 -0
- package/dist/lib/billing/usage-cost.d.ts +214 -0
- package/dist/lib/billing/usage-cost.js +440 -0
- package/dist/lib/billing/usage-only.d.ts +87 -0
- package/dist/lib/billing/usage-only.js +262 -0
- package/dist/lib/billing/usage-rate-card.d.ts +51 -0
- package/dist/lib/billing/usage-rate-card.js +62 -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/plan-limit-nag.d.ts +4 -6
- package/dist/lib/plan-limit-nag.js +19 -1
- package/dist/lib/provider-launcher.d.ts +22 -0
- package/dist/lib/provider-launcher.js +65 -0
- package/dist/lib/search-index/index.d.ts +2 -0
- package/dist/lib/search-index/index.js +34 -0
- package/dist/lib/search-index/max-doc-bytes.d.ts +2 -1
- package/dist/lib/search-index/max-doc-bytes.js +2 -1
- package/dist/main.d.ts +9 -0
- package/dist/main.js +20 -3
- package/dist/utils/billing-gate.d.ts +19 -4
- package/dist/utils/billing-gate.js +25 -4
- package/dist/utils/plan-gate-error.d.ts +10 -0
- package/dist/utils/plan-gate-error.js +54 -0
- package/dist/utils/vault-api.js +4 -1
- package/package.json +2 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq library add|tag|ls` — put files into the company media library.
|
|
3
|
+
*
|
|
4
|
+
* `add` uploads each source (a local file or an http(s) URL) straight to the
|
|
5
|
+
* company vault offload prefix (`.hq-offload/blobs/<sha256>`) through
|
|
6
|
+
* `POST /v1/files/presign` (op "put"), then writes only `<dir>/<name>.hqcloud`
|
|
7
|
+
* into the tree: a `vault: company` placeholder with an `hq-asset` block. The
|
|
8
|
+
* bytes never land in the tree, and a URL source is never copied to disk: it
|
|
9
|
+
* is streamed once to hash it and once more into the vault.
|
|
10
|
+
*
|
|
11
|
+
* Presign wire contract (hq-pro files-presign):
|
|
12
|
+
* body { company: <cmp_uid>, keys: [{ key, op: "put", contentType,
|
|
13
|
+
* metadata: { "hq-content-sha256": <hex> }, checksumSha256: <b64>,
|
|
14
|
+
* ifNoneMatch: "*" }] }
|
|
15
|
+
* 200 { results: [{ key, op, url, headers? }
|
|
16
|
+
* | { key, op, error, code, refusalReason?, currentEtag?, upgradeUrl? }] }
|
|
17
|
+
* 402 PlanLimitReachedBody (blocked: "files.create") for the whole request
|
|
18
|
+
*
|
|
19
|
+
* Duplicates: the key is content-addressed, so a `content-identical` refusal
|
|
20
|
+
* or a 412 from the create-only PUT means the bytes are already in the vault
|
|
21
|
+
* and nothing is uploaded again. The PUT replays the presign `headers`
|
|
22
|
+
* verbatim, so S3 validates `x-amz-checksum-sha256` against the bytes it gets;
|
|
23
|
+
* the CLI also hashes what it streamed and checks the response checksum.
|
|
24
|
+
*/
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import https from "node:https";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { Readable } from "node:stream";
|
|
31
|
+
import { formatCloudAssetBlock, formatCloudPlaceholder, parseCloudPlaceholder, } from "@indigoai-us/hq-cloud";
|
|
32
|
+
import { PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
|
|
33
|
+
import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
|
|
34
|
+
/** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
|
|
35
|
+
const CONTENT_SHA256_META_KEY = "hq-content-sha256";
|
|
36
|
+
const MEDIA_TYPES = {
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".jpg": "image/jpeg",
|
|
39
|
+
".jpeg": "image/jpeg",
|
|
40
|
+
".gif": "image/gif",
|
|
41
|
+
".webp": "image/webp",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
".avif": "image/avif",
|
|
44
|
+
".heic": "image/heic",
|
|
45
|
+
".mp4": "video/mp4",
|
|
46
|
+
".mov": "video/quicktime",
|
|
47
|
+
".webm": "video/webm",
|
|
48
|
+
".m4v": "video/x-m4v",
|
|
49
|
+
".mp3": "audio/mpeg",
|
|
50
|
+
".wav": "audio/wav",
|
|
51
|
+
".pdf": "application/pdf",
|
|
52
|
+
".zip": "application/zip",
|
|
53
|
+
".json": "application/json",
|
|
54
|
+
".txt": "text/plain",
|
|
55
|
+
".md": "text/markdown",
|
|
56
|
+
};
|
|
57
|
+
export function contentTypeFor(name, served) {
|
|
58
|
+
const fromServer = served?.split(";")[0]?.trim().toLowerCase();
|
|
59
|
+
if (fromServer && fromServer !== "application/octet-stream")
|
|
60
|
+
return fromServer;
|
|
61
|
+
return MEDIA_TYPES[path.extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
62
|
+
}
|
|
63
|
+
export function mediaKindFor(contentType) {
|
|
64
|
+
if (contentType.startsWith("image/"))
|
|
65
|
+
return "image";
|
|
66
|
+
if (contentType.startsWith("video/"))
|
|
67
|
+
return "video";
|
|
68
|
+
return "other";
|
|
69
|
+
}
|
|
70
|
+
/** A file name safe to place in one directory: no separators, no control chars. */
|
|
71
|
+
export function safeAssetName(raw) {
|
|
72
|
+
const cleaned = raw
|
|
73
|
+
// eslint-disable-next-line no-control-regex -- strip C0 controls and DEL from a remote file name
|
|
74
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
75
|
+
.replace(/[\\/]/g, "_")
|
|
76
|
+
.trim();
|
|
77
|
+
if (!cleaned || cleaned === "." || cleaned === "..")
|
|
78
|
+
return "download";
|
|
79
|
+
return cleaned.endsWith(PLACEHOLDER_SUFFIX) ? cleaned.slice(0, -PLACEHOLDER_SUFFIX.length) || "download" : cleaned;
|
|
80
|
+
}
|
|
81
|
+
/** Classify an argument: an http(s) URL, otherwise a local file. */
|
|
82
|
+
export function parseSource(input) {
|
|
83
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
|
|
84
|
+
let url;
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(input);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(`'${input}' is not a valid URL.`);
|
|
90
|
+
}
|
|
91
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
92
|
+
throw new Error(`Only http and https URLs can be added ('${url.protocol}' given).`);
|
|
93
|
+
}
|
|
94
|
+
const last = url.pathname.split("/").filter(Boolean).pop() ?? "";
|
|
95
|
+
let decoded = last;
|
|
96
|
+
try {
|
|
97
|
+
decoded = decodeURIComponent(last);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Keep the raw segment.
|
|
101
|
+
}
|
|
102
|
+
return { kind: "url", input, name: safeAssetName(decoded) };
|
|
103
|
+
}
|
|
104
|
+
return { kind: "file", input, name: safeAssetName(path.basename(input)) };
|
|
105
|
+
}
|
|
106
|
+
/** `companies/<slug>/...` HQ-relative POSIX path → slug, or null. */
|
|
107
|
+
function companyOf(rel) {
|
|
108
|
+
return /^companies\/([^/]+)(?:\/|$)/.exec(rel)?.[1] ?? null;
|
|
109
|
+
}
|
|
110
|
+
function toPosix(p) {
|
|
111
|
+
return p.split(path.sep).join("/");
|
|
112
|
+
}
|
|
113
|
+
function hqRelative(hqRoot, abs) {
|
|
114
|
+
return toPosix(path.relative(fs.realpathSync(path.resolve(hqRoot)), abs));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The library directory, HQ-relative. `--to` resolves against the HQ root and
|
|
118
|
+
* must be inside `companies/<co>/`, so `hq storage fetch` can find the
|
|
119
|
+
* company vault from the placeholder path. The default is
|
|
120
|
+
* `companies/<co>/library`.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveLibraryDir(hqRoot, options) {
|
|
123
|
+
if (options.to === undefined) {
|
|
124
|
+
if (!options.company)
|
|
125
|
+
throw new Error("Pass --company <slug> or --to companies/<slug>/<dir>.");
|
|
126
|
+
const dirRel = `companies/${options.company}/library`;
|
|
127
|
+
return { slug: options.company, dirRel, dirAbs: containedPath(hqRoot, dirRel) };
|
|
128
|
+
}
|
|
129
|
+
const dirAbs = containedPath(hqRoot, options.to);
|
|
130
|
+
const dirRel = hqRelative(hqRoot, dirAbs);
|
|
131
|
+
const slug = companyOf(dirRel);
|
|
132
|
+
if (!slug)
|
|
133
|
+
throw new Error(`--to must be inside companies/<slug>/ ('${options.to}' is not).`);
|
|
134
|
+
if (options.company && options.company !== slug) {
|
|
135
|
+
throw new Error(`--to is inside companies/${slug}/, but --company is ${options.company}.`);
|
|
136
|
+
}
|
|
137
|
+
return { slug, dirRel, dirAbs };
|
|
138
|
+
}
|
|
139
|
+
async function hashStream(stream) {
|
|
140
|
+
const hash = crypto.createHash("sha256");
|
|
141
|
+
let size = 0;
|
|
142
|
+
for await (const chunk of stream) {
|
|
143
|
+
const buf = chunk;
|
|
144
|
+
hash.update(buf);
|
|
145
|
+
size += buf.length;
|
|
146
|
+
}
|
|
147
|
+
return { size, sha256: hash.digest("hex") };
|
|
148
|
+
}
|
|
149
|
+
function sha256Base64(hex) {
|
|
150
|
+
return Buffer.from(hex, "hex").toString("base64");
|
|
151
|
+
}
|
|
152
|
+
export function offloadTimestamp(now) {
|
|
153
|
+
return now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
154
|
+
}
|
|
155
|
+
function presignOutcome(row) {
|
|
156
|
+
const r = (row ?? {});
|
|
157
|
+
if (typeof r.url === "string") {
|
|
158
|
+
const headers = {};
|
|
159
|
+
if (r.headers && typeof r.headers === "object") {
|
|
160
|
+
for (const [k, v] of Object.entries(r.headers)) {
|
|
161
|
+
if (typeof v === "string")
|
|
162
|
+
headers[k] = v;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { kind: "upload", grant: { url: r.url, headers } };
|
|
166
|
+
}
|
|
167
|
+
if (r.refusalReason === "content-identical")
|
|
168
|
+
return { kind: "exists" };
|
|
169
|
+
const message = typeof r.error === "string" ? r.error : "The vault refused the upload.";
|
|
170
|
+
if (r.code === "PLAN_LIMIT_EXCEEDED") {
|
|
171
|
+
return {
|
|
172
|
+
kind: "refused",
|
|
173
|
+
error: formatUpgradeRefusal(message, typeof r.upgradeUrl === "string" ? r.upgradeUrl : undefined),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { kind: "refused", error: message };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Stream one source into the vault and check what arrived: S3 validated the
|
|
180
|
+
* checksum header (when presign bound it), the bytes streamed hash to the
|
|
181
|
+
* digest, and any checksum S3 echoes matches. Returns false on 412 (the
|
|
182
|
+
* content-addressed key already exists).
|
|
183
|
+
*/
|
|
184
|
+
async function uploadVerified(source, digest, grant, deps) {
|
|
185
|
+
const { stream } = await deps.openSource(source);
|
|
186
|
+
const hash = crypto.createHash("sha256");
|
|
187
|
+
let sent = 0;
|
|
188
|
+
stream.on("data", (chunk) => {
|
|
189
|
+
hash.update(chunk);
|
|
190
|
+
sent += chunk.length;
|
|
191
|
+
});
|
|
192
|
+
const res = await deps.put(grant.url, grant.headers, stream, digest.size);
|
|
193
|
+
if (res.status === 412)
|
|
194
|
+
return false;
|
|
195
|
+
if (res.status < 200 || res.status >= 300) {
|
|
196
|
+
throw new Error(`Upload failed (HTTP ${res.status}). Nothing was written.`);
|
|
197
|
+
}
|
|
198
|
+
const echoed = res.headers["x-amz-checksum-sha256"];
|
|
199
|
+
if (sent !== digest.size ||
|
|
200
|
+
hash.digest("hex") !== digest.sha256 ||
|
|
201
|
+
(echoed !== undefined && echoed !== sha256Base64(digest.sha256))) {
|
|
202
|
+
throw new Error("The uploaded bytes changed while uploading; their sha256 does not match. Nothing was written.");
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Testable core. Every source is hashed first, then one presign request
|
|
208
|
+
* covers all of them (hq-pro rate-limits presign per user). A 402 for the
|
|
209
|
+
* request refuses every source and writes nothing; a per-key refusal fails
|
|
210
|
+
* only that source.
|
|
211
|
+
*/
|
|
212
|
+
export async function runLibraryAdd(inputs, options, deps) {
|
|
213
|
+
if (inputs.length === 0)
|
|
214
|
+
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);
|
|
217
|
+
const items = [];
|
|
218
|
+
const planned = [];
|
|
219
|
+
const claimed = new Set();
|
|
220
|
+
for (const input of inputs) {
|
|
221
|
+
const item = { source: input, ok: false };
|
|
222
|
+
items.push(item);
|
|
223
|
+
try {
|
|
224
|
+
const source = parseSource(input);
|
|
225
|
+
const originalRel = `${dirRel}/${source.name}`;
|
|
226
|
+
const originalAbs = path.join(dirAbs, source.name);
|
|
227
|
+
const placeholderAbs = `${originalAbs}${PLACEHOLDER_SUFFIX}`;
|
|
228
|
+
if (claimed.has(originalRel))
|
|
229
|
+
throw new Error(`Another source is also named '${source.name}'.`);
|
|
230
|
+
if (fs.existsSync(placeholderAbs))
|
|
231
|
+
throw new Error(`'${originalRel}${PLACEHOLDER_SUFFIX}' already exists.`);
|
|
232
|
+
if (fs.existsSync(originalAbs)) {
|
|
233
|
+
throw new Error(`'${originalRel}' already exists in the tree. Use \`hq storage offload\` for files already in HQ.`);
|
|
234
|
+
}
|
|
235
|
+
claimed.add(originalRel);
|
|
236
|
+
const opened = await deps.openSource(source);
|
|
237
|
+
const { size, sha256 } = await hashStream(opened.stream);
|
|
238
|
+
const digest = { size, sha256, contentType: contentTypeFor(source.name, opened.contentType) };
|
|
239
|
+
Object.assign(item, { originalPath: originalRel, size, sha256, key: companyOffloadKey(sha256) });
|
|
240
|
+
planned.push({ source, item, digest, placeholderAbs, originalRel });
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
item.error = err.message;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (planned.length === 0)
|
|
247
|
+
return { company: slug, items };
|
|
248
|
+
// One presign row per distinct sha256: identical sources share one key.
|
|
249
|
+
const bySha = new Map();
|
|
250
|
+
for (const p of planned)
|
|
251
|
+
if (!bySha.has(p.digest.sha256))
|
|
252
|
+
bySha.set(p.digest.sha256, p);
|
|
253
|
+
const keys = [...bySha.values()].map((p) => ({
|
|
254
|
+
key: companyOffloadKey(p.digest.sha256),
|
|
255
|
+
op: "put",
|
|
256
|
+
contentType: p.digest.contentType,
|
|
257
|
+
metadata: { [CONTENT_SHA256_META_KEY]: p.digest.sha256 },
|
|
258
|
+
checksumSha256: sha256Base64(p.digest.sha256),
|
|
259
|
+
ifNoneMatch: "*",
|
|
260
|
+
}));
|
|
261
|
+
const raw = await deps.presign({ company: company.uid, op: "put", keys });
|
|
262
|
+
const { error: planError, response } = await planGateErrorFromResponse(raw, company.uid);
|
|
263
|
+
if (planError) {
|
|
264
|
+
const message = formatPlanGateError(planError);
|
|
265
|
+
for (const p of planned)
|
|
266
|
+
p.item.error = message;
|
|
267
|
+
return { company: slug, items };
|
|
268
|
+
}
|
|
269
|
+
const body = (await response.json().catch(() => ({})));
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const msg = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
272
|
+
for (const p of planned)
|
|
273
|
+
p.item.error = `Could not get an upload URL: ${msg}`;
|
|
274
|
+
return { company: slug, items };
|
|
275
|
+
}
|
|
276
|
+
const rows = Array.isArray(body.results) ? body.results : [];
|
|
277
|
+
const outcomes = new Map();
|
|
278
|
+
for (const row of rows) {
|
|
279
|
+
const key = row?.key;
|
|
280
|
+
if (typeof key === "string")
|
|
281
|
+
outcomes.set(key, presignOutcome(row));
|
|
282
|
+
}
|
|
283
|
+
const uploads = new Map();
|
|
284
|
+
for (const p of planned) {
|
|
285
|
+
try {
|
|
286
|
+
const key = companyOffloadKey(p.digest.sha256);
|
|
287
|
+
const outcome = outcomes.get(key);
|
|
288
|
+
if (!outcome)
|
|
289
|
+
throw new Error("The vault returned no upload URL for this file.");
|
|
290
|
+
if (outcome.kind === "refused")
|
|
291
|
+
throw new Error(outcome.error);
|
|
292
|
+
let uploaded = false;
|
|
293
|
+
if (outcome.kind === "upload") {
|
|
294
|
+
let pending = uploads.get(key);
|
|
295
|
+
const first = pending === undefined;
|
|
296
|
+
if (first) {
|
|
297
|
+
pending = uploadVerified(p.source, p.digest, outcome.grant, deps);
|
|
298
|
+
uploads.set(key, pending);
|
|
299
|
+
}
|
|
300
|
+
const didUpload = await pending;
|
|
301
|
+
uploaded = first && didUpload;
|
|
302
|
+
}
|
|
303
|
+
await writeAssetPlaceholder(p, options, deps, dirAbs);
|
|
304
|
+
Object.assign(p.item, { ok: true, uploaded, placeholderPath: `${p.originalRel}${PLACEHOLDER_SUFFIX}` });
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
p.item.error = err.message;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const mediaKind = mediaKindFor(p.digest.contentType);
|
|
311
|
+
if (deps.caption && mediaKind !== "other") {
|
|
312
|
+
p.item.caption = await deps
|
|
313
|
+
.caption({
|
|
314
|
+
source: p.source,
|
|
315
|
+
placeholderAbs: p.placeholderAbs,
|
|
316
|
+
companyUid: company.uid,
|
|
317
|
+
contentType: p.digest.contentType,
|
|
318
|
+
mediaKind,
|
|
319
|
+
title: options.title ?? p.source.name,
|
|
320
|
+
})
|
|
321
|
+
.catch((err) => ({ status: "failed", note: err.message }));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { company: slug, items };
|
|
325
|
+
}
|
|
326
|
+
async function writeAssetPlaceholder(p, options, deps, dirAbs) {
|
|
327
|
+
const ts = offloadTimestamp(deps.now());
|
|
328
|
+
const pointer = {
|
|
329
|
+
path: p.originalRel,
|
|
330
|
+
size: p.digest.size,
|
|
331
|
+
sha256: p.digest.sha256,
|
|
332
|
+
vault: "company",
|
|
333
|
+
key: companyOffloadKey(p.digest.sha256),
|
|
334
|
+
offloadedAt: ts,
|
|
335
|
+
};
|
|
336
|
+
const asset = {
|
|
337
|
+
title: options.title ?? p.source.name,
|
|
338
|
+
tags: options.tags,
|
|
339
|
+
...(options.note ? { description: options.note, captionedBy: "person" } : {}),
|
|
340
|
+
media: { kind: mediaKindFor(p.digest.contentType) },
|
|
341
|
+
updatedAt: ts,
|
|
342
|
+
};
|
|
343
|
+
await fs.promises.mkdir(dirAbs, { recursive: true });
|
|
344
|
+
await deps.writePlaceholder(p.placeholderAbs, renderPointer(pointer, formatCloudAssetBlock(asset)));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Add and remove tags on a library placeholder. The placeholder is parsed and
|
|
348
|
+
* re-rendered by hq-cloud's placeholder writer, then replaced atomically
|
|
349
|
+
* (temp file + rename), so a sync watcher never reads a half-written file.
|
|
350
|
+
*/
|
|
351
|
+
export async function runLibraryTag(hqRoot, target, edits, now = () => new Date()) {
|
|
352
|
+
if (edits.add.length === 0 && edits.remove.length === 0)
|
|
353
|
+
throw new Error("Pass --add and/or --remove.");
|
|
354
|
+
const file = resolvePlaceholder(hqRoot, target);
|
|
355
|
+
const placeholderPath = hqRelative(hqRoot, file);
|
|
356
|
+
const placeholder = parseCloudPlaceholder(await fs.promises.readFile(file, "utf8"));
|
|
357
|
+
if (!placeholder || placeholder.vault !== "company") {
|
|
358
|
+
throw new Error(`'${placeholderPath}' is not a company library placeholder.`);
|
|
359
|
+
}
|
|
360
|
+
const before = placeholder.asset ?? { tags: [] };
|
|
361
|
+
const drop = new Set(edits.remove.map((tag) => tag.toLowerCase()));
|
|
362
|
+
const tags = before.tags.filter((tag) => !drop.has(tag.toLowerCase()));
|
|
363
|
+
for (const tag of edits.add) {
|
|
364
|
+
if (!tags.some((have) => have.toLowerCase() === tag.toLowerCase()))
|
|
365
|
+
tags.push(tag);
|
|
366
|
+
}
|
|
367
|
+
const changed = tags.length !== before.tags.length || tags.some((tag, i) => tag !== before.tags[i]);
|
|
368
|
+
if (!changed)
|
|
369
|
+
return { placeholderPath, tags, changed };
|
|
370
|
+
await replacePlaceholderText(file, formatCloudPlaceholder({
|
|
371
|
+
...placeholder,
|
|
372
|
+
asset: { ...before, tags, updatedAt: offloadTimestamp(now()) },
|
|
373
|
+
}));
|
|
374
|
+
return { placeholderPath, tags, changed };
|
|
375
|
+
}
|
|
376
|
+
/** Replace a placeholder atomically (temp file + rename). */
|
|
377
|
+
export async function replacePlaceholderText(file, text) {
|
|
378
|
+
const tmp = `${file}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
379
|
+
try {
|
|
380
|
+
await fs.promises.writeFile(tmp, text, { encoding: "utf8", flag: "wx" });
|
|
381
|
+
await fs.promises.rename(tmp, file);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await fs.promises.rm(tmp, { force: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ── Default IO ──────────────────────────────────────────────────────────────
|
|
388
|
+
/** Open a local file or GET a URL as a Node stream. */
|
|
389
|
+
export async function openLibrarySource(source) {
|
|
390
|
+
if (source.kind === "file") {
|
|
391
|
+
const st = await fs.promises.stat(source.input);
|
|
392
|
+
if (!st.isFile())
|
|
393
|
+
throw new Error(`'${source.input}' is not a file.`);
|
|
394
|
+
return { stream: fs.createReadStream(source.input) };
|
|
395
|
+
}
|
|
396
|
+
const res = await fetch(source.input, { redirect: "follow" });
|
|
397
|
+
if (!res.ok || !res.body)
|
|
398
|
+
throw new Error(`Could not download ${source.input} (HTTP ${res.status}).`);
|
|
399
|
+
return {
|
|
400
|
+
stream: Readable.fromWeb(res.body),
|
|
401
|
+
contentType: res.headers.get("content-type") ?? undefined,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Stream a PUT with an explicit Content-Length: S3 presigned PUTs refuse
|
|
406
|
+
* chunked transfer encoding, which fetch would use for a stream body.
|
|
407
|
+
*/
|
|
408
|
+
export function streamPut(url, headers, body, size) {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
const target = new URL(url);
|
|
411
|
+
const request = (target.protocol === "http:" ? http : https).request(target, { method: "PUT", headers: { ...headers, "content-length": String(size) } }, (res) => {
|
|
412
|
+
res.resume();
|
|
413
|
+
res.on("end", () => {
|
|
414
|
+
const out = {};
|
|
415
|
+
for (const [k, v] of Object.entries(res.headers))
|
|
416
|
+
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
417
|
+
resolve({ status: res.statusCode ?? 0, headers: out });
|
|
418
|
+
});
|
|
419
|
+
res.on("error", reject);
|
|
420
|
+
});
|
|
421
|
+
request.on("error", reject);
|
|
422
|
+
body.on("error", (err) => {
|
|
423
|
+
request.destroy(err);
|
|
424
|
+
reject(err);
|
|
425
|
+
});
|
|
426
|
+
body.pipe(request);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
export const defaultLibraryIo = {
|
|
430
|
+
openSource: openLibrarySource,
|
|
431
|
+
put: streamPut,
|
|
432
|
+
writePlaceholder: durablePlaceholderFs.writePlaceholder,
|
|
433
|
+
};
|
|
434
|
+
//# sourceMappingURL=library-add.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type ProviderCaptureOptions, type ProviderCaptureResult } from "../lib/provider-launcher.js";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import { type CloudAsset, type CloudAssetMediaKind } from "@indigoai-us/hq-cloud";
|
|
4
|
+
import { type LibraryCompany, type LibrarySource, type OpenedSource } from "./library-add.js";
|
|
5
|
+
/** Frame limits for a local caption run. */
|
|
6
|
+
export declare const CAPTION_MAX_FRAMES = 4;
|
|
7
|
+
export declare const CAPTION_MAX_FRAME_BYTES = 3750000;
|
|
8
|
+
export declare const CAPTION_MAX_TOTAL_BYTES = 4000000;
|
|
9
|
+
export declare const CAPTION_TIMEOUT_MS = 120000;
|
|
10
|
+
export declare const LOCAL_CAPTION_PROVIDER = "claude";
|
|
11
|
+
export type CaptionMediaKind = Exclude<CloudAssetMediaKind, "other">;
|
|
12
|
+
export interface LibraryCaptionRequest {
|
|
13
|
+
source: LibrarySource;
|
|
14
|
+
placeholderAbs: string;
|
|
15
|
+
companyUid: string;
|
|
16
|
+
contentType: string;
|
|
17
|
+
mediaKind: CaptionMediaKind;
|
|
18
|
+
title?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface LibraryCaptionNote {
|
|
21
|
+
status: "captioned" | "skipped" | "failed";
|
|
22
|
+
captionedBy?: string;
|
|
23
|
+
/** Plain-language reason when not captioned. */
|
|
24
|
+
note?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface CaptionReply {
|
|
27
|
+
description: string;
|
|
28
|
+
tags: string[];
|
|
29
|
+
captionedBy: string;
|
|
30
|
+
}
|
|
31
|
+
export type CaptionOutcome = {
|
|
32
|
+
kind: "captioned";
|
|
33
|
+
reply: CaptionReply;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "skipped";
|
|
36
|
+
note: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: "failed";
|
|
39
|
+
note: string;
|
|
40
|
+
};
|
|
41
|
+
export type RunLocalCaption = (args: readonly string[], options: ProviderCaptureOptions) => Promise<ProviderCaptureResult>;
|
|
42
|
+
export declare const runLocalClaude: RunLocalCaption;
|
|
43
|
+
export interface CaptionDeps {
|
|
44
|
+
/** Run the local Claude Code CLI headless (`claude -p ...`). */
|
|
45
|
+
runLocal: RunLocalCaption;
|
|
46
|
+
/** Frames (encoded image bytes) for a local file. */
|
|
47
|
+
extractFrames: (file: string, mediaKind: CaptionMediaKind) => Promise<Buffer[]>;
|
|
48
|
+
}
|
|
49
|
+
/** png / jpeg / gif / webp by magic bytes (formats Claude Code can read). */
|
|
50
|
+
export declare function isCaptionableImage(bytes: Buffer): boolean;
|
|
51
|
+
/** Keep frames in order while they fit the per-frame and total byte limits. */
|
|
52
|
+
export declare function fitFrames(frames: readonly Buffer[]): Buffer[];
|
|
53
|
+
/** Pull { description, tags } out of `claude -p --output-format json` output. */
|
|
54
|
+
export declare function parseLocalCaption(stdout: string): {
|
|
55
|
+
description: string;
|
|
56
|
+
tags: string[];
|
|
57
|
+
} | null;
|
|
58
|
+
/** Map a local Claude Code run to an outcome, with one plain note when skipped. */
|
|
59
|
+
export declare function captionOutcomeFromRun(run: ProviderCaptureResult): CaptionOutcome;
|
|
60
|
+
/** Extract frames from `file` and caption them with local Claude Code. Never throws. */
|
|
61
|
+
export declare function requestCaption(file: string, request: {
|
|
62
|
+
mediaKind: CaptionMediaKind;
|
|
63
|
+
title?: string;
|
|
64
|
+
}, deps: CaptionDeps): Promise<CaptionOutcome>;
|
|
65
|
+
/**
|
|
66
|
+
* Merge a caption into an asset. The user's tags stay first and suggested tags
|
|
67
|
+
* follow, deduplicated case-insensitively. A description a person wrote is
|
|
68
|
+
* kept unless `replaceDescription` is set (`hq library describe`).
|
|
69
|
+
*/
|
|
70
|
+
export declare function mergeCaption(asset: CloudAsset, reply: CaptionReply, options: {
|
|
71
|
+
replaceDescription: boolean;
|
|
72
|
+
updatedAt: string;
|
|
73
|
+
}): CloudAsset;
|
|
74
|
+
export interface AddCaptionDeps extends CaptionDeps {
|
|
75
|
+
now: () => Date;
|
|
76
|
+
openSource: (source: LibrarySource) => Promise<OpenedSource>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Caption one asset `hq library add` just wrote. A URL source is read again
|
|
80
|
+
* into a temp file (it is never written into the tree); a local file is read
|
|
81
|
+
* in place. Never throws.
|
|
82
|
+
*/
|
|
83
|
+
export declare function captionAddedAsset(request: LibraryCaptionRequest, deps: AddCaptionDeps): Promise<LibraryCaptionNote>;
|
|
84
|
+
export interface LibraryDescribeDeps extends CaptionDeps {
|
|
85
|
+
hqRoot: string;
|
|
86
|
+
now: () => Date;
|
|
87
|
+
resolveCompany: (slug: string) => Promise<LibraryCompany>;
|
|
88
|
+
/** POST /v1/files/presign. */
|
|
89
|
+
presign: (body: Record<string, unknown>) => Promise<Response>;
|
|
90
|
+
/** GET a presigned URL as a stream. */
|
|
91
|
+
download: (url: string) => Promise<Readable>;
|
|
92
|
+
}
|
|
93
|
+
export interface LibraryDescribeResult {
|
|
94
|
+
placeholderPath: string;
|
|
95
|
+
description: string;
|
|
96
|
+
tags: string[];
|
|
97
|
+
captionedBy: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `hq library describe <placeholder>`: download the asset, caption it again,
|
|
101
|
+
* and replace the description and captioned-by. Suggested tags are added to
|
|
102
|
+
* the existing tags. A caption that does not succeed is an error here.
|
|
103
|
+
*/
|
|
104
|
+
export declare function runLibraryDescribe(target: string, deps: LibraryDescribeDeps): Promise<LibraryDescribeResult>;
|
|
105
|
+
export type RunTool = (command: string, args: string[]) => Promise<{
|
|
106
|
+
stdout: string;
|
|
107
|
+
}>;
|
|
108
|
+
/** Seek points at 10%, 35%, 60% and 85% of a known duration. */
|
|
109
|
+
export declare function videoSeekPoints(durationSeconds: number | null): number[];
|
|
110
|
+
/** 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[]>;
|
|
112
|
+
//# sourceMappingURL=library-caption.d.ts.map
|