@sprid/cli 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 1.2.5 - 2026-10-12
7
+
8
+ - `sprid docs chat` carries the updated chat guide: an agent creates a missing content account itself with `create_content_account`, and asks whether the app has users before offering analytics and revenue connections.
9
+
10
+ ## 1.2.4 - 2026-10-11
11
+
12
+ - `sprid post sync` no longer reports a post as synced when its new media never reached Sprid. A file rebuilt since `register`, or a post whose checks are not passing, is now listed as not synced with the step that fixes it, and the command exits 1. `draft` also reads each slide back and says so when Sprid is still holding the old file.
13
+
6
14
  ## 1.2.3 - 2026-10-08
7
15
 
8
16
  - `sprid screenshots` carries @sprid/shots 0.2.2: bounded device commands, retries for transient capture errors, and Android cold-boot recovery for capture hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -1665,8 +1665,8 @@ export const CONTENT_GUIDES = [
1665
1665
  "title": "Use Sprid in chat",
1666
1666
  "summary": "Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.",
1667
1667
  "url": "https://sprid.studio/docs/chat",
1668
- "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: when available, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. A missing account is created in the dashboard.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Schedule a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a scheduled direct post afterwards, the edit's result carries `tiktokApproval`: the scheduled post will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nTikTok's published guidelines specify a privacy dropdown and posting controls. Acceptance of this chat-based confirmation flow has not been verified with TikTok. Keep that distinction explicit in review submissions.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1669
- "revision": "bcf0b60a52e82938"
1668
+ "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: once the app exists, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. If the app has no account, ask what to call it and create it with `create_content_account`.\n\nThen ask whether the app has users yet. If it does, offer the analytics and revenue connections `next_actions` lists first: each link opens Sprid’s secure form, and the first recommendation then reads real numbers. If it has none yet, go on to the first post; connecting stays optional.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Schedule a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a scheduled direct post afterwards, the edit's result carries `tiktokApproval`: the scheduled post will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nTikTok's published guidelines specify a privacy dropdown and posting controls. Acceptance of this chat-based confirmation flow has not been verified with TikTok. Keep that distinction explicit in review submissions.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1669
+ "revision": "73520c8ba4e3e270"
1670
1670
  },
1671
1671
  {
1672
1672
  "id": "local",
package/src/post/cli.mjs CHANGED
@@ -384,6 +384,14 @@ const fill = (tpl, slug) => tpl.replaceAll("<slug>", slug);
384
384
  const kindOf = (lane) => lane.kind ?? (lane.slides ? "carousel" : "reel");
385
385
  const isComposed = (x) => (x?.kind ?? (x?.slides ? "carousel" : "reel")) === "composed";
386
386
 
387
+ /** What the media files hash to right now, in the manifest's sha shape. Null if any is missing. */
388
+ function diskSha(cfg, m) {
389
+ const files = (m.kind ?? "reel") === "carousel" ? (m.slides ?? []).map((sl) => sl.file) : [m.source];
390
+ const paths = files.map((f) => (f ? resolve(cfg.root, f) : null));
391
+ if (paths.some((f) => !f || !existsSync(f))) return null;
392
+ return paths.map(sha256).join(",");
393
+ }
394
+
387
395
  /** The path template a lane's media lives at, whichever kind it is. */
388
396
  const mediaTemplate = (lane) =>
389
397
  kindOf(lane) === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : kindOf(lane) === "carousel" ? lane.slides : lane.media;
@@ -1561,9 +1569,14 @@ async function cmdPush(cfg, argv) {
1561
1569
  );
1562
1570
  if (!targets.length) return console.log("\n nothing to push (everything is already in Sprid)\n");
1563
1571
 
1572
+ // **What was refused is returned, not only printed.** `sync` calls this and
1573
+ // used to count a slug as synced whatever happened here, so a refused upload
1574
+ // reported "1 synced" while the draft kept the old file.
1575
+ const pushed = [], refused = [];
1564
1576
  for (const m of targets) {
1565
1577
  if (!checksPass(m)) {
1566
1578
  console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
1579
+ refused.push({ slug: m.slug, why: "checks not passing - run `sprid post check`" });
1567
1580
  continue;
1568
1581
  }
1569
1582
  // **A composed post has nothing here to upload.** Its pixels are the
@@ -1574,18 +1587,21 @@ async function cmdPush(cfg, argv) {
1574
1587
  continue;
1575
1588
  }
1576
1589
  if ((m.kind ?? "reel") === "carousel") {
1577
- await pushCarousel(cfg, m);
1590
+ const why = await pushCarousel(cfg, m);
1591
+ (why ? refused : pushed).push(why ? { slug: m.slug, why } : m.slug);
1578
1592
  continue;
1579
1593
  }
1580
1594
  const file = resolve(cfg.root, m.source);
1581
1595
  if (!existsSync(file)) {
1582
1596
  console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${m.source} is not on disk; rebuild it`)}`);
1597
+ refused.push({ slug: m.slug, why: `${m.source} is not on disk` });
1583
1598
  continue;
1584
1599
  }
1585
1600
  // Guard the record against a silent rebuild: the manifest is only true of
1586
1601
  // the bytes it measured.
1587
1602
  if (sha256(file) !== m.media.sha256) {
1588
1603
  console.log(` ${C.bad("stale")} ${m.slug} ${C.dim("- the file changed since it was registered; re-register it")}`);
1604
+ refused.push({ slug: m.slug, why: "the file changed since `register` - run `register`, then `check`" });
1589
1605
  continue;
1590
1606
  }
1591
1607
 
@@ -1633,9 +1649,11 @@ async function cmdPush(cfg, argv) {
1633
1649
  // scheduled post looking finished. Record the exact bytes uploaded.
1634
1650
  m.sprid = { ...m.sprid, account: cfg.account, videoId, status: "uploaded", mediaShaAtPush: m.media.sha256 };
1635
1651
  writeManifest(cfg, m);
1652
+ pushed.push(m.slug);
1636
1653
  console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} video ${videoId}`);
1637
1654
  }
1638
1655
  console.log();
1656
+ return { pushed, refused };
1639
1657
  }
1640
1658
 
1641
1659
  /**
@@ -1651,12 +1669,12 @@ async function pushCarousel(cfg, m) {
1651
1669
  const missing = files.filter((f) => !existsSync(f));
1652
1670
  if (missing.length) {
1653
1671
  console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${missing.length} slide(s) not on disk; rebuild it`)}`);
1654
- return;
1672
+ return `${missing.length} slide(s) not on disk`;
1655
1673
  }
1656
1674
  const changed = files.filter((f, i) => sha256(f) !== m.slides[i].sha256);
1657
1675
  if (changed.length) {
1658
1676
  console.log(` ${C.bad("stale")} ${m.slug} ${C.dim(`- ${changed.length} slide(s) changed since registering; re-register it`)}`);
1659
- return;
1677
+ return `${changed.length} slide(s) changed since \`register\` - run \`register\`, then \`check\``;
1660
1678
  }
1661
1679
 
1662
1680
  const imageIds = [];
@@ -1699,6 +1717,7 @@ async function pushCarousel(cfg, m) {
1699
1717
  };
1700
1718
  writeManifest(cfg, m);
1701
1719
  console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} ${imageIds.length} images`);
1720
+ return null;
1702
1721
  }
1703
1722
 
1704
1723
  /** A 400px-wide copy, or null if ffmpeg will not make one. */
@@ -1725,6 +1744,7 @@ async function cmdDraft(cfg, argv) {
1725
1744
  );
1726
1745
  if (!targets.length) return console.log("\n nothing to draft (push first, or everything is drafted)\n");
1727
1746
 
1747
+ const drafted = [], refused = [];
1728
1748
  for (const m of targets) {
1729
1749
  // **`push` refused a failing post and `draft` did not, which left the gate
1730
1750
  // ungated.** For a composed post there is nothing to push at all, so
@@ -1734,6 +1754,7 @@ async function cmdDraft(cfg, argv) {
1734
1754
  // preference.
1735
1755
  if (!checksPass(m)) {
1736
1756
  console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
1757
+ refused.push({ slug: m.slug, why: "checks not passing - run `sprid post check`" });
1737
1758
  continue;
1738
1759
  }
1739
1760
  const caption = existsSync(captionPath(cfg, m.slug))
@@ -1858,8 +1879,16 @@ async function cmdDraft(cfg, argv) {
1858
1879
  // with a second music bed laid over its own - which is the exact failure
1859
1880
  // the field exists to prevent. The consuming repo has no way to know that
1860
1881
  // from the output, so the record is read back and compared.
1861
- const dropped = verifyDraft(await api(cfg, "GET", `/posts/${post.id}`), wanted);
1882
+ const back = await api(cfg, "GET", `/posts/${post.id}`);
1883
+ const dropped = verifyDraft(back, wanted);
1862
1884
  for (const [field, why] of dropped) console.log(` ${C.bad("dropped")} ${m.slug.padEnd(40)} ${field} - ${why}`);
1885
+ // **The media is read back too.** Same reason as the fields above: the
1886
+ // slide PATCH answering 200 is not proof the post now shows the new file.
1887
+ const mediaDrift = composed ? null : slideMediaDrift(back, carousel ? m.sprid.imageIds ?? [] : [m.sprid.videoId], carousel);
1888
+ if (mediaDrift) {
1889
+ console.log(` ${C.bad("media")} ${m.slug.padEnd(40)} ${mediaDrift}`);
1890
+ refused.push({ slug: m.slug, why: mediaDrift });
1891
+ }
1863
1892
 
1864
1893
  m.sprid = {
1865
1894
  ...m.sprid,
@@ -1872,12 +1901,28 @@ async function cmdDraft(cfg, argv) {
1872
1901
  dropped: dropped.length ? dropped.map(([f]) => f) : null,
1873
1902
  };
1874
1903
  writeManifest(cfg, m);
1904
+ if (mediaDrift) continue;
1905
+ drafted.push(m.slug);
1875
1906
  console.log(
1876
1907
  ` ${C.ok(existingPost ? "updated" : "drafted")} ${m.slug.padEnd(40)} post ${post.id}` +
1877
1908
  (composed ? C.dim(` ${seats.length} cards`) : carousel ? C.dim(` ${m.sprid.imageIds.length} slides`) : ""),
1878
1909
  );
1879
1910
  }
1880
1911
  console.log(`\n ${C.dim("review in Sprid, then move each to `ready` yourself.")}\n`);
1912
+ return { drafted, refused };
1913
+ }
1914
+
1915
+ /**
1916
+ * Whether the post's slides carry the files the registry pushed, in order.
1917
+ * Null when they do; otherwise what a person needs to read.
1918
+ */
1919
+ export function slideMediaDrift(back, ids, carousel) {
1920
+ const slides = [...(back.slides ?? [])].sort((a, b) => a.position - b.position);
1921
+ const held = slides.map((sl) => (carousel ? sl.imageId : sl.videoId) ?? null);
1922
+ if (held.length === ids.length && held.every((id, i) => id === ids[i])) return null;
1923
+ return carousel
1924
+ ? `Sprid's slides hold images ${held.join(",") || "none"}, not the pushed ${ids.join(",")}`
1925
+ : `Sprid's slide holds video ${held[0] ?? "none"}, not the pushed ${ids[0]}`;
1881
1926
  }
1882
1927
 
1883
1928
  /**
@@ -2007,7 +2052,18 @@ async function cmdSync(cfg, argv) {
2007
2052
  : (m.kind ?? "reel") === "carousel"
2008
2053
  ? (m.slides ?? []).map((sl) => sl.sha256).join(",")
2009
2054
  : m.media?.sha256 ?? null;
2010
- const media = isComposed(m) ? false : pushedSha == null ? "unknown" : pushedSha !== localSha;
2055
+ // **The registry's sha is not the file.** A rebuild that was never
2056
+ // re-registered leaves the manifest agreeing with what was pushed, and sync
2057
+ // answered "in sync" while the drafts held the old video. So the bytes on
2058
+ // disk are hashed too, and a mismatch is reported as the step it needs.
2059
+ // A file no longer on disk proves nothing either way, so it falls back to
2060
+ // the record: scratch builds get cleaned up after a push.
2061
+ const onDisk = isComposed(m) ? null : diskSha(cfg, m);
2062
+ const media = isComposed(m)
2063
+ ? false
2064
+ : onDisk != null && onDisk !== localSha
2065
+ ? "unregistered"
2066
+ : pushedSha == null ? "unknown" : pushedSha !== localSha;
2011
2067
  const caption = (m.post?.captionSha ?? null) !== (m.sprid.captionShaAtDraft ?? null);
2012
2068
  // The Pin's board, link and words. Unrecorded counts as changed, same as
2013
2069
  // media: a record that cannot say what was sent cannot say it is current.
@@ -2024,7 +2080,10 @@ async function cmdSync(cfg, argv) {
2024
2080
  console.log(`\n ${C.bold(execute ? "syncing" : "would sync")} ${plan.length} of ${all.length}\n`);
2025
2081
  for (const { m, media, caption, title, pin, unreadable } of plan) {
2026
2082
  const what = [
2027
- media === "unknown" ? C.warn("media (never recorded, assumed stale)") : media ? C.warn("media") : null,
2083
+ media === "unregistered"
2084
+ ? C.bad("media (file on disk changed since `register`)")
2085
+ : media === "unknown" ? C.warn("media (never recorded, assumed stale)") : media ? C.warn("media") : null,
2086
+ !checksPass(m) ? C.bad("checks not passing") : null,
2028
2087
  caption ? C.warn("caption") : null,
2029
2088
  title ? C.warn(unreadable ? "title (could not read Sprid's, assumed stale)" : "title") : null,
2030
2089
  pin ? C.warn("pin") : null,
@@ -2038,7 +2097,20 @@ async function cmdSync(cfg, argv) {
2038
2097
 
2039
2098
  console.log();
2040
2099
  let done = 0, skipped = 0;
2100
+ const failed = [];
2041
2101
  for (const { m, media } of plan) {
2102
+ // Refused before touching Sprid: pushing or drafting would either do
2103
+ // nothing or re-send the old file, and both used to be counted as synced.
2104
+ if (media === "unregistered") {
2105
+ console.log(` ${C.bad("not synced")} ${m.slug.padEnd(36)} ${C.dim("file changed since `register` - run `register`, then `check`")}`);
2106
+ failed.push({ slug: m.slug, why: "file changed since `register`" });
2107
+ continue;
2108
+ }
2109
+ if (!checksPass(m)) {
2110
+ console.log(` ${C.bad("not synced")} ${m.slug.padEnd(36)} ${C.dim("checks not passing - run `check`")}`);
2111
+ failed.push({ slug: m.slug, why: "checks not passing" });
2112
+ continue;
2113
+ }
2042
2114
  // The live status decides whether this one may be touched at all. Read it
2043
2115
  // here rather than trusting the manifest: the manifest's `status` is what
2044
2116
  // this machine last wrote, and the queue moves without us.
@@ -2059,13 +2131,31 @@ async function cmdSync(cfg, argv) {
2059
2131
  skipped++;
2060
2132
  continue;
2061
2133
  }
2062
- if (media) await cmdPush(cfg, [m.slug, "--force"]);
2134
+ if (media) {
2135
+ const pushed = await cmdPush(cfg, [m.slug, "--force"]);
2136
+ if (!pushed?.pushed.includes(m.slug)) {
2137
+ const why = pushed?.refused.find((r) => r.slug === m.slug)?.why ?? "nothing was uploaded";
2138
+ console.log(` ${C.bad("not synced")} ${m.slug.padEnd(36)} ${C.dim(why)}`);
2139
+ failed.push({ slug: m.slug, why });
2140
+ continue;
2141
+ }
2142
+ }
2063
2143
  // Re-read: push rewrote the manifest, and draft has to send the new videoId.
2064
- await cmdDraft(cfg, [readManifest(cfg, m.slug).slug, "--force"]);
2144
+ const drafted = await cmdDraft(cfg, [readManifest(cfg, m.slug).slug, "--force"]);
2145
+ if (!drafted?.drafted.includes(m.slug)) {
2146
+ const why = drafted?.refused.find((r) => r.slug === m.slug)?.why ?? "the draft was not updated";
2147
+ console.log(` ${C.bad("not synced")} ${m.slug.padEnd(36)} ${C.dim(why)}`);
2148
+ failed.push({ slug: m.slug, why });
2149
+ continue;
2150
+ }
2065
2151
  done++;
2066
2152
  }
2067
- console.log(` ${done} synced, ${skipped} left alone\n`);
2068
- return { execute, synced: done, skipped, plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })) };
2153
+ console.log(` ${done} synced, ${skipped} left alone${failed.length ? `, ${C.bad(`${failed.length} not synced`)}` : ""}\n`);
2154
+ return {
2155
+ exitCode: failed.length ? 1 : 0,
2156
+ execute, synced: done, skipped, failed,
2157
+ plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })),
2158
+ };
2069
2159
  }
2070
2160
 
2071
2161
  /**