`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
environments.yaml could say where things deploy to, and how a project you
have already named is placed once it is there. It could not say which
projects exist. "Every project" was a thing the operator remembered — so
fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27)
was an assumption, since you cannot restore what you cannot enumerate.
A new optional top-level block, keyed by the full <org>/<repo> slug:
projects:
heavy-duty/incubator:
environments: [prod, staging]
The key IS the repo — no `repo:` field, because a second place to write the
same string is a second place for it to be wrong. No bare-<repo> fallback,
unlike github_apps and environments.<env>.projects: those carry one because
state files in the wild are keyed that way, and this block has none to
support. A bare <repo> is unique only within an org, which is why it is not
a key (#12, twice learned).
Validated in loadBindings, so every verb refuses a registry that lies:
- an environment no `environments:` block defines is an error — the project
would be registered into an environment no command can visit
- every environments.<env>.projects.<slug> binding must be registered for
that env, or the two blocks describe two different fleets: a destination
or smoke_target real enough for a direct apply, invisible to every fleet
run. Only enforced when `projects:` is present, so pre-registry state
files keep loading unchanged.
Both defend one failure: a silently skipped project reads exactly like a
clean one. Errors render multi-line now — zod's own .message is the issue
array as JSON, which flattened the refusals into a line of \n escapes.
projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no
registry. The --all flag that consumes it is #26's, not here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>