@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ sprid follows semver: a breaking change to a command's arguments, its output
4
+ shape or its exit codes is a major release.
5
+
6
+ ## 0.1.2 - 2026-09-22
7
+
8
+ - Published as `@sprid/cli`. The command is still `sprid`.
9
+ - Reports the API's supported-version floor, and stops with the upgrade
10
+ instruction when the API refuses a version as too old.
11
+ - `bugs` reaches the package, the README drops a maintainer-only section, and
12
+ the sample output is generic.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Väder AB. All rights reserved.
2
+
3
+ Use is subject to Sprid's Terms of Service at https://sprid.studio/terms,
4
+ which set out what you may do with these files.
5
+
6
+ Bundled third-party components keep their own licences, which are included
7
+ beside the files they cover.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
11
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
12
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
13
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
14
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README-post.md ADDED
@@ -0,0 +1,615 @@
1
+ # `sprid post` — build posts in your own repo, then push them to Sprid
2
+
3
+ Build posts wherever you like, then push them to Sprid. **A post is a reel (one
4
+ video file) or a carousel (a directory of images)** - the registry, the gate,
5
+ the preview and the upload are the same for both, and a lane says which it is.
6
+
7
+ ```bash
8
+ bunx @sprid/cli post init # what are your reels made of, and do you need this
9
+ bunx @sprid/cli post new <slug> # scaffold a spec (recipe lanes)
10
+ bunx @sprid/cli post build # run the lane's builder - yours, or the recipe
11
+ bunx @sprid/cli post register # measure every finished file into the registry
12
+ bunx @sprid/cli post check # the gate
13
+ bunx @sprid/cli post preview # the whole batch on one page: a grid, and a feed
14
+ bunx @sprid/cli post push # upload to Sprid
15
+ bunx @sprid/cli post draft # post + slide + video + caption
16
+ bunx @sprid/cli post status # one table, local and remote
17
+ ```
18
+
19
+ ## Start with the pixels
20
+
21
+ Cloud reel rendering is paused. Keep a text reel's spec, visual system and
22
+ renderer in the app repo, then use Sprid Local to register, check, upload and
23
+ publish the finished MP4. The same handoff covers photographs, screen recordings
24
+ and any other composition whose pixels the app owns.
25
+
26
+ ## Two ways to make the file
27
+
28
+ A lane says how its media is built, and there are exactly two answers:
29
+
30
+ ```ts
31
+ build: "bun run scripts/make-reel.ts --spec <spec> --out <out>" // your renderer
32
+ build: { recipe: "stills", track: "assets/bed.mp3" } // the built-in
33
+ ```
34
+
35
+ Everything downstream - `register`, `check`, `push`, `draft` - cannot tell them
36
+ apart, so you can start on the recipe and grow into your own renderer without
37
+ changing anything else.
38
+
39
+ There are two built-in recipes, `stills` and `screen`.
40
+
41
+ **`stills`**: pictures, a slow eased push on each, a
42
+ cross-fade between, music measured and baked. It answers the thing nothing else
43
+ does - material that is neither text nor already a video. It **prints the ffmpeg
44
+ command it ran**, which is the whole difference between a recipe and a
45
+ framework: the output is a starting point you edit, not a black box.
46
+
47
+ A **spec** is one JSON file per reel and it is the durable artifact - the mp4
48
+ regenerates, the spec is what you review and commit:
49
+
50
+ ```json
51
+ { "slug": "…", "images": ["a.jpg", "b.jpg"], "track": "bed.mp3",
52
+ "holdMs": 5800, "caption": "…" }
53
+ ```
54
+
55
+ **`screen`** records your own app being used. See the next section.
56
+
57
+ **Beyond that it renders nothing.** How a file was made is your repo's business.
58
+ This package's whole subject is *a finished local file becoming a Sprid draft,
59
+ reproducibly and with the checks* - which is why it serves a repo with a bespoke
60
+ render pipeline and a repo whose reels come out of After Effects equally well.
61
+
62
+ **It does not need an agent.** Everything goes through the REST API with a
63
+ `sprd_` personal access token, so CI can run it. The MCP server is the
64
+ agent-facing convenience over the same endpoints, not the only door.
65
+
66
+ ## `screen`: the app, driven and recorded
67
+
68
+ ```ts
69
+ build: { recipe: "screen", app: { bundleId: "com.example.app", prepare: "…", launchMs: 19000 } }
70
+ ```
71
+
72
+ It boots an iOS simulator, dresses the status bar, runs a **gesture script**
73
+ against the app, records the screen, and cuts the recording into a 9:16 reel
74
+ with TikTok-style captions, a touch indicator and music.
75
+
76
+ **The reason it exists is the seven-language version.** A demo reel is normally
77
+ impossible to remake in another language, because the work is a person holding a
78
+ phone. Here that work splits in two:
79
+
80
+ ```
81
+ social/scripts/complete-task.json the finger's path. NO WORDS.
82
+ social/specs/complete-task-en.json the locale, the captions, the music.
83
+ social/specs/complete-task-sv.json the same path, a different language.
84
+ ```
85
+
86
+ A locale then costs its translation and one 90-second run. Nothing else.
87
+
88
+ ### The script
89
+
90
+ Normalised coordinates, `0..1` of the device screen, and five kinds of step:
91
+
92
+ ```json
93
+ { "steps": [
94
+ { "mark": "home" },
95
+ { "wait": 1600 },
96
+ { "tap": [0.388, 0.730], "note": "Create task" },
97
+ { "settle": 1900 },
98
+ { "swipe": [0.8, 0.62, 0.22, 0.62], "duration": 300 },
99
+ { "swipe": [0.5, 0.72, 0.5, 0.46], "duration": 800, "precise": true },
100
+ { "shell": "xcrun simctl openurl <udid> \"app:///demo?lang=<locale>\"" }
101
+ ] }
102
+ ```
103
+
104
+ - **`mark`** names a moment. Captions bind to marks, never to seconds, which is
105
+ the only reason the same script survives a run that is two seconds longer.
106
+ - **`settle`** waits for the screen to change and then to stop changing, up to
107
+ the given ceiling. Both halves matter - see below.
108
+ - **`swipe`** is a flick by default: it is still moving when the finger lifts,
109
+ so iOS computes momentum. `precise: true` is an eased drag that stops where
110
+ the finger stops, which is what you want for reading rather than browsing.
111
+ - **`shell`** runs a command mid-script, with `<udid>`, `<locale>` and
112
+ `<bundleId>` filled in from the spec - so an app that picks its language
113
+ through a deep link keeps ONE script.
114
+
115
+ Everything else - Metro, the dev client, the demo seed, the login - is welded to
116
+ your repo and belongs in `app.prepare`, a command the recipe runs and waits for.
117
+ **`prepare` must not launch the app**; the recipe does, after the clapperboard.
118
+
119
+ ### The spec
120
+
121
+ ```json
122
+ { "slug": "complete-task-sv", "locale": "sv", "device": "iPhone 17",
123
+ "script": "social/scripts/complete-task.json",
124
+ "render": { "speed": 1.7, "capY": 0.78, "capSize": 76, "pill": 0, "background": "blur", "drift": 0 },
125
+ "captions": [ { "at": "home", "text": "Jag säger ja innan jag läst klart." } ],
126
+ "broll": { "clip": "assets/broll/couch.mp4", "seconds": 2.4 },
127
+ "track": "assets/audio/bed.mp3",
128
+ "caption": "the post caption" }
129
+ ```
130
+
131
+ Captions are chunked into two or three words and timed by the same prosody model
132
+ Sprid's own renderer uses: **weights, not milliseconds**, so a word's share of
133
+ its beat tracks its syllables and the pauses sit at the punctuation. A beat that
134
+ is longer than its words need is **left empty at the end rather than stretched**
135
+ - that silence is the moment the viewer looks at the app with nothing written
136
+ over it, which is the product demo.
137
+
138
+ `speed` speeds the recording up; 1.6-1.7 is where a UI demo lands in the 15-18s
139
+ band without reading as a fast-forward. `broll` cuts a clip in front, hard, never
140
+ a dissolve.
141
+
142
+ **`drift` is 0 and should stay there.** It offsets the phone a few pixels on two
143
+ sine pairs to imitate a hand. It does not read as one: the status bar in a screen
144
+ recording is pin-sharp, so the movement registers as the video being unstable
145
+ rather than as being filmed. A screen recording holds still.
146
+
147
+ ### Iterating: `--recut`
148
+
149
+ ```bash
150
+ sprid post build <slug> # drive the app and cut
151
+ sprid post build <slug> --recut # re-cut what is already recorded, no simulator
152
+ ```
153
+
154
+ **This is the loop that matters.** A caption, a beat, where the captions sit,
155
+ the speed, the background - none of it needs the app driven again. `cut.json`
156
+ next to the recording holds what a re-cut needs: the timeline, the second
157
+ clapper's wall time and the device's pixel size.
158
+
159
+ ### Five things it does that are not obvious
160
+
161
+ **`settle` waits for the screen to become something else, not to stop moving.**
162
+ Waiting only for quiet cannot tell *the transition has finished* from *it has not
163
+ started*, and a Debug build behind Metro can run seconds behind the script - and
164
+ while it is behind, the screen it is still showing is perfectly quiet. Every
165
+ settle then returns on a stale frame and the next tap lands on a screen that is
166
+ about to be replaced. It compares against a frame captured before the gesture
167
+ that caused the change.
168
+
169
+ **Two clapperboards, because the recorder's clock is not the wall clock.**
170
+ `simctl io recordVideo` reports nothing about when it opened the stream, so the
171
+ run flashes the springboard dark before the app comes up and again after the
172
+ script, and solves `videoSeconds = v0 + k · wallMs`. Measured `k` here ranged
173
+ 0.76 to 1.00 across runs. One flash cannot see a rate error - the first version
174
+ had one, and its last caption was four seconds off the screen it described.
175
+
176
+ **Full-resolution probes starve the recorder.** `settle` screenshots twice a
177
+ second while recording. As PNG, one heavier app came back holding 76% of the
178
+ elapsed time; the probes now run through `baguette screenshot --scale 4`, a
179
+ twentieth of the pixels for ffmpeg to decode. A recording that solves below
180
+ **0.9x wall is treated as spoiled and fails**: the solve keeps the captions in
181
+ sync but the missing time cannot be put back, so the app's own motion plays fast
182
+ on top of whatever `speed` asked for.
183
+
184
+ **Scratch goes outside your repo** (`$TMPDIR/sprid/<repo>/<slug>`) unless a
185
+ lane sets `tmp`. Inside it, Metro's watcher sees every probe screenshot, sends a
186
+ Fast Refresh, and RN drops its blue "Refreshing…" banner over the frame.
187
+
188
+ **Input goes through `baguette`, which does not touch the machine.** It injects
189
+ through SimulatorKit's private symbols with the iOS-26 calling conventions: the
190
+ pointer never moves, the Simulator never comes to the front, and you can keep
191
+ working through a run. Its `input` command streams newline-delimited JSON
192
+ gestures, which is the part that matters - **we keep our own velocity curve**,
193
+ where a canned `swipe --duration` would be the linear interpolation that makes a
194
+ scripted scroll read as scripted.
195
+
196
+ Two details inside that, both of which cost a take:
197
+
198
+ - **Swipes stream, taps do not.** The streaming path is `IOHIDDigitizerDispatch`,
199
+ built for continuous gestures. React Native surfaces can accept a
200
+ streamed tap and the one UIKit surface, the tab bar, silently did not.
201
+ `baguette tap` lands on it every time, and a tap has no timing worth owning.
202
+ - **A tap must not move.** An earlier version jittered the touch a pixel or two
203
+ during the hold, on the theory that a finger is never still. Nothing can see it
204
+ - a screen recording has no cursor in it - and UIKit reads those moves as the
205
+ start of a drag and cancels the tap.
206
+
207
+ Without baguette it falls back to posting CGEvents through a compiled Swift
208
+ helper (`src/screen/simkit.swift`), and **that path owns the pointer for the
209
+ length of the run**. It is a fallback, not a preference: the Simulator ignores
210
+ events posted to its process, tested both plain and with the window-number
211
+ routing trick, so the global HID tap is the only other way in. simkit stays
212
+ either way, because it also rasterises the caption cards through CoreText -
213
+ Homebrew's ffmpeg has neither `drawtext` nor `subtitles`.
214
+
215
+ ### What it needs
216
+
217
+ macOS with Xcode 26, an Apple Silicon Mac, a simulator with your app installed,
218
+ `ffmpeg`, and `brew install baguette`. No npm dependency. Without baguette it
219
+ still runs, on the CGEvent fallback, which needs the terminal to hold macOS
220
+ Accessibility permission and **takes over the pointer for the length of the
221
+ run**.
222
+
223
+ The status bar is dressed for a reel rather than for the store: the **host
224
+ clock**, not 9:41, and a battery in the seventies rather than 100% and charging.
225
+ 9:41 reads as an advertisement, and the app's own content gives the real time
226
+ away anyway - a row logged during the recording carries the wall clock, so a
227
+ 9:41 status bar above it is a tell. `statusBarTime` and `battery` in the spec
228
+ override both.
229
+
230
+ ## Two kinds of post
231
+
232
+ ```ts
233
+ films: { media: "out/<slug>.mp4", caption: "out/<slug>.caption.txt" }, // a reel
234
+ decks: { slides: "decks/<slug>/*.jpg", caption: "decks/<slug>/caption.txt" }, // a carousel
235
+ ```
236
+
237
+ A lane that names `slides:` is a carousel and needs to say nothing else; the
238
+ slug is the **directory**, its files are the slides, and **they are ordered by
239
+ filename**, so name them `01`, `02`, `03`. That order is the most consequential
240
+ thing about a deck and it belongs somewhere a person can see it, not in a JSON
241
+ array. The record hashes the slides *and their order*, since the same pictures
242
+ in a different order are a different post.
243
+
244
+ What each kind is measured and gated on:
245
+
246
+ | | reel | carousel |
247
+ |---|---|---|
248
+ | record | duration, dimensions, LUFS, sha256 | per-slide dimensions, ratio, sha256; count |
249
+ | gate | `duration` `loudness` `batchSpread` | `slides` `aspect` |
250
+ | both | `caption`, `provenance` | `caption`, `provenance` |
251
+ | in Sprid | one video on one slide, `contentType: reel` | one image per slide, `contentType: image` |
252
+
253
+ **`aspect` is the one that catches a real defect you cannot see locally.**
254
+ Instagram crops every later slide to the first slide's shape, so a deck mixing
255
+ 4:5 and 1:1 publishes with the sides cut off things nobody checked - and every
256
+ local preview shows the files as they are, uncropped and fine.
257
+
258
+ A check that does not apply to a kind reports `skip`, never `pass`: a carousel
259
+ with four green video checks would be worth less than no check at all.
260
+
261
+ ## The third kind: a post Sprid composes
262
+
263
+ **Most posts are words on a background, and those are rendered on the server** -
264
+ templates, backdrops, fonts, timing. There is no local file, so there is nothing
265
+ for ffmpeg to measure and nothing to upload. A `composed` lane is for wanting
266
+ the *copy* here anyway:
267
+
268
+ ```ts
269
+ carousels: {
270
+ kind: "composed",
271
+ spec: "social/specs/<slug>.json",
272
+ // format: <format id>, // choose from this account’s formats
273
+ // template: <template id>, // choose from this account’s templates
274
+ expect: { slides: [5, 9], words: [0, 30] },
275
+ neverSay: ["the phrase this account decided it does not say"],
276
+ sourcesRequired: true,
277
+ },
278
+ ```
279
+
280
+ The spec IS the post:
281
+
282
+ ```json
283
+ { "slug": "…",
284
+ "slides": [{ "role": "hook", "text": "…" },
285
+ { "role": "body", "text": "The plan includes 10 projects", "source": "docs/guides/plans.md" }],
286
+ "caption": "… \n\n#a #b #c" }
287
+ ```
288
+
289
+ **One caption per platform, when the repo has one.** Vocabulary can differ between
290
+ platforms. A lane may write a second file beside its caption,
291
+ `<caption>` with `.tiktok` before the extension (`out/<slug>.caption.tiktok.txt`).
292
+ `register` copies it to `social/captions/<slug>.tiktok.txt` and `draft` sends
293
+ it as `captionTiktok`; absent, TikTok gets the Instagram string.
294
+
295
+ **The tags go at the end of the caption.** Sprid publishes one string per
296
+ platform and has no hashtag field to send them to, so there is no `hashtags`
297
+ key here either - a spec carrying one is refused by name rather than quietly
298
+ ignored. The caption in the registry is therefore the whole published text,
299
+ which is what the tag-count gate and the drift check read.
300
+
301
+ `build` and `push` have nothing to do; `register`, `check`, `draft` and `status`
302
+ work exactly as they do for a file. What you get for it is three checks a
303
+ rendered post cannot have:
304
+
305
+ - **`words`, per card and never totalled.** A deck averaging twenty words with
306
+ one card at sixty is the deck an average hides, and the long card is the one a
307
+ reader stops on.
308
+ - **`voice`.** The phrases this account has decided it does not say, plus the
309
+ punctuation it does not use. The mechanical half of a voice; the rest still
310
+ needs a reader.
311
+ - **`sources`.** A card stating a number names the file it came from and the
312
+ check *opens* it. This is the whole gate for anything where a wrong figure is
313
+ the unforgivable error: a slide keeps reading true long after the file behind
314
+ it was renamed.
315
+
316
+ **It is opt-in, and skipping it is a real answer.** If the words do not have to
317
+ answer to anything in your repo, compose the post in Sprid and keep nothing
318
+ here.
319
+
320
+ ## The registry
321
+
322
+ Committed to *your* repo:
323
+
324
+ ```
325
+ social/posts/<slug>.json the record: sha256, size, sprid ids, how it was built
326
+ social/captions/<slug>.txt the caption, verbatim
327
+ ```
328
+
329
+ The directory used to be `social/reels/`, which is still read - a record already
330
+ committed in somebody's repo is not worth breaking over a rename - and `status`
331
+ says how many are still sitting there. Renaming it is the whole migration.
332
+
333
+ **No media in git.** The bytes go into Sprid's own storage through the presigned
334
+ upload, and the manifest keeps a sha256, so a file that no longer matches its
335
+ record is caught before it is pushed - without keeping the file.
336
+
337
+ The caption is copied *into* the registry rather than pointed at, because it is
338
+ usually generated into a scratch directory. It is the most reviewable artifact
339
+ most pipelines produce and it belongs where somebody can read it in a diff.
340
+
341
+ ## Config
342
+
343
+ ```ts
344
+ // sprid.config.ts
345
+ export default {
346
+ account: "myapp", // the ACCOUNT slug, not the workspace name
347
+ tokenEnv: "SPRID_PAT_MYAPP", // optional, and see Auth before skipping it
348
+ registry: "social/",
349
+ lanes: {
350
+ reels: {
351
+ // `media:` one file per post · `slides:` a deck of images
352
+ // · `kind: "composed"` is for server-rendered carousel copy
353
+ media: "path/to/output/<slug>.mp4",
354
+ caption: "path/to/output/<slug>.caption.txt",
355
+ expect: { durationMs: [15_000, 50_000], lufs: [-16, -12] },
356
+ hashtags: [4, 6],
357
+ captionMustContain: ["Photos via Wikimedia Commons:"],
358
+ },
359
+ },
360
+ checks: ["duration", "loudness", "batchSpread", "caption"],
361
+ };
362
+ ```
363
+
364
+ A **lane** is where one kind of finished media lives. The package ships none,
365
+ because what a lane is depends entirely on how you make things. `<slug>` in a
366
+ path is the only substitution there is, and it is also how `register` discovers
367
+ what exists.
368
+
369
+ ### The checks
370
+
371
+ | check | what it refuses |
372
+ |---|---|
373
+ | `duration` | a file outside the lane's range - usually a render that stopped early |
374
+ | `loudness` | integrated LUFS outside the range |
375
+ | `batchSpread` | a file more than `maxDeviationDb` (1.2) from **its own lane's** median. One file's loudness says nothing; the step between two posts in a row is what a listener hears |
376
+ | `caption` | a `neverSay` match, an invalid configured ending, a missing `captionMustContain` string, or a hashtag count outside `hashtags` |
377
+ | `slides` | a deck outside `expect.slides`. Instagram takes 20, TikTok 35, and a deck of two is a post that did not get made |
378
+ | `aspect` | a deck that mixes ratios, or slides under `expect.minWidth`. Instagram crops every later slide to the FIRST one's shape, so a mixed deck publishes with the sides cut off things nobody checked - and it looks right in every local preview |
379
+ | `provenance` | nothing. It reports whether the record knows the command that made the file, so a batch where most rows skip it is a batch whose provenance lives in somebody's terminal history |
380
+
381
+ `captionMustContain` is for licence terms rather than style. A Creative Commons
382
+ import is somebody's work and the licence is only free if the credit is there;
383
+ a Mapbox static image may only drop its on-image attribution if the attribution
384
+ appears beside it. Both are things you notice a week after publishing, or not at
385
+ all.
386
+
387
+ A check that cannot run reports `skip`, never `pass`.
388
+
389
+ ## The record remembers how the file was made
390
+
391
+ ```bash
392
+ sprid post register <slug> --built-with "make-reel --spec x.json --map --depth"
393
+ ```
394
+
395
+ Record the command that produced the measured bytes so the build can be repeated.
396
+ A lane with a `build:` records that command automatically; a
397
+ pipeline run by hand records it with `--built-with`; and the `provenance` check
398
+ reports - never refuses - when a record cannot say. The preview prints the line
399
+ under each card, one click to copy.
400
+
401
+ ## `preview`, looking at the batch before it is real
402
+
403
+ ```bash
404
+ bunx @sprid/cli post preview --serve # a local server, opens in your browser
405
+ bunx @sprid/cli post preview --open # file://, fine for watching, cannot scrub
406
+ bunx @sprid/cli post preview --lane collage --copy --out ~/Desktop/reels
407
+ ```
408
+
409
+ It reads the registry and nothing else, so it is the same command in every repo,
410
+ and it writes one disposable HTML file with **two views**, because a batch and a
411
+ post are two different questions:
412
+
413
+ - **Grid.** Everything at once, small. Hover plays a reel and walks a deck, the filter matches slug and
414
+ caption, and a card carries the caption itself and nothing derived from it -
415
+ the length, the loudness, the check result and the Sprid state ride along as
416
+ badges. This is where you see repetition, an outlier, a caption that ran long.
417
+ - **Feed.** One post at reading size with its caption under it, arrow keys
418
+ between them, `m` for sound, `esc` back. A carousel advances by itself at
419
+ reading pace (`space` stops it, `[` `]` step it, the dots say how many are
420
+ left), because the question a batch review asks of a deck is whether it holds
421
+ together as one set, and that is a thing you watch rather than click through. It is the only view that answers
422
+ *would I stop on this*, because it is the shape a reader meets. Each reel has
423
+ its own `#slug` address, so a position is a link.
424
+
425
+ **`--serve` watches and reloads.** The registry, the lanes' media directories
426
+ and this package's own source are all watched; a change rebuilds the page and
427
+ refreshes the tab you already have open, and since the current reel lives in the
428
+ URL hash it comes back to where you were. Running the command again while a
429
+ server is up says so and opens nothing, rather than stacking tabs.
430
+
431
+ Media is **symlinked**, not copied: a batch is gigabytes and this page is meant
432
+ to be cheap enough to regenerate without thinking about it. `--copy` is for a
433
+ page that has to travel. **`--serve` is worth the extra word** - over `file://`
434
+ a browser cannot seek inside an mp4 and Safari will not play a symlink at all.
435
+
436
+ A registered reel whose file is not on disk is drawn as a **missing card**,
437
+ never dropped: the mp4s regenerate from their specs and routinely are not there,
438
+ and silently omitting missing files would hide incomplete work. A
439
+ file whose size no longer matches its record is badged instead of trusted
440
+ (`--verify` spends the sha256 rather than comparing sizes).
441
+
442
+ ## What `draft` does
443
+
444
+ 1. `POST /videos/upload-url` → PUT the file → `POST /videos/:id/confirm`
445
+ 2. `POST /posts`
446
+ 3. `PATCH /slides/:id { videoId }` - **this is what makes the post publish your
447
+ exact file** with its own audio, rather than be re-composed from the slide's
448
+ template in the render container
449
+ 4. `PATCH /posts/:id { contentType: "reel", captions, location,
450
+ audio: { silent: true }, status: "draft" }` - the caption carries the
451
+ hashtags; there is no separate tag field
452
+ 5. `GET /posts/:id`, and compare it to what was asked for
453
+
454
+ **`audio: { silent: true }` is not optional when the music is already in your
455
+ file.** A reel with no `trackAssetId` round-robins the account's track library,
456
+ so leaving `audio` unset lays a second bed over the first.
457
+
458
+ **Step 5 verifies that fields were stored.** An older server may accept a request
459
+ while ignoring fields its schema does not support. Read them back and retain any
460
+ mismatch in the registry so `status` can report it.
461
+
462
+ **A new post arrives with a slide already on it**, and `POST /posts` answers
463
+ with the post alone - no `slides` key. `draft` therefore reads the seats back,
464
+ adds the ones it is short of and deletes the ones it does not need. Trusting the
465
+ create response is how every draft ends up with an empty leading slide and the
466
+ real media behind it.
467
+
468
+ `draft` stops at `draft`. Moving a post to `ready` is the last gate before
469
+ something is public, and that stays a person's call.
470
+
471
+ ## Local production and crossposting
472
+
473
+ The consuming repo owns media, specs and its build commands. Sprid owns accounts,
474
+ channels and publishing state. The registry connects them without requiring a
475
+ particular renderer or access to another repository.
476
+
477
+ The CLI bundles the rules used by its offline checks. `check --deep` asks the
478
+ server for the actual platform projection of an existing post. Review both the
479
+ content and each platform's title, caption and cover before publishing.
480
+
481
+ - `postTitle()` derives a title from the caption's first line when present and
482
+ reconciles it on subsequent drafts. Review it as published copy.
483
+ - `coverAtMs` selects a cover frame per lane. Choose a completed, readable frame.
484
+ - A `.tiktok.txt` sibling provides a separate TikTok caption; otherwise it uses
485
+ the shared caption. YouTube's description uses the shared caption.
486
+
487
+ ## `sync`: a re-render, into posts that already exist
488
+
489
+ **A booked post is never edited. The source is re-rendered and the queue catches
490
+ up.** That is the design decision, and the alternative is worse than it sounds:
491
+ reaching into Sprid to swap a file on a scheduled post makes the queue the
492
+ source of truth for content, and then a spec and a published post can disagree
493
+ with nothing able to say which is right. Here the registry owns the content,
494
+ Sprid owns the calendar, and `sync` is the one command that reconciles them.
495
+
496
+ ```bash
497
+ sprid post sync # what differs, and what it would send
498
+ sprid post sync --execute # send it
499
+ sprid post sync --lane collage # scope it
500
+ ```
501
+
502
+ It diffs local against remote, uploads only what changed, re-points the existing
503
+ post's slide at the new file, and **leaves the schedule alone** - the post keeps
504
+ its slot. Three rules, two of them refusals:
505
+
506
+ - **A post that is out is frozen.** It reads the live status and writes only to
507
+ `draft` and `ready`. Anything else is skipped by name, including a status this
508
+ package has never heard of: an unknown state is far more likely to be further
509
+ along the pipeline than behind it. Re-posting is a new slug, not an edit.
510
+ - **It never creates a post.** No `postId`, no sync - that is `draft`'s job, so
511
+ a sync cannot widen a batch by accident.
512
+ - **It does the minimum.** Media changed, caption changed, or neither. A slug
513
+ whose caption moved does not get its video re-uploaded.
514
+
515
+ Changing media across a booked queue requires reconciling both uploaded media and post metadata.
516
+ `sync` keeps that operation reproducible.
517
+
518
+ **`mediaShaAtPush` is what makes it possible**, and it is the counterpart of
519
+ `captionShaAtDraft`, which existed from the start. A manifest written before it
520
+ reads as *unknown*, and unknown counts as stale - a record that cannot say what
521
+ was uploaded cannot say it is current. `sync --adopt` is the one-time migration
522
+ for a batch that was never rebuilt: it records what is on disk as what was
523
+ uploaded and sends nothing. It is an assertion and it can be wrong, so scope it.
524
+
525
+ ## `doctor`: which Sprid, and can it be reached
526
+
527
+ **A credential names the service it belongs to. Use both halves or neither.**
528
+
529
+ `sprid login` writes `~/.sprid/credentials.json` with a token *and* the API it
530
+ was issued against. Reading a token from one configuration and defaulting to another
531
+ server can make a working service appear unavailable. Keep the token and URL paired.
532
+
533
+ - `credentials.json` is read for **both** the token and `apiUrl`, after
534
+ `SPRID_URL` and a repo's pinned `tokenEnv`.
535
+ - `sprid post doctor` dials **every** candidate URL it knows about, not only
536
+ the winner, and prints the account and its live channels from whichever
537
+ answers. A disagreement between candidates is the failure mode, so the command
538
+ shows the disagreement rather than resolving it silently.
539
+
540
+ ```
541
+ yourapp using http://localhost:4005
542
+
543
+ → ~/.sprid/credentials.json http://localhost:4005
544
+ default https://api.sprid.studio
545
+
546
+ http://localhost:4005 ok 175ms account 3 instagram:active tiktok:active youtube:active
547
+ https://api.sprid.studio unreachable TimeoutError
548
+ ```
549
+
550
+ **`status` prints the registry and `doctor` prints the world.** "Several posts drafted"
551
+ reads identically whether Sprid is up, down, or a different Sprid than the one
552
+ holding your posts; `doctor` is the command that can tell you which.
553
+
554
+ ## `pull`: the schedule, back into the registry
555
+
556
+ **The queue lives in Sprid, and until `pull` nothing local could read it.** A
557
+ post is committed; when it publishes was not. So the repo could not answer "what
558
+ goes out tomorrow" or "which one went out last" without the API being up, and a
559
+ booking made weeks ago left no trace anybody could review in a diff.
560
+
561
+ `pull` writes each post's status and dates into `sprid.remote` on its manifest,
562
+ which is what lets `status` print a calendar and work offline.
563
+
564
+ > **It was written blind.** The API was unreachable for the whole session it
565
+ > went in, so `scheduleOf` tries the key names a post plausibly carries and
566
+ > returns null rather than inventing one. `pull` prints how many posts it could
567
+ > not read a date from - **if that is not zero on the first real run, put the
568
+ > real key in `scheduleOf` and add it to the test.** `sync` has no such doubt:
569
+ > every call it makes is one `push` and `draft` were already making.
570
+
571
+ ## Auth
572
+
573
+ `SPRID_TOKEN`, this repo's `.env`, or `~/.sprid/token`. Make one in Sprid under
574
+ Settings → Tokens; it starts with `sprd_`. `SPRID_URL` overrides the API host,
575
+ default `https://api.sprid.studio`.
576
+
577
+ **A PAT is workspace-scoped, and a machine that posts for three brands holds
578
+ three of them.** `tokenEnv` names the one this repo is allowed to use, and when
579
+ it is set nothing else is tried - a run that quietly fell back to the ambient
580
+ token would authenticate against workspaces this repo must not be able to reach,
581
+ and nothing in the output would say so. Set it in any repo that is not the only
582
+ one on the machine.
583
+
584
+ ## Account-specific copy checks
585
+
586
+ `neverSay` lists exact strings forbidden in captions and composed slide text.
587
+ Punctuation is allowed unless configured there, for example `["—", "!"]` for an
588
+ account that avoids those characters. `captionEndsSentence` optionally requires
589
+ terminal punctuation. `captionEndWords` optionally lists trailing words that
590
+ indicate an incomplete caption in this account's language; the default is empty.
591
+ Do not reuse another account's phrase list or language rules without reviewing them.
592
+
593
+ ## Destination selection and structured output
594
+
595
+ Local media can come from any renderer or framework. The `screen` recipe is an
596
+ optional iOS simulator workflow; existing Expo projects can keep their capture
597
+ scripts. Finished MP4s and image decks do not require Expo or Xcode. A custom
598
+ `build` command can run a named slug without a Sprid spec; built-in recipes still
599
+ require their specs. Use `--lane` when multiple builders could handle that slug.
600
+
601
+ Set `platforms: ["instagram"]` at the config or lane level to check only your
602
+ intended destinations in the `crosspost` check. A post spec/manifest can override it; `check --platforms
603
+ instagram,tiktok` overrides the selection for that check. `register --platforms
604
+ instagram` records a post's selection. With no selection, the existing Instagram,
605
+ TikTok and YouTube checks remain the default. This selects validation targets;
606
+ publishing destinations are still chosen when scheduling in Sprid. Local projection
607
+ rules currently cover Instagram, TikTok and YouTube; other platforms report a
608
+ skip, and `--deep` reads the booked post’s server projection. Media checks retain
609
+ their existing defaults and can be customized with `lane.expect`.
610
+
611
+ `sprid post <command> --json` writes one JSON result to stdout and diagnostics to
612
+ stderr, including output from custom builders. Results include `command`,
613
+ `account` and registry `posts` when available; checks include failures and a
614
+ nonzero exit status. `post help --json` includes the usage text. Original argument
615
+ order is preserved, including values of local flags such as `--lane` and `--out`.