@slopperday/cli 0.1.0 → 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 (3) hide show
  1. package/README.md +11 -5
  2. package/dist/slopper.mjs +170 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @slopperday/cli
2
2
 
3
- Throw slop at the wall from your terminal. `slopper` launches, updates and votes on
3
+ Too much slop? Not enough yet! Throw it at the wall from your terminal: `slopper` launches, updates and votes on
4
4
  [slopper.day](https://slopper.day) through its API, and prints JSON, so it's built for agents as much as people.
5
5
 
6
6
  ## Run it
@@ -28,13 +28,19 @@ Or install it once with `npm i -g @slopperday/cli` and run `slopper <command>`.
28
28
 
29
29
  ## Sign in
30
30
 
31
- Make a token at [slopper.day/settings/api](https://slopper.day/settings/api), then:
32
-
33
31
  ```sh
34
- npx -y @slopperday/cli login --token slp_... # or pipe it in: echo "$TOKEN" | npx -y @slopperday/cli login
32
+ npx -y @slopperday/cli login
35
33
  ```
36
34
 
37
- `login` checks the token first, then saves it with its site in `~/.config/slopper/config.json`
35
+ `login` opens slopper.day in your browser with a one-time code. Check it matches the one in your terminal, click
36
+ Approve, and the CLI gets an API token of its own, named after your machine (logging in again replaces it). On a
37
+ machine without a browser, `--no-browser` prints the link instead; open it anywhere you're signed in. The code lasts
38
+ ten minutes.
39
+
40
+ Already have a token from [slopper.day/settings/api](https://slopper.day/settings/api)? Pass it instead:
41
+ `login --token slp_...`, or pipe it in (`echo "$TOKEN" | npx -y @slopperday/cli login`).
42
+
43
+ Either way, `login` checks the token, then saves it with its site in `~/.config/slopper/config.json`
38
44
  (`$XDG_CONFIG_HOME/slopper`, or `$SLOPPER_CONFIG_DIR`), readable only by you. `logout` deletes it.
39
45
 
40
46
  Agents that shouldn't write a config file can skip `login` and pass the token per call:
package/dist/slopper.mjs CHANGED
@@ -1,12 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import { homedir } from "node:os";
2
+ import { homedir, hostname } from "node:os";
3
3
  import process from "node:process";
4
4
  import { text } from "node:stream/consumers";
5
+ import { setTimeout } from "node:timers/promises";
6
+ import { spawn } from "node:child_process";
5
7
  import { parseArgs } from "node:util";
6
8
  import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
7
9
  import { dirname, extname, join, resolve } from "node:path";
10
+ //#region src/browser.ts
11
+ /**
12
+ * Opens a page in the default browser. Resolves false when this machine
13
+ * can't (a server, a container, no desktop); callers print the link anyway.
14
+ */
15
+ function openInBrowser(url) {
16
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
17
+ "/c",
18
+ "start",
19
+ "",
20
+ url
21
+ ]] : ["xdg-open", [url]];
22
+ return new Promise((resolve) => {
23
+ const child = spawn(command, args, {
24
+ stdio: "ignore",
25
+ detached: true
26
+ });
27
+ child.once("error", () => resolve(false));
28
+ child.once("spawn", () => {
29
+ child.unref();
30
+ resolve(true);
31
+ });
32
+ });
33
+ }
34
+ //#endregion
8
35
  //#region package.json
9
- var version = "0.1.0";
36
+ var version = "0.1.2";
10
37
  //#endregion
11
38
  //#region src/client.ts
12
39
  /**
@@ -107,38 +134,61 @@ function parseJson(text) {
107
134
  return;
108
135
  }
109
136
  }
137
+ /**
138
+ * One request to the site, answered in JSON; anything else is a SlopperError.
139
+ * `path` is what errors name, under `base` (`/api/v1` for the REST API).
140
+ */
141
+ async function send({ url, fetch, userAgent }, method, base, path, init = {}) {
142
+ const headers = {
143
+ accept: "application/json",
144
+ "user-agent": userAgent
145
+ };
146
+ if (init.token) headers.authorization = `Bearer ${init.token}`;
147
+ if (init.contentType) headers["content-type"] = init.contentType;
148
+ let response;
149
+ let text;
150
+ try {
151
+ response = await fetch(`${url}${base}${path}`, {
152
+ method,
153
+ headers,
154
+ body: init.body
155
+ });
156
+ text = await response.text();
157
+ } catch (error) {
158
+ throw new SlopperError(`Couldn't reach ${url}: ${networkReason(error)}`);
159
+ }
160
+ const parsed = parseJson(text);
161
+ if (response.ok && parsed !== void 0) return parsed;
162
+ if (isErrorBody(parsed)) throw new SlopperError(parsed.error, { details: parsed.details ?? null });
163
+ if (response.ok) throw new SlopperError(`${url} answered ${method} ${path} without JSON. Is it a Slopper site?`);
164
+ throw new SlopperError(`${method} ${path} failed with HTTP ${response.status}.`);
165
+ }
166
+ /** A POST that needs no token, like the browser sign-in's (`/api/cli/*`). */
167
+ function postWithoutToken(site, path, body) {
168
+ return send(site, "POST", "", path, {
169
+ body: JSON.stringify(body),
170
+ contentType: "application/json"
171
+ });
172
+ }
110
173
  function createApi({ url, token, fetch, userAgent }) {
111
174
  async function request(method, path, body, contentType) {
112
- if (!token) throw new SlopperError(`No API token. Make one at ${url}/settings/api, then run \`slopper login --token <token>\` or set SLOPPER_TOKEN.`);
113
- const headers = {
114
- authorization: `Bearer ${token}`,
115
- accept: "application/json",
116
- "user-agent": userAgent
117
- };
118
- if (contentType) headers["content-type"] = contentType;
119
- let response;
120
- let text;
121
- try {
122
- response = await fetch(`${url}/api/v1${path}`, {
123
- method,
124
- headers,
125
- body
126
- });
127
- text = await response.text();
128
- } catch (error) {
129
- throw new SlopperError(`Couldn't reach ${url}: ${networkReason(error)}`);
130
- }
131
- const parsed = parseJson(text);
132
- if (response.ok && parsed !== void 0) return parsed;
133
- if (isErrorBody(parsed)) throw new SlopperError(parsed.error, { details: parsed.details ?? null });
134
- if (response.ok) throw new SlopperError(`${url} answered ${method} ${path} without JSON. Is it a Slopper site?`);
135
- throw new SlopperError(`${method} ${path} failed with HTTP ${response.status}.`);
175
+ if (!token) throw new SlopperError(`No API token. Run \`slopper login\` to sign in from your browser, or set SLOPPER_TOKEN to one from ${url}/settings/api.`);
176
+ return await send({
177
+ url,
178
+ fetch,
179
+ userAgent
180
+ }, method, "/api/v1", path, {
181
+ token,
182
+ body,
183
+ contentType
184
+ });
136
185
  }
137
186
  return {
138
187
  url,
139
188
  get: (path) => request("GET", path),
140
189
  post: (path, body) => request("POST", path, JSON.stringify(body), "application/json"),
141
190
  patch: (path, body) => request("PATCH", path, JSON.stringify(body), "application/json"),
191
+ put: (path, body) => request("PUT", path, JSON.stringify(body), "application/json"),
142
192
  delete: (path) => request("DELETE", path),
143
193
  upload: (path, bytes, contentType) => request("POST", path, bytes, contentType)
144
194
  };
@@ -146,7 +196,7 @@ function createApi({ url, token, fetch, userAgent }) {
146
196
  //#endregion
147
197
  //#region src/skill.ts
148
198
  async function readSkill() {
149
- return "---\nname: slopper\ndescription: Ship to slopper.day from the terminal with the `slopper` CLI (npm @slopperday/cli). Use when the user wants to launch a project as today's slop or schedule launches for later days, post an update or field test, set a daily shipping goal, log costs or revenue, check today's slop of the day race, or vote on slops.\n---\n\n# Slopper\n\n[slopper.day](https://slopper.day) is where people throw slop at the wall every day: they ship something small\ndaily (a site, a TestFlight or Play beta, a repo), set a goal like 30 slops in 30 days, and keep slopping until one is\nslopcessful. Every UTC day the slops launched that day race on votes; the leader at midnight UTC wins slop of the day\nand goes slopcessful. Slopcessful is earned, never declared: nobody can mark their own slop slopcessful.\n\n## Setup\n\nRun the CLI without installing it, with the runner the project already uses:\n\n```sh\nnpx -y @slopperday/cli <command> # -y, or npx stops to ask \"Ok to proceed?\" and waits\npnpm dlx @slopperday/cli <command>\nbunx @slopperday/cli <command>\nyarn dlx -q @slopperday/cli <command> # -q, or yarn's install log lands in the JSON\ndeno run -A npm:@slopperday/cli <command>\n```\n\nOr install it once (`npm i -g @slopperday/cli`) and run `slopper`. The examples below say `slopper`: put the runner's\ncommand in its place.\n\nIt needs the user's API token. They make one at https://slopper.day/settings/api. Ask them for it if you don't have one,\nthen either save it or pass it per call:\n\n```sh\nslopper login --token slp_... # checks it, then saves it readable by the user only\nSLOPPER_TOKEN=slp_... slopper whoami # saves nothing: use this when you shouldn't write files\n```\n\n`--url` (or `SLOPPER_URL`) points it at another site, like a local one.\n\nEvery command prints JSON on stdout. Errors go to stderr with exit code 1 (2 for a usage mistake), and validation\nerrors list each field that needs fixing. `slopper help <command>` shows every flag. A value that starts with a dash,\nlike a Markdown list, needs `=`: `--description=\"- one\"`.\n\n## Start with whoami\n\n`slopper whoami` returns the user's username, time zone, today's date in that zone, the open goal and its progress, the\nstreak, and the votes left today. Goal days end at midnight in the user's time zone; the race runs on UTC days.\n\n## Launch a slop\n\n1. Read the project: README, package.json or pubspec.yaml, and any deployed URL, App Store, TestFlight or Play beta\n links.\n2. Take 1 to 3 screenshots of it running. The first is the cover.\n3. Pick a category from `slopper categories`.\n4. Launch it. Screenshot files are uploaded for you:\n\n```sh\nslopper launch \\\n --name \"TiltMaze\" \\\n --tagline \"A marble maze you play by tilting your phone\" \\\n --category games \\\n --description \"Built in a day. Tilt to roll, find the exit before the timer runs out.\" \\\n --link website=https://tiltmaze.app \\\n --link testflight=https://testflight.apple.com/join/abc123 \\\n --screenshot ./shots/cover.png --screenshot ./shots/level-2.png \\\n --made-with Flutter --made-with Claude \\\n --note \"Day 12: a maze you tilt. Try to beat 40 seconds.\" \\\n --autopost\n```\n\nLink kinds: `website`, `app_store`, `testflight`, `play_store`, `play_beta`, `source`, `other`. A bare URL is a\nwebsite. Store links must use their store's domain.\n\nThe result has the slop's `id`, `slug` and `url`, and the content check's verdict. Give the user the URL.\n\n`--autopost` posts the launch to the user's connected X, LinkedIn and Bluesky accounts. It's held (with the reason)\nunless the slop passed the automated content check and nobody has flagged it. Write the `--note` in the user's voice,\ntwo sentences at most.\n\nA new platform or a 2.0 is a new launch with its own shot at slop of the day:\n`slopper launch --relaunch-of <slop> --label \"iOS app\" ...`. A new feature is an update, not a launch.\n\n## Schedule launches\n\nWhen several things are ready at once, ask the user whether to put them all out now or line them up. Every launch\ncounts towards the goal on the day it goes out: three today are three slops today, and three lined up fill three days,\neach with its own shot at slop of the day.\n\n```sh\nslopper launch --at next ... # the next day with nothing out or scheduled, as its race starts\nslopper launch --at 2026-10-15T09:00 ... # the user's time zone, unless it has an offset\nslopper scheduled # what's waiting, soonest first\nslopper release <scheduled> # put it out now instead\nslopper unschedule <scheduled> # drop it\n```\n\nNobody else sees a scheduled launch until it goes out. `--autopost` posts it then.\n\n## Keep it alive\n\n```sh\nslopper post tiltmaze --kind feature --body \"Level editor: build a maze and share the link.\" --autopost\nslopper post tiltmaze --kind field --body \"Showed it to 5 people at the bus stop. 3 got hooked.\"\nslopper edit tiltmaze --tagline \"Now with a level editor\" --screenshot ./shots/editor.png\nslopper money tiltmaze cost 12.99 --category domain --note \"tiltmaze.app\"\n```\n\nUpdate kinds: `update` (fixes, news), `feature` (something new shipped), `field` (a real-world test: ask the user who\ntried it and what they said), `repromote` (bump it back up). `<slop>` is one of the user's slops, by id or slug (see\n`slopper slops`). `edit` changes only the flags passed; `--link` and `--screenshot` replace the whole list.\n\n## Goals\n\nAsk how many days first (7, 14, 30 or 100 are common), then how hard: one slop every five days is safe, every other day\nis getting there, one a day is hardcore, two a day is impossible.\n\n```sh\nslopper goal --days 30 --target 30\n```\n\nOnly one goal can be open at a time.\n\n## The race and votes\n\n`slopper today` ranks today's slops by votes, with each slop's `id`, whether it's the user's (`yours`), how they voted\n(`yourVote`), and `votesLeft`.\n\n```sh\nslopper vote <slop id> up # or down (private: lowers the ranking, nobody sees it), or clear\n```\n\nEveryone gets 5 votes per UTC day. Switching today's vote is free and clearing it gives it back. Nobody can vote on\ntheir own slops. Votes decide slop of the day, so only vote the way the user asks; never spend their votes on your own\njudgement.\n\n## Don't\n\n- Don't launch ideas, waitlists or anything that isn't live: a slop is something people can try today.\n- Don't launch features as new slops; post them as updates.\n- Don't put the token in files you commit. Use `slopper login` or `SLOPPER_TOKEN`.\n";
199
+ return "---\nname: slopper\ndescription: Ship to slopper.day from the terminal with the `slopper` CLI (npm @slopperday/cli). Use when the user wants to launch a project as today's slop or schedule launches for later days, post an update or field test, set a daily shipping goal, log costs or revenue, check today's slop of the day race, or vote on slops.\n---\n\n# Slopper\n\n[slopper.day](https://slopper.day) is where people throw slop at the wall every day: they ship something small\ndaily (a site, a TestFlight or Play beta, a repo), set a goal like 30 slops in 30 days, and keep slopping until one is\nslopcessful. Every UTC day the slops launched that day race on votes; the leader at midnight UTC wins slop of the day\nand goes slopcessful. Slopcessful is earned, never declared: nobody can mark their own slop slopcessful.\n\n## Setup\n\nRun the CLI without installing it, with the runner the project already uses:\n\n```sh\nnpx -y @slopperday/cli <command> # -y, or npx stops to ask \"Ok to proceed?\" and waits\npnpm dlx @slopperday/cli <command>\nbunx @slopperday/cli <command>\nyarn dlx -q @slopperday/cli <command> # -q, or yarn's install log lands in the JSON\ndeno run -A npm:@slopperday/cli <command>\n```\n\nOr install it once (`npm i -g @slopperday/cli`) and run `slopper`. The examples below say `slopper`: put the runner's\ncommand in its place.\n\nIt needs to be signed in as the user. Run `slopper login` with no token: it opens their browser on a one-time code and\nwaits (up to ten minutes) until they approve it, then saves a token of its own. Tell the user to check the code matches\nand click Approve. If no browser can open (a remote or sandboxed machine), use `--no-browser`, run it in the\nbackground and give the user the link it prints on stderr. If they hand you a token instead, use it:\n\n```sh\nslopper login # browser sign-in; saves a token readable by the user only\nslopper login --token slp_... # or check and save a token from /settings/api\nSLOPPER_TOKEN=slp_... slopper whoami # saves nothing: use this when you shouldn't write files\n```\n\n`--url` (or `SLOPPER_URL`) points it at another site, like a local one.\n\nEvery command prints JSON on stdout. Errors go to stderr with exit code 1 (2 for a usage mistake), and validation\nerrors list each field that needs fixing. `slopper help <command>` shows every flag. A value that starts with a dash,\nlike a Markdown list, needs `=`: `--description=\"- one\"`.\n\n## Start with whoami\n\n`slopper whoami` returns the user's username, time zone, today's date in that zone, the open goal and its progress, the\nstreak, and the votes left today. Goal days end at midnight in the user's time zone; the race runs on UTC days.\n\n## Launch a slop\n\n1. Read the project: README, package.json or pubspec.yaml, and any deployed URL, App Store, TestFlight or Play beta\n links.\n2. Take 1 to 3 screenshots of it running. The first is the cover.\n3. Pick a category from `slopper categories`.\n4. Launch it. Screenshot files are uploaded for you:\n\n```sh\nslopper launch \\\n --name \"TiltMaze\" \\\n --tagline \"A marble maze you play by tilting your phone\" \\\n --category games \\\n --description \"Built in a day. Tilt to roll, find the exit before the timer runs out.\" \\\n --link website=https://tiltmaze.app \\\n --link testflight=https://testflight.apple.com/join/abc123 \\\n --screenshot ./shots/cover.png --screenshot ./shots/level-2.png \\\n --made-with Flutter --made-with Claude \\\n --note \"Day 12: a maze you tilt. Try to beat 40 seconds.\" \\\n --autopost\n```\n\nLink kinds: `website`, `app_store`, `testflight`, `play_store`, `play_beta`, `source`, `other`. A bare URL is a\nwebsite. Store links must use their store's domain.\n\nThe result has the slop's `id`, `slug` and `url`, and the content check's verdict. Give the user the URL.\n\n`--autopost` posts the launch to the user's connected X, LinkedIn and Bluesky accounts. It's held (with the reason)\nunless the slop passed the automated content check and nobody has flagged it. Write the `--note` in the user's voice,\ntwo sentences at most.\n\nA new platform or a 2.0 is a new launch with its own shot at slop of the day:\n`slopper launch --relaunch-of <slop> --label \"iOS app\" ...`. A new feature is an update, not a launch.\n\n## Schedule launches\n\nWhen several things are ready at once, ask the user whether to put them all out now or line them up. Every launch\ncounts towards the goal on the day it goes out: three today are three slops today, and three lined up fill three days,\neach with its own shot at slop of the day.\n\n```sh\nslopper launch --at next ... # the next day with nothing out or scheduled, as its race starts\nslopper launch --at 2026-10-15T09:00 ... # the user's time zone, unless it has an offset\nslopper scheduled # what's waiting, soonest first\nslopper release <scheduled> # put it out now instead\nslopper unschedule <scheduled> # drop it\n```\n\nNobody else sees a scheduled launch until it goes out. `--autopost` posts it then.\n\n## Keep it alive\n\n```sh\nslopper post tiltmaze --kind feature --body \"Level editor: build a maze and share the link.\" --autopost\nslopper post tiltmaze --kind field --body \"Showed it to 5 people at the bus stop. 3 got hooked.\"\nslopper edit tiltmaze --tagline \"Now with a level editor\" --screenshot ./shots/editor.png\nslopper video tiltmaze ./promo.mp4\nslopper money tiltmaze cost 12.99 --category domain --note \"tiltmaze.app\"\n```\n\nUpdate kinds: `update` (fixes, news), `feature` (something new shipped), `field` (a real-world test: ask the user who\ntried it and what they said), `repromote` (bump it back up). `<slop>` is one of the user's slops, by id or slug (see\n`slopper slops`). `edit` changes only the flags passed; `--link` and `--screenshot` replace the whole list.\n`video` puts a promo (MP4 or WebM, up to 4 MB) on a slop, replacing its last; it says `pending` until a moderator\napproves it, because the content check can't watch video. `video <slop> --remove` takes it off.\n\n## Goals\n\nAsk how many days first (7, 14, 30 or 100 are common), then how hard: one slop every five days is safe, every other day\nis getting there, one a day is hardcore, two a day is impossible.\n\n```sh\nslopper goal --days 30 --target 30\n```\n\nOnly one goal can be open at a time.\n\n## The race and votes\n\n`slopper today` ranks today's slops by votes, with each slop's `id`, whether it's the user's (`yours`), how they voted\n(`yourVote`), and `votesLeft`.\n\n```sh\nslopper vote <slop id> up # or down (private: lowers the ranking, nobody sees it), or clear\n```\n\nEveryone gets 5 votes per UTC day. Switching today's vote is free and clearing it gives it back. Nobody can vote on\ntheir own slops. Votes decide slop of the day, so only vote the way the user asks; never spend their votes on your own\njudgement.\n\n## Don't\n\n- Don't launch ideas, waitlists or anything that isn't live: a slop is something people can try today.\n- Don't launch features as new slops; post them as updates.\n- Don't put the token in files you commit. Use `slopper login` or `SLOPPER_TOKEN`.\n";
150
200
  }
151
201
  //#endregion
152
202
  //#region src/commands.ts
@@ -155,6 +205,8 @@ async function readSkill() {
155
205
  * agree). Bigger screenshots are refused, so they aren't sent.
156
206
  */
157
207
  const MEDIA_BYTES = 4194304;
208
+ /** The site's LIMITS.videoBytes, for promo videos. */
209
+ const VIDEO_BYTES = 4194304;
158
210
  function text$1(values, key) {
159
211
  const value = values[key];
160
212
  return typeof value === "string" ? value : void 0;
@@ -217,6 +269,26 @@ async function uploadImage(api, file) {
217
269
  if (typeof uploaded !== "object" || uploaded === null || typeof uploaded.id !== "string") throw new SlopperError(`Uploading ${file} returned no media id.`);
218
270
  return uploaded;
219
271
  }
272
+ const VIDEO_TYPES = {
273
+ ".mp4": "video/mp4",
274
+ ".m4v": "video/mp4",
275
+ ".webm": "video/webm"
276
+ };
277
+ async function uploadVideoFile(api, file) {
278
+ const contentType = VIDEO_TYPES[extname(file).toLowerCase()];
279
+ if (!contentType) throw usageError(`${file} isn't an MP4 or WebM video.`);
280
+ let bytes;
281
+ try {
282
+ bytes = new Uint8Array(await readFile(resolve(file)));
283
+ } catch (error) {
284
+ if (!isSystemError(error)) throw error;
285
+ throw usageError(`Couldn't read ${file} (${error.code}).`);
286
+ }
287
+ if (bytes.byteLength > 4194304) throw usageError(`${file} is bigger than ${VIDEO_BYTES / 1024 / 1024} MB, the most a video can be.`);
288
+ const uploaded = await api.upload("/videos", bytes, contentType);
289
+ if (typeof uploaded !== "object" || uploaded === null || typeof uploaded.id !== "string") throw new SlopperError(`Uploading ${file} returned no media id.`);
290
+ return uploaded;
291
+ }
220
292
  /**
221
293
  * Screenshots are image files, uploaded first, or media ids from
222
294
  * `slopper upload`. Anything with an image extension or a path in it is a file.
@@ -274,18 +346,57 @@ const SLOP_FLAGS = {
274
346
  "--made-with <tool>": "What built it; repeat for more."
275
347
  };
276
348
  const AUTOPOST_FLAG = { "--autopost": "Also post it to the connected X, LinkedIn and Bluesky accounts. Held unless it passed the content check." };
349
+ function isBrowserSignIn(value) {
350
+ if (typeof value !== "object" || value === null) return false;
351
+ const { deviceCode, userCode, url, interval } = value;
352
+ return typeof deviceCode === "string" && typeof userCode === "string" && typeof url === "string" && typeof interval === "number";
353
+ }
354
+ /** The longest a browser sign-in is polled; the site expires it after ten minutes. */
355
+ const SIGN_IN_TIMEOUT_MS = 66e4;
356
+ /**
357
+ * Signs in the way TVs do: the site makes a one-time code, the person
358
+ * approves it at /cli in a browser where they're signed in, and the CLI
359
+ * collects a new API token named after this machine. Progress goes to
360
+ * stderr so stdout stays JSON.
361
+ */
362
+ async function signInFromBrowser({ io, url, userAgent }, openBrowser) {
363
+ const site = {
364
+ url,
365
+ fetch: io.fetch,
366
+ userAgent
367
+ };
368
+ const started = await postWithoutToken(site, "/api/cli/login", { name: `slopper CLI on ${io.hostname}` });
369
+ if (!isBrowserSignIn(started)) throw new SlopperError(`${url} didn't start a browser sign-in. Is it a Slopper site?`);
370
+ const opened = openBrowser && await io.openUrl(started.url);
371
+ io.stderr(`${opened ? "Opened" : "Open"} ${started.url}\nCheck the page shows ${started.userCode}, then approve it. Waiting…\n`);
372
+ for (let waited = 0; waited < SIGN_IN_TIMEOUT_MS; waited += started.interval * 1e3) {
373
+ await io.sleep(started.interval * 1e3);
374
+ const polled = await postWithoutToken(site, "/api/cli/token", { deviceCode: started.deviceCode });
375
+ const status = typeof polled === "object" && polled !== null ? polled.status : void 0;
376
+ if (status === "pending") continue;
377
+ if (status === "approved") {
378
+ const token = polled.token;
379
+ if (typeof token === "string" && token) return token;
380
+ }
381
+ if (status === "denied") throw new SlopperError("The sign-in was denied in the browser.");
382
+ if (status === "expired") throw new SlopperError("The sign-in expired. Run `slopper login` again.");
383
+ throw new SlopperError(`${url} answered the sign-in with something unexpected.`);
384
+ }
385
+ throw new SlopperError("Nobody approved the sign-in in time. Run `slopper login` again.");
386
+ }
277
387
  const COMMANDS = [
278
388
  {
279
389
  name: "login",
280
- summary: "Check an API token (from /settings/api), then save it and its site for the other commands.",
390
+ summary: "Sign in from your browser, then save the token and its site for the other commands.",
281
391
  flags: {
282
- "--token <token>": "The token. Without it, it's read from stdin: `echo $TOKEN | slopper login`.",
392
+ "--no-browser": "Print the sign-in link instead of opening it, for a machine without a browser.",
393
+ "--token <token>": "Save this token from /settings/api instead. Piped in, it's read from stdin.",
283
394
  "--url <url>": "A different site to save, like a local one."
284
395
  },
285
- options: {},
286
- async run({ values, io, url, configFile, connect }) {
287
- const token = (text$1(values, "token") ?? await io.readStdin() ?? "").trim();
288
- if (!token) throw usageError(`Pass --token, or pipe one in. Make a token at ${url}/settings/api.`);
396
+ options: { "no-browser": { type: "boolean" } },
397
+ async run(context) {
398
+ const { values, io, url, configFile, connect } = context;
399
+ const token = (text$1(values, "token") ?? await io.readStdin() ?? "").trim() || await signInFromBrowser(context, values["no-browser"] !== true);
289
400
  const me = await connect(token).get("/me");
290
401
  const username = typeof me === "object" && me !== null ? me.username : void 0;
291
402
  if (typeof username !== "string") throw new SlopperError(`${url} didn't say whose token this is.`);
@@ -423,6 +534,24 @@ const COMMANDS = [
423
534
  return context.api.patch(slopPath(slop), fields);
424
535
  }
425
536
  },
537
+ {
538
+ name: "video",
539
+ args: "<slop> [file|id]",
540
+ summary: "Put a promo video (MP4 or WebM, up to 4 MB) on one of your slops, replacing its last. It plays for everyone once a moderator approves it.",
541
+ flags: { "--remove": "Take the slop's video off instead." },
542
+ options: { remove: { type: "boolean" } },
543
+ async run(context) {
544
+ const { api, values, positionals } = context;
545
+ const slop = positional(context, 0, "slop");
546
+ if (values.remove === true) {
547
+ if (positionals[1] !== void 0) throw usageError("Pass a video or --remove, not both.");
548
+ return api.put(`${slopPath(slop)}/video`, { video: null });
549
+ }
550
+ const video = positional(context, 1, "file|id");
551
+ const id = video.includes("/") || video.includes("\\") || extname(video).toLowerCase() in VIDEO_TYPES ? (await uploadVideoFile(api, video)).id : video;
552
+ return api.put(`${slopPath(slop)}/video`, { video: id });
553
+ }
554
+ },
426
555
  {
427
556
  name: "post",
428
557
  args: "<slop>",
@@ -536,7 +665,7 @@ function usage(command) {
536
665
  function overview() {
537
666
  const width = Math.max(...COMMANDS.map((command) => command.name.length));
538
667
  return [
539
- `slopper ${version}: throw slop at the wall, from your terminal. Built for agents.`,
668
+ `slopper ${version}: too much slop? Not enough yet! Throw it at the wall from your terminal. Built for agents.`,
540
669
  "",
541
670
  "Usage: slopper <command> [flags]",
542
671
  "",
@@ -548,7 +677,7 @@ function overview() {
548
677
  ...flagLines(GLOBAL_FLAGS),
549
678
  "",
550
679
  "Results print as JSON. Errors go to stderr; the exit code is 1, or 2 for a usage mistake.",
551
- `Start with \`slopper login\` (tokens live at ${DEFAULT_URL}/settings/api), then \`slopper whoami\`.`,
680
+ "Start with `slopper login`, which signs you in from your browser, then `slopper whoami`.",
552
681
  "Agents that shouldn't save a token can set SLOPPER_TOKEN instead."
553
682
  ].join("\n");
554
683
  }
@@ -587,16 +716,18 @@ async function siteContext(local) {
587
716
  const saved = await readConfig(configFile);
588
717
  const flag = (key) => typeof values[key] === "string" ? values[key] : void 0;
589
718
  const url = resolveUrl(flag("url") ?? envValue(io.env, "SLOPPER_URL") ?? saved.url ?? "https://slopper.day");
719
+ const userAgent = `slopper-cli/${version}`;
590
720
  const connect = (token) => createApi({
591
721
  url,
592
722
  token,
593
723
  fetch: io.fetch,
594
- userAgent: `slopper-cli/${version}`
724
+ userAgent
595
725
  });
596
726
  const token = flag("token") ?? envValue(io.env, "SLOPPER_TOKEN") ?? saved.token;
597
727
  return {
598
728
  ...local,
599
729
  url,
730
+ userAgent,
600
731
  api: connect(token),
601
732
  connect
602
733
  };
@@ -663,8 +794,11 @@ async function main(argv, io) {
663
794
  process.exitCode = await main(process.argv.slice(2), {
664
795
  env: process.env,
665
796
  home: homedir(),
797
+ hostname: hostname(),
666
798
  fetch: globalThis.fetch,
667
799
  readStdin: async () => process.stdin.isTTY ? null : await text(process.stdin),
800
+ openUrl: openInBrowser,
801
+ sleep: async (ms) => await setTimeout(ms),
668
802
  stdout: (output) => process.stdout.write(output),
669
803
  stderr: (output) => process.stderr.write(output)
670
804
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slopperday/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Throw slop at the wall from your terminal: launch, update and vote on slopper.day. Built for agents.",
5
5
  "keywords": [
6
6
  "agent",