@sprid/cli 1.2.2 → 1.2.4

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.4 - 2026-10-11
7
+
8
+ - `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.
9
+
10
+ ## 1.2.3 - 2026-10-08
11
+
12
+ - `sprid screenshots` carries @sprid/shots 0.2.2: bounded device commands, retries for transient capture errors, and Android cold-boot recovery for capture hooks.
13
+
6
14
  ## 1.2.2 - 2026-10-08
7
15
 
8
16
  - `sprid screenshots` carries @sprid/shots 0.2.1: native capture saves a verified
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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",
@@ -38,8 +38,8 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "dependencies": {
41
- "@sprid/shots": "0.2.1",
42
- "@sprid/release": "1.2.0"
41
+ "@sprid/shots": "0.2.2",
42
+ "@sprid/release": "1.2.1"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
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
  /**