w17ctl

w17ctl — the wandering-compiler developer CLI

w17ctl is the single client and entrypoint for a w17 project. Everything a developer or a pipeline does — scaffolding, change requests, codegen, applying migrations, running the local stack — goes through it.

w17ctl <command> --help     # context-sensitive help for any command

Install

curl -fsSL https://get.w17.dev/install.sh | sh -s -- --pre

Installs into /usr/local/bin, verifying the download against the release’s SHA256SUMS first — always, with no flag to skip it.

--pre is needed while w17ctl is in release candidates: latest means the newest STABLE release, and there is not one yet. Drop it once v0.1.0 ships.

curl -fsSL https://get.w17.dev/install.sh | sh -s -- --version v0.1.0-rc.1 --dir ~/bin

Or take a .tar.gz straight from Releases — linux and macOS, amd64 and arm64. There is nothing to configure after unpacking: w17ctl is a single static binary and keeps its state in ~/.w17/.

w17ctl version           # what you have, and which console it defaults to
w17ctl version --check   # …and whether a newer release exists
w17ctl update            # upgrade in place, no install command to paste
w17ctl login             # point it at a console

Upgrading

w17ctl update replaces this binary with the newest release, into the directory it is already running from. You do not have to remember the install line, and you do not have to know which tag is newest.

It invokes the installer above rather than resolving the release itself, which is the point rather than an implementation detail: one place knows which release is newest, and that place already verifies the download against SHA256SUMS. Prereleases are included automatically while your binary is itself a prerelease — which is every binary today, since latest means the newest stable release and there is not one yet. --stable opts out, --version <tag> pins, --dry-run only reports what would be installed.

w17ctl version --check asks the same question without changing anything. It is a flag rather than part of version because it costs a network call, and version has to keep working with no network — it is what you run when something is already wrong.

w17ctl version prints the console address compiled into the binary. That is only the LAST resort in the resolution order — --console, then the console you are logged into, then this default — so logging in is what decides where commands go.

Start here — you + a coding agent

w17 is built to be driven by a coding agent (Claude Code, …). Two steps:

1. Log in once:

w17ctl login

2. Paste this to your coding agent ONCE to onboard it (it’s context, not a task — the point is that the agent learns w17 and records it, so every later session already knows the drill and you can just ask for features):

This project is built with w17 (wandering-compiler): a declarative compiler where
you declare schemas, DB models, queries, mutations, events, and APIs as proto +
annotations, and it generates the gRPC binaries, DB layer, SQL migrations,
gateways, and clients — the only hand-written code is the business-handler bodies.

Do this now to learn how it works:
1. Run `w17ctl guide`. It writes AGENTS.md (how to drive the w17ctl CLI) and
   w17/specs/ (the platform reference: the annotation catalog, the generation
   model, the event system, the directory structure).
2. Read AGENTS.md and ALL of w17/specs/.
3. If it isn't already recorded there, note the essentials in this project's
   AGENTS.md (or whichever instruction file your agent reads) and in your long-term memory, so future sessions start
   from it: this is a w17 project driven via w17ctl; you change behaviour by
   editing proto/ and running `w17ctl codegen`, NEVER by hand-editing generated
   files; only business-handler bodies are hand-written; the full platform
   reference is w17/specs/ (refresh with `w17ctl guide`).

From now on I'll just ask you to implement features. Always follow the w17 way:
declare then generate, hand-write only handler bodies, and re-read w17/specs/ when
unsure about an annotation or the architecture. Do not invent DB or gRPC plumbing
by hand.

That’s it — the agent learns w17 once, records it, and from then on you just describe features. The platform reference is regenerated by the compiler, so it always matches your w17 version. Everything below is reference detail you’ll rarely need to read.

Mental model — what w17ctl is (and isn’t)

w17ctl is deliberately dumb. It holds no compiler know-how. Every real decision — compiling the proto IR, planning SQL migrations, running the compatibility engine, signing the lock — happens on the console (the backend), reached over a gRPC API. w17ctl’s whole job is three things:

  1. Transport — talk to the console’s gRPC API (always over TLS).
  2. Local disk — read/write the project’s files: the signed w17/lock.yaml, generated code, snapshots, and the dev-machine config under ~/.w17/.
  3. Docker — drive the local compose stack.
   you / CI  ──▶  w17ctl  ──▶  console gRPC API  ──▶  the compiler core
              (transport +      (all the know-how)     (private IP)
               disk + docker)
        ◀──  finished artifacts the client writes verbatim  ──

So when a command “generates code” or “plans a migration”, w17ctl is uploading your inputs and applying what the console sends back — it never compiles anything itself. This is why the client is small, publishable, and safe to run in CI: it can’t produce a broken or forged artifact, only apply a console-signed one.

w17ctl finds your project by walking parent directories to the nearest w17/lock.yaml, so you can run it from anywhere inside the tree.

State it keeps

Location What Notes
w17/lock.yaml the signed project lock — connections, targets, generated-code config, pinned schema edited by scaffolding commands; signed by the console
~/.w17/auth.yaml console credentials (bearer + org memberships) per console written by login
~/.w17/device-<hash>.key this machine’s device key for one console (PKCS #8 PEM, mode 0600; refused when others can read it) — signs DPoP proofs for consoles that bind devices made by the first login to that console; W17_DPOP_KEY names one file for every console
~/.w17/config.yaml dev-machine project registry + per-project host-port assignments + presets written by init / project
w17/certs/, w17/snapshots/ local dev PKI + branch schema snapshots certs / db / initiative

The certificate committed in this repo

internal/core/devca.crt is a dev CA certificate, and it is meant to be here. Only the public half ships — the private key is dev-only and never leaves the monorepo — and the client trusts it for loopback dials only, so that w17ctl login localhost:… can verify your local dev terminator with no environment set up. It grants nothing against a remote console: a dial to anything but loopback goes through the system trust store like any other TLS client.


Command reference

Getting started

Command What it does · under the hood
guide Write AGENTS.md (how to DRIVE w17ctl — golden rules + workflow + task→command cheat-sheet; client-embedded, works offline) and fetch w17/specs/ (how the w17 TECHNOLOGY works — the annotation catalog + architecture primer, generated server-side so it always matches the compiler). This is how a coding agent is onboarded: it reads AGENTS.md automatically, which points it at w17/specs/. --stdout prints the usage guide; --force refreshes AGENTS.md; --no-specs skips the server fetch. The w17/specs/ fetch needs a reachable console and a logged-in identity; it is NOT scoped to an organization, so it works before you pick one (and before init).
version [--check] Print this binary’s version, commit, build date and compiled-in console address. --check also asks the installer which release is newest and says whether you are behind — one network call, and it reports rather than fails when there is none.
update Upgrade this binary in place, into the directory it runs from. Invokes install.sh rather than resolving the release itself, so the download is verified against SHA256SUMS and there is only one implementation of “which release is newest”. --stable refuses prereleases, --version <tag> pins, --dir targets elsewhere, --dry-run reports without installing.

Auth & organizations

Talk to a console and pick your org scope. Login offers three sign-ins and asks which in a terminal (--method picks one): the console’s own page in a browser on this computer (OAuth 2.1 with a loopback redirect and PKCE — w17ctl never sees the password); the same page in a browser on any device, which then shows a code to paste into w17ctl (for w17ctl over ssh or in a remote workspace); and email + password in the terminal, a single gRPC call to the console — email + password in, bearer out.

Command What it does · under the hood
login [<host>] Sign in and store the bearer + org memberships in ~/.w17/auth.yaml. In a terminal it asks how; --method browser\|code\|password answers, and without a terminal (a script) it is browser. browser — a browser on this computer: fetches the console’s OAuth metadata (/.well-known/oauth-authorization-server), registers w17ctl once per console (the client_id is kept under oauth_clients in auth.yaml), prints and opens the sign-in URL, waits up to 5 minutes for the redirect to http://127.0.0.1:<random port>/callback, and exchanges the code with a DPoP proof of this machine’s device key. code — a browser on any device: the same, except that nothing listens here and nothing is opened: the redirect is the console’s code page (https://<issuer>/oauth/code), which after the approval shows code#state to paste into w17ctl (its state is checked as the redirect’s would be; the code lasts 5 minutes). The console offers it when its auth plugin sets oauth_code_redirect. The issuer is https://<console host> (the gRPC port dropped: grpcs://api.w17.app:50051 → https://api.w17.app); --issuer or W17_CONSOLE_OAUTH_ISSUER names another. password (or --no-browser) — the terminal: AuthService.SignIn over gRPC, prompting for anything not given (email → password (hidden) → host); --email, --password or W17_PASSWORD select it as well, and --email + W17_PASSWORD keep it non-interactive.
logout [<url>] Drops a console’s stored credential from ~/.w17/auth.yaml.
key public [<console>] Prints a device key’s public half as a JWK on stdout (making the key first if there is none): the file W17_DPOP_KEY names, else this machine’s key for the console (default: the active one). It is what the console needs to issue a machine account’s token. Stderr names the file and its thumbprint (the device id the console shows).
whoami Prints the stored identity + orgs for the active console (--all = every logged-in console), and the session expiry when one is known (with a warning once it has passed). Pure local read.
org list / org use <slug> List orgs you belong to (server read) and pick the default; the default org scopes subsequent commands.
org pin <slug> Writes a minimal w17/lock.yaml (org_id, org_slug, w17_url, nothing else) in a directory that is not a project, e.g. a plugins repository. Every w17ctl run there then acts in that organization, a W17_TOKEN run included. Refuses inside a project, whose lock is the console’s.

Unattended callers don’t log in. CI and a production deployment have no terminal to run login on and no home directory worth writing a credential into. They set two variables instead:

Variable  
W17_TOKEN an API token minted for a machine account (console → Organizations → your org → Machine accounts), not a person’s password. Takes precedence over a stored login.
W17_CONSOLE_ADDR the console the token was minted for. Not optional.

The second is what makes the first safe: a token is presented only to the console it is bound to, never to a host named by a --console flag. Without the binding it is presented nowhere — which arrives as invalid credentials, so w17ctl warns on stderr when a token is set and then not sent. (A build with a compiled-in DefaultConsoleAddr — every released binary — uses that when W17_CONSOLE_ADDR is unset.)

Nothing here is required locally: with no token set, the credential store answers exactly as before.

The organization comes from the lock. A token carries no organization of its own, and an account in several organizations is refused on every org-scoped call that names none (missing permission …): the console will not guess. Inside a project, the lock’s org_id decides. w17ctl translates it to the slug the w17-org header carries, asking the console (ListMyOrgs, once per run) when there is no login cache to do it, which is always the case for W17_TOKEN. A lock naming an organization the account is not in stops the command with an error that names both. The lock’s organization is translated through the login cache wherever that cache knows the id, and asked of the console only when its w17_url names the console being dialed; never beside an explicit --org. project register and init, which replace the lock, act in the organization they register under for every call. A directory that is not a project, such as a plugins repository where plugin sign / plugin gen run, gets its organization from w17ctl org pin <slug>: run it once, signed in as a person, and commit the file. plugin sign <dir> and plugin gen <dir> read the lock above <dir> first, then the working directory’s.

Device-bound sessions (DPoP). A console whose auth binds devices ties every token to a key and accepts it only with a proof (RFC 9449) signed by that key, so a token copied off a machine is useless without the key file.

Project setup & scaffolding

These edit the signed lock and write starter proto files. No codegen — they prepare inputs.

Command What it does · under the hood
init Wizard: prompts for project name + generated-code paths, registers the project with the console, writes a fresh signed w17/lock.yaml, and auto-registers it in ~/.w17/config.yaml. Refuses if a lock exists.
connection <cmd> Wizard to add a DB/store connection to the lock (identity = connection name). Edits the signed lock.
domain <cmd> / module <cmd> Scaffold the proto/domains/<domain>[/<module>]/w17.proto cascade sentinel; --with-example seeds the four-layer (queries / mutations / business / types) shape. Pure local file writes.
template [<surface>] Prints a commented example .proto for one surface (domain, module, types, queries, mutations, business, rest, admin, events, subscribers, rpc, mcp, cli). A “how do I annotate X” reference; --list to enumerate.
target <cmd> Declare what codegen emits + how it deploys — the lock’s generated_code entries: client (FE clients), grpc-client (Go client pkg), business, binary (composed binaries), ci, scale (prod replicas). All edit the signed lock.
plugin <cmd> List embedded + installed plugins; install one (e.g. auth) into the project — stages its proto tree + records activation.
secrets <cmd> Production-secrets (age tier): init mints a project age keypair; encrypt turns a plain .secrets into a committable .secrets.age. Runtime decrypts when a key is present, reads plain otherwise — seamless dev, strong devops. Local crypto only.
secrets set\|unset\|list\|pull\|open\|exec\|fetch An infrastructure environment’s values, held by the console (no files in git): set --env <env> SERVICE KEY… seals each value to a write code from the project’s page; list shows keys only; pull reads one service’s values back after a read code (--seal-to keeps them age-encrypted, open / exec use such a file); fetch --env <env> --out DIR is the deploy’s read — as a deploy-fetch machine account (W17_TOKEN) with Deploy access to the environment, checked against every .env.example.
ci init\|update\|show\|remove The GitHub automation of the declared infrastructure: codegen then writes .github/workflows/w17-ci.yaml, w17-build.yaml and w17-deploy-<env>.yaml (prunes only its own). show prints what to set up in GitHub.
deploy --env <env> [--with-tests] One person’s MVP/PoC shortcut: mint + pin migrations, codegen, fetch the environment’s values from the console, build the images, ship them over SSH (no registry), blue/green deploy on the environment’s server. Always warns that it is not the recommended way.
certs Fill a directory with a local dev PKI (self-signed CA + CA-signed leaf) for the internal-mesh TLS switch (W17_INTERNAL_TLS=on). Idempotent — never overwrites.

Codegen & schema

The compiler-driven commands. Each uploads your protos to the console and writes back what it returns.

Command What it does · under the hood
codegen Generate all derived code: Storage gRPC handlers + the ACL / eventbus / MCP bundles. Uploads every .proto under proto/ to the console, which compiles the IR + emits the files; w17ctl writes them under <root>/<gen_dir>/.
verify Re-checks that every committed generated lock (ACL + eventbus) still matches the current proto — the CI drift hook paired with codegen. Offline (no console); non-zero exit on drift.
push Push everything (schema + fixtures + future artifact types) to the console in one call; the server diffs + stores. Idempotent — safe to re-run whenever inputs change. Builds the whole project; --proto <file> narrows it to those files and their imports, and a table that build leaves out is planned as a DROP the console refuses until --decide <table>:table_drop=needs_confirm confirms it. A directory is not accepted as --proto.
migrate generate Compile + push the schema; the console plans the SQL migrations (initial create vs. revision diff, auto-detected) and pins the lock target. migrate list shows history; migrate push-raw is the hand-authored-body escape hatch. To READ the planned SQL, w17ctl schema render writes w17/schema/<connection>.ddl; to APPLY it, your generated server binary owns the fetch + apply step — w17ctl plans migrations, the binary that owns the database applies them.
fixtures <cmd> Apply console-rendered fixture seeds (parameterized upserts) to a connection’s target store (DSN via W17_TARGET_<CONN> env).
clean Remove codegen output (pb stubs / FE clients / languages / generated bundle files) while preserving hand-written packages.

Schema lifecycle — the review flow (PR analog)

Snapshots + a compatibility engine + a review/merge gate. Git branch → initiative (main/master → trunk).

Command What it does · under the hood
initiative <cmd> Initiatives + snapshots: current / list / show / materialize / push. A git branch maps to an initiative; the first push lazily materializes it. Schema state lives in the console’s generated storage.
compat <cmd> Compatibility report between two snapshots — DB / WIRE / API findings + severity — with a dev/prod policy gate. report --base --head, or against-trunk. The console’s compat engine does the analysis; w17ctl renders the verdict.
review <cmd> open / show / approve / merge a set of schema changes. Merging into a prod-mode env runs compat and blocks BREAKING changes (exit non-zero); dev warns + proceeds.
env <cmd> Deployment environments + mode (dev/prod). A prod-mode env turns the compat engine’s BREAKING verdict into a hard merge block.

Local development

Docker + local project ergonomics. This is where w17ctl drives compose.

Command What it does · under the hood
stack <cmd> Local docker-compose lifecycle: build / up / down / logs / ps / restart. build SYNCS the stores to your protos — it reads what each database holds, the console plans the difference, the client applies it; --no-build does only that. up runs the same sync after starting the containers (--no-sync to skip). A sync that would DROP a table or column, or RETYPE one, is refused with the list: --lossy=apply does it, --lossy=snapshot keeps a copy first (dumped inside the store’s container when this machine has no pg_dump). Injects the project’s unique host ports from ~/.w17/config.yaml.
project <cmd> Dev-machine project registry: list / import / remove / ports / ps + run presets. Keeps unique host ports across all installed projects (no env-file juggling) in ~/.w17/config.yaml.
db <cmd> Local dev DB snapshots scoped to the current initiative: save / list / activate / delete. Branch-scoped checkpoints for fast switching.
test Run the generated e2e suite against a deployed gateway. Locates the e2erunner module and runs it in Docker against --target — a pure HTTP/MCP client that never touches a DB.
ui <cmd> Serve the local web app — a human-facing view + controller over the project registry, presets, and Docker stacks (the GUI counterpart to project / stack). On-demand, 127.0.0.1 only; no daemon, no image.

Why the split matters (design invariant)

The compiler’s IP and all trust-bearing crypto (lock signing) live on the console, never in the client. w17ctl ships as a standalone public tool and imports zero private compiler code — only the public sdk/go module + the console’s gRPC contracts. That’s what lets the same minimal client both scaffold a project on your laptop and gate a production release in CI: it verifies + applies console-signed artifacts, and can’t forge one.

This repository is an export

The sources here are generated from the private monorepo where w17ctl is developed, which is why you will find no _test.go files and comments that speak of test seams (“indirected so tests can…”). The tests exist; they live beside the code they cover, in the monorepo, and they run there before every release — the same pipeline also builds this exported tree and fails the release if it does not compile with zero private compiler code in its import graph.

So nothing here is untested, but nothing here is testable by you either. If you hit a bug, a description that reproduces it is worth as much to us as a patch would be: we turn it into a regression test in the tree where the test actually runs.