@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.
- package/README.md +11 -5
- package/dist/slopper.mjs +170 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @slopperday/cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
32
|
+
npx -y @slopperday/cli login
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
`login`
|
|
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.
|
|
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.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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: "
|
|
390
|
+
summary: "Sign in from your browser, then save the token and its site for the other commands.",
|
|
281
391
|
flags: {
|
|
282
|
-
"--
|
|
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(
|
|
287
|
-
const
|
|
288
|
-
|
|
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}:
|
|
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
|
-
|
|
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
|
|
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
|
});
|