The command line talks to the app with the person's token, and never to GitHub
Decision record 0116
Amends 0094 (the command line runs init and the check and nothing else, holds no token and makes no network call). Built as slice 5.53. The owner decided on 2026-09-25 that
npx sluicewaygains commands that talk to the hosted app, so a person and their coding agent can check status, read a stack’s preview, tick, rescan and change settings from a terminal.
Record 0094 made the command line two commands that read files and nothing else. The hosted app (sluiceway/app, private) now has a versioned API for the command line, /api/v1 at https://app.sluiceway.dev, described by an OpenAPI document (the app’s records 0220 to 0222): a personal token a person makes on their own page in the app, for one org, shown once, for 30, 90 or 365 days; every read and write counts as that person, by the same rules as the app’s pages; the audit log says “via the command line”.
Decision
- Seven commands talk to the app.
sluiceway loginandlogout,status [repo],stack <repo> <stack id>,tick <repo> <stack id> [--yes],rescan <repo>andsettings <repo> [set <key>=<value> ...]. A repo is its name, ororg/namewhen the org is the token’s; another org is refused before anything is asked. They call/api/v1of the app and no other address, over HTTPS, with the token the person gave at login, inAuthorization: Bearer. They never call GitHub: the app does what GitHub needs, as the person, by its own rules.initandcheckare unchanged and still make no network call; a test walks the imports and finds one file that callsfetch, the app’s client, which init and the check never reach. - Arguments, not the environment, still. The token is read from a prompt that does not echo it, or from stdin, at
login, and never from a variable: there is noSLUICEWAY_TOKEN. The one thing the entry reads from the environment is where the operating system keeps a person’s settings (XDG_CONFIG_HOME,APPDATA, the home directory), for the token file below. NoINPUT_*orGITHUB_*variable is read, as 0094 promised. - login checks the token and keeps it. A token must start with
sluiceway_(the app’s prefix), so a GitHub token pasted by mistake is refused before it is sent anywhere. The token is checked withGET /api/v1/me, and kept only when the app says it works. It goes to the operating system’s keychain where there is one: the macOS keychain throughsecurity, the Secret Service (GNOME Keyring, KWallet) throughsecret-tool, each given the token on stdin and never as an argument, where any process could read it; a keychain that says it kept the token is read back to be sure. Otherwise, and always on Windows, where the credential manager has no command that takes a secret on stdin, it goes tosluiceway/tokens.jsonunder the config directory, with mode 0600 in a directory of mode 0700. Tokens are kept per app address, so a token for a test app never takes the real one’s place.logouttakes it out of every place it is in and asks the app nothing: the API has no revoke, so it says the token still works until it expires and names the Tokens page. - The keychain’s command is the only process the command line starts. It is not the process runner of the adapters (0094’s walk still finds no
adapters/process.ts), and it runs one of two fixed commands with fixed arguments. - status groups the stacks as the org view does: Needs you (pending, drifted, failed), In flight (deploying), In sync; inside a group the states in that order, a pending stack that deletes or replaces first. An In sync stack is its name alone. The locked repos and the allowance’s sentence follow.
- stack prints the row as the app gives it: state, the line, the counts in the dashboard’s words, the destroy line, drift, the preview page (the check run’s name and the commit’s checks), the run, the last deploy in the audit log’s words and the dashboard.
- tick reads the stack first, and a destroy needs
--yes. A stack whose row deletes or replaces anything is not ticked without it; the command says what it destroys and exits as refused. Then it asks the app, which judges the tick by the action’s own rule and opens the deployment record, and prints the app’s sentence. When the app opened a record, the command polls it every two seconds, for at most a minute, until the app shows it, and prints the app’s result:waiting to startordeploying now, and that the workflow deploys it through a fresh preview and the hash check. It never waits for the deploy and never says in its own words that a deploy went out; a record the app already shows as ended is quoted as “the app says …”, and a failed one ends the command as failed. - settings reads, and
setopens a pull request.settings <repo>prints the keys a pull request may change, with the values the file gives them.settings <repo> set <key>=<value> ...sends the changes to the app, which opens edit mode’s pull request onsluiceway/dashboardfrom the scanned commit; the command prints its link. A value is JSON when it reads as JSON (true,3,null,["pending"]) and the text as typed otherwise; astacks[<stack id>]key is split at the first=after its]. The app judges every key and value, and a refused one is named in its words. - Every command has
--jsonfor an agent: the app’s answer as it came (for a tick,{ tick, deploy }), and on a failure{ error, code, exit }on stdout, wherecodeis the app’s error code or one of the command line’s own (not-signed-in,needs-yes,not-shown-yet,unreachable,not-the-app,no-token,not-a-sluiceway-token). - Exit codes an agent can act on. 0 done, 1 failed (the app unreachable or not answering as the app, a tick or pull request that failed, a failed record), 2 not understood (as 0094, and a missing or wrong token at login), 3 not signed in or the token does not work (the app’s
no-token,token-not-working,org-gone,not-a-member), 4 not found (the app’snot-found, and a repo of another org), 5 refused (a tick or rescan the app refused or sent to GitHub, changes refused, a pull request already open or a config the action refuses, a destroy without--yes), 6 try again later (rate-limitedwith the seconds ofRetry-After,github-silent, a record the app has not shown after a minute). --app <address>names another app, for tests and for a self-hosted one;https://app.sluiceway.devis the default. It must be https, or http to this machine only (localhost,127.0.0.1,[::1]), so the token never crosses a network in the clear. Redirects are refused, so the token goes nowhere the person did not name.- The contract is the app’s OpenAPI document, version 1.0.0, copied to
test/fixtures/app/openapi.jsonfrom the app at commitdf5c973. The tests run every command against a fake app whose every answer is checked against the schema the document names for it, errors included, so a fake that drifts from the app fails a test.src/cli/app-api.tsnames only the fields the command line reads. - The bundle stays small and holds no new package.
dist/cli.jsgrows from 1.20 to 1.23 MB; a test holds it under 1.5 MB.node:child_processandnode:osjoin its imports. - Standalone binaries on every release.
bun build --compileofsrc/cli.tsfor linux x64 and arm64, macOS x64 and arm64 and windows x64, namedsluiceway-<target>(.exeon Windows) without the version, soreleases/latest/download/sluiceway-linux-x64always works, with aSHA256SUMSfile. The version is compiled in (SLUICEWAY_VERSION) because a binary has nopackage.jsonbeside it; the bundle on npm still reads its own, as 0094 says. They are built in a job ofrelease.ymlthat can write nothing, from the release tag, which runs the binary of its own runner and stops when it prints another version, and uploaded by a second job that hascontents: writeand runs nothing of the repo’s. The macOS binaries are not signed or notarized.
Considered
- The token in an environment variable, as many command lines take one. It breaks 0094’s promise that the environment is never a way in, and a variable leaks into every child process and many logs. A pipe into
loginserves a script. - A keychain package (keytar and the like). A native module per platform, a runtime dependency to ask the owner for, and it cannot be bundled into one file. The platforms’ own commands do the same with no package.
- Windows Credential Manager through
cmdkey. It takes the secret as an argument, and cannot read one back. - Following the
statusaddress the tick answers with. The command builds the same path from the record’s id instead, so the token is only ever sent to the address the person named. - Polling until the deploy ends. A deploy runs for minutes in the person’s own runner and ends in a row the dashboard writes; a command that waits for it, or says it went out, would claim what the command line cannot see. The owner asked for a command that stops at waiting to start.
- The version in the binary’s name. A stable name gives a download address that always names the newest release.
- Signing and notarizing the macOS binaries. It needs an Apple developer account and a secret in the release;
curldoes not set the quarantine flag, and a browser download is cleared withxattr -d com.apple.quarantine. Left for later.
Consequences
- The build plan’s “Ask the owner before” list names the app’s API as a network call the command line may make.
CONTEXT.mdamends Command line and adds App and Personal token.docs/command-line.mddocuments the package, the binaries and every command;docs/init.mdpoints at it. The README gains a link to it when the docs site has the page.- On 2026-09-26
https://app.sluiceway.dev/api/v1/openapi.jsonanswered 404: the app’s API was merged and not yet deployed. The commands were built from the app’s source, and are first proven against the real app once it is.
Amended, 2026-09-26
The owner moved the app from https://app.sluiceway.dev to https://console.sluiceway.dev; the app keeps the old address answering /api/v1 for a transition. The command line’s default app is now https://console.sluiceway.dev. --app https://app.sluiceway.dev still works as given, with its own token. A token kept under the old address is read for the default app when none is kept under the new one, and the next login to the default app keeps the token under the new address and takes the old entry out of every place; logout takes out both. The servers line of test/fixtures/app/openapi.json names the new address ahead of the app’s own document.
Amended, 2026-09-27: sluiceway preview
An agent asked what a deploy would change had to leave the command line for the preview page’s link. sluiceway preview <repo> <stack id> prints the stack’s full preview: GET /api/v1/orgs/{org}/repos/{repo}/stacks/{stack}/preview of the app’s API, version 1.1.0 (the app’s record 0280), which reads the stack’s preview page (record 0050) from GitHub at the request with the app’s Checks permission and answers every change as data: its action and tracking, type, name, the property paths whole with those that force a replace, and the values dashboard.showValues shows; the drift; the policies; the page’s address, commit and time; and how many changes the page left out past GitHub’s limit and how many lines the app could not read. The app keeps none of it.
- It still calls the app alone. The page is on GitHub, and the command line never asks GitHub: the app reads it as the person, by the same access as every other read, and hands it on.
- The words are the dashboard’s details’: the op as the key cap writes it, in capitals for a delete or a replace, the type, the name,
forced byandalso changes, and a shown value asold → newwithnothingfor a side that is absent; every path whole, as on the page.--jsonis the app’s answer as it came. - Two more codes of the app.
no-preview, a stack with nothing waiting and no drift, or no page on GitHub, exits 4 as not found.preview-unreadable, an org that has not accepted the app’s Checks permission yet, exits 1 as failed, because running it again changes nothing until an admin of the org accepts it.github-silentexits 6, as before. - The fixture is the app’s document at version 1.1.0, as the app serves it with the preview. It also carries what the app added since 1.0.0 (
unsharedon the org and the audit log, record 0270 of the app), and the fake app answers it.