@slopperday/cli 0.1.0 → 0.1.1
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 +10 -4
- package/dist/slopper.mjs +128 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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.1";
|
|
10
37
|
//#endregion
|
|
11
38
|
//#region src/client.ts
|
|
12
39
|
/**
|
|
@@ -107,32 +134,54 @@ 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,
|
|
@@ -146,7 +195,7 @@ function createApi({ url, token, fetch, userAgent }) {
|
|
|
146
195
|
//#endregion
|
|
147
196
|
//#region src/skill.ts
|
|
148
197
|
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
|
|
198
|
+
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 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";
|
|
150
199
|
}
|
|
151
200
|
//#endregion
|
|
152
201
|
//#region src/commands.ts
|
|
@@ -274,18 +323,57 @@ const SLOP_FLAGS = {
|
|
|
274
323
|
"--made-with <tool>": "What built it; repeat for more."
|
|
275
324
|
};
|
|
276
325
|
const AUTOPOST_FLAG = { "--autopost": "Also post it to the connected X, LinkedIn and Bluesky accounts. Held unless it passed the content check." };
|
|
326
|
+
function isBrowserSignIn(value) {
|
|
327
|
+
if (typeof value !== "object" || value === null) return false;
|
|
328
|
+
const { deviceCode, userCode, url, interval } = value;
|
|
329
|
+
return typeof deviceCode === "string" && typeof userCode === "string" && typeof url === "string" && typeof interval === "number";
|
|
330
|
+
}
|
|
331
|
+
/** The longest a browser sign-in is polled; the site expires it after ten minutes. */
|
|
332
|
+
const SIGN_IN_TIMEOUT_MS = 66e4;
|
|
333
|
+
/**
|
|
334
|
+
* Signs in the way TVs do: the site makes a one-time code, the person
|
|
335
|
+
* approves it at /cli in a browser where they're signed in, and the CLI
|
|
336
|
+
* collects a new API token named after this machine. Progress goes to
|
|
337
|
+
* stderr so stdout stays JSON.
|
|
338
|
+
*/
|
|
339
|
+
async function signInFromBrowser({ io, url, userAgent }, openBrowser) {
|
|
340
|
+
const site = {
|
|
341
|
+
url,
|
|
342
|
+
fetch: io.fetch,
|
|
343
|
+
userAgent
|
|
344
|
+
};
|
|
345
|
+
const started = await postWithoutToken(site, "/api/cli/login", { name: `slopper CLI on ${io.hostname}` });
|
|
346
|
+
if (!isBrowserSignIn(started)) throw new SlopperError(`${url} didn't start a browser sign-in. Is it a Slopper site?`);
|
|
347
|
+
const opened = openBrowser && await io.openUrl(started.url);
|
|
348
|
+
io.stderr(`${opened ? "Opened" : "Open"} ${started.url}\nCheck the page shows ${started.userCode}, then approve it. Waiting…\n`);
|
|
349
|
+
for (let waited = 0; waited < SIGN_IN_TIMEOUT_MS; waited += started.interval * 1e3) {
|
|
350
|
+
await io.sleep(started.interval * 1e3);
|
|
351
|
+
const polled = await postWithoutToken(site, "/api/cli/token", { deviceCode: started.deviceCode });
|
|
352
|
+
const status = typeof polled === "object" && polled !== null ? polled.status : void 0;
|
|
353
|
+
if (status === "pending") continue;
|
|
354
|
+
if (status === "approved") {
|
|
355
|
+
const token = polled.token;
|
|
356
|
+
if (typeof token === "string" && token) return token;
|
|
357
|
+
}
|
|
358
|
+
if (status === "denied") throw new SlopperError("The sign-in was denied in the browser.");
|
|
359
|
+
if (status === "expired") throw new SlopperError("The sign-in expired. Run `slopper login` again.");
|
|
360
|
+
throw new SlopperError(`${url} answered the sign-in with something unexpected.`);
|
|
361
|
+
}
|
|
362
|
+
throw new SlopperError("Nobody approved the sign-in in time. Run `slopper login` again.");
|
|
363
|
+
}
|
|
277
364
|
const COMMANDS = [
|
|
278
365
|
{
|
|
279
366
|
name: "login",
|
|
280
|
-
summary: "
|
|
367
|
+
summary: "Sign in from your browser, then save the token and its site for the other commands.",
|
|
281
368
|
flags: {
|
|
282
|
-
"--
|
|
369
|
+
"--no-browser": "Print the sign-in link instead of opening it, for a machine without a browser.",
|
|
370
|
+
"--token <token>": "Save this token from /settings/api instead. Piped in, it's read from stdin.",
|
|
283
371
|
"--url <url>": "A different site to save, like a local one."
|
|
284
372
|
},
|
|
285
|
-
options: {},
|
|
286
|
-
async run(
|
|
287
|
-
const
|
|
288
|
-
|
|
373
|
+
options: { "no-browser": { type: "boolean" } },
|
|
374
|
+
async run(context) {
|
|
375
|
+
const { values, io, url, configFile, connect } = context;
|
|
376
|
+
const token = (text$1(values, "token") ?? await io.readStdin() ?? "").trim() || await signInFromBrowser(context, values["no-browser"] !== true);
|
|
289
377
|
const me = await connect(token).get("/me");
|
|
290
378
|
const username = typeof me === "object" && me !== null ? me.username : void 0;
|
|
291
379
|
if (typeof username !== "string") throw new SlopperError(`${url} didn't say whose token this is.`);
|
|
@@ -548,7 +636,7 @@ function overview() {
|
|
|
548
636
|
...flagLines(GLOBAL_FLAGS),
|
|
549
637
|
"",
|
|
550
638
|
"Results print as JSON. Errors go to stderr; the exit code is 1, or 2 for a usage mistake.",
|
|
551
|
-
|
|
639
|
+
"Start with `slopper login`, which signs you in from your browser, then `slopper whoami`.",
|
|
552
640
|
"Agents that shouldn't save a token can set SLOPPER_TOKEN instead."
|
|
553
641
|
].join("\n");
|
|
554
642
|
}
|
|
@@ -587,16 +675,18 @@ async function siteContext(local) {
|
|
|
587
675
|
const saved = await readConfig(configFile);
|
|
588
676
|
const flag = (key) => typeof values[key] === "string" ? values[key] : void 0;
|
|
589
677
|
const url = resolveUrl(flag("url") ?? envValue(io.env, "SLOPPER_URL") ?? saved.url ?? "https://slopper.day");
|
|
678
|
+
const userAgent = `slopper-cli/${version}`;
|
|
590
679
|
const connect = (token) => createApi({
|
|
591
680
|
url,
|
|
592
681
|
token,
|
|
593
682
|
fetch: io.fetch,
|
|
594
|
-
userAgent
|
|
683
|
+
userAgent
|
|
595
684
|
});
|
|
596
685
|
const token = flag("token") ?? envValue(io.env, "SLOPPER_TOKEN") ?? saved.token;
|
|
597
686
|
return {
|
|
598
687
|
...local,
|
|
599
688
|
url,
|
|
689
|
+
userAgent,
|
|
600
690
|
api: connect(token),
|
|
601
691
|
connect
|
|
602
692
|
};
|
|
@@ -663,8 +753,11 @@ async function main(argv, io) {
|
|
|
663
753
|
process.exitCode = await main(process.argv.slice(2), {
|
|
664
754
|
env: process.env,
|
|
665
755
|
home: homedir(),
|
|
756
|
+
hostname: hostname(),
|
|
666
757
|
fetch: globalThis.fetch,
|
|
667
758
|
readStdin: async () => process.stdin.isTTY ? null : await text(process.stdin),
|
|
759
|
+
openUrl: openInBrowser,
|
|
760
|
+
sleep: async (ms) => await setTimeout(ms),
|
|
668
761
|
stdout: (output) => process.stdout.write(output),
|
|
669
762
|
stderr: (output) => process.stderr.write(output)
|
|
670
763
|
});
|