Skip to content

feat: hub parity — projects API, PATs, releases, addons, AI/CI-ready contract - #2

Merged
ashishmax31 merged 23 commits into
mainfrom
feat/stackdome-cli-overhaul
Aug 5, 2026
Merged

feat: hub parity — projects API, PATs, releases, addons, AI/CI-ready contract#2
ashishmax31 merged 23 commits into
mainfrom
feat/stackdome-cli-overhaul

Conversation

@ashishmax31

Copy link
Copy Markdown
Contributor

Summary

Brings the CLI in sync with the hub's projects API and adds the high-value missing surface, with a machine-friendly contract so agents/CI can drive it end-to-end. 23 commits, each task independently reviewed; whole-branch final review clean; live-verified against stackdome.io (2 releases deployed, 52-command functional sweep).

Core

  • Projects migration: public module github.com/Stackdome/stackdome (no replace, plain go build/go mod tidy), teams/{name}projects/{name}, default project resolved via /users/current/projects with org-endpoint fallback (server bug workaround, marked for removal)
  • Canonical stackfile: forked internal/stackfile deleted; consumes hub's pkg/stackfile — output vocabulary can no longer drift (test pins emitted self_output against the server's descriptor list)
  • Deploy = apply + release: PUT stacks/apply then POST releases, --wait follows that release's SSE event stream with after_sequence resume; Failed/Cancelled/Superseded exit non-zero

New commands

token (scoped PATs) · release list/info/cancel/events · addon postgres (CRUD/credentials/backup) · build logs (SSE) · volume create · whoami

AI/CI contract

  • Env-only auth: STACKDOME_TOKEN/URL/ORG/PROJECT, zero config file, nothing persisted
  • Transparent token refresh (incl. the server's 403+"token expired" contract, mirroring the web UI)
  • -o json|yaml everywhere, data→stdout / humans→stderr, exit codes 0/1/2/3/4/5/130, TTY-guarded prompts (--yes for scripts), short-ID prefix resolution for build/release args

Verification

  • go build && go vet && go test -race ./... clean throughout
  • Live E2E on stackdome.io: signup→login→deploy→release stream→public URL serving; functional sweep evidence in the SDD ledger (untracked)
  • 10 hub-side issues found & documented in api-server/HUB-ISSUES-2026-08-06.md (separate repo)

…rs/current/projects

The hub renamed teams to projects and moved runtime status off the Stack and
StackResource entities onto releases. Swap the API module to the public
github.com/Stackdome/stackdome, rename every Teams/TeamName call site to its
Projects/ProjectName equivalent, and read resource status from the stack's
release live status.

- config: team_name -> project_name, with a legacy read of team_name
- auth: ResolveDefaultProject via GET /api/v1/users/current/projects
- stackfile: ImageSpec/BuildSpec -> SourceSpec{Image,Git}, Stateful ->
  WorkloadType=StatefulService
- volumes: volumes are per-stack now; volume list/delete take --stack
…rminal

GetStackLiveStatus preferred the latest release, so `status` and `open`
described a release that may still be rolling out instead of the one serving
traffic. Prefer the converged release, falling back to latest only when nothing
has converged yet. deploy --wait polls via output.StackRelease and still
prefers latest.

waitForStack also spun until timeout on a superseded release; add Superseded to
the terminal states.
Deploy now resolves stackfile secret/addon names to IDs via ResolveStack and
sends the whole document to PUT /stacks/apply, replacing the FindStackByName
read-modify-write (which raced and tripped the server's readOnly-PUT rejection).

--wait follows the release the apply just created — identified from the apply
response's latest_release, or the newest release as fallback — instead of
polling GetStack, which could latch onto the *previous* release. Events stream
over SSE to stderr and resume with ?after_sequence=N when the connection drops.
Failed/Cancelled/Superseded releases now exit non-zero with the failure detail;
only Released exits 0.

Adds `stackdome release list|info|cancel|events [-f]` and a minimal postgres
addon list/find in the client for name resolution.
PUT /stacks/apply only stores the stack document — the server creates no
release for it, so deploy rolled out nothing and --wait followed whichever
release happened to already exist (reporting a stale success for existing
stacks, erroring for new ones). Deploy now applies and then calls
CreateRelease, following the id it returns; the latest-release guessing is
gone. The release is created with or without --wait, since that is the deploy.

Also: Ctrl-C during --wait exits 130 rather than 1, `release events -f` exits
non-zero when the stream ends on an error frame, and the stream's give-up send
no longer blocks on a cancelled context.
- add `stackdome whoami`: user, org, project, auth method, token source, server
- destroy takes a stack name via -s/--stack like every other command
- status gains the -s shorthand; both now use resolveStackID
- -o json/yaml: secret create/set, deploy (no --wait), and version print the
  result object; release events -f streams NDJSON instead of formatted prose
- root --help documents non-interactive usage and exit codes
- -o yaml marshals through JSON so wire names survive
- token timestamps carry a timezone (local RFC3339)
- status --watch -o json/yaml emitted the human table plus TTY clear-screen
  escapes; structured mode now prints one object per tick, no redraw
- config view -o json/yaml serialized access/refresh tokens verbatim; redact on
  a copy at the view layer so Save() still persists the real values
- release events -f NDJSON keeps the SSE event name: {"event":..,"data":..}
…uctured output gaps

- logs: openLogStream reused the 30s-timeout HTTP client, cutting `logs -f`
  and `build logs -f` off mid-stream. Reuse only the transport, as
  openReleaseEventStream does; the context governs the stream's lifetime.
- status: output.StackRelease preferred the latest release while
  GetStackLiveStatus reports on the converged one, so the header and the
  resource rows described different releases. Both prefer converged now.
- login/signup: Save reverts values still equal to STACKDOME_URL /
  STACKDOME_TOKEN, so logging in with those exact values wrote a config
  missing server_url/token. New Config.AdoptEnvValues clears the latches.
- usage errors now exit 4 as the root help promises: ParseFormat returns a
  ValidationError, SetFlagErrorFunc wraps flag errors, and wrapArgErrors
  wraps every command's positional-args check.
- open: added the -o json|yaml path (prints the public URLs, no browser).
- deploy: structured output is now a {stack, release} envelope so scripted
  deploys can follow the release's events.
… token

GET /users/current/projects returns an empty list on deployed servers even
when the user has a default project, so login and signup failed outright with
"No projects found for your account." ResolveDefaultProject now falls back to
GET /organizations/{org_id}/projects, which reports it correctly. Remove the
fallback once the per-user endpoint is fixed server-side.

Login also resolved the project before saving, throwing away a valid token
when the lookup failed. The new persistLogin helper — shared by login
--token, login with credentials, and signup — writes the credential first and
only warns when the project cannot be resolved; resolveScope retries the
lookup on the next command.
internal/stackfile was a stale copy of the hub's pkg/stackfile. It had
drifted on self-references: it emitted self_output "public.http.url" while
the server only declares "public_url" for a single-port resource (see
pkg/models/output_descriptor.go stackResourceOutputKey), which 400'd deploys.

Delete the forked types/parse/convert/resolve and their tests, and keep
internal/stackfile as a thin CLI layer over the hub package: path-based
Load with CLI errors, the CLI-only env_file merge (now applied in Load, so
validate resolves it too), the compose converter and JSON passthrough.

env_file and stateful are not in the hub schema. env_file survives as a
CLI extension read off the raw YAML; stateful was already a no-op since
openapi.StackResource has no such field, so it is dropped and init no
longer emits it. init's starter template also used the multi-port spelling
{{ db.port.postgres }} for a single-port resource — fixed.
List tables print IDs through shortID(), but build info/logs and
release info/cancel/events passed the arg straight to the API, so the
IDs the CLI displayed came back as "Resource not found".

Add docker-style prefix resolution: exact match wins, unique prefix
resolves, ambiguous prefixes list the matches, no match is a NotFound.
STACKDOME_ORG/STACKDOME_PROJECT overlay the config like STACKDOME_URL and
STACKDOME_TOKEN do (never persisted), letting a narrowly scoped API token skip
project discovery it is not allowed to perform. When discovery does fail under
token auth the error names the missing scope instead of suggesting `login`.

`config set-stack` stored its argument verbatim, so a stack name bricked every
later command. Both it and the --stack flag now go through one resolver that
accepts a name, a full ID, or an ID prefix.

Also: redact tokens in `config view` with a fixed marker, drop the SSE end frame
that printed a stray `{}` after logs, default an empty port protocol to HTTP,
and print release info timestamps in local time like everything else.
…helper

The api-server always closes a stream with `event: end`, so IsEnd no longer
sniffs the payload — a log line that is literally `{}` prints again.

A config written before set-stack resolved its argument still holds a stack
name; resolveStackID now heals that on first use instead of failing, and says
so when an env-token session cannot persist the fix.
An expired or unparseable access token comes back as 403 with the reason in the
error body, not 401, so the 401-only refresh transport let every session die at
access-token expiry. Gate on the reason instead, matching the web UI's patterns
(frontend/src/api/client.ts) on both reason and items[0].reason. A genuine
permission denial still surfaces with its body intact.
The API's ImageBuild payload has no created_at/updated_at (hub #10), so
build list and build info rendered Started and Duration as "-". Fall back
to status.conditions[].last_transition_time: BuildJobCreated (else earliest)
for the start, Available (else latest) for the end. Duration is only derived
for terminal builds; in-progress builds show elapsed-since-started.
@ashishmax31
ashishmax31 merged commit fa36e33 into main Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant