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