@sprid/cli 0.1.2

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 (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
@@ -0,0 +1,2285 @@
1
+ // sprid post — the local half of Sprid.
2
+ //
3
+ // **It never renders anything.** How a file was made is the consuming repo's
4
+ // business; this package's whole subject is a finished local file becoming a
5
+ // Sprid draft, reproducibly and with the checks. That boundary is what lets it
6
+ // serve a repo whose reels come out of a bespoke pipeline and one whose reels
7
+ // come out of After Effects.
8
+ //
9
+ // **It does not need an agent.** Everything goes through the REST API with a
10
+ // `sprd_` personal access token, so a CI job can run it. The MCP server is the
11
+ // agent-facing convenience over the same endpoints, not the only door.
12
+ //
13
+ // **A post here is a reel OR a carousel**, and the difference is one field.
14
+ // Everything the package does - measure, gate, preview, upload, draft - is the
15
+ // same shape for a set of images as for one mp4, and the two kinds differ only
16
+ // in what "the media" is and what can be true of it. Naming the whole thing
17
+ // after reels was a bet that the pixels are always video, which they are not.
18
+ //
19
+ // The registry lives in the CONSUMING repo and is committed:
20
+ //
21
+ // <registry>/posts/<slug>.json the record (legacy: reels/, still read)
22
+ // <registry>/captions/<slug>.txt the caption, verbatim
23
+ //
24
+ // No media in git. The manifest carries the sha256, so a file that no longer
25
+ // matches its record is detectable without keeping the file, and the specs in
26
+ // the consuming repo reproduce it.
27
+
28
+ import { execFileSync, spawnSync } from "node:child_process";
29
+ import { createHash } from "node:crypto";
30
+ import {
31
+ copyFileSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ readFileSync,
35
+ readdirSync,
36
+ rmSync,
37
+ statSync,
38
+ symlinkSync,
39
+ writeFileSync,
40
+ } from "node:fs";
41
+ import { watch } from "node:fs";
42
+ import { createServer } from "node:http";
43
+ import { previewHandler } from "./preview-server.mjs";
44
+ import { projectPost } from "./rules/platform-rules.mjs";
45
+ import { homedir, tmpdir } from "node:os";
46
+ import { basename, dirname, join, resolve } from "node:path";
47
+ import { pathToFileURL } from "node:url";
48
+
49
+ // Resolve both the token and its service URL from the same credentials.
50
+ import { resolveAuth, DEFAULT_API_URL } from "../creds.mjs";
51
+ import { assertCredentialDestination } from '../endpoint.mjs';
52
+
53
+ /**
54
+ * The API these verbs talk to, from the one login.
55
+ *
56
+ * **Lazy and guarded, and both matter.** `resolveAuth` throws when nobody is
57
+ * logged in, and `doctor` exists precisely to be run in that state; and reading
58
+ * it at module load would freeze the answer before `sprid.config` or an env var
59
+ * could be considered. Taking only the token from the login and defaulting the
60
+ * URL to production can select the wrong service, so both halves come from
61
+ * the same place or neither does.
62
+ */
63
+ function defaultApi() {
64
+ if (process.env.SPRID_URL) return process.env.SPRID_URL;
65
+ try {
66
+ return resolveAuth().apiUrl || DEFAULT_API_URL;
67
+ } catch {
68
+ return DEFAULT_API_URL;
69
+ }
70
+ }
71
+
72
+ // ─── plumbing ────────────────────────────────────────────────────────────────
73
+
74
+ const C = {
75
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
76
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
77
+ ok: (s) => `\x1b[32m${s}\x1b[0m`,
78
+ bad: (s) => `\x1b[31m${s}\x1b[0m`,
79
+ warn: (s) => `\x1b[33m${s}\x1b[0m`,
80
+ };
81
+
82
+ function flag(argv, name) {
83
+ const args = argv.slice(0, argv.includes("--") ? argv.indexOf("--") : argv.length);
84
+ const inline = args.find(a => a.startsWith(`--${name}=`));
85
+ if (inline) return inline.slice(name.length + 3);
86
+ const i = args.indexOf(`--${name}`);
87
+ return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : undefined;
88
+ }
89
+ const has = (argv, name) => argv.slice(0, argv.includes("--") ? argv.indexOf("--") : argv.length).includes(`--${name}`);
90
+
91
+ /**
92
+ * The one bare word: a slug.
93
+ *
94
+ * **A flag's value is not a positional argument**, and reading the first
95
+ * non-`--` token as one made `register --built-with "…"` try to register a
96
+ * reel called "ffmpeg fixture". Every flag that takes a value is named here.
97
+ */
98
+ const VALUE_FLAGS = new Set(["out", "lane", "built-with", "port", "serve", "kind", "platforms"]);
99
+ function positional(argv) {
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const a = argv[i];
102
+ if (a === "--") return argv[i + 1];
103
+ if (a.startsWith("--")) {
104
+ if (VALUE_FLAGS.has(a.slice(2))) i++;
105
+ continue;
106
+ }
107
+ return a;
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** `KEY=value` out of the consuming repo's `.env`, so a token need not be exported. */
113
+ function readDotEnv(root) {
114
+ const f = join(root, ".env");
115
+ if (!existsSync(f)) return {};
116
+ const out = {};
117
+ for (const line of readFileSync(f, "utf8").split("\n")) {
118
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
119
+ if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * The token - and **which** token matters more than where it is found.
126
+ *
127
+ * A PAT is workspace-scoped, and a machine that posts for three brands holds
128
+ * three of them. `tokenEnv` names the one THIS repo is allowed to use, and when
129
+ * it is set nothing else is tried: a run that quietly fell back to the personal
130
+ * token would authenticate against workspaces this repo must not be able to
131
+ * reach, and nothing in the output would say so. Without `tokenEnv` the order
132
+ * is the one a person would expect.
133
+ */
134
+ function token(cfg = {}) {
135
+ assertCredentialDestination(cfg.apiUrl || defaultApi(), defaultApi());
136
+ // A repo may pin itself to one workspace's token, and when it does nothing
137
+ // else is tried: a run that quietly fell back to the personal token would
138
+ // authenticate against workspaces this repo must not reach, and nothing in
139
+ // the output would say so.
140
+ if (cfg.tokenEnv) {
141
+ const env = cfg.root ? readDotEnv(cfg.root) : {};
142
+ const v = process.env[cfg.tokenEnv] || env[cfg.tokenEnv];
143
+ if (v) return v.trim();
144
+ throw new Error(
145
+ `No token. sprid.config pins this repo to \`tokenEnv: "${cfg.tokenEnv}"\`, so that is the only one used.\n` +
146
+ ` Export it, or put it in ${join(cfg.root ?? ".", ".env")}. It must be scoped to the workspace that owns "${cfg.account}".`,
147
+ );
148
+ }
149
+ const env = cfg.root ? readDotEnv(cfg.root) : {};
150
+ if (env.SPRID_TOKEN) return env.SPRID_TOKEN.trim();
151
+ return resolveAuth().token;
152
+ }
153
+
154
+ /**
155
+ * Config, `.ts` preferred and `.json` accepted.
156
+ *
157
+ * The `.ts` form is the one worth writing - it takes comments and a type - and
158
+ * it loads under bun and under node 22's type stripping. The `.json` fallback
159
+ * exists so a customer on an older node is not blocked by a file format.
160
+ */
161
+ async function loadConfig(cwd = process.cwd()) {
162
+ for (const name of ["sprid.config.ts", "sprid.config.mjs", "sprid.config.js"]) {
163
+ const p = join(cwd, name);
164
+ if (existsSync(p)) {
165
+ const mod = await import(pathToFileURL(p).href);
166
+ return normalizeConfig(mod.default ?? mod, cwd);
167
+ }
168
+ }
169
+ const j = join(cwd, "sprid.config.json");
170
+ if (existsSync(j)) return normalizeConfig(JSON.parse(readFileSync(j, "utf8")), cwd);
171
+ throw new Error("No sprid.config.ts (or .json) here. Run `sprid post init` first.");
172
+ }
173
+
174
+ const ALL_CHECKS = ["duration", "loudness", "batchSpread", "caption", "slides", "aspect", "provenance", "words", "voice", "sources", "crosspost"];
175
+ const LANE_KEYS = new Set([
176
+ "kind", "media", "slides", "caption", "spec", "build",
177
+ "expect", "hashtags", "captionMustContain", "captionEndsSentence", "captionEndWords", "maxDeviationDb",
178
+ // Where in the video the poster comes from. Frame 0 by default; a lane whose
179
+ // opening card is still arriving there says so. See `coverFrame`.
180
+ "coverAtMs",
181
+ // composed lanes: what Sprid is asked to render, and what may not be said
182
+ "format", "template", "contentType", "neverSay", "sourcesRequired", "platforms",
183
+ ]);
184
+
185
+ /**
186
+ * **A misspelled key used to be a silent skip**, which is the worst thing a
187
+ * gate can do: `lufs:` written at the top of a lane instead of inside
188
+ * `expect:` reports "skip: no lufs range in the lane" on every row, and a
189
+ * skipped check reads as a batch that was measured. Anything not recognised is
190
+ * named here, once, at the top of every command.
191
+ */
192
+ export function normalizeConfig(cfg, cwd) {
193
+ if (!cfg?.account) throw new Error("sprid.config: `account` is required (the Sprid account slug).");
194
+ if (!cfg.lanes || !Object.keys(cfg.lanes).length)
195
+ throw new Error("sprid.config: at least one lane is required. A lane says where finished media lives.");
196
+
197
+ const complaints = [];
198
+ for (const [name, lane] of Object.entries(cfg.lanes)) {
199
+ if (lane.kind !== "composed" && !lane.media && !lane.slides)
200
+ throw new Error(
201
+ `sprid.config: lane "${name}" needs \`media\` (one file per post), \`slides\` (a deck of images), ` +
202
+ "or `kind: \"composed\"` (Sprid renders it; this repo holds the copy).",
203
+ );
204
+ checkedPlatforms(lane.platforms);
205
+ for (const k of Object.keys(lane)) if (!LANE_KEYS.has(k)) complaints.push(`lane "${name}": unknown key \`${k}\``);
206
+ const tpl = lane.kind === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : (lane.slides ?? lane.media);
207
+ if (!tpl.includes("<slug>")) complaints.push(`lane "${name}": the path has no <slug>, so nothing can be discovered in it`);
208
+ }
209
+ for (const c of cfg.checks ?? []) if (!ALL_CHECKS.includes(c)) complaints.push(`unknown check "${c}"`);
210
+ for (const c of complaints) console.log(` ${C.warn("config")} ${c}`);
211
+
212
+ return {
213
+ apiUrl: cfg.apiUrl || defaultApi(),
214
+ account: cfg.account,
215
+ platforms: checkedPlatforms(cfg.platforms),
216
+ // The PAT is workspace-scoped; a repo that must only reach one workspace
217
+ // names its variable here rather than trusting the ambient one.
218
+ tokenEnv: cfg.tokenEnv ?? null,
219
+ registry: resolve(cwd, cfg.registry || "social"),
220
+ root: cwd,
221
+ lanes: cfg.lanes,
222
+ checks: cfg.checks || ALL_CHECKS,
223
+ };
224
+ }
225
+
226
+ async function api(cfg, method, path, body, extraHeaders = {}) {
227
+ const url = `${cfg.apiUrl}/api${path}`;
228
+ const res = await fetch(url, {
229
+ method,
230
+ redirect: 'error',
231
+ signal: AbortSignal.timeout(30000),
232
+ headers: { Authorization: `Bearer ${token(cfg)}`, "Content-Type": "application/json", ...extraHeaders },
233
+ body: body === undefined ? undefined : JSON.stringify(body),
234
+ });
235
+ if (!res.ok) {
236
+ const text = await res.text().catch(() => "");
237
+ throw new Error(`${method} ${path} -> ${res.status}${text ? `\n ${text.slice(0, 400)}` : ""}`);
238
+ }
239
+ return res.status === 204 ? null : res.json();
240
+ }
241
+
242
+ // ─── media measurement ───────────────────────────────────────────────────────
243
+
244
+ function ffprobe(file, entries, streamSelect = []) {
245
+ return execFileSync(
246
+ "ffprobe",
247
+ ["-v", "error", ...streamSelect, "-show_entries", entries, "-of", "default=nw=1:nk=1", file],
248
+ { encoding: "utf8" },
249
+ ).trim();
250
+ }
251
+
252
+ /**
253
+ * Loudness, integrated, from ffmpeg's own two-pass measurement.
254
+ *
255
+ * Read the SPREAD across a batch, not the value: a set of reels that are each
256
+ * within a decibel of each other sounds like one library, and a set that
257
+ * averages -14 with a 4 dB spread is audible between two posts in a row.
258
+ */
259
+ function loudness(file) {
260
+ // **loudnorm prints its JSON to stderr, and execFileSync returns stdout.**
261
+ // Reading the return value gave null and the whole command died on it.
262
+ const r = spawnSync(
263
+ "ffmpeg",
264
+ ["-hide_banner", "-nostats", "-i", file, "-filter:a", "loudnorm=print_format=json", "-f", "null", "-"],
265
+ { encoding: "utf8" },
266
+ );
267
+ const m = `${r.stderr ?? ""}${r.stdout ?? ""}`.match(/"input_i"\s*:\s*"(-?[\d.]+)"/);
268
+ return m ? Number(m[1]) : null;
269
+ }
270
+
271
+ function sha256(file) {
272
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
273
+ }
274
+
275
+ /** The first frame, which is what a draft shows in a feed before it plays. */
276
+ /**
277
+ * The still a feed shows before anybody presses play.
278
+ *
279
+ * **Frame 0 is the wrong frame for any reel whose opening card assembles**, and
280
+ * that is a measured rule rather than a preference: a hook that fades in from
281
+ * nothing gives a feed a cover with no words on it, reproduced on two accounts
282
+ * (`docs/REEL_FIRST_SECONDS.md` § The skip window). A reel that opens on a
283
+ * camera move has the same problem in a milder form - the cover is a crop
284
+ * nobody composed.
285
+ *
286
+ * So a lane says when its opening card is finished assembling, and the poster
287
+ * comes from there. `coverAtMs` defaults to 0, so a lane that does not animate
288
+ * its opener is unaffected.
289
+ */
290
+ function coverFrame(file, out, atMs = 0) {
291
+ mkdirSync(dirname(out), { recursive: true });
292
+ execFileSync("ffmpeg", [
293
+ "-loglevel", "error", "-y",
294
+ ...(atMs > 0 ? ["-ss", (atMs / 1000).toFixed(3)] : []),
295
+ "-i", file, "-frames:v", "1", out,
296
+ ]);
297
+ return out;
298
+ }
299
+
300
+ // ─── the registry ────────────────────────────────────────────────────────────
301
+
302
+ // **`posts/` is the directory; `reels/` is read because it was the directory.**
303
+ // A record already committed in a consuming repo is not something to break over
304
+ // a rename, so both are read and a manifest is rewritten where it already
305
+ // lives. Anything new lands in `posts/`.
306
+ const POSTS_DIR = "posts";
307
+ const LEGACY_DIR = "reels";
308
+ const captionPath = (cfg, slug) => join(cfg.registry, "captions", `${slug}.txt`);
309
+ // A sibling .tiktok caption file supplies the TikTok override. Without it,
310
+ // TikTok uses the shared caption. Vocabulary can differ by platform.
311
+ const tiktokCaptionPath = (cfg, slug) => join(cfg.registry, "captions", `${slug}.tiktok.txt`);
312
+ const tiktokSibling = (file) => file.replace(/(\.[^./]+)$/, ".tiktok$1");
313
+
314
+ function manifestPath(cfg, slug) {
315
+ const legacy = join(cfg.registry, LEGACY_DIR, `${slug}.json`);
316
+ if (existsSync(legacy) && !existsSync(join(cfg.registry, POSTS_DIR, `${slug}.json`))) return legacy;
317
+ return join(cfg.registry, POSTS_DIR, `${slug}.json`);
318
+ }
319
+
320
+ function readManifest(cfg, slug) {
321
+ const p = manifestPath(cfg, slug);
322
+ if (!existsSync(p)) throw new Error(`${slug} is not registered. Run \`sprid post register ${slug}\`.`);
323
+ return JSON.parse(readFileSync(p, "utf8"));
324
+ }
325
+
326
+ function writeManifest(cfg, m) {
327
+ const p = manifestPath(cfg, m.slug);
328
+ mkdirSync(dirname(p), { recursive: true });
329
+ writeFileSync(p, JSON.stringify(m, null, 2) + "\n");
330
+ return p;
331
+ }
332
+
333
+ function allManifests(cfg) {
334
+ const seen = new Map();
335
+ for (const d of [LEGACY_DIR, POSTS_DIR]) {
336
+ const dir = join(cfg.registry, d);
337
+ if (!existsSync(dir)) continue;
338
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
339
+ const m = JSON.parse(readFileSync(join(dir, f), "utf8"));
340
+ seen.set(m.slug, m); // posts/ is read second, so it wins
341
+ }
342
+ }
343
+ return [...seen.values()].sort((a, b) => a.slug.localeCompare(b.slug));
344
+ }
345
+
346
+ /** `<slug>` in a lane's path template is the only substitution there is. */
347
+ const fill = (tpl, slug) => tpl.replaceAll("<slug>", slug);
348
+
349
+ /**
350
+ * What a lane produces. Inferred rather than required, because a lane that
351
+ * names `slides:` cannot be anything else and a config should not have to say
352
+ * the same thing twice.
353
+ */
354
+ /**
355
+ * What a lane produces. Three answers, and only one of them owns any pixels.
356
+ *
357
+ * **`composed` is the case where Sprid renders and this repo holds the copy.**
358
+ * A template carousel or a text reel has no local file at all - the slides are
359
+ * words, the pixels are the server's templates and backdrops, and there is
360
+ * nothing to measure with ffmpeg. What is worth committing is the copy, the
361
+ * caption, the gate it passed and where each number came from; the mp4 or the
362
+ * PNG is downstream of that and disposable. Every other verb is unchanged,
363
+ * which is the point: one repo can hold a composed lane and a bespoke-render
364
+ * lane side by side and `status` is still one table.
365
+ */
366
+ const kindOf = (lane) => lane.kind ?? (lane.slides ? "carousel" : "reel");
367
+ const isComposed = (x) => (x?.kind ?? (x?.slides ? "carousel" : "reel")) === "composed";
368
+
369
+ /** The path template a lane's media lives at, whichever kind it is. */
370
+ const mediaTemplate = (lane) =>
371
+ kindOf(lane) === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : kindOf(lane) === "carousel" ? lane.slides : lane.media;
372
+
373
+ /**
374
+ * The files of one carousel, in order.
375
+ *
376
+ * **Order is the filename, sorted the way a person counts**, so `2.jpg` comes
377
+ * before `10.jpg`. A deck's slide order is the single most consequential thing
378
+ * about it and it is not worth hiding in a JSON array somewhere: name the files
379
+ * and the order is visible in the directory listing.
380
+ */
381
+ function slideFiles(cfg, lane, slug) {
382
+ const tpl = resolve(cfg.root, fill(mediaTemplate(lane), slug));
383
+ const dir = dirname(tpl);
384
+ if (!existsSync(dir)) return [];
385
+ const pattern = basename(tpl);
386
+ const [pre, post] = pattern.includes("*") ? pattern.split("*") : [pattern, ""];
387
+ return readdirSync(dir)
388
+ .filter((f) => !f.startsWith(".") && f.startsWith(pre) && f.endsWith(post) && f.length >= pre.length + post.length)
389
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
390
+ .map((f) => join(dir, f));
391
+ }
392
+
393
+ /** An image's pixel size, from ffprobe - already a dependency for the video. */
394
+ function imageSize(file) {
395
+ const [w, h] = ffprobe(file, "stream=width,height", ["-select_streams", "v:0"]).split("\n").map(Number);
396
+ return { width: w, height: h };
397
+ }
398
+
399
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
400
+ function ratioLabel(w, h) {
401
+ const g = gcd(w, h) || 1;
402
+ return `${w / g}:${h / g}`;
403
+ }
404
+
405
+ /**
406
+ * Every slug a lane can see.
407
+ *
408
+ * Two shapes, because a reel is a file and a carousel is a directory of them:
409
+ * `out/<slug>.mp4` names the slug inside a filename, `decks/<slug>/*.jpg` names
410
+ * it as the directory, and which one a lane is, is visible in its template.
411
+ */
412
+ export function slugsInLane(cfg, laneName) {
413
+ const lane = cfg.lanes[laneName];
414
+ const tpl = resolve(cfg.root, mediaTemplate(lane));
415
+ const [pre, post] = tpl.split("<slug>");
416
+
417
+ if (post.startsWith("/")) {
418
+ const dir = pre.replace(/\/$/, "");
419
+ if (!existsSync(dir)) return [];
420
+ return readdirSync(dir)
421
+ .filter((f) => !f.startsWith(".") && statSync(join(dir, f)).isDirectory())
422
+ .filter((slug) => slideFiles(cfg, lane, slug).length > 0)
423
+ .sort();
424
+ }
425
+
426
+ const dir = dirname(tpl);
427
+ if (!existsSync(dir)) return [];
428
+ const [fpre, fpost] = basename(tpl).split("<slug>");
429
+ return readdirSync(dir)
430
+ .filter((f) => f.startsWith(fpre) && f.endsWith(fpost) && f.length > fpre.length + fpost.length)
431
+ .map((f) => f.slice(fpre.length, f.length - fpost.length))
432
+ .sort();
433
+ }
434
+
435
+ function laneOf(cfg, slug) {
436
+ for (const name of Object.keys(cfg.lanes)) {
437
+ const lane = cfg.lanes[name];
438
+ if (kindOf(lane) === "carousel") {
439
+ if (slideFiles(cfg, lane, slug).length) return name;
440
+ } else if (existsSync(resolve(cfg.root, fill(lane.media, slug)))) return name;
441
+ }
442
+ return null;
443
+ }
444
+
445
+ // ─── commands ────────────────────────────────────────────────────────────────
446
+
447
+ const STARTER_OWN = `// sprid post. \`sprid post status\` to see where everything stands.
448
+ //
449
+ // A LANE is where one kind of finished media lives, and there are two kinds of
450
+ // post: a REEL is one video file, a CAROUSEL is a directory of images. The
451
+ // package ships no lanes, because what a lane is depends on how you make
452
+ // things.
453
+ export default {
454
+ account: "ACCOUNT_SLUG",
455
+ registry: "social/", // manifests + captions, committed to this repo
456
+ lanes: {
457
+ films: {
458
+ media: "path/to/output/<slug>.mp4",
459
+ caption: "path/to/output/<slug>.caption.txt",
460
+ // Optional, and worth writing: the command that makes one. It is what
461
+ // \`build\` runs, and what the record remembers so a re-render is the
462
+ // same render.
463
+ build: "your-renderer --slug <slug> --out <out>",
464
+ // What \`check\` enforces. Ranges are inclusive.
465
+ expect: { durationMs: [5000, 90000], lufs: [-16, -12] },
466
+ },
467
+ decks: {
468
+ // A carousel: the slug is the directory, the slides are its files, and
469
+ // they are ordered by filename - so name them 01, 02, 03.
470
+ slides: "path/to/decks/<slug>/*.jpg",
471
+ caption: "path/to/decks/<slug>/caption.txt",
472
+ expect: { slides: [3, 20], aspect: "4:5", minWidth: 1080 },
473
+ },
474
+ },
475
+ // Drop any you do not want. \`batchSpread\` is measured across a lane rather
476
+ // than per file, which is the only way it means anything; \`aspect\` refuses a
477
+ // deck whose slides are not all the same shape, because Instagram crops every
478
+ // later slide to the first one's.
479
+ checks: ["duration", "loudness", "batchSpread", "caption", "slides", "aspect", "provenance"],
480
+ };
481
+ `;
482
+
483
+ const STARTER_STILLS = `// sprid post, on the built-in recipe.
484
+ //
485
+ // One spec per reel in social/specs/, pictures and a track, and one command:
486
+ // sprid post new my-first-reel -> edit the spec -> sprid post build
487
+ export default {
488
+ account: "ACCOUNT_SLUG",
489
+ registry: "social/",
490
+ lanes: {
491
+ reels: {
492
+ build: { recipe: "stills", track: "assets/audio/bed.mp3" },
493
+ spec: "social/specs/<slug>.json",
494
+ media: "tmp/reels/<slug>.mp4",
495
+ caption: "tmp/reels/<slug>.caption.txt",
496
+ // Example limits. Set these for your format and audio delivery requirements.
497
+ expect: { durationMs: [8_000, 20_000], lufs: [-16, -12] },
498
+ },
499
+ },
500
+ checks: ["duration", "loudness", "batchSpread", "caption"],
501
+ };
502
+ `;
503
+
504
+ const STARTER_COMPOSED = `// sprid post, composed: Sprid renders the pixels, this repo holds the copy.
505
+ //
506
+ // One spec per post in social/specs/, then:
507
+ // sprid post new my-first-post -> edit the spec -> register -> check -> draft
508
+ export default {
509
+ account: "ACCOUNT_SLUG",
510
+ // A PAT is workspace-scoped and this machine may hold several. Name the one
511
+ // this repo is allowed to use and nothing else is tried.
512
+ tokenEnv: "SPRID_TOKEN",
513
+ registry: "social/",
514
+ lanes: {
515
+ carousels: {
516
+ kind: "composed",
517
+ spec: "social/specs/<slug>.json",
518
+ // Optional: the Sprid format a post is created from, and the template
519
+ // every card renders through.
520
+ // format: <format id from this account>,
521
+ // template: <template id from this account>,
522
+ expect: { slides: [5, 9], words: [0, 30] },
523
+ hashtags: [3, 6],
524
+ // The phrases this account has decided it does not say. The mechanical
525
+ // half of a voice; everything else still needs a reader.
526
+ neverSay: [],
527
+ // Every card stating a number names the file it came from, and the check
528
+ // opens it. Turn this on for anything where a wrong figure is the one
529
+ // unforgivable error.
530
+ sourcesRequired: false,
531
+ },
532
+ },
533
+ checks: ["slides", "words", "voice", "sources", "caption"],
534
+ };
535
+ `;
536
+
537
+ async function cmdInit(argv) {
538
+ const kind = flag(argv, "kind");
539
+ if (!kind) {
540
+ console.log(`
541
+ ${C.bold("What are your posts made of?")}
542
+
543
+ ${C.bold("--kind text")} words on a background, a template, a hook and a payoff
544
+ ${C.dim("-> render in the app repo, then let Sprid check and upload")}
545
+ ${C.dim(" the finished mp4. Cloud reel rendering is paused.")}
546
+
547
+ ${C.bold("--kind stills")} photographs, screenshots, generated images
548
+ ${C.dim("-> the built-in recipe. A spec per reel, one command,")}
549
+ ${C.dim(" and it prints the ffmpeg it ran so you can take it over.")}
550
+
551
+ ${C.bold("--kind own")} you already render your own mp4s
552
+ ${C.dim("-> sprid post registers, checks and pushes them.")}
553
+ ${C.dim(" Point a lane's build: at your existing command.")}
554
+
555
+ ${C.dim("Pick one and run it again, e.g.")} sprid post init --kind stills
556
+ `);
557
+ return;
558
+ }
559
+ if (kind === "text") {
560
+ const p = join(process.cwd(), "sprid.config.ts");
561
+ if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists. Pass --force to overwrite.`);
562
+ writeFileSync(p, STARTER_OWN);
563
+ console.log(`\n wrote ${C.bold("sprid.config.ts")}`);
564
+ console.log(` ${C.dim("point the lane's build command at the app's text renderer, then:")} sprid post build\n`);
565
+ return;
566
+ }
567
+ if (kind === "composed") {
568
+ throw new Error("Cloud reel composition is paused. Use --kind text with the app's local renderer.");
569
+ }
570
+ const p = join(process.cwd(), "sprid.config.ts");
571
+ if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists. Pass --force to overwrite.`);
572
+ writeFileSync(p, kind === "stills" ? STARTER_STILLS : STARTER_OWN);
573
+ console.log(`\n wrote ${C.bold("sprid.config.ts")}`);
574
+ console.log(
575
+ kind === "stills"
576
+ ? ` ${C.dim("set the account slug, then:")} sprid post new my-first-reel\n`
577
+ : ` ${C.dim("set the account slug and point build: at your renderer, then:")} sprid post build\n`,
578
+ );
579
+ }
580
+
581
+ /**
582
+ * The step that was missing from the front of this pipeline.
583
+ *
584
+ * **`register` used to assume a finished file had appeared by magic**, which is
585
+ * fine for a repo that already has a renderer and is the entire problem for one
586
+ * that does not. A lane now says how its media is MADE, and there are exactly
587
+ * two answers:
588
+ *
589
+ * build: "bun run scripts/make-reel.ts --spec <spec> --out <out>"
590
+ * build: { recipe: "stills", track: "assets/bed.mp3" }
591
+ *
592
+ * The first runs your own renderer and this package stays out of it. The second
593
+ * runs the one built-in, so a repo with pictures and no pipeline is producing
594
+ * something on day one. Both land in the same `media` path and everything
595
+ * downstream - register, check, push, draft - cannot tell them apart, which is
596
+ * the point: a customer can start on the recipe and grow into their own
597
+ * renderer without changing anything else.
598
+ */
599
+ async function cmdBuild(cfg, argv) {
600
+ const only = positional(argv);
601
+ const onlyLane = flag(argv, "lane");
602
+ const targets = [];
603
+ for (const laneName of Object.keys(cfg.lanes)) {
604
+ if (onlyLane && laneName !== onlyLane) continue;
605
+ const lane = cfg.lanes[laneName];
606
+ if (!lane.build) continue;
607
+ // **A named slug belongs to ONE lane.** Without this a repo with two build
608
+ // lanes - a screen recipe and a stills one - runs the named slug through
609
+ // both, and the second fails on a spec it was never given.
610
+ const slugs = only
611
+ ? (typeof lane.build === "string" || existsSync(specPath(cfg, laneName, only)) ? [only] : [])
612
+ : specSlugs(cfg, laneName);
613
+ for (const slug of slugs) targets.push([laneName, slug]);
614
+ }
615
+ if (only && targets.length > 1 && !onlyLane)
616
+ throw new Error(
617
+ `"${only}" has a spec in ${targets.length} lanes (${targets.map((t) => t[0]).join(", ")}). ` +
618
+ "Say which: `build " + only + " --lane " + targets[0][0] + "`, or give the lanes separate spec directories.",
619
+ );
620
+ if (!targets.length)
621
+ throw new Error(
622
+ Object.values(cfg.lanes).every((l) => isComposed(l))
623
+ ? "Nothing to build, and nothing to build it with: every lane here is `composed`, which means Sprid renders it.\n" +
624
+ " Write the copy in a spec, then `register` -> `check` -> `draft`."
625
+ : "Nothing to build. A lane needs a `build:` - either a command of your own, or { recipe: \"stills\" }.\n" +
626
+ " With a recipe, each reel is a spec file; `sprid post new <slug>` writes one.",
627
+ );
628
+
629
+ for (const [laneName, slug] of targets) {
630
+ const lane = cfg.lanes[laneName];
631
+ const out = resolve(cfg.root, fill(lane.media, slug));
632
+ mkdirSync(dirname(out), { recursive: true });
633
+
634
+ if (typeof lane.build === "string") {
635
+ const cmd = lane.build.replaceAll("<slug>", slug).replaceAll("<out>", out).replaceAll("<spec>", specPath(cfg, laneName, slug));
636
+ console.log(` ${C.dim(cmd)}`);
637
+ const r = spawnSync(cmd, { shell: true, stdio: "inherit", cwd: cfg.root });
638
+ if (r.status !== 0) throw new Error(`build failed for ${slug}`);
639
+ } else if (lane.build.recipe === "screen") {
640
+ // The screen recipe drives a simulator, so it is async and it needs the
641
+ // repo root, a scratch directory and the spec's own script file. It
642
+ // writes the caption exactly like the stills recipe does below.
643
+ const { buildScreen } = await import("./recipes/screen.mjs");
644
+ const spec = readSpec(cfg, laneName, slug);
645
+ const scriptPath = resolve(cfg.root, spec.script ?? `${basename(cfg.registry)}/scripts/${slug}.json`);
646
+ if (!existsSync(scriptPath))
647
+ throw new Error(`${slug}: no gesture script at ${scriptPath}. A screen spec names one with \`script\`; ` +
648
+ `one script serves every locale.`);
649
+ const r = await buildScreen({
650
+ spec: { ...lane.build, ...spec },
651
+ script: JSON.parse(readFileSync(scriptPath, "utf8")),
652
+ root: cfg.root,
653
+ out,
654
+ // **Scratch goes OUTSIDE the repo unless a lane says otherwise.** The
655
+ // recording writes a settle screenshot twice a second into this
656
+ // directory; with it inside the project, Metro's file watcher sees
657
+ // every one of them, sends the app a Fast Refresh, and RN drops its
658
+ // blue "Refreshing…" banner over the top of the frame. It landed on
659
+ // three of eight sampled frames of the first reel built here.
660
+ recut: has(argv, "recut"),
661
+ tmp: lane.build.tmp
662
+ ? resolve(cfg.root, lane.build.tmp, slug)
663
+ : join(tmpdir(), "sprid", basename(cfg.root), slug),
664
+ });
665
+ for (const a of r.advice) console.log(` ${C.warn("note")} ${a}`);
666
+ // The graph is 8KB of overlay expressions; printing it drowns the log it
667
+ // is supposed to accompany. It still gets written down - a recipe that
668
+ // hides the command it ran is a framework.
669
+ console.log(` ${C.dim(`ffmpeg command: ${r.commandPath}`)}`);
670
+ if (spec.caption && lane.caption) {
671
+ const cp = resolve(cfg.root, fill(lane.caption, slug));
672
+ mkdirSync(dirname(cp), { recursive: true });
673
+ writeFileSync(cp, spec.caption.trim() + "\n");
674
+ }
675
+ } else {
676
+ const { buildStills } = await import("./recipes/stills.mjs");
677
+ const spec = readSpec(cfg, laneName, slug);
678
+ const r = buildStills({
679
+ images: (spec.images ?? []).map((i) => resolve(cfg.root, i)),
680
+ track: spec.track ? resolve(cfg.root, spec.track) : lane.build.track ? resolve(cfg.root, lane.build.track) : undefined,
681
+ out,
682
+ ...(lane.build.holdMs ? { holdMs: lane.build.holdMs } : {}),
683
+ ...(spec.holdMs ? { holdMs: spec.holdMs } : {}),
684
+ });
685
+ for (const a of r.advice) console.log(` ${C.warn("note")} ${a}`);
686
+ console.log(` ${C.dim(r.command)}`);
687
+ // A spec's caption is written out where `register` will find it, so a
688
+ // recipe lane needs no separate caption step.
689
+ if (spec.caption && lane.caption) {
690
+ const cp = resolve(cfg.root, fill(lane.caption, slug));
691
+ mkdirSync(dirname(cp), { recursive: true });
692
+ writeFileSync(cp, spec.caption.trim() + "\n");
693
+ }
694
+ }
695
+ // The record is now wrong about the bytes and right about nothing until
696
+ // `register` runs, but the command is knowable here and nowhere else.
697
+ if (existsSync(manifestPath(cfg, slug))) {
698
+ const m = readManifest(cfg, slug);
699
+ m.build = buildRecord(cfg, laneName, slug, undefined, {});
700
+ writeManifest(cfg, m);
701
+ }
702
+ console.log(` ${C.ok("built")} ${slug.padEnd(40)} ${out.replace(cfg.root + "/", "")}\n`);
703
+ }
704
+ }
705
+
706
+ const specPath = (cfg, laneName, slug) =>
707
+ resolve(cfg.root, fill(cfg.lanes[laneName].spec ?? `${basename(cfg.registry)}/specs/<slug>.json`, slug));
708
+
709
+ function readSpec(cfg, laneName, slug) {
710
+ const p = specPath(cfg, laneName, slug);
711
+ if (!existsSync(p)) throw new Error(`No spec at ${p}. Write one, or \`sprid post new ${slug}\`.`);
712
+ const spec = JSON.parse(readFileSync(p, "utf8"));
713
+ // **Hashtags are part of the caption.** Sprid publishes one string per
714
+ // platform and has no tag field to send them to, so a spec carrying them
715
+ // separately is named rather than quietly dropped - a silently ignored key
716
+ // is how a post ships without its tags and nobody finds out until it is up.
717
+ if (spec.hashtags !== undefined)
718
+ throw new Error(`${p}: \`hashtags\` is not a field. Put the tags at the end of \`caption\`.`);
719
+ return spec;
720
+ }
721
+
722
+ /** Every slug a recipe lane can build, from its spec directory. */
723
+ function specSlugs(cfg, laneName) {
724
+ const tpl = specPath(cfg, laneName, " ");
725
+ const dir = dirname(tpl);
726
+ if (!existsSync(dir)) return [];
727
+ const [pre, post] = basename(tpl).split(" ");
728
+ return readdirSync(dir)
729
+ .filter((f) => f.startsWith(pre) && f.endsWith(post) && f.length > pre.length + post.length)
730
+ .map((f) => f.slice(pre.length, f.length - post.length))
731
+ .sort();
732
+ }
733
+
734
+ /**
735
+ * A spec is the durable artifact and the file a person edits.
736
+ *
737
+ * Carried over from the pipeline this package was drawn from, where it is the
738
+ * single best structural decision in the whole thing: the mp4 is disposable and
739
+ * regenerates, the spec is what is reviewed, committed and argued about.
740
+ */
741
+ async function cmdNew(cfg, argv) {
742
+ const slug = positional(argv);
743
+ if (!slug) throw new Error("Give it a slug: `sprid post new my-first-reel`.");
744
+ // Whichever lane keeps specs: a composed one, or a recipe one. Those are the
745
+ // two kinds of lane a spec means anything to.
746
+ const laneName =
747
+ flag(argv, "lane") ??
748
+ Object.keys(cfg.lanes).find((l) => isComposed(cfg.lanes[l]) || typeof cfg.lanes[l].build === "object") ??
749
+ Object.keys(cfg.lanes)[0];
750
+ const p = specPath(cfg, laneName, slug);
751
+ if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists.`);
752
+ mkdirSync(dirname(p), { recursive: true });
753
+ // The spec's shape is the lane's kind: a composed post is its copy, a recipe
754
+ // reel is its pictures. Scaffolding the wrong one is a file somebody has to
755
+ // delete before they can start.
756
+ const starter = isComposed(cfg.lanes[laneName])
757
+ ? {
758
+ slug,
759
+ slides: [
760
+ { role: "hook", text: "The line that makes someone stop." },
761
+ { role: "body", text: "One thought.", source: "docs/where-this-came-from.md" },
762
+ { role: "cta", text: "The line they screenshot." },
763
+ ],
764
+ caption: "What this post says. Written here, committed, reviewed in a diff.\n\n#one #two #three",
765
+ }
766
+ : {
767
+ slug,
768
+ images: ["path/to/first.jpg", "path/to/second.jpg", "path/to/third.jpg"],
769
+ track: cfg.lanes[laneName].build?.track ?? "path/to/bed.mp3",
770
+ holdMs: 3000,
771
+ caption: "What this post says. Written here, committed, reviewed in a diff.",
772
+ };
773
+ writeFileSync(p, JSON.stringify(starter, null, 2) + "\n");
774
+ console.log(`\n wrote ${C.bold(p.replace(cfg.root + "/", ""))}`);
775
+ console.log(` ${C.dim("point `images` at real files, then:")} sprid post build ${slug}\n`);
776
+ }
777
+
778
+ /** Everything that is true of one finished mp4. */
779
+ function measureReel(cfg, lane, slug) {
780
+ const media = resolve(cfg.root, fill(cfg.lanes[lane].media, slug));
781
+ const durationMs = Math.round(Number(ffprobe(media, "format=duration")) * 1000);
782
+ const { width, height } = imageSize(media);
783
+ const sizeBytes = statSync(media).size;
784
+ const lufs = loudness(media);
785
+ const digest = sha256(media);
786
+ return {
787
+ digest,
788
+ record: { media: { sha256: digest, durationMs, width, height, sizeBytes, lufs } },
789
+ summary:
790
+ `${(durationMs / 1000).toFixed(1)}s ${(sizeBytes / 1e6).toFixed(1)} MB ` +
791
+ `${lufs === null ? C.dim("no audio") : `${lufs.toFixed(1)} LUFS`}`,
792
+ };
793
+ }
794
+
795
+ /**
796
+ * Everything that is true of one carousel.
797
+ *
798
+ * **The digest is over the slides AND their order**, since two decks holding
799
+ * the same pictures in a different order are two different posts, and the order
800
+ * is the half a reviewer argues about.
801
+ */
802
+ function measureCarousel(cfg, lane, slug) {
803
+ const files = slideFiles(cfg, cfg.lanes[lane], slug);
804
+ if (!files.length) throw new Error(`No slides for ${slug} at ${fill(mediaTemplate(cfg.lanes[lane]), slug)}`);
805
+ const slides = files.map((f) => {
806
+ const { width, height } = imageSize(f);
807
+ return {
808
+ file: f.replace(cfg.root + "/", ""),
809
+ sha256: sha256(f),
810
+ width,
811
+ height,
812
+ sizeBytes: statSync(f).size,
813
+ aspect: ratioLabel(width, height),
814
+ };
815
+ });
816
+ const digest = createHash("sha256").update(slides.map((s) => s.sha256).join("\n")).digest("hex");
817
+ const bytes = slides.reduce((n, s) => n + s.sizeBytes, 0);
818
+ const ratios = [...new Set(slides.map((s) => s.aspect))];
819
+ return {
820
+ digest,
821
+ record: { media: { sha256: digest, slideCount: slides.length, sizeBytes: bytes, aspect: ratios[0] }, slides },
822
+ summary:
823
+ `${slides.length} slides ${(bytes / 1e6).toFixed(1)} MB ` +
824
+ (ratios.length === 1 ? ratios[0] : C.warn(`mixed ratios: ${ratios.join(", ")}`)),
825
+ };
826
+ }
827
+
828
+ /**
829
+ * Everything that is true of one composed post.
830
+ *
831
+ * **The spec IS the artifact here**, not a recipe for one, so the digest is
832
+ * over the spec and the words are measured off it. Word counts are kept per
833
+ * slide rather than totalled: a deck whose average is fine and whose third card
834
+ * runs to sixty words is exactly the deck the total hides.
835
+ */
836
+ function measureComposed(cfg, lane, slug) {
837
+ const spec = readSpec(cfg, lane, slug);
838
+ const slides = spec.slides ?? [];
839
+ if (!slides.length) throw new Error(`${slug}: the spec has no \`slides\`. A composed post is its copy.`);
840
+ const words = slides.map((sl) => wordsOf(sl));
841
+ const body = JSON.stringify(slides);
842
+ return {
843
+ digest: createHash("sha256").update(body).digest("hex"),
844
+ record: {
845
+ media: {
846
+ sha256: createHash("sha256").update(body).digest("hex"),
847
+ slideCount: slides.length,
848
+ sizeBytes: Buffer.byteLength(body),
849
+ words,
850
+ },
851
+ copy: slides,
852
+ },
853
+ summary: `${slides.length} cards ${Math.max(...words)} words at the longest`,
854
+ };
855
+ }
856
+
857
+ /** Every word a card shows, headline and body and bullets alike. */
858
+ const wordsOf = (sl) =>
859
+ [sl.text, sl.subtitle, ...(sl.bullets ?? [])]
860
+ .filter(Boolean)
861
+ .join(" ")
862
+ .split(/\s+/)
863
+ .filter(Boolean).length;
864
+
865
+ const digestOf = (m) => m?.media?.sha256 ?? null;
866
+
867
+ /**
868
+ * How this file was made.
869
+ *
870
+ * **A record that measures the bytes and forgets the command is half a record.**
871
+ * On 2026-09-03 a reel registered at 17.2s came back from a re-render at 13.8s
872
+ * without `--map` and 20.6s with it, and nothing in the registry could say
873
+ * which invocation the 17.2s belonged to - the render line was living in a
874
+ * deck's notes. The command is the difference between a spec that reproduces a
875
+ * post and a spec that reproduces something like it.
876
+ *
877
+ * Three sources, in the order you would trust them: what the operator says they
878
+ * just ran, what the lane says it runs, and what the record already held.
879
+ */
880
+ function buildRecord(cfg, lane, slug, builtWith, prev) {
881
+ if (builtWith) return { command: builtWith, source: "operator", at: new Date().toISOString() };
882
+ const b = cfg.lanes[lane]?.build;
883
+ if (typeof b === "string")
884
+ return { command: fill(b, slug).replaceAll("<out>", fill(mediaTemplate(cfg.lanes[lane]), slug)), source: "lane", at: new Date().toISOString() };
885
+ if (b && typeof b === "object")
886
+ return { command: `sprid post build ${slug}`, source: "recipe", at: new Date().toISOString() };
887
+ return prev?.build ?? null;
888
+ }
889
+
890
+ async function cmdRegister(cfg, argv) {
891
+ const only = positional(argv);
892
+ const targets = [];
893
+ if (only) {
894
+ const lane = flag(argv, "lane") ?? laneOf(cfg, only);
895
+ if (!lane) throw new Error(`No lane in sprid.config has media for "${only}".`);
896
+ targets.push([lane, only]);
897
+ } else {
898
+ for (const lane of Object.keys(cfg.lanes)) for (const slug of slugsInLane(cfg, lane)) targets.push([lane, slug]);
899
+ }
900
+ if (!targets.length) throw new Error("Nothing to register. Check the `media` paths in your lanes.");
901
+
902
+ for (const [lane, slug] of targets) {
903
+ const kind = kindOf(cfg.lanes[lane]);
904
+ const measured =
905
+ kind === "composed"
906
+ ? measureComposed(cfg, lane, slug)
907
+ : kind === "carousel"
908
+ ? measureCarousel(cfg, lane, slug)
909
+ : measureReel(cfg, lane, slug);
910
+
911
+ // The caption is copied INTO the registry rather than pointed at. It is the
912
+ // most reviewable thing the pipeline produces and it usually lives in a
913
+ // scratch directory, where nobody can read it in a diff.
914
+ // A composed post keeps its caption in the spec, next to the copy it
915
+ // belongs to; a rendered one keeps it wherever the renderer dropped it.
916
+ // Either way it is COPIED into the registry rather than pointed at - it is
917
+ // the most reviewable thing any of these pipelines produce and it usually
918
+ // lives in a scratch directory nobody reads a diff of.
919
+ const capSrc = cfg.lanes[lane].caption ? resolve(cfg.root, fill(cfg.lanes[lane].caption, slug)) : null;
920
+ let caption = null;
921
+ if (kind === "composed") {
922
+ const spec = readSpec(cfg, lane, slug);
923
+ caption = spec.caption ? String(spec.caption).trim() : null;
924
+ } else if (capSrc && existsSync(capSrc)) {
925
+ caption = readFileSync(capSrc, "utf8").trim();
926
+ }
927
+ if (caption) {
928
+ mkdirSync(dirname(captionPath(cfg, slug)), { recursive: true });
929
+ writeFileSync(captionPath(cfg, slug), caption + "\n");
930
+ }
931
+ const ttSrc = capSrc ? tiktokSibling(capSrc) : null;
932
+ const captionTiktok = ttSrc && existsSync(ttSrc) ? readFileSync(ttSrc, "utf8").trim() : null;
933
+ if (captionTiktok) writeFileSync(tiktokCaptionPath(cfg, slug), captionTiktok + "\n");
934
+ else if (existsSync(tiktokCaptionPath(cfg, slug))) rmSync(tiktokCaptionPath(cfg, slug));
935
+
936
+ const prev = existsSync(manifestPath(cfg, slug)) ? readManifest(cfg, slug) : {};
937
+ const authored = existsSync(specPath(cfg, lane, slug)) ? readSpec(cfg, lane, slug) : {};
938
+ if (authored.remotePostId !== undefined && (!Number.isInteger(authored.remotePostId) || authored.remotePostId <= 0))
939
+ throw new Error(`${slug}: remotePostId must be a positive integer`);
940
+ const m = {
941
+ slug,
942
+ lane,
943
+ kind,
944
+ build: buildRecord(cfg, lane, slug, flag(argv, "built-with"), prev),
945
+ source: fill(mediaTemplate(cfg.lanes[lane]), slug),
946
+ ...measured.record,
947
+ post: {
948
+ platforms: checkedPlatforms(flag(argv, "platforms")?.split(",") ??
949
+ authored.platforms ??
950
+ prev.post?.platforms),
951
+ title: authored.title ?? prev.post?.title,
952
+ captionFile: caption ? `${basename(cfg.registry)}/captions/${slug}.txt` : null,
953
+ captionSha: caption ? createHash("sha256").update(caption + (captionTiktok ?? "")).digest("hex").slice(0, 12) : null,
954
+ captionTiktokFile: captionTiktok ? `${basename(cfg.registry)}/captions/${slug}.tiktok.txt` : null,
955
+ instagramLocationId: prev.post?.instagramLocationId ?? null,
956
+ },
957
+ // A record is only true of the bytes it measured, so a changed digest
958
+ // drops the check result with it.
959
+ checks: measured.digest === digestOf(prev) ? (prev.checks ?? null) : null,
960
+ sprid: {
961
+ account: cfg.account,
962
+ videoId: null,
963
+ imageIds: null,
964
+ postId: authored.remotePostId ?? null,
965
+ status: "none",
966
+ captionShaAtDraft: null,
967
+ ...(prev.sprid ?? {}),
968
+ ...(authored.remotePostId !== undefined ? { postId: authored.remotePostId } : {}),
969
+ },
970
+ registeredAt: new Date().toISOString(),
971
+ };
972
+ writeManifest(cfg, m);
973
+ console.log(` ${C.ok("registered")} ${slug.padEnd(40)} ${measured.summary}`);
974
+ }
975
+ console.log(`\n ${targets.length} registered in ${C.bold(cfg.registry)}\n`);
976
+ }
977
+
978
+ /**
979
+ * The gate.
980
+ *
981
+ * Every check is a refusal to publish something a person would have caught by
982
+ * watching the file, and the reason each one exists is written next to it. A
983
+ * check that cannot run reports that it could not, never a pass.
984
+ */
985
+ /** Validate the platform projections before a local post is uploaded. */
986
+ export function checkedPlatforms(platforms) {
987
+ if (platforms === undefined) return undefined;
988
+ const supported = ["instagram", "tiktok", "youtube", "facebook", "linkedin", "pinterest", "x"];
989
+ if (!Array.isArray(platforms) || !platforms.length || platforms.some(p => !supported.includes(p)))
990
+ throw new Error(`platforms must be a non-empty array from: ${supported.join(", ")}`);
991
+ return [...new Set(platforms)];
992
+ }
993
+
994
+ export function crosspostCheck(m, caption, slug, platforms) {
995
+ const title = m.post?.title ?? postTitle(caption, slug);
996
+ const selected = checkedPlatforms(platforms ?? m.post?.platforms);
997
+ const modeled = selected?.filter(p => ["instagram", "tiktok", "youtube", "pinterest"].includes(p));
998
+ const unmodeled = selected?.filter(p => !["instagram", "tiktok", "youtube", "pinterest"].includes(p)) ?? [];
999
+ const problems = projectPost({ title, captionInstagram: caption,
1000
+ pinterestOptions: m.post?.pinterestOptions,
1001
+ platforms: modeled })
1002
+ .flatMap((p) => p.problems.map((x) => `${p.platform}: ${x}`));
1003
+ return problems.length ? `fail: ${problems.join("; ")}`
1004
+ : unmodeled.length ? `skip: local projection rules unavailable for ${unmodeled.join(", ")}; use --deep for booked posts` : "pass";
1005
+ }
1006
+
1007
+
1008
+ export function runChecks(cfg, m, batch) {
1009
+ const lane = cfg.lanes[m.lane] ?? {};
1010
+ const want = lane.expect ?? {};
1011
+ const out = {};
1012
+ const enabled = new Set(cfg.checks);
1013
+ const kind = m.kind ?? kindOf(lane);
1014
+ const carousel = kind === "carousel";
1015
+ const composed = kind === "composed";
1016
+
1017
+ // **A check that does not apply says so; it never passes.** Duration and
1018
+ // loudness are properties of a video, and a carousel that reported four
1019
+ // green checks it never ran would be worth less than no check at all. A
1020
+ // composed post has no pixels here at all, so it skips the aspect check too.
1021
+ if (carousel || composed) {
1022
+ for (const name of ["duration", "loudness", "batchSpread"]) {
1023
+ if (enabled.has(name)) out[name] = `skip: not applicable to a ${kind === "composed" ? "composed post" : "carousel"}`;
1024
+ }
1025
+ }
1026
+ if (composed && enabled.has("aspect")) out.aspect = "skip: Sprid renders this one, so the shape is the template's";
1027
+
1028
+ // How many slides. Instagram takes 20, TikTok 35, and a deck of two is a
1029
+ // post that did not get made.
1030
+ if ((carousel || composed) && enabled.has("slides")) {
1031
+ const n = m.media?.slideCount ?? m.slides?.length ?? 0;
1032
+ const [lo, hi] = want.slides ?? [2, 20];
1033
+ out.slides = n >= lo && n <= hi ? "pass" : `fail: ${n} slides, wanted ${lo}-${hi}`;
1034
+ }
1035
+
1036
+ // **One ratio for the whole deck.** Instagram crops every later slide to the
1037
+ // first one's shape, so a deck that mixes 4:5 and 1:1 publishes with the
1038
+ // sides cut off things nobody checked - and it looks fine in every local
1039
+ // preview that shows the files as they are.
1040
+ if (carousel && enabled.has("aspect")) {
1041
+ const ratios = [...new Set((m.slides ?? []).map((s) => s.aspect))];
1042
+ const small = (m.slides ?? []).filter((s) => s.width < (want.minWidth ?? 1080));
1043
+ const problems = [];
1044
+ if (ratios.length > 1) problems.push(`mixed ratios: ${ratios.join(", ")}`);
1045
+ else if (want.aspect && ratios[0] !== want.aspect) problems.push(`${ratios[0]}, wanted ${want.aspect}`);
1046
+ if (small.length) problems.push(`${small.length} slide(s) under ${want.minWidth ?? 1080}px wide`);
1047
+ out.aspect = problems.length ? `fail: ${problems.join("; ")}` : "pass";
1048
+ }
1049
+
1050
+ if (!carousel && enabled.has("duration") && want.durationMs) {
1051
+ const [lo, hi] = want.durationMs;
1052
+ out.duration =
1053
+ m.media.durationMs >= lo && m.media.durationMs <= hi
1054
+ ? "pass"
1055
+ : `fail: ${(m.media.durationMs / 1000).toFixed(1)}s outside ${(lo / 1000).toFixed(1)}-${(hi / 1000).toFixed(1)}s`;
1056
+ }
1057
+
1058
+ if (!carousel && enabled.has("loudness")) {
1059
+ if (m.media.lufs === null) out.loudness = "skip: no audio stream";
1060
+ else if (want.lufs) {
1061
+ const [lo, hi] = want.lufs;
1062
+ out.loudness = m.media.lufs >= lo && m.media.lufs <= hi ? "pass" : `fail: ${m.media.lufs.toFixed(1)} LUFS outside ${lo}..${hi}`;
1063
+ } else out.loudness = "skip: no lufs range in the lane";
1064
+ }
1065
+
1066
+ // Compare loudness within a lane and fail only the outliers.
1067
+ if (!carousel && enabled.has("batchSpread")) {
1068
+ const peers = batch.filter((x) => x.lane === m.lane && typeof x.media?.lufs === "number");
1069
+ const vals = peers.map((x) => x.media.lufs);
1070
+ if (vals.length < 3) out.batchSpread = "skip: fewer than three measured files in this lane";
1071
+ else {
1072
+ const sorted = [...vals].sort((a, b) => a - b);
1073
+ const median = sorted[Math.floor(sorted.length / 2)];
1074
+ const tol = lane.maxDeviationDb ?? 1.2;
1075
+ const off = Math.abs(m.media.lufs - median);
1076
+ out.batchSpread =
1077
+ off <= tol
1078
+ ? "pass"
1079
+ : `fail: ${m.media.lufs.toFixed(1)} LUFS is ${off.toFixed(1)} dB off the ${m.lane} median (${median.toFixed(1)}), ` +
1080
+ `lane spread ${(Math.max(...vals) - Math.min(...vals)).toFixed(1)} dB`;
1081
+ }
1082
+ }
1083
+
1084
+ // Not a pass/fail on the media, a statement about the record: can this file
1085
+ // be made again, exactly? A batch where most rows skip this is a batch whose
1086
+ // provenance lives in somebody's terminal history.
1087
+ if (enabled.has("provenance")) {
1088
+ out.provenance = m.build?.command
1089
+ ? "pass"
1090
+ : "skip: no build command recorded - pass --built-with \"<command>\" to register, or give the lane a build:";
1091
+ }
1092
+
1093
+ // **The word budget is per CARD, never the total.** A deck averaging twenty
1094
+ // words with one card at sixty is exactly the deck an average hides, and the
1095
+ // long card is the one a reader stops on.
1096
+ if (composed && enabled.has("words") && want.words) {
1097
+ const [lo, hi] = want.words;
1098
+ const over = (m.media?.words ?? []).map((n, i) => [i + 1, n]).filter(([, n]) => n < lo || n > hi);
1099
+ out.words = over.length
1100
+ ? `fail: card ${over.map(([i, n]) => `${i} (${n} words)`).join(", ")} outside ${lo}-${hi}`
1101
+ : "pass";
1102
+ }
1103
+
1104
+ // Account-specific phrase restrictions belong in the lane configuration.
1105
+ if (composed && enabled.has("voice")) {
1106
+ const banned = lane.neverSay ?? [];
1107
+ if (!banned.length) out.voice = "skip: no `neverSay` list in the lane";
1108
+ else {
1109
+ const text = (m.copy ?? []).map((sl) => [sl.text, sl.subtitle, ...(sl.bullets ?? [])].filter(Boolean).join(" ")).join("\n");
1110
+ const hits = banned.filter((phrase) => text.toLowerCase().includes(String(phrase).toLowerCase()));
1111
+ const problems = hits.map((h) => `"${h}"`);
1112
+ out.voice = problems.length ? `fail: ${problems.join(", ")}` : "pass";
1113
+ }
1114
+ }
1115
+
1116
+ // **Where the number came from, as a file that exists.** For a YMYL account
1117
+ // this is the whole gate: a slide claiming a cost, a threshold or a rule
1118
+ // names the fact-checked file it came from, and the check opens it. A source
1119
+ // that has been renamed or deleted is the failure worth catching, because the
1120
+ // slide keeps reading true long after the file behind it stopped being.
1121
+ if (composed && enabled.has("sources") && lane.sourcesRequired) {
1122
+ const missing = [];
1123
+ const unsourced = [];
1124
+ (m.copy ?? []).forEach((sl, i) => {
1125
+ const src = sl.source ? [sl.source] : (sl.sources ?? []);
1126
+ const numeric = /\d/.test([sl.text, sl.subtitle, ...(sl.bullets ?? [])].filter(Boolean).join(" "));
1127
+ if (!src.length) {
1128
+ if (numeric) unsourced.push(i + 1);
1129
+ return;
1130
+ }
1131
+ for (const one of src) if (!existsSync(resolve(cfg.root, String(one).split("#")[0]))) missing.push(`${i + 1}: ${one}`);
1132
+ });
1133
+ const problems = [
1134
+ ...(missing.length ? [`source file gone - ${missing.join(", ")}`] : []),
1135
+ ...(unsourced.length ? [`card ${unsourced.join(", ")} states a number with no source`] : []),
1136
+ ];
1137
+ out.sources = problems.length ? `fail: ${problems.join("; ")}` : "pass";
1138
+ }
1139
+
1140
+ if (enabled.has("crosspost")) {
1141
+ const cp = captionPath(cfg, m.slug);
1142
+ // The local half. `check` runs offline and before anything is pushed, so it
1143
+ // asserts what the registry can see. The authoritative half lives in Sprid
1144
+ // and is asked for by `check --deep`, which projects the actual post - see
1145
+ // `deepCrosspost`.
1146
+ out.crosspost = existsSync(cp)
1147
+ ? crosspostCheck(m, readFileSync(cp, "utf8"), m.slug, cfg.checkPlatforms ?? m.post?.platforms ?? lane.platforms ?? cfg.platforms)
1148
+ : "fail: no caption in the registry";
1149
+ }
1150
+
1151
+ if (enabled.has("caption")) {
1152
+ const p = captionPath(cfg, m.slug);
1153
+ if (!existsSync(p)) out.caption = "fail: no caption in the registry";
1154
+ else {
1155
+ const text = readFileSync(p, "utf8");
1156
+ const problems = [];
1157
+ for (const phrase of lane.neverSay ?? []) {
1158
+ if (text.toLowerCase().includes(String(phrase).toLowerCase())) problems.push(`forbidden "${phrase}"`);
1159
+ }
1160
+ // Attribution and hashtag lines do not participate in sentence checks.
1161
+ const prose = text
1162
+ .split("\n")
1163
+ .filter((l) => !/^\s*#/.test(l) && !/^(Photos via|Map ©|Photo:)/.test(l.trim()))
1164
+ .join("\n")
1165
+ .trim();
1166
+ // Language-dependent endings are configured per lane.
1167
+ const tail = prose.split(/\s+/).pop()?.toLowerCase().replace(/[^\p{L}]/gu, "") ?? "";
1168
+ const endWords = new Set((lane.captionEndWords ?? []).map((word) => String(word).toLowerCase()));
1169
+ if (/,$/.test(prose)) problems.push("prose ends on a comma");
1170
+ else if (endWords.has(tail)) problems.push(`prose ends on "${tail}"`);
1171
+ else if (lane.captionEndsSentence && !/[.!?。!?:]$/.test(prose)) problems.push("prose does not end a sentence");
1172
+ for (const rule of lane.captionMustContain ?? []) if (!text.includes(rule)) problems.push(`missing "${rule}"`);
1173
+ if (lane.hashtags) {
1174
+ // `\w` stops at the first non-ASCII letter, so a Swedish lane counted
1175
+ // "#tvätt" as "#tv" and passed a caption that was short of its range.
1176
+ const n = (text.match(/#[\p{L}\p{M}\p{N}_]+/gu) ?? []).length;
1177
+ const [lo, hi] = lane.hashtags;
1178
+ if (n < lo || n > hi) problems.push(`${n} hashtags, wanted ${lo}-${hi}`);
1179
+ }
1180
+ out.caption = problems.length ? `fail: ${problems.join(", ")}` : "pass";
1181
+ }
1182
+ }
1183
+
1184
+ out.at = new Date().toISOString();
1185
+ return out;
1186
+ }
1187
+
1188
+ /**
1189
+ * What Sprid says each platform will publish, for posts that exist there.
1190
+ *
1191
+ * **The local `crosspost` check asserts preconditions; this one reads the
1192
+ * answer.** A copy of the platform rules living in this package would drift
1193
+ * from the adapters that actually publish and end up agreeing with the bug, so
1194
+ * the rules stay server-side (`projectPost`, beside the adapters) and this asks
1195
+ * for the projection. It is `--deep` rather than the default because `check`
1196
+ * has to work offline and before a post exists.
1197
+ *
1198
+ */
1199
+ async function deepCrosspost(cfg, manifests) {
1200
+ const rows = [];
1201
+ for (const m of manifests) {
1202
+ if (!m.sprid?.postId) continue;
1203
+ const p = await api(cfg, "GET", `/posts/${m.sprid.postId}/projection`);
1204
+ if (!p) continue;
1205
+ for (const problem of p.problems ?? []) rows.push({ slug: m.slug, ...problem });
1206
+ }
1207
+ return rows;
1208
+ }
1209
+
1210
+ async function cmdCheck(cfg, argv) {
1211
+ const only = positional(argv);
1212
+ cfg.checkPlatforms = checkedPlatforms(flag(argv, "platforms")?.split(","));
1213
+ const batch = allManifests(cfg);
1214
+ const hasCarousel = Object.values(cfg.lanes).some((l) => kindOf(l) === "carousel");
1215
+ const missingGates = ["slides", "aspect"].filter((n) => !cfg.checks.includes(n));
1216
+ if (hasCarousel && missingGates.length)
1217
+ console.log(` ${C.warn("note")} carousel lane with no ${missingGates.join("/")} check - add them to \`checks\` in sprid.config\n`);
1218
+ const targets = only ? [readManifest(cfg, only)] : batch;
1219
+ let failed = 0;
1220
+ for (const m of targets) {
1221
+ m.checks = runChecks(cfg, m, batch);
1222
+ writeManifest(cfg, m);
1223
+ const bad = Object.entries(m.checks).filter(([k, v]) => k !== "at" && String(v).startsWith("fail"));
1224
+ const skipped = Object.entries(m.checks).filter(([k, v]) => k !== "at" && String(v).startsWith("skip"));
1225
+ if (bad.length) {
1226
+ failed++;
1227
+ console.log(` ${C.bad("fail")} ${m.slug}`);
1228
+ for (const [k, v] of bad) console.log(` ${k}: ${v.replace(/^fail: /, "")}`);
1229
+ } else {
1230
+ console.log(` ${C.ok("pass")} ${m.slug}${skipped.length ? C.dim(` (${skipped.map(([k]) => k).join(", ")} skipped)`) : ""}`);
1231
+ }
1232
+ }
1233
+ console.log(`\n ${targets.length - failed}/${targets.length} pass\n`);
1234
+
1235
+ // `--deep` asks Sprid what each platform will actually publish. Off by
1236
+ // default because `check` must work offline and before a post exists.
1237
+ let deepProblems = [];
1238
+ if (has(argv, "deep")) {
1239
+ const rows = await deepCrosspost(cfg, targets);
1240
+ if (!rows.length) console.log(` ${C.ok("crosspost")} every booked platform publishes what it should\n`);
1241
+ else {
1242
+ deepProblems = rows;
1243
+ console.log(` ${C.bad(`crosspost: ${rows.length} problem(s) in Sprid`)}\n`);
1244
+ for (const r of rows) console.log(` ${r.slug.padEnd(42)}${r.platform.padEnd(11)}${r.message}`);
1245
+ console.log(`\n ${C.dim("`sprid post sync --execute` rewrites a post's fields from the registry")}\n`);
1246
+ }
1247
+ }
1248
+ return { exitCode: failed || deepProblems.length ? 1 : 0, checked: targets.length, failed, deepProblems };
1249
+ }
1250
+
1251
+ const checksPass = (m) =>
1252
+ m.checks && Object.entries(m.checks).every(([k, v]) => k === "at" || !String(v).startsWith("fail"));
1253
+
1254
+ async function cmdPush(cfg, argv) {
1255
+ const only = positional(argv);
1256
+ const targets = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter(
1257
+ (m) => has(argv, "force") || (m.sprid.videoId == null && !(m.sprid.imageIds?.length > 0)),
1258
+ );
1259
+ if (!targets.length) return console.log("\n nothing to push (everything is already in Sprid)\n");
1260
+
1261
+ for (const m of targets) {
1262
+ if (!checksPass(m)) {
1263
+ console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
1264
+ continue;
1265
+ }
1266
+ // **A composed post has nothing here to upload.** Its pixels are the
1267
+ // server's templates; the copy travels in `draft`. Saying so beats
1268
+ // silently doing nothing, because "0 pushed" otherwise reads as a failure.
1269
+ if (isComposed(m)) {
1270
+ console.log(` ${C.dim("composed")} ${m.slug.padEnd(40)} ${C.dim("nothing to upload - go straight to `draft`")}`);
1271
+ continue;
1272
+ }
1273
+ if ((m.kind ?? "reel") === "carousel") {
1274
+ await pushCarousel(cfg, m);
1275
+ continue;
1276
+ }
1277
+ const file = resolve(cfg.root, m.source);
1278
+ if (!existsSync(file)) {
1279
+ console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${m.source} is not on disk; rebuild it`)}`);
1280
+ continue;
1281
+ }
1282
+ // Guard the record against a silent rebuild: the manifest is only true of
1283
+ // the bytes it measured.
1284
+ if (sha256(file) !== m.media.sha256) {
1285
+ console.log(` ${C.bad("stale")} ${m.slug} ${C.dim("- the file changed since it was registered; re-register it")}`);
1286
+ continue;
1287
+ }
1288
+
1289
+ const filename = `${m.slug}.mp4`;
1290
+ const { uploadUrl, videoId } = await api(cfg, "POST", "/videos/upload-url", {
1291
+ filename,
1292
+ contentType: "video/mp4",
1293
+ accountSlug: cfg.account,
1294
+ });
1295
+ const put = await fetch(uploadUrl, {
1296
+ method: "PUT",
1297
+ headers: { "Content-Type": "video/mp4" },
1298
+ body: readFileSync(file),
1299
+ });
1300
+ if (!put.ok) throw new Error(`PUT to storage -> ${put.status}`);
1301
+
1302
+ // **Both stills, or the grids show nothing.** Sprid's card reads the small
1303
+ // one and its player reads the big one, and a row that carries only a
1304
+ // poster used to be recorded as carrying both - so every tile asked the
1305
+ // CDN for a thumbnail that was never written. ffmpeg is already required
1306
+ // here for measuring, so the second frame costs one more invocation.
1307
+ const cover = coverFrame(
1308
+ file,
1309
+ join(cfg.registry, ".cache", `${m.slug}-cover.png`),
1310
+ cfg.lanes[m.lane]?.coverAtMs ?? 0,
1311
+ );
1312
+ const small = thumbnail(cfg, cover, `${m.slug}-cover`);
1313
+ await api(cfg, "POST", `/videos/${videoId}/confirm`, {
1314
+ durationMs: m.media.durationMs,
1315
+ width: m.media.width,
1316
+ height: m.media.height,
1317
+ sizeBytes: m.media.sizeBytes,
1318
+ posterBase64: `data:image/png;base64,${readFileSync(cover).toString("base64")}`,
1319
+ thumbBase64: small
1320
+ ? `data:image/jpeg;base64,${readFileSync(small).toString("base64")}`
1321
+ : undefined,
1322
+ });
1323
+ rmSync(cover, { force: true });
1324
+ if (small) rmSync(small, { force: true });
1325
+
1326
+ // **`mediaShaAtPush` is what makes a re-render detectable**, and it is the
1327
+ // exact counterpart of `captionShaAtDraft`, which has existed since the
1328
+ // beginning. Without it the registry can say the file changed and cannot
1329
+ // say whether Sprid still holds the old one, so a rebuilt reel sits in a
1330
+ // scheduled post looking finished. Record the exact bytes uploaded.
1331
+ m.sprid = { ...m.sprid, account: cfg.account, videoId, status: "uploaded", mediaShaAtPush: m.media.sha256 };
1332
+ writeManifest(cfg, m);
1333
+ console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} video ${videoId}`);
1334
+ }
1335
+ console.log();
1336
+ }
1337
+
1338
+ /**
1339
+ * The same two phases as a video, once per slide.
1340
+ *
1341
+ * **Every slide is uploaded twice, main and thumb**, because that is the shape
1342
+ * Sprid's image record has and the thumbnail is what its own grids read. The
1343
+ * small one is made with ffmpeg, which is already required here for measuring,
1344
+ * so the package still has no dependencies.
1345
+ */
1346
+ async function pushCarousel(cfg, m) {
1347
+ const files = (m.slides ?? []).map((s) => resolve(cfg.root, s.file));
1348
+ const missing = files.filter((f) => !existsSync(f));
1349
+ if (missing.length) {
1350
+ console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${missing.length} slide(s) not on disk; rebuild it`)}`);
1351
+ return;
1352
+ }
1353
+ const changed = files.filter((f, i) => sha256(f) !== m.slides[i].sha256);
1354
+ if (changed.length) {
1355
+ console.log(` ${C.bad("stale")} ${m.slug} ${C.dim(`- ${changed.length} slide(s) changed since registering; re-register it`)}`);
1356
+ return;
1357
+ }
1358
+
1359
+ const imageIds = [];
1360
+ for (const [i, file] of files.entries()) {
1361
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
1362
+ const type = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
1363
+ const res = await api(cfg, "POST", "/images/upload-url", {
1364
+ filename: `${m.slug}-${String(i + 1).padStart(2, "0")}.${ext}`,
1365
+ contentType: type,
1366
+ accountSlug: cfg.account,
1367
+ tag: i === 0 ? "hero" : "body",
1368
+ source: "sprid post",
1369
+ });
1370
+ // A deduped answer means Sprid already holds this file; nothing to upload.
1371
+ if (res.deduped) {
1372
+ imageIds.push(res.image.id);
1373
+ continue;
1374
+ }
1375
+ const bytes = readFileSync(file);
1376
+ const thumb = thumbnail(cfg, file, `${m.slug}-${i}`);
1377
+ for (const [url, body] of [
1378
+ [res.mainUploadUrl, bytes],
1379
+ [res.thumbUploadUrl, thumb ? readFileSync(thumb) : bytes],
1380
+ ]) {
1381
+ const put = await fetch(url, { method: "PUT", headers: { "Content-Type": type }, body });
1382
+ if (!put.ok) throw new Error(`PUT to storage -> ${put.status}`);
1383
+ }
1384
+ if (thumb) rmSync(thumb, { force: true });
1385
+ await api(cfg, "POST", `/images/${res.image.id}/confirm`, {
1386
+ width: m.slides[i].width,
1387
+ height: m.slides[i].height,
1388
+ sizeBytes: m.slides[i].sizeBytes,
1389
+ });
1390
+ imageIds.push(res.image.id);
1391
+ }
1392
+
1393
+ m.sprid = {
1394
+ ...m.sprid, account: cfg.account, imageIds, status: "uploaded",
1395
+ mediaShaAtPush: (m.slides ?? []).map((sl) => sl.sha256).join(","),
1396
+ };
1397
+ writeManifest(cfg, m);
1398
+ console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} ${imageIds.length} images`);
1399
+ }
1400
+
1401
+ /** A 400px-wide copy, or null if ffmpeg will not make one. */
1402
+ function thumbnail(cfg, file, name) {
1403
+ const out = join(cfg.registry, ".cache", `${name}-thumb.jpg`);
1404
+ mkdirSync(dirname(out), { recursive: true });
1405
+ const r = spawnSync("ffmpeg", ["-loglevel", "error", "-y", "-i", file, "-vf", "scale=400:-2", out]);
1406
+ return r.status === 0 && existsSync(out) ? out : null;
1407
+ }
1408
+
1409
+ /**
1410
+ * Post, slide, video, caption. Stops at `draft` on purpose.
1411
+ *
1412
+ * Moving a post to `ready` is the last gate before something is public, and
1413
+ * that stays a person's decision.
1414
+ */
1415
+ async function cmdDraft(cfg, argv) {
1416
+ const only = positional(argv);
1417
+ // A composed post never gets uploaded, so waiting for an asset id would keep
1418
+ // it out of every batch forever.
1419
+ const uploaded = (m) => isComposed(m) || m.sprid.videoId != null || (m.sprid.imageIds?.length ?? 0) > 0;
1420
+ const targets = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter(
1421
+ (m) => uploaded(m) && (has(argv, "force") || m.sprid.postId == null),
1422
+ );
1423
+ if (!targets.length) return console.log("\n nothing to draft (push first, or everything is drafted)\n");
1424
+
1425
+ for (const m of targets) {
1426
+ // **`push` refused a failing post and `draft` did not, which left the gate
1427
+ // ungated.** For a composed post there is nothing to push at all, so
1428
+ // `draft` was the only verb that touched Sprid and it happily drafted a
1429
+ // deck that had failed its own checks. Both verbs refuse now; `--force`
1430
+ // does not override this, because a gate you can pass with a flag is a
1431
+ // preference.
1432
+ if (!checksPass(m)) {
1433
+ console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
1434
+ continue;
1435
+ }
1436
+ const caption = existsSync(captionPath(cfg, m.slug))
1437
+ ? readFileSync(captionPath(cfg, m.slug), "utf8").trim()
1438
+ : "";
1439
+ const captionTiktok = existsSync(tiktokCaptionPath(cfg, m.slug))
1440
+ ? readFileSync(tiktokCaptionPath(cfg, m.slug), "utf8").trim()
1441
+ : caption;
1442
+
1443
+ // **`--force` re-syncs the post it already made; it does not make another
1444
+ // one.** The only reason to run `draft` twice is that something here
1445
+ // changed - the caption was edited after the draft went over, which
1446
+ // `status` reports and had no command to resolve, or the media was rebuilt
1447
+ // and re-pushed. Answering that with a second post leaves the first one in
1448
+ // the account looking equally real. A post that was deleted in Sprid is the
1449
+ // one case where a new one is right, and a 404 is how that is known.
1450
+ const existingPost = m.sprid.postId
1451
+ ? await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch((e) => {
1452
+ // Only a post that is GONE justifies making another one. A network
1453
+ // failure that fell through to the create branch would quietly
1454
+ // duplicate the whole batch.
1455
+ if (/-> 404/.test(e.message)) return null;
1456
+ throw e;
1457
+ })
1458
+ : null;
1459
+ const post =
1460
+ existingPost ??
1461
+ (await api(cfg, "POST", `/posts?account=${encodeURIComponent(cfg.account)}`, {
1462
+ // Public titles come from approved copy; identifiers stay in internalName.
1463
+ title: m.post?.title ?? postTitle(caption, m.slug),
1464
+ internalName: m.post?.internalName ?? m.slug,
1465
+ // A composed lane may name the Sprid format its posts are made from,
1466
+ // and creating with it is what scaffolds the right slide roles.
1467
+ ...(cfg.lanes[m.lane]?.format ? { formatId: cfg.lanes[m.lane].format } : {}),
1468
+ }));
1469
+ const carousel = (m.kind ?? "reel") === "carousel";
1470
+ const composed = isComposed(m);
1471
+
1472
+ // **A new post already has slides, and `POST /posts` does not return
1473
+ // them.** It scaffolds one row (or one per the format's template) and
1474
+ // answers with the post alone, so `post.slides?.[0]` was always undefined
1475
+ // and every draft this package made carried an EMPTY leading slide with
1476
+ // the real media behind it - a blank first frame on a reel, a blank first
1477
+ // card on a deck. Verified against the API on 2026-09-03: create returns
1478
+ // no `slides` key, `GET /posts/:id` shows exactly one row at position 0.
1479
+ //
1480
+ // So: read the seats the post came with, add the ones it is short of,
1481
+ // delete the ones it does not need, and fill them in order.
1482
+ const seats = ((existingPost ? post : await api(cfg, "GET", `/posts/${post.id}`)).slides ?? []).sort(
1483
+ (a, b) => a.position - b.position,
1484
+ );
1485
+ const need = composed ? (m.copy ?? []).length : carousel ? (m.sprid.imageIds ?? []).length : 1;
1486
+ while (seats.length < need)
1487
+ seats.push(await api(cfg, "POST", `/posts/${post.id}/slides`, { role: "body", text: "", subtitle: "" }));
1488
+ for (const spare of seats.splice(need)) await api(cfg, "DELETE", `/slides/${spare.id}`);
1489
+
1490
+ if (composed) {
1491
+ // **The words are the payload.** One card per spec slide, in the spec's
1492
+ // order, and the template if the lane names one - after which Sprid holds
1493
+ // everything it needs to render, and this repo still holds the copy that
1494
+ // was gated.
1495
+ const tpl = cfg.lanes[m.lane]?.template ?? null;
1496
+ for (const [i, seat] of seats.entries()) {
1497
+ const sl = m.copy[i];
1498
+ await api(cfg, "PATCH", `/slides/${seat.id}`, {
1499
+ text: sl.text ?? "",
1500
+ subtitle: sl.subtitle ?? "",
1501
+ ...(sl.bullets ? { bullets: sl.bullets } : {}),
1502
+ role: sl.role ?? (i === 0 ? "hook" : "body"),
1503
+ ...(sl.template || tpl ? { templateId: sl.template ?? tpl } : {}),
1504
+ });
1505
+ }
1506
+ } else if (carousel) {
1507
+ // One slide per image, in the registry's order.
1508
+ const ids = m.sprid.imageIds ?? [];
1509
+ for (const [i, seat] of seats.entries())
1510
+ await api(cfg, "PATCH", `/slides/${seat.id}`, { imageId: ids[i], role: i === 0 ? "hook" : "body" });
1511
+ } else {
1512
+ // A finished reel is the whole composition. Clear every old render
1513
+ // instruction as it is attached, or the publisher correctly refuses to
1514
+ // guess whether the video or the leftover template should win.
1515
+ await api(cfg, "PATCH", `/slides/${seats[0].id}`, {
1516
+ videoId: m.sprid.videoId,
1517
+ durationMs: m.media.durationMs,
1518
+ templateId: null,
1519
+ beats: [],
1520
+ });
1521
+ }
1522
+
1523
+ // **`audio: { silent: true }` is not optional when the music is already in
1524
+ // the file.** A reel with no trackAssetId round-robins the account's track
1525
+ // library, so leaving audio unset lays a second bed over the first.
1526
+ const wanted = {
1527
+ // Reconcile the title on every draft, including previously created posts.
1528
+ title: m.post?.title ?? postTitle(caption, m.slug),
1529
+ internalName: m.post?.internalName ?? m.slug,
1530
+ // Sprid's two content types: a carousel is `image`, whatever it holds.
1531
+ // Sprid's two content types. A composed lane says which it is with
1532
+ // `kind: "composed"` plus its own `contentType`; a deck of images is
1533
+ // `image` whatever it holds.
1534
+ contentType: composed ? (cfg.lanes[m.lane]?.contentType ?? "image") : carousel ? "image" : "reel",
1535
+ // One string per platform, tags included. Sprid has no hashtags field.
1536
+ captionInstagram: caption,
1537
+ captionTiktok,
1538
+ ...(m.post.instagramLocationId ? { instagramLocationId: m.post.instagramLocationId } : {}),
1539
+ ...(m.post.pinterestOptions ? { pinterestOptions: m.post.pinterestOptions } : {}),
1540
+ // **Silence is a reel's problem only.** A carousel has no audio to
1541
+ // double up, and Sprid picks its own track for one if it wants.
1542
+ // **Silence is a rendered reel's problem only.** It says "the music is
1543
+ // already inside this file"; a composed reel has no file yet, so Sprid's
1544
+ // own track rotation is exactly what should happen to it.
1545
+ ...(carousel || composed ? {} : { audio: { silent: true }, motion: null }),
1546
+ };
1547
+ await api(cfg, "PATCH", `/posts/${post.id}`, { ...wanted, status: "draft" });
1548
+
1549
+ // **A 200 does not prove the field landed.** The API validates with a
1550
+ // schema that STRIPS what it does not declare, so a field the server is
1551
+ // too old to know about is dropped and answered with a success. Measured
1552
+ // 2026-09-03 against api.sprid.studio: `audio: { silent: true }` PATCHed
1553
+ // cleanly, came back `null`, and the reel it drafted would have published
1554
+ // with a second music bed laid over its own - which is the exact failure
1555
+ // the field exists to prevent. The consuming repo has no way to know that
1556
+ // from the output, so the record is read back and compared.
1557
+ const dropped = verifyDraft(await api(cfg, "GET", `/posts/${post.id}`), wanted);
1558
+ for (const [field, why] of dropped) console.log(` ${C.bad("dropped")} ${m.slug.padEnd(40)} ${field} - ${why}`);
1559
+
1560
+ m.sprid = {
1561
+ ...m.sprid,
1562
+ postId: post.id,
1563
+ status: "drafted",
1564
+ captionShaAtDraft: m.post.captionSha ?? null,
1565
+ // Kept, so `status` can still say it a week later and so a re-run after
1566
+ // the server is updated has something to compare against.
1567
+ dropped: dropped.length ? dropped.map(([f]) => f) : null,
1568
+ };
1569
+ writeManifest(cfg, m);
1570
+ console.log(
1571
+ ` ${C.ok(existingPost ? "updated" : "drafted")} ${m.slug.padEnd(40)} post ${post.id}` +
1572
+ (composed ? C.dim(` ${seats.length} cards`) : carousel ? C.dim(` ${m.sprid.imageIds.length} slides`) : ""),
1573
+ );
1574
+ }
1575
+ console.log(`\n ${C.dim("review in Sprid, then move each to `ready` yourself.")}\n`);
1576
+ }
1577
+
1578
+ /**
1579
+ * What the server kept, against what was asked of it.
1580
+ *
1581
+ * Only fields where a silent drop changes what gets PUBLISHED - the caption,
1582
+ * the shape of the post, the music, the location tag. Each answer carries the
1583
+ * consequence rather than the diff, because "audio.silent missing" is not a
1584
+ * thing anybody can act on and "this reel will publish with two soundtracks"
1585
+ * is.
1586
+ */
1587
+ export function verifyDraft(back, wanted) {
1588
+ const out = [];
1589
+ if (wanted.audio?.silent && back.audio?.silent !== true)
1590
+ out.push(["audio.silent", "this reel will publish with a rotated track OVER its own audio. Update Sprid, then `draft --force`"]);
1591
+ if (wanted.instagramLocationId && back.instagramLocationId !== wanted.instagramLocationId)
1592
+ out.push(["instagramLocationId", "the post will publish untagged"]);
1593
+ if (wanted.contentType && back.contentType !== wanted.contentType)
1594
+ out.push(["contentType", `the post is a "${back.contentType}", not a "${wanted.contentType}"`]);
1595
+ if ((back.captionInstagram ?? "") !== (wanted.captionInstagram ?? ""))
1596
+ out.push(["captionInstagram", "the caption in Sprid is not the one in the registry"]);
1597
+ if (wanted.title && back.title !== wanted.title)
1598
+ out.push(["title", "YouTube publishes this as the Short's title; Sprid is holding a different one"]);
1599
+ if (wanted.pinterestOptions && JSON.stringify(back.pinterestOptions ?? null) !== JSON.stringify(wanted.pinterestOptions))
1600
+ out.push(["pinterestOptions", "the Pin will lose its reviewed board, destination or metadata"]);
1601
+ return out;
1602
+ }
1603
+
1604
+ /**
1605
+ * What is different here from what Sprid is holding, and the smallest set of
1606
+ * calls that closes the gap.
1607
+ *
1608
+ * **A post is never edited; a deck is re-rendered and the queue catches up.**
1609
+ * That is the whole design decision. The alternative - reaching into Sprid to
1610
+ * swap a file on a scheduled post - makes the queue the source of truth for
1611
+ * content, and then a spec and a published reel can disagree with nothing able
1612
+ * to say which is right. Here the registry owns the content, Sprid owns the
1613
+ * calendar, and this is the one command that reconciles them.
1614
+ *
1615
+ * Reconcile media and metadata together so a rebuilt queue remains reproducible.
1616
+ *
1617
+ * Three rules, and the first two are refusals:
1618
+ *
1619
+ * - **A post that is out is frozen.** Sync reads the post's status and touches
1620
+ * only `draft` and `ready`. Anything else - published, publishing, whatever
1621
+ * Sprid adds next - is skipped by name. Fail-closed on an unknown status is
1622
+ * deliberate: re-posting is a new slug, not an edit.
1623
+ * - **It never creates anything.** No postId, no sync; that is what `draft` is
1624
+ * for. So a sync can never widen a batch by accident.
1625
+ * - **It does the minimum.** Media drifted, caption drifted, or neither. A slug
1626
+ * whose caption changed does not get its video re-uploaded.
1627
+ *
1628
+ * The schedule is not touched at all. Swapping the file behind a booked post
1629
+ * keeps its slot, which is the point.
1630
+ */
1631
+ async function cmdSync(cfg, argv) {
1632
+ const only = positional(argv);
1633
+ const execute = has(argv, "execute");
1634
+ const lane = flag(argv, "lane");
1635
+ const all = (only ? [readManifest(cfg, only)] : allManifests(cfg))
1636
+ .filter((m) => m.sprid?.postId)
1637
+ .filter((m) => !lane || m.lane === lane);
1638
+ if (!all.length) return console.log("\n nothing drafted yet - `push` then `draft` first\n");
1639
+
1640
+ // **`--adopt` is a migration, not a sync.** Every manifest written before
1641
+ // `mediaShaAtPush` existed reads as stale, which for a batch that was never
1642
+ // rebuilt means uploading files Sprid already holds. Adopting records what is
1643
+ // on disk as what was uploaded and sends nothing.
1644
+ //
1645
+ // It is an assertion, so it can be wrong: adopt a slug that WAS rebuilt and
1646
+ // Sprid keeps serving the old file with nothing left to say so. Scope it -
1647
+ // `--lane`, or a slug - to the ones you know were never touched.
1648
+ if (has(argv, "adopt")) {
1649
+ const orphans = all.filter((m) => m.sprid.mediaShaAtPush == null && !isComposed(m));
1650
+ if (!orphans.length) return console.log("\n nothing to adopt - every post already records what was uploaded\n");
1651
+ if (!execute) {
1652
+ console.log(`\n ${C.bold("would adopt")} ${orphans.length} post(s)`);
1653
+ console.log(` ${C.dim("asserts Sprid is holding the file now on disk. Wrong for anything rebuilt since.")}\n`);
1654
+ for (const m of orphans) console.log(` ${m.slug.padEnd(42)}${C.dim(m.lane ?? "")}`);
1655
+ return console.log(`\n ${C.dim("nothing written. re-run with --execute")}\n`);
1656
+ }
1657
+ for (const m of orphans) {
1658
+ m.sprid = {
1659
+ ...m.sprid,
1660
+ mediaShaAtPush:
1661
+ (m.kind ?? "reel") === "carousel"
1662
+ ? (m.slides ?? []).map((sl) => sl.sha256).join(",")
1663
+ : m.media?.sha256 ?? null,
1664
+ };
1665
+ writeManifest(cfg, m);
1666
+ }
1667
+ return console.log(`\n ${C.ok("adopted")} ${orphans.length} post(s) - nothing uploaded\n`);
1668
+ }
1669
+
1670
+ // **A third kind of drift, and it is invisible to a sha.** The title Sprid
1671
+ // holds is what TikTok and YouTube publish, and it is derived from the
1672
+ // caption rather than stored beside it - so a post whose media and caption
1673
+ // are both current can still be carrying an outdated title.
1674
+ // One GET each during planning; the execute pass needs the record anyway.
1675
+ /** A post whose current title could not be read. Counts as drift. */
1676
+ const UNREADABLE = Symbol("unreadable");
1677
+ const remoteTitle = new Map();
1678
+ for (const m of all) {
1679
+ const cp = captionPath(cfg, m.slug);
1680
+ if (!existsSync(cp)) continue;
1681
+ // **A read that failed is not a post that agrees with us.** The first cut
1682
+ // swallowed the error and left the slug out of the map, which reads as "no
1683
+ // title drift" - the same unverified-negative that cost the whole day. One
1684
+ // retry for a restarting dev server, then it counts as drift.
1685
+ let post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
1686
+ if (!post) post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
1687
+ remoteTitle.set(m.slug, post ? (post.title ?? "") : UNREADABLE);
1688
+ }
1689
+
1690
+ const plan = [];
1691
+ for (const m of all) {
1692
+ // **Unknown counts as stale, and it has to.** `mediaShaAtPush` did not
1693
+ // exist before 2026-09-09, so every manifest written before it carries
1694
+ // none - and a record that cannot say what was uploaded cannot say it is
1695
+ // current. Assuming fresh would silently leave old files in the queue,
1696
+ // which is the failure this command exists for.
1697
+ const pushedSha = m.sprid.mediaShaAtPush ?? null;
1698
+ const localSha = isComposed(m)
1699
+ ? null
1700
+ : (m.kind ?? "reel") === "carousel"
1701
+ ? (m.slides ?? []).map((sl) => sl.sha256).join(",")
1702
+ : m.media?.sha256 ?? null;
1703
+ const media = isComposed(m) ? false : pushedSha == null ? "unknown" : pushedSha !== localSha;
1704
+ const caption = (m.post?.captionSha ?? null) !== (m.sprid.captionShaAtDraft ?? null);
1705
+ const cp = captionPath(cfg, m.slug);
1706
+ const want = m.post?.title ?? (existsSync(cp) ? postTitle(readFileSync(cp, "utf8"), m.slug) : null);
1707
+ const title = remoteTitle.has(m.slug) && want !== null && remoteTitle.get(m.slug) !== want;
1708
+ const unreadable = remoteTitle.get(m.slug) === UNREADABLE;
1709
+ if (media || caption || title) plan.push({ m, media, caption, title, unreadable });
1710
+ }
1711
+
1712
+ if (!plan.length) { console.log("\n in sync - nothing in the registry differs from Sprid\n"); return { plan: [], execute }; }
1713
+
1714
+ console.log(`\n ${C.bold(execute ? "syncing" : "would sync")} ${plan.length} of ${all.length}\n`);
1715
+ for (const { m, media, caption, title, unreadable } of plan) {
1716
+ const what = [
1717
+ media === "unknown" ? C.warn("media (never recorded, assumed stale)") : media ? C.warn("media") : null,
1718
+ caption ? C.warn("caption") : null,
1719
+ title ? C.warn(unreadable ? "title (could not read Sprid's, assumed stale)" : "title") : null,
1720
+ ].filter(Boolean).join(" + ");
1721
+ console.log(` ${m.slug.padEnd(42)}${what}`);
1722
+ }
1723
+ if (!execute) {
1724
+ console.log(`\n ${C.dim("nothing written. re-run with --execute")}\n`);
1725
+ return { execute, plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })) };
1726
+ }
1727
+
1728
+ console.log();
1729
+ let done = 0, skipped = 0;
1730
+ for (const { m, media } of plan) {
1731
+ // The live status decides whether this one may be touched at all. Read it
1732
+ // here rather than trusting the manifest: the manifest's `status` is what
1733
+ // this machine last wrote, and the queue moves without us.
1734
+ let post;
1735
+ try {
1736
+ post = await api(cfg, "GET", `/posts/${m.sprid.postId}`);
1737
+ } catch (e) {
1738
+ if (/-> 404/.test(e.message)) {
1739
+ console.log(` ${C.bad("gone")} ${m.slug.padEnd(40)} ${C.dim("no such post in Sprid - `draft` makes a new one")}`);
1740
+ skipped++;
1741
+ continue;
1742
+ }
1743
+ throw e;
1744
+ }
1745
+ const state = post.status ?? "unknown";
1746
+ if (!SYNCABLE.has(state)) {
1747
+ console.log(` ${C.dim("frozen")} ${m.slug.padEnd(40)} ${C.dim(`status "${state}" - not a draft any more, leave it`)}`);
1748
+ skipped++;
1749
+ continue;
1750
+ }
1751
+ if (media) await cmdPush(cfg, [m.slug, "--force"]);
1752
+ // Re-read: push rewrote the manifest, and draft has to send the new videoId.
1753
+ await cmdDraft(cfg, [readManifest(cfg, m.slug).slug, "--force"]);
1754
+ done++;
1755
+ }
1756
+ console.log(` ${done} synced, ${skipped} left alone\n`);
1757
+ return { execute, synced: done, skipped, plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })) };
1758
+ }
1759
+
1760
+ /**
1761
+ * The post states a sync may write to.
1762
+ *
1763
+ * An allowlist rather than a list of the ones to avoid: a status this package
1764
+ * has never heard of is far more likely to be further along the pipeline than
1765
+ * behind it, and the cost of skipping one is a line of output.
1766
+ */
1767
+ const SYNCABLE = new Set(["draft", "ready"]);
1768
+
1769
+ /**
1770
+ * Bring Sprid's side of each post back into the registry.
1771
+ *
1772
+ * **The queue lives in Sprid and nothing here could read it**, which is a
1773
+ * bigger hole than it sounds: the repo could not answer "what goes out
1774
+ * tomorrow" or "which one went out last" without the API being reachable, and
1775
+ * a booking made weeks ago left no trace anybody could review in a diff. A post
1776
+ * is committed; when it publishes was not.
1777
+ *
1778
+ * So this writes what Sprid knows back into `sprid.remote`: the status, and the
1779
+ * schedule if the post carries one. The schedule's shape is read defensively -
1780
+ * whatever key it arrives under, it is recorded verbatim under `raw` as well,
1781
+ * so a field this package does not recognise is visible rather than lost.
1782
+ */
1783
+ /**
1784
+ * A post's remote state, from its publish rows.
1785
+ *
1786
+ * Track each platform separately: a reel may be published on one and scheduled
1787
+ * on another. Summary dates are the earliest observed date of each kind.
1788
+ */
1789
+ export function remoteOf(publishes) {
1790
+ return {
1791
+ publishes,
1792
+ scheduledAt: publishes.map((p) => p.scheduledFor).filter(Boolean).sort()[0] ?? null,
1793
+ publishedAt: publishes.map((p) => p.publishedAt).filter(Boolean).sort()[0] ?? null,
1794
+ };
1795
+ }
1796
+
1797
+ async function cmdPull(cfg, argv) {
1798
+ const only = positional(argv);
1799
+ const all = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter((m) => m.sprid?.postId);
1800
+ if (!all.length) return console.log("\n nothing drafted yet\n");
1801
+
1802
+ // Publish rows hold schedule dates; post summaries do not include them.
1803
+ const rows = await api(cfg, "GET", `/publishes?account=${encodeURIComponent(cfg.account)}&limit=1000`);
1804
+ const byPost = new Map();
1805
+ for (const r of Array.isArray(rows) ? rows : []) {
1806
+ const list = byPost.get(r.postId) ?? [];
1807
+ list.push({
1808
+ platform: r.platform,
1809
+ status: r.status,
1810
+ scheduledFor: r.scheduledFor ?? null,
1811
+ publishedAt: r.publishedAt ?? null,
1812
+ lastError: r.lastError ?? null,
1813
+ attempts: r.attempts ?? 0,
1814
+ });
1815
+ byPost.set(r.postId, list);
1816
+ }
1817
+
1818
+ let orphans = 0;
1819
+ for (const m of all) {
1820
+ const publishes = (byPost.get(m.sprid.postId) ?? []).sort((a, b) => a.platform.localeCompare(b.platform));
1821
+ if (!publishes.length) orphans++;
1822
+ m.sprid = { ...m.sprid, remote: { ...remoteOf(publishes), at: new Date().toISOString() } };
1823
+ writeManifest(cfg, m);
1824
+ const when = m.sprid.remote.publishedAt ?? m.sprid.remote.scheduledAt;
1825
+ const failed = publishes.filter((p) => p.lastError);
1826
+ console.log(
1827
+ ` ${C.ok("pulled")} ${m.slug.padEnd(42)}` +
1828
+ publishes.map((p) => `${p.platform[0]}${p.status === "published" ? C.ok("✓") : p.status === "scheduled" ? C.dim("·") : C.bad("!")}`).join(" ").padEnd(22) +
1829
+ C.dim(when ? when.slice(0, 16).replace("T", " ") : "not booked") +
1830
+ (failed.length ? ` ${C.bad(failed.map((f) => `${f.platform}: ${f.lastError}`).join("; ").slice(0, 60))}` : ""),
1831
+ );
1832
+ }
1833
+ if (orphans)
1834
+ console.log(`\n ${C.warn(`${orphans} post(s) carry no publish row at all - drafted but never booked`)}`);
1835
+ console.log();
1836
+ }
1837
+
1838
+ /**
1839
+ * What a post is created with, and it is published rather than filed.
1840
+ *
1841
+ * YouTube's adapter takes `post.title` ahead of the caption for a Short's
1842
+ * `snippet.title`, so a slug here ships "some-slug-like-this #Shorts" to a
1843
+ * viewer. A caption's first line is a title in every repo using this package;
1844
+ * the slug is stored as internalName so an uncaptioned post stays findable
1845
+ * without making an identifier public.
1846
+ */
1847
+ export function postTitle(caption, slug) {
1848
+ return (caption ?? "").split("\n")[0].trim();
1849
+ }
1850
+
1851
+ /**
1852
+ * Where this repo is pointed, whether it answers, and what it says back.
1853
+ *
1854
+ * Probe each candidate URL and report disagreements. A saved registry does
1855
+ * not establish whether its configured service is reachable.
1856
+ */
1857
+ async function cmdDoctor(cfg) {
1858
+ let logged = null;
1859
+ try {
1860
+ logged = resolveAuth();
1861
+ } catch {
1862
+ // Not logged in is a state to report, not to throw on: it is exactly what
1863
+ // this command is for.
1864
+ }
1865
+ const candidates = [
1866
+ ["SPRID_URL", process.env.SPRID_URL],
1867
+ ["sprid.config apiUrl", cfg.apiUrl === defaultApi() ? null : cfg.apiUrl],
1868
+ [`sprid login (${logged?.source ?? "not logged in"})`, logged?.apiUrl],
1869
+ ["default", DEFAULT_API_URL],
1870
+ ].filter(([, v]) => v);
1871
+
1872
+ console.log(`\n ${C.bold(cfg.account)} ${C.dim("using")} ${C.bold(cfg.apiUrl)}\n`);
1873
+ for (const [where, url] of candidates)
1874
+ console.log(` ${url === cfg.apiUrl ? C.ok("→") : " "} ${where.padEnd(28)}${C.dim(url)}`);
1875
+ if (new Set(candidates.map(([, u]) => u)).size > 1)
1876
+ console.log(`\n ${C.dim("more than one candidate: the arrow is the one in use")}`);
1877
+
1878
+ // Dial every candidate, not only the chosen one. A repo pointed at a host
1879
+ // that is down while another candidate answers is the whole failure mode.
1880
+ console.log();
1881
+ for (const url of new Set(candidates.map(([, u]) => u))) {
1882
+ const t0 = Date.now();
1883
+ let line;
1884
+ try {
1885
+ const res = await fetch(`${url}/api/accounts`, {
1886
+ redirect: 'error',
1887
+ headers: url === cfg.apiUrl ? { Authorization: `Bearer ${token(cfg)}` } : {},
1888
+ signal: AbortSignal.timeout(8000),
1889
+ });
1890
+ const ms = Date.now() - t0;
1891
+ if (!res.ok) line = `${C.bad(String(res.status))} ${C.dim(`${ms}ms`)}`;
1892
+ else {
1893
+ const accounts = await res.json();
1894
+ const mine = Array.isArray(accounts) ? accounts.find((a) => a.slug === cfg.account) : null;
1895
+ line = mine
1896
+ ? `${C.ok("ok")} ${C.dim(`${ms}ms`)} ${C.bold(`account ${mine.id}`)} ` +
1897
+ (mine.channelSummary ?? [])
1898
+ .map((c) => `${c.platform}:${c.status === "active" ? C.ok(c.status) : C.bad(c.status)}`)
1899
+ .join(" ")
1900
+ : `${C.warn("reachable, but no account")} "${cfg.account}" ${C.dim(`(${ms}ms)`)}`;
1901
+ }
1902
+ } catch (e) {
1903
+ line = `${C.bad("unreachable")} ${C.dim(String(e?.cause?.code ?? e?.name ?? e?.message).slice(0, 40))}`;
1904
+ }
1905
+ console.log(` ${url.padEnd(30)} ${line}`);
1906
+ }
1907
+
1908
+ // How stale the local view of the queue is. `status` cannot say this and it
1909
+ // is the difference between "Several posts drafted" and "Several posts drafted, as of the last pull".
1910
+ const pulled = allManifests(cfg)
1911
+ .map((m) => m.sprid?.remote?.at)
1912
+ .filter(Boolean)
1913
+ .sort();
1914
+ console.log(
1915
+ `\n ${pulled.length ? `${pulled.length} post(s) pulled, oldest ${pulled[0].slice(0, 16).replace("T", " ")}` : C.warn("never pulled - `sprid post pull` brings the schedule in")}\n`,
1916
+ );
1917
+ }
1918
+
1919
+ async function cmdStatus(cfg) {
1920
+ const all = allManifests(cfg);
1921
+ if (!all.length) return console.log("\n nothing registered yet\n");
1922
+ const lufs = all.map((m) => m.media?.lufs).filter((v) => typeof v === "number");
1923
+
1924
+ console.log(`\n ${C.bold(cfg.account)} ${C.dim(cfg.apiUrl)}\n`);
1925
+ console.log(` ${"slug".padEnd(42)}${"lane".padEnd(15)}${"size".padEnd(11)}${"checks".padEnd(9)}sprid`);
1926
+ console.log(` ${C.dim("-".repeat(91))}`);
1927
+ for (const m of all) {
1928
+ const checks = !m.checks ? C.dim("-") : checksPass(m) ? C.ok("pass") : C.bad("fail");
1929
+ let state = m.sprid?.status ?? "none";
1930
+ // The one way the two halves drift apart in silence: the caption was
1931
+ // edited here after the draft went over.
1932
+ if (state === "drafted" && m.sprid.captionShaAtDraft && m.post.captionSha !== m.sprid.captionShaAtDraft)
1933
+ state = C.warn("caption changed");
1934
+ // The other way they drift, and the one that had no name until 2026-09-09:
1935
+ // the media was rebuilt and re-registered, so Sprid is still serving the
1936
+ // file from before. `sync` is the answer to both.
1937
+ if (m.sprid?.postId && !isComposed(m) && m.sprid.mediaShaAtPush && m.media?.sha256 !== m.sprid.mediaShaAtPush)
1938
+ state = C.warn("media changed");
1939
+ // A post that is out is a fact about the world, and outranks any local drift.
1940
+ if (m.sprid?.remote?.publishedAt) state = C.dim(`published ${m.sprid.remote.publishedAt.slice(0, 10)}`);
1941
+ else if (m.sprid?.remote?.scheduledAt) state += C.dim(` ${m.sprid.remote.scheduledAt.slice(0, 16).replace("T", " ")}`);
1942
+ // A field the server silently refused stays visible: it is the one kind of
1943
+ // damage that is invisible in Sprid's own UI too.
1944
+ if (m.sprid?.dropped?.length) state = C.bad(`${m.sprid.dropped.join(", ")} dropped`);
1945
+ // What "how big" means depends on the kind: a reel is seconds, a carousel
1946
+ // is slides.
1947
+ const size = isComposed(m)
1948
+ ? `${m.media?.slideCount ?? 0} cards`
1949
+ : (m.kind ?? "reel") === "carousel"
1950
+ ? `${m.media?.slideCount ?? m.slides?.length ?? 0} slides`
1951
+ : `${(m.media.durationMs / 1000).toFixed(1)}s`;
1952
+ console.log(
1953
+ ` ${m.slug.padEnd(42)}${(m.lane ?? "").padEnd(15)}${size.padEnd(11)}` +
1954
+ `${checks.padEnd(checks.includes("\x1b") ? 18 : 9)}${state}`,
1955
+ );
1956
+ }
1957
+ const drafted = all.filter((m) => m.sprid?.postId).length;
1958
+ const pushed = all.filter((m) => m.sprid?.videoId || m.sprid?.imageIds?.length).length;
1959
+ const legacy = existsSync(join(cfg.registry, LEGACY_DIR))
1960
+ ? readdirSync(join(cfg.registry, LEGACY_DIR)).filter((f) => f.endsWith(".json")).length
1961
+ : 0;
1962
+ if (legacy)
1963
+ console.log(
1964
+ `\n ${C.dim(`${legacy} record(s) still in ${LEGACY_DIR}/ - the directory is called ${POSTS_DIR}/ now, a rename is the whole migration`)}`,
1965
+ );
1966
+ console.log(
1967
+ `\n ${all.length} registered · ${pushed} uploaded · ${drafted} drafted` +
1968
+ (lufs.length > 1 ? ` · loudness spread ${(Math.max(...lufs) - Math.min(...lufs)).toFixed(1)} dB` : "") +
1969
+ "\n",
1970
+ );
1971
+ }
1972
+
1973
+
1974
+ /**
1975
+ * The preview — the whole batch on one page, before any of it is a post.
1976
+ *
1977
+ * **It reads the registry and nothing else**, so it is the same command in
1978
+ * every repo: manifests carry the lane, the length, the check result and the
1979
+ * Sprid state, the captions sit beside them, and the media is wherever the lane
1980
+ * says. That is the entire input. The page it writes is disposable.
1981
+ *
1982
+ * The media is symlinked rather than copied, because a batch is gigabytes and a
1983
+ * preview is meant to be cheap enough to regenerate without thinking about it.
1984
+ * `--copy` is there for the case where the page has to travel (an AirDrop, a
1985
+ * zip to somebody who is not on this machine), and it is the slow path on
1986
+ * purpose.
1987
+ *
1988
+ * **A file that is not on disk is drawn as a missing card, never dropped.** The
1989
+ * mp4s regenerate from their specs and routinely are not there; a preview that
1990
+ * silently omitted them would hide incomplete work.
1991
+ */
1992
+ async function cmdPreview(cfg, argv) {
1993
+ const opts = {
1994
+ outDir: resolve(cfg.root, flag(argv, "out") ?? "tmp/sprid-preview"),
1995
+ laneOnly: flag(argv, "lane"),
1996
+ copy: has(argv, "copy"),
1997
+ verify: has(argv, "verify"),
1998
+ };
1999
+ const { indexFile } = await buildPreview(cfg, opts);
2000
+ if (has(argv, "serve"))
2001
+ return serve(cfg, opts, Number(flag(argv, "serve") ?? flag(argv, "port") ?? portForAccount(cfg.account)));
2002
+ if (has(argv, "open")) spawnSync("open", [indexFile]);
2003
+ return { indexFile };
2004
+ }
2005
+
2006
+ async function buildPreview(cfg, { outDir, laneOnly, copy, verify }) {
2007
+ const { renderPreview } = await import(`./preview.mjs?v=${Date.now()}`);
2008
+ const mediaDir = join(outDir, "media");
2009
+
2010
+ let all = allManifests(cfg);
2011
+ if (laneOnly) all = all.filter((m) => m.lane === laneOnly);
2012
+ if (!all.length) throw new Error(laneOnly ? `Nothing registered in lane "${laneOnly}".` : "Nothing registered yet.");
2013
+
2014
+ rmSync(mediaDir, { recursive: true, force: true });
2015
+ mkdirSync(mediaDir, { recursive: true });
2016
+
2017
+ let missing = 0;
2018
+ let stale = 0;
2019
+ const items = all.map((m) => {
2020
+ const kind = m.kind ?? "reel";
2021
+ const file = kind === "carousel" ? null : resolve(cfg.root, m.source);
2022
+ const present = kind === "carousel" ? (m.slides ?? []).every((sl) => existsSync(resolve(cfg.root, sl.file))) : existsSync(file);
2023
+ let src = null;
2024
+ let slideSrcs = null;
2025
+ let isStale = false;
2026
+
2027
+ if (kind === "carousel" && present) {
2028
+ // Each slide gets its own link, numbered so the page can rely on order.
2029
+ slideSrcs = (m.slides ?? []).map((sl, i) => {
2030
+ const from = resolve(cfg.root, sl.file);
2031
+ if (statSync(from).size !== sl.sizeBytes) isStale = true;
2032
+ const ext = from.slice(from.lastIndexOf("."));
2033
+ const link = join(mediaDir, `${m.slug}-${String(i + 1).padStart(2, "0")}${ext}`);
2034
+ if (copy) copyFileSync(from, link);
2035
+ else symlinkSync(from, link);
2036
+ return `media/${basename(link)}`;
2037
+ });
2038
+ }
2039
+
2040
+ if (kind !== "carousel" && present) {
2041
+ // Cheap by default: a size that no longer matches the record means the
2042
+ // file was rebuilt since it was measured. `--verify` spends the sha.
2043
+ const size = statSync(file).size;
2044
+ isStale = verify ? sha256(file) !== m.media?.sha256 : size !== m.media?.sizeBytes;
2045
+ const link = join(mediaDir, `${m.slug}.mp4`);
2046
+ if (copy) copyFileSync(file, link);
2047
+ else symlinkSync(file, link);
2048
+ src = `media/${m.slug}.mp4`;
2049
+ }
2050
+ if (!present) missing++;
2051
+ if (isStale) stale++;
2052
+
2053
+ const capFile = captionPath(cfg, m.slug);
2054
+ const named = Object.entries(m.checks ?? {}).filter(([k]) => k !== "at");
2055
+ const failures = named.filter(([, v]) => String(v).startsWith("fail")).map(([k]) => k);
2056
+ const passed = named.filter(([, v]) => v === "pass").map(([k]) => k);
2057
+
2058
+ return {
2059
+ slug: m.slug,
2060
+ lane: m.lane,
2061
+ kind,
2062
+ source: m.source,
2063
+ src,
2064
+ slides: slideSrcs,
2065
+ stale: isStale,
2066
+ caption: existsSync(capFile) ? readFileSync(capFile, "utf8").trim() : null,
2067
+ durationMs: m.media?.durationMs ?? null,
2068
+ slideCount: m.media?.slideCount ?? m.slides?.length ?? null,
2069
+ aspect: m.media?.aspect ?? null,
2070
+ sizeBytes: m.media?.sizeBytes ?? null,
2071
+ lufs: typeof m.media?.lufs === "number" ? m.media.lufs : null,
2072
+ checks: !m.checks ? null : failures.length ? "fail" : "pass",
2073
+ failures,
2074
+ passed,
2075
+ build: m.build?.command ?? null,
2076
+ spridStatus: m.sprid?.status ?? "none",
2077
+ captionDrifted:
2078
+ m.sprid?.status === "drafted" &&
2079
+ m.sprid?.captionShaAtDraft != null &&
2080
+ m.post?.captionSha !== m.sprid.captionShaAtDraft,
2081
+ };
2082
+ });
2083
+
2084
+ const indexFile = join(outDir, "index.html");
2085
+ writeFileSync(
2086
+ indexFile,
2087
+ renderPreview({ account: cfg.account, items, generatedAt: new Date().toISOString().replace("T", " ").slice(0, 16) }),
2088
+ );
2089
+
2090
+ console.log(
2091
+ ` ${C.ok("preview")} ${items.length} post${items.length === 1 ? "" : "s"}` +
2092
+ (missing ? C.warn(` ${missing} with no file on disk`) : "") +
2093
+ (stale ? C.warn(` ${stale} differing from their record`) : "") +
2094
+ ` ${C.dim(indexFile.replace(cfg.root + "/", ""))}`,
2095
+ );
2096
+ return { indexFile, outDir, count: items.length };
2097
+ }
2098
+
2099
+ /**
2100
+ * A static server for the preview, with Range and a live reload.
2101
+ *
2102
+ * **`file://` is enough to watch a reel and not enough to scrub one**: without
2103
+ * a 206 the browser cannot seek inside an mp4, and Safari will not play a
2104
+ * symlinked file at all.
2105
+ *
2106
+ * **The reload is here because the alternative is a pile of tabs.** Registering
2107
+ * a reel, editing a caption or changing this page's own code is something you
2108
+ * do a dozen times in a sitting, and re-running the command to see it opens a
2109
+ * new tab each time and loses where you were. The page holds an EventSource;
2110
+ * a change rebuilds the file and tells the tab you already have to refresh -
2111
+ * and since the current reel lives in the URL hash, a refresh comes back to it.
2112
+ */
2113
+ /**
2114
+ * A stable port per account, so several repos can preview at once.
2115
+ *
2116
+ * Derive a stable preferred port from the account slug to reduce collisions
2117
+ * between projects. Hash collisions remain possible; --serve <port> overrides.
2118
+ */
2119
+ export function portForAccount(account, base = 4599) {
2120
+ let h = 0;
2121
+ for (const ch of account) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
2122
+ return base + (h % 40);
2123
+ }
2124
+
2125
+ async function serve(cfg, opts, port) {
2126
+ const dir = opts.outDir;
2127
+ const clients = new Set();
2128
+ const srv = createServer(previewHandler({ directory: dir, account: cfg.account, clients }));
2129
+
2130
+ // **A busy port is answered by asking who is on it, never by assuming.** If
2131
+ // it is this account's own preview, re-running the command is how you find
2132
+ // out one is already up and the right answer is to say so. If it is another
2133
+ // project's, walking to the next free port is the only answer that does not
2134
+ // hand somebody the wrong batch.
2135
+ for (let tries = 0; tries < 12; tries++, port++) {
2136
+ const holder = await whoIsServing(port);
2137
+ if (holder === cfg.account) {
2138
+ console.log(
2139
+ ` ${C.warn("already serving")} ${C.bold(cfg.account)} on http://localhost:${port}/` +
2140
+ ` - that tab is live, it reloads itself\n`,
2141
+ );
2142
+ return { url: `http://localhost:${port}/`, alreadyServing: true };
2143
+ }
2144
+ if (holder != null) {
2145
+ // A server too old to answer `/__whoami` reports "", which still means
2146
+ // "not ours" - say that rather than printing an empty pair of quotes.
2147
+ console.log(
2148
+ ` ${C.dim(`:${port} is serving ${holder ? `"${holder}"` : "something else"}, trying :${port + 1}`)}`,
2149
+ );
2150
+ continue;
2151
+ }
2152
+ const bound = await new Promise((ok) => {
2153
+ srv.once("error", (e) => (e.code === "EADDRINUSE" ? ok(false) : (() => { throw e; })()));
2154
+ srv.listen(port, "127.0.0.1", () => ok(true));
2155
+ });
2156
+ if (!bound) continue;
2157
+ const url = `http://localhost:${port}/`;
2158
+ console.log(` ${C.bold(url)} ${C.bold(cfg.account)} ${C.dim("watching for changes · ctrl-c to stop")}\n`);
2159
+ spawnSync("open", [url]);
2160
+ watchAndRebuild(cfg, opts, clients);
2161
+ return { url };
2162
+ }
2163
+ throw new Error(`no free port from ${port - 12}; pass --serve <port>`);
2164
+ }
2165
+
2166
+ /**
2167
+ * The account whose preview is on this port, or null if nothing is.
2168
+ *
2169
+ * A server too old to answer `/__whoami` returns "" rather than null, which
2170
+ * still means "occupied by something that is not us" - the safe reading.
2171
+ */
2172
+ async function whoIsServing(port) {
2173
+ try {
2174
+ const res = await fetch(`http://localhost:${port}/__whoami`, { signal: AbortSignal.timeout(600) });
2175
+ if (!res.ok) return "";
2176
+ const body = await res.json().catch(() => null);
2177
+ return typeof body?.account === "string" ? body.account : "";
2178
+ } catch (e) {
2179
+ // ECONNREFUSED is the only "nothing there"; a timeout is something there.
2180
+ return /ECONNREFUSED|refused/i.test(String(e?.cause?.code ?? e?.message ?? "")) ? null : "";
2181
+ }
2182
+ }
2183
+
2184
+ /** Everything a preview is a function of: the registry, the media, this code. */
2185
+ function watchAndRebuild(cfg, opts, clients) {
2186
+ // **Both registry directories.** The records moved to `posts/` and the
2187
+ // watcher stayed on `reels/`, so in a repo that had migrated, registering
2188
+ // something while the server was up changed nothing on the page.
2189
+ const dirs = new Set([
2190
+ join(cfg.registry, POSTS_DIR),
2191
+ join(cfg.registry, LEGACY_DIR),
2192
+ join(cfg.registry, "captions"),
2193
+ dirname(new URL(import.meta.url).pathname),
2194
+ ]);
2195
+ for (const name of Object.keys(cfg.lanes)) {
2196
+ if (opts.laneOnly && name !== opts.laneOnly) continue;
2197
+ dirs.add(resolve(cfg.root, dirname(fill(mediaTemplate(cfg.lanes[name]), "x"))));
2198
+ if (cfg.lanes[name].caption) dirs.add(resolve(cfg.root, dirname(fill(cfg.lanes[name].caption, "x"))));
2199
+ }
2200
+
2201
+ let timer = null;
2202
+ const rebuild = () => {
2203
+ clearTimeout(timer);
2204
+ timer = setTimeout(async () => {
2205
+ try {
2206
+ await buildPreview(cfg, opts);
2207
+ for (const res of clients) res.write("data: reload\n\n");
2208
+ } catch (e) {
2209
+ console.log(` ${C.bad("build failed")} ${e.message}`);
2210
+ }
2211
+ }, 200);
2212
+ };
2213
+
2214
+ for (const d of dirs) {
2215
+ if (!existsSync(d)) continue;
2216
+ try {
2217
+ watch(d, rebuild);
2218
+ } catch {
2219
+ // A directory that cannot be watched is not worth failing the server for.
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ // ─── entry ───────────────────────────────────────────────────────────────────
2225
+
2226
+ const USAGE = `
2227
+ sprid post — build posts in your own repo, then push them to Sprid.
2228
+ A post is a reel (one video), a carousel (a directory of images), or composed
2229
+ (no local file at all - Sprid renders it and this repo holds the copy).
2230
+
2231
+ post init what are your posts made of, and do you even need this
2232
+ --kind text | composed | stills | own
2233
+ post new <slug> scaffold a spec - the copy, or the pictures
2234
+ post build [slug] run the lane's builder - yours, or the stills recipe
2235
+ post register [slug] measure finished media into the registry (no slug = every lane)
2236
+ --built-with "<cmd>" record the command that made it, so it can be made again
2237
+ post check [slug] run the gate; writes the result into each manifest
2238
+ --deep also ask Sprid what each platform will publish
2239
+ post push [slug] upload to Sprid (refuses anything not passing check)
2240
+ post draft [slug] create the post, attach the media or the copy, set the caption
2241
+ refuses anything not passing check, --force included
2242
+ post sync [slug] a re-render into the posts that already exist, keeping their
2243
+ slot. Plan only, --execute to write. Refuses anything past
2244
+ 'ready' - a published post is frozen.
2245
+ --adopt one-time: record what is on disk as what was uploaded,
2246
+ for a batch that predates the record. Uploads nothing.
2247
+ --lane <name> scope either of the above to one lane
2248
+ post pull [slug] bring Sprid's status and schedule back into the registry,
2249
+ so 'status' is a calendar and works offline
2250
+ post preview one page of the whole batch: a grid, and a feed you arrow through
2251
+ post status one table of everything, local and remote
2252
+ post doctor which API, can it be reached, which channels are live
2253
+
2254
+ --force push/draft again for something already done
2255
+ --json one JSON result; diagnostics and builder output on stderr
2256
+ check --platforms instagram,tiktok override crosspost validation targets
2257
+ register --platforms instagram save a post's intended validation targets
2258
+ preview --serve [port] a local server (needed to scrub, and for Safari)
2259
+ --open file:// straight into the browser · --lane <name> · --copy
2260
+ --verify sha every file instead of trusting its size
2261
+ Config sprid.config.ts in this repo says where finished media lives and what
2262
+ has to be true of it. Registry: social/, committed.
2263
+ Login \`sprid login\` - one credentials file for every sprid command.
2264
+ `;
2265
+
2266
+
2267
+ export async function run(argv, { cwd = process.cwd() } = {}) {
2268
+ const cmd = argv[0];
2269
+ const rest = argv.slice(1);
2270
+ if (!cmd || cmd === "help" || cmd === "--help") {
2271
+ console.log(USAGE);
2272
+ return { command: "help", usage: USAGE };
2273
+ }
2274
+ if (cmd === "init") {
2275
+ await cmdInit(rest);
2276
+ return { command: cmd };
2277
+ }
2278
+ const commands = { new: cmdNew, build: cmdBuild, register: cmdRegister, check: cmdCheck,
2279
+ push: cmdPush, draft: cmdDraft, preview: cmdPreview, doctor: cmdDoctor, sync: cmdSync,
2280
+ pull: cmdPull, status: cmdStatus };
2281
+ if (!commands[cmd]) throw new Error(`Unknown command "${cmd}".${USAGE}`);
2282
+ const cfg = await loadConfig(cwd);
2283
+ const details = await commands[cmd](cfg, rest);
2284
+ return { command: cmd, account: cfg.account, posts: allManifests(cfg), ...details };
2285
+ }