From 150828221d5f7436211a75ba798fe77a03914a96 Mon Sep 17 00:00:00 2001 From: Robbe Verhelst Date: Sat, 26 Sep 2026 00:52:46 +0200 Subject: [PATCH] docs: a Command line page for sluiceway v0.45.0, its commands, their --json answers and exit codes Follow sluiceway v0.45.0. The page reads the action's docs/command-line.md, so the links the action's own pages make to it land here, and adds one example of each command with what it prints and its --json answer. A test holds every example the app answers to the app's OpenAPI document at the pinned tag. Using the app links to it from ticking and the audit log. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitmodules | 2 +- src/content/docs/hosted-app/using-the-app.mdx | 7 + src/lib/command-line.ts | 400 ++++++++++++++++++ src/lib/pages.ts | 37 +- test/command-line.test.ts | 115 +++++ vendor/sluiceway | 2 +- 6 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/lib/command-line.ts create mode 100644 test/command-line.test.ts diff --git a/.gitmodules b/.gitmodules index 73d967d..2d67584 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "vendor/sluiceway"] path = vendor/sluiceway url = https://github.com/sluiceway/sluiceway.git - branch = v0.42.2 + branch = v0.45.0 diff --git a/src/content/docs/hosted-app/using-the-app.mdx b/src/content/docs/hosted-app/using-the-app.mdx index c1387ef..b42832b 100644 --- a/src/content/docs/hosted-app/using-the-app.mdx +++ b/src/content/docs/hosted-app/using-the-app.mdx @@ -95,6 +95,10 @@ on GitHub. Ticking and Rescan from the app work in a repo whose `sluiceway.yaml` names `sluiceway[bot]` in `recordWriters`, and in any other repo they open the dashboard on GitHub. +You can also tick and rescan from a terminal, or have a coding agent do it with a token you +make: the command line asks the app, which judges +it by the same rules. + ## Rescan **Rescan** in the top bar asks for a full scan: of the repo @@ -132,6 +136,9 @@ who merged, the commit, and the result in the trail's words. A deploy outside th flagged in red, "outside the dashboard", and names nobody, because the tool's history never says who. +A tick from the command line counts as the person +whose token it used, and its line says "via the command line". + Filter by repo, by stack or to the flagged lines only. Each filter is a plain link, so a filtered log has an address you can paste into a ticket. **CSV** downloads what the page shows, with the same filters, the time in UTC and the whole commit. diff --git a/src/lib/command-line.ts b/src/lib/command-line.ts new file mode 100644 index 0000000..8af69a5 --- /dev/null +++ b/src/lib/command-line.ts @@ -0,0 +1,400 @@ +// The examples on the command line page: each command the action's docs/command-line.md +// lists, what it prints, and what it prints with --json. The action's page says what each +// command does; this part shows it. +// +// Every example is what the command line at the pinned tag prints against the fake app of its +// own tests (test/cli/fake-app.ts), whose answers the action holds to the app's OpenAPI +// document. test/command-line.test.ts holds each app answer below to the copy of that document +// at the pinned tag, test/fixtures/app/openapi.json, so a field the app renames fails a test +// here. + +export interface Example { + /** The command, as typed. */ + command: string; + /** What it prints without --json, or on stderr for a failure. */ + words?: string; + /** What it prints with --json. */ + json?: unknown; + /** The schema in the app's OpenAPI document that `json` is, when it is the app's answer as it came. */ + schema?: string; + exit: number; +} + +export interface ExampleGroup { + /** The heading, a command in backticks. */ + heading: string; + /** The sentences under the heading, Markdown. */ + intro: string; + examples: Example[]; +} + +const COUNTS = { pending: 2, deploying: 1, drifted: 0, failed: 0, "in-sync": 1 }; + +const STACKS = [ + { + stack: "site:prod", + repo: "acme/infra", + state: "in-sync", + word: "in sync", + destroys: false, + line: "", + at: null, + }, + { + stack: "network:prod", + repo: "acme/infra", + state: "pending", + word: "pending", + destroys: false, + line: "1 update · from #12 by alice", + at: null, + }, + { + stack: "apps/api:prod", + repo: "acme/infra", + state: "pending", + word: "pending", + destroys: true, + line: "1 update, 1 replace · from #14 by bob", + at: null, + }, + { + stack: "db:prod", + repo: "acme/infra", + state: "deploying", + word: "queued", + destroys: false, + line: "ticked by carol", + at: null, + }, +]; + +const ORG = { login: "acme", kind: "organization", page: "https://app.sluiceway.dev/acme" }; + +const REPO_LINE = { + name: "acme/infra", + page: "https://app.sluiceway.dev/acme/infra", + dashboard: "https://github.com/acme/infra/issues/7", + stacks: 4, + counts: COUNTS, + recordWriter: null, +}; + +const DASHBOARD = "https://github.com/acme/infra/issues/7"; + +export const EXAMPLES: ExampleGroup[] = [ + { + heading: "`sluiceway login`", + intro: + "Asks for the token, checks it with the app, and says who it signs you in as and where it keeps the token.", + examples: [ + { + command: "sluiceway login", + words: [ + "Signed in to https://app.sluiceway.dev as alice, for acme, with the token laptop, which works until 2026-12-25T00:00:00Z.", + "The token is kept in the macOS keychain.", + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway login --json < token.txt", + json: { + app: "https://app.sluiceway.dev", + login: "alice", + org: "acme", + token: { name: "laptop", expiresAt: "2026-12-25T00:00:00Z" }, + kept: "the macOS keychain", + }, + exit: 0, + }, + ], + }, + { + heading: "`sluiceway status`", + intro: + "The org's stacks by state, the way the app's org view groups them. With a repo, the same for that repo, with its last scan and its dashboard.", + examples: [ + { + command: "sluiceway status", + words: [ + "acme: 2 pending, 1 deploying, 1 in sync, 4 stacks", + "Last scan: 0a1b2c3 of acme/infra at 2026-09-26T08:00:00Z", + "", + "Needs you", + " acme/infra apps/api:prod pending, deletes or replaces 1 update, 1 replace · from #14 by bob", + " acme/infra network:prod pending 1 update · from #12 by alice", + "In flight", + " acme/infra db:prod queued ticked by carol", + "In sync", + " acme/infra site:prod", + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway status infra --json", + schema: "Repo", + json: { + org: ORG, + repo: REPO_LINE, + scan: { sha: "0a1b2c3d4e5f", at: "2026-09-26T08:00:00Z", run: null }, + counts: COUNTS, + total: 4, + stacks: STACKS, + }, + exit: 0, + }, + ], + }, + { + heading: "`sluiceway stack`", + intro: + "One stack's row: its state, the counts in the dashboard's words, what it deletes or replaces, drift, the preview page on GitHub, the run of a deploy and the last deploy. A line with nothing to say is left out.", + examples: [ + { + command: "sluiceway stack infra network:prod", + words: [ + "network:prod in acme/infra: pending", + "1 update · from #12 by alice", + "Changes: 1 update", + "Preview: sluiceway / network:prod, on https://github.com/acme/infra/commit/0a1b2c3d/checks", + `Dashboard: ${DASHBOARD}`, + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway stack infra network:prod --json", + schema: "Stack", + json: { + stack: "network:prod", + repo: "acme/infra", + state: "pending", + word: "pending", + line: "1 update · from #12 by alice", + counts: { + creates: 0, + updates: 1, + replaces: 0, + deletes: 0, + tracking: 0, + changed: 0, + gone: 0, + }, + changes: "1 update", + destroys: null, + drift: null, + ticked: false, + failed: false, + dashboard: DASHBOARD, + preview: { + name: "sluiceway / network:prod", + url: "https://github.com/acme/infra/commit/0a1b2c3d/checks", + }, + run: null, + lastDeploy: null, + at: "2026-09-26T08:00:00Z", + }, + exit: 0, + }, + ], + }, + { + heading: "`sluiceway tick`", + intro: + "Ticks the stack as you and stops once the app shows the deployment record. With `--json` the answer is two of the app's: `tick`, what the tick came to, and `deploy`, the record as the app showed it. The audit log's line for it says `via the command line`.", + examples: [ + { + command: "sluiceway tick infra network:prod", + words: [ + "The tick of network:prod is asked: the deployment record is open.", + "Deployment record 4242: waiting to start.", + `The workflow deploys it through a fresh preview and the hash check, and the dashboard says how it went: ${DASHBOARD}`, + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway tick infra network:prod --json", + json: { + tick: { + outcome: "asked", + sentence: "The tick of network:prod is asked: the deployment record is open.", + deployment: { + id: 4242, + status: "https://app.sluiceway.dev/api/v1/orgs/acme/repos/infra/deployments/4242", + }, + dashboard: DASHBOARD, + }, + deploy: { + deployment: 4242, + stack: "network:prod", + repo: "acme/infra", + who: "ticked by alice via the command line", + via: "via the command line", + result: "waiting to start", + state: "deploying", + sha: "0a1b2c3d4e5f", + run: "https://github.com/acme/infra/actions/runs/99", + at: "2026-09-26T08:05:00Z", + flagged: false, + approvedBy: null, + }, + }, + exit: 0, + }, + { + command: "sluiceway tick infra apps/api:prod --json", + json: { + error: + "apps/api:prod deletes or replaces resources (1 replace). Run the tick again with --yes to deploy that.", + code: "needs-yes", + exit: 5, + }, + exit: 5, + }, + ], + }, + { + heading: "`sluiceway rescan`", + intro: + "Asks GitHub for a full scan of the repo, as Rescan in the app does. It deploys nothing.", + examples: [ + { + command: "sluiceway rescan infra", + words: ["GitHub was asked for a full scan of acme/infra.", `Dashboard: ${DASHBOARD}`].join( + "\n", + ), + exit: 0, + }, + { + command: "sluiceway rescan infra --json", + schema: "RescanAnswer", + json: { + repo: "acme/infra", + outcome: "asked", + sentence: "GitHub was asked for a full scan of acme/infra.", + dashboard: DASHBOARD, + }, + exit: 0, + }, + ], + }, + { + heading: "`sluiceway settings`", + intro: + "Without `set`, the keys a settings pull request may change and the values the file gives them. With `set`, the pull request it opened. A person merges it.", + examples: [ + { + command: "sluiceway settings infra", + words: [ + "sluiceway.yaml of acme/infra at 0a1b2c3: https://github.com/acme/infra/blob/0a1b2c3d4e5f/sluiceway.yaml", + " dashboard.redact = false", + ' tickers = "write"', + " drift.enabled = false", + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway settings infra --json", + schema: "Config", + json: { + repo: "acme/infra", + file: "sluiceway.yaml", + sha: "0a1b2c3d4e5f", + url: "https://github.com/acme/infra/blob/0a1b2c3d4e5f/sluiceway.yaml", + text: "dashboard:\n redact: false\n", + problem: null, + keys: { "dashboard.redact": false, tickers: "write", "drift.enabled": false }, + pullRequest: { + title: "Sluiceway: dashboard settings", + branch: "sluiceway/dashboard", + base: "main", + }, + }, + exit: 0, + }, + { + command: "sluiceway settings infra set dashboard.redact=true", + words: [ + "Opened pull request #31 on sluiceway.yaml.", + " dashboard.redact: false → true", + "https://github.com/acme/infra/pull/31", + ].join("\n"), + exit: 0, + }, + { + command: "sluiceway settings infra set dashboard.redact=true --json", + schema: "PullRequestAnswer", + json: { + outcome: "opened", + pullRequest: 31, + url: "https://github.com/acme/infra/pull/31", + sentence: "Opened pull request #31 on sluiceway.yaml.", + changes: ["dashboard.redact: false → true"], + problems: [], + }, + exit: 0, + }, + ], + }, + { + heading: "`sluiceway logout`", + intro: + "Takes the token out of every place it is kept, and asks the app nothing. With `--json`, `removed` names those places.", + examples: [ + { + command: "sluiceway logout", + words: [ + "Signed out of https://app.sluiceway.dev: the token is gone from the macOS keychain.", + "It still works until it expires. Revoke it on https://app.sluiceway.dev/settings/tokens to stop it now.", + ].join("\n"), + exit: 0, + }, + ], + }, + { + heading: "When it fails", + intro: + "Without `--json` the reason goes to stderr. With `--json` it is one document on stdout, and its `exit` is the exit code. The table below says what each one means.", + examples: [ + { + command: "sluiceway stack infra nope:prod --json", + json: { error: "Not found.", code: "not-found", exit: 4 }, + exit: 4, + }, + { + command: "sluiceway status --json", + json: { + error: + "Not signed in to https://app.sluiceway.dev. Make a token on https://app.sluiceway.dev/settings/tokens, then run sluiceway login.", + code: "not-signed-in", + exit: 3, + }, + exit: 3, + }, + ], + }, +]; + +function fence(lang: string, body: string): string { + return `\`\`\`${lang}\n${body}\n\`\`\``; +} + +function example(one: Example): string { + const out = one.json === undefined ? (one.words ?? "") : JSON.stringify(one.json, null, 2); + const code = one.exit === 0 ? "" : `\n# exit ${one.exit}`; + return [ + fence("sh", `${one.command}${code}`), + fence(one.json === undefined ? "text" : "json", out), + ].join("\n\n"); +} + +/** The examples, as one h2 section of Markdown for the command line page. */ +export function commandLineExamples(): string { + const groups = EXAMPLES.map((group) => + [`### ${group.heading}`, group.intro, ...group.examples.map(example)].join("\n\n"), + ); + return [ + "## Each command, by example", + "The examples are for an org, `acme`, with one repo, `infra`, and four stacks: two pending, one queued and one in sync. Each command is shown with what it prints, and with `--json`, the app's answer as it came.", + ...groups, + ].join("\n\n"); +} diff --git a/src/lib/pages.ts b/src/lib/pages.ts index b03d3bd..1f40542 100644 --- a/src/lib/pages.ts +++ b/src/lib/pages.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import GithubSlugger, { slug as githubSlugOne } from "github-slugger"; +import { commandLineExamples } from "./command-line"; import type { SiteMap } from "./links"; import { blocks, @@ -89,6 +90,12 @@ export const HOSTED_APP = { plans: "hosted-app/plans", } as const; +/** + * The command line's page, in the Hosted app section after Using the app: most of its commands + * talk to the app. It is read from the action's docs/command-line.md, not written here. + */ +export const COMMAND_LINE = "hosted-app/command-line"; + /** * Whether one of slice 3's pages is in src/content/docs yet. Until it is, the sidebar leaves it * out and links to it go to GitHub, so no link points at a page that is not built. @@ -628,6 +635,28 @@ export function pages(repoUrl: string, base: string): Page[] { fromFirstH2("docs/security.md"), ], }, + { + id: COMMAND_LINE, + title: titleOf("docs/command-line.md"), + sidebarLabel: "Command line", + description: + "Install the sluiceway command, sign in with a token from the app, then check status, tick, rescan and change settings, with --json and exit codes for agents.", + source: "docs/command-line.md", + headerPicture: { + picture: "deploying-2", + alt: "A stack is deploying, 2 stacks are pending: the gate is open, one crate goes through it and two wait upstream", + }, + parts: [ + preamble("docs/command-line.md"), + sectionPart("docs/command-line.md", "Install it"), + sectionPart("docs/command-line.md", "Sign in to the app"), + sectionPart("docs/command-line.md", "The commands"), + // Before the exit codes, so a reader has seen the answers the table talks about. + generated("docs/command-line.md", commandLineExamples()), + sectionPart("docs/command-line.md", "For agents and scripts"), + sectionPart("docs/command-line.md", "Where it connects"), + ], + }, { id: "reference/sluiceway-yaml", title: "sluiceway.yaml", @@ -1017,7 +1046,13 @@ export function sidebar(): SidebarItem[] { }, { label: "Hosted app", - items: Object.values(HOSTED_APP).map((slug) => ({ slug })), + items: [ + HOSTED_APP.install, + HOSTED_APP.using, + COMMAND_LINE, + HOSTED_APP.keeps, + HOSTED_APP.plans, + ].map((slug) => ({ slug })), }, { label: "Reference", diff --git a/test/command-line.test.ts b/test/command-line.test.ts new file mode 100644 index 0000000..aba2adb --- /dev/null +++ b/test/command-line.test.ts @@ -0,0 +1,115 @@ +// The examples on the command line page keep to the app's contract at the pinned tag: every +// answer the app gives is held to the schema the action's copy of the app's OpenAPI document +// names for it, so a field the app renames or adds fails here and not on the page. + +import { describe, expect, test } from "bun:test"; +import { commandLineExamples, EXAMPLES } from "../src/lib/command-line"; +import { readVendor } from "../src/lib/markdown"; + +type Schema = Record; + +const OPENAPI = JSON.parse(readVendor("test/fixtures/app/openapi.json", "test")) as { + components: { schemas: Record }; +}; + +// The parts of JSON Schema the document uses, as the action's test/cli/fake-app.ts checks them. +function problems(value: unknown, schema: Schema, at = "$"): string[] { + if (typeof schema.$ref === "string") { + const target = OPENAPI.components.schemas[schema.$ref.replace("#/components/schemas/", "")]; + return target ? problems(value, target, at) : [`${at}: no schema ${schema.$ref}`]; + } + if (Array.isArray(schema.anyOf)) { + const fits = (schema.anyOf as Schema[]).some((one) => problems(value, one, at).length === 0); + return fits ? [] : [`${at}: matches no anyOf`]; + } + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) { + return [`${at}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`]; + } + switch (schema.type) { + case "string": + return typeof value === "string" ? [] : [`${at}: not a string`]; + case "integer": + return Number.isInteger(value) ? [] : [`${at}: not an integer`]; + case "boolean": + return typeof value === "boolean" ? [] : [`${at}: not a boolean`]; + case "null": + return value === null ? [] : [`${at}: not null`]; + case "array": + if (!Array.isArray(value)) return [`${at}: not a list`]; + return value.flatMap((item, i) => problems(item, schema.items as Schema, `${at}[${i}]`)); + case "object": { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return [`${at}: not an object`]; + } + const record = value as Record; + const properties = (schema.properties ?? {}) as Record; + const found = ((schema.required ?? []) as string[]) + .filter((key) => !(key in record)) + .map((key) => `${at}.${key}: missing`); + for (const [key, item] of Object.entries(record)) { + const property = properties[key]; + if (property) found.push(...problems(item, property, `${at}.${key}`)); + else if (schema.additionalProperties === false) + found.push(`${at}.${key}: not in the schema`); + } + return found; + } + default: + return []; + } +} + +const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` }); +const all = EXAMPLES.flatMap((group) => group.examples); + +describe("the command line examples", () => { + test("every app answer fits its schema", () => { + const answers = all.filter((one) => one.schema); + expect(answers.length).toBeGreaterThan(3); + for (const one of answers) { + expect({ command: one.command, problems: problems(one.json, ref(one.schema ?? "")) }).toEqual( + { command: one.command, problems: [] }, + ); + } + }); + + test("a tick's answer is the app's tick and the app's record", () => { + const tick = all.find((one) => one.command === "sluiceway tick infra network:prod --json"); + const json = tick?.json as { tick: unknown; deploy: unknown }; + expect(problems(json.tick, ref("TickAnswer"))).toEqual([]); + expect(problems(json.deploy, ref("Deploy"))).toEqual([]); + }); + + test("login's answer is the app's Me, with where it came from and where the token is kept", () => { + const login = all.find((one) => one.command.startsWith("sluiceway login --json")); + const { app, kept, ...me } = (login?.json ?? {}) as Record; + expect(typeof app).toBe("string"); + expect(typeof kept).toBe("string"); + expect(problems(me, ref("Me"))).toEqual([]); + }); + + test("a failure says its exit code, and the example exits with it", () => { + const failures = all.filter((one) => one.exit !== 0); + expect(failures.length).toBeGreaterThan(0); + for (const one of failures) { + if (one.json === undefined) continue; + expect(Object.keys(one.json as object).sort()).toEqual(["code", "error", "exit"]); + expect((one.json as { exit: number }).exit).toBe(one.exit); + } + }); + + test("every command the action's page lists has an example", () => { + const listed = [ + ...readVendor("docs/command-line.md", "test").matchAll(/^\| `sluiceway (\w+)/gm), + ].map((m) => m[1]); + const shown = new Set(all.map((one) => one.command.split(" ")[1])); + expect(listed.length).toBeGreaterThan(0); + for (const command of [...listed, "login", "logout"]) expect(shown).toContain(command); + }); + + test("the section is one h2 with one h3 per group", () => { + const md = commandLineExamples(); + expect(md.match(/^## /gm)).toHaveLength(1); + expect(md.match(/^### /gm)).toHaveLength(EXAMPLES.length); + }); +}); diff --git a/vendor/sluiceway b/vendor/sluiceway index 346f8ba..8d49a76 160000 --- a/vendor/sluiceway +++ b/vendor/sluiceway @@ -1 +1 @@ -Subproject commit 346f8ba72e778ceffd17c848b4a104d3c57cea43 +Subproject commit 8d49a76e60aaef6b396ce76c2103d97c700ffd21