loki deploy (original design: advisory print-only; --execute now exists)Current behavior (v11.7.0). loki deploy still prints the deploy command by default and
runs nothing. Loki deploys only behind --execute plus a VERIFIED, HEAD-anchored receipt, a
clean tree, a destructive-op refusal and a policy permission (.loki/policies.json
autonomy: operate, or an explicit actions.deploy allow). With --health-url URL it probes the
deployed URL after the command. A non-2xx result is recorded as FAILED_HEALTH and exits non-zero;
with --auto-rollback it also runs the provider’s documented rollback verb (vercel rollback,
fly releases rollback <captured version>; Netlify and Cloudflare print a manual note). With no
URL, health is recorded as “unchecked”, never healthy. The deploy record in .loki/deploys is a
signed loki.deploy/1 attestation bound to the receipt that authorized it (fields: receipt_sha256,
head_sha, target, command, health_status, health_url, rollback_attempted, rollback_exit,
started_at, finished_at). It is signed with the existing receipt signer (Ed25519, no new crypto)
and the receipt itself is unchanged. loki verify <record.json> re-checks it offline (also with
--pubkey FILE; --receipt FILE names the cited receipt when it is not beside the record). It
fails closed: a flipped byte is TAMPERED, an unsigned record or an unknown key is UNCHECKED, and a
cited receipt that is not VERIFIED is never reported as VERIFIED.
loki deploy --execute --canary is a staged rollout using only documented provider verbs: Vercel
deploys without --prod, health-checks the preview URL, then runs vercel promote <url> (a failed
probe never promotes); Fly runs fly deploy --strategy canary; Netlify and Cloudflare document no
staged path and refuse --canary with a sentence saying so.
loki start --deploy [--health-url U] [--auto-rollback] chains the same gated command after a start.
Everything below is the original print-only design plan and is kept for history.
Status: Architecture plan. No implementation code here. Pattern family: the just-shipped
loki preview --public (_preview_public at autonomy/loki:5306, helpers from _read_app_state
at :5216, arg parsing in cmd_preview at :5546-:5609, dispatch arm preview) at :14966).
Why the original design was print-only. The README then stated that deploy commands were run by
a human and that Loki did not access cloud accounts. The first version was an advisory printer:
it ran command -v <cli> ONLY (never the CLI itself, not even --version), printed the exact
command, and the human ran it. Fully reversible; touched no cloud account.
These are the decisions the Product Owner / integrator should lock before build. Each has a clear recommended default.
loki deploy (not a flag)deploy) dispatch arm + cmd_deploy(), NOT loki preview --deploy.preview is “show me the local app I already built/started” and is gated on a
running app (state.json status=running, live port). deploy is conceptually distinct:
it is a static, filesystem-only advisory about project type that must work with nothing
running and no build started. Folding it into preview would force the running-app
preconditions onto a feature that must not have them (see LOCK 6). Per the CLI-consolidation
mandate (autonomy/loki:675, lean ~17-entry front page), deploy does not go on the
front-page command list; it lives in the “More commands” footer (:728) and is fully
documented via loki deploy --help. It earns a command (verb users already expect) but not
front-page real estate.(project-type x installed-CLI) pairs are viable, print every
viable option, clearly labeled, idiomatic one first. Never silently pick one.command -v).Confidence column: “task” = exactly the form specified in the approved task brief (authoritative
for this build); placeholders are <...> the user fills. No flag is invented.
| Provider | CLI binary (command -v) |
Command printed | Notes / placeholders | Confidence |
|---|---|---|---|---|
| Vercel | vercel |
vercel --prod |
none | task / high |
| Netlify | netlify |
netlify deploy --prod |
for static, append --dir=<build-output> (e.g. dist or build); print the placeholder, do not guess |
task / high |
| Fly.io | flyctl |
fly deploy |
Detect flyctl, print fly deploy – the binary is flyctl but the canonical subcommand verb is fly deploy; this is intentional, not a typo. If no fly.toml, append a one-line note: “run fly launch once first to create fly.toml” (note text only, never executed) |
task / high |
| Cloudflare Pages (static) | wrangler |
wrangler pages deploy <build-output> |
placeholder dir | task / high |
| Cloudflare Workers/containers | wrangler |
wrangler deploy |
none | task / high |
<placeholder> and reference official
docs rather than inventing. Each printed block ends with the provider’s official docs URL.copied to clipboard: <cmd> (other options shown above). Cleaner than “copy only when exactly one”.[ ! -t 1 ] (meaningless over SSH / pipes / non-TTY).command -v: pbcopy (macOS); wl-copy, xclip -selection clipboard,
xsel --clipboard --input (Linux); clip.exe/clip (Windows/WSL).printf '%s' "$cmd" | <tool> 2>/dev/null || true. If no tool exists, print is the
primary output and the command still exits 0. Clipboard failure never changes the exit code.--version.command -v <cli> ONLY. No $cli ... call anywhere in the code path.loki deploy runs, assert all four sentinels ABSENT and the expected
command string was printed (both halves – sentinel-absent alone passes vacuously).loki deploy is a static advisory. It must work with nothing running, no build started.app-runner/state.json and has no status=running / live-port
precondition (this is the key divergence from _preview_public). Detection is from project
files in the target dir, using the same ${TARGET_DIR:-.} resolution app-runner uses.FEAT-HUD) and preview (FEAT-PREVIEW-LINK)
are bash-only; the Bun runner is dormant for the live/advisory path. State explicitly in CHANGELOG.Reuse the signals already proven in autonomy/app-runner.sh. All checks are read-only file/dir
existence + grep on package.json. Resolve the directory as local dir="${TARGET_DIR:-.}"
(same idiom as app_runner_init at app-runner.sh:739 and _detect_nextjs_standalone at :594).
Detection cascade (first match wins for the primary type label; multiple provider options can still be offered per LOCK 2):
| # | Type | Signal (exact checks) | Source in repo |
|---|---|---|---|
| 1 | Next.js | grep -q '"next"' "$dir/package.json" OR [ -f "$dir/next.config.js" ] OR next.config.mjs/next.config.ts present |
mirrors next detection near app-runner.sh:800-813 |
| 2 | Static / SPA | ([ -d "$dir/dist" ] OR [ -d "$dir/build" ]) AND a built index.html in that dir; OR grep -q '"vite"' package.json / CRA (react-scripts) |
vite signal at app-runner.sh:697 |
| 3 | Dockerfile / container | [ -f "$dir/Dockerfile" ] (and/or docker-compose.yml/compose.yml) |
app-runner.sh:759, :774 |
| 4 | Generic Node server | [ -f "$dir/package.json" ] with a "start"/"dev" script and no static build dir |
app-runner.sh:798, :814, :821 |
| 5 | Python | [ -f "$dir/requirements.txt" ] OR [ -f "$dir/pyproject.toml" ] |
app-runner.sh:953 |
_detect_nextjs_standalone’s artifact-only path as the sole Next signal; for an
advisory we want the source signal ("next" in package.json / next.config.*) since the build
may not have run yet. (The artifact path keys on .next/standalone/server.js – too narrow here.)Add a deploy) arm to the dispatch case next to preview) (autonomy/loki:14966):
deploy)
cmd_deploy "$@"
;;
No deprecated alias (brand-new verb; nothing to alias).
Place ALL deploy code as one contiguous block so the SDET test can extract it by name anchor
the same way test-preview-public.sh extracts the preview block (awk from the first helper def to
the closing } of cmd_deploy). Recommended location: immediately AFTER cmd_preview() ends
(after autonomy/loki:5649-area close), so the two advisory features sit together. The block:
_deploy_detect_type() – echoes the primary type label (Section 1); always returns 0._deploy_options_for_type()– given type, echoes ordered provider|cli|command rows (pure, testable)._deploy_copy_clipboard() – best-effort copy (LOCK 4); always returns 0; TTY/command -v guarded.cmd_deploy() – arg parse + orchestration + printing.Keep helpers pure where possible (take dir / type as args, echo to stdout, return 0) so unit
tests can call them directly without a process – mirrors the pure extractors _extract_tunnel_url_*.
cmd_deploy() flowwhile [ $# -gt 0 ] loop (mirror cmd_preview :5555):
--help|-h|help -> print help block (Section 2.4), return 0.--dir <path> -> override the project dir to scan (default ${TARGET_DIR:-.} then .).--no-clip -> disable clipboard copy.--json -> machine-readable output (optional; see 2.5). Lenient on unknown args (no hard
error -> no behavior drift), matching cmd_preview. Guard value-consuming shifts exactly as
--provider does at :5595-:5599 (avoid set -e underflow when a flag is the last arg).type=$(_deploy_detect_type "$dir").return 1.options=$(_deploy_options_for_type "$type"); filter to rows whose CLI is installed
(command -v "$cli" >/dev/null 2>&1).return 1 (Section 3).return 0.--helpMirror the cmd_preview help block (:5557-:5585). Must state: advisory/print-only; it does NOT
deploy and does NOT run any cloud CLI; it detects project type + your installed CLI and prints the
command for YOU to run; clipboard is best-effort. List options --dir, --no-clip, --json, --help.
--json (optional, recommended)Emit {"type": "...", "options": [{"provider","cli","command","docs"}], "copied": "<cmd|>"}.
Honest: empty options when no CLI installed. Suppress clipboard note under --json.
Mirror the gh-missing / tunnel-missing block at autonomy/loki:5413-:5433. For each candidate
provider for the detected type, print to stderr, then return 1:
No deploy CLI found for this <type> project.
Loki never accesses your cloud account or runs deploy for you -- you run the printed command.
Install one of the following, then re-run 'loki deploy':
Vercel: brew install vercel | https://vercel.com/docs/cli
Netlify: brew install netlify-cli | https://docs.netlify.com/cli/get-started/
Fly.io: brew install flyctl | https://fly.io/docs/flyctl/install/
Cloudflare: npm i -g wrangler | https://developers.cloudflare.com/workers/wrangler/install-and-update/
Honest message to stderr naming the signals checked (Section 1), return 1.
set -euo pipefail)i=$((i + 1)) (never ((i++)), which returns 1 at zero and aborts).grep/command -v that may “fail” guarded: grep -q ... || true or if command -v ...."$dir", "$dir/package.json". Pass paths as argv to any python helper
(never interpolate into a heredoc) – mirror _read_app_state (:5219-:5227).--json), escape $ that bash must not expand
(\$); follow tests/check-heredoc-dollar-digit.sh (the repo gate for $1/$2 in heredocs).|| true; never let a missing tool or non-TTY abort.[ $# -ge 2 ] && shift) – see :5595.return $rc (don’t bare-return a non-zero call line
under set -e) – mirror :5614-:5617.tests/run-shellcheck.sh clean.tests/test-deploy.sh (NEW)Structure mirrors tests/test-preview-public.sh: extract the contiguous deploy block by name anchor
into a temp lib, source it, drive cmd_deploy and the pure helpers. pass/fail counters, mktemp -d
WORKROOT, trap cleanup EXIT INT TERM. Color globals exported empty. Every un-runnable case emits a
visible FAIL/SKIP – never a silent pass. Fixtures live under temp dirs (fake package.json,
Dockerfile, dist/index.html, requirements.txt).
Extraction sanity (non-vacuity gate, like preview lines 113-126): assert all 4 deploy function defs are present in the extracted lib, else fail loudly and abort.
fake-bin with stubs vercel, netlify, flyctl, wrangler, each:
#!/usr/bin/env bash -> echo ran > "$SENTINEL_<name>" -> exit 0.fake-bin to PATH so command -v RESOLVES all four (CLIs DETECTED + printed)."next").cmd_deploy --dir <fixture>; capture stdout.vercel --prod) was printed (proves non-vacuity – a
deploy that prints nothing would pass half 1 falsely).For each fixture (Next.js / static dist+index.html / Dockerfile / generic Node / Python), assert
_deploy_detect_type "$dir" returns the expected label. Include a “none” fixture (empty dir) ->
empty label.
With all four stubs on PATH, assert _deploy_options_for_type next lists Vercel before Netlify
before Fly; static lists Netlify/Cloudflare before Vercel; Dockerfile lists Fly first. Assert the
printed command strings are EXACTLY the LOCK 3 canonical forms (mutation-proof: a wrong flag fails).
Curated clean PATH with NONE of the four CLIs (build_clean_bin idiom, preview lines 191-201; symlink only coreutils). Next.js fixture. Assert: honest install hint printed (contains “Install one of the following” and at least the Vercel brew + URL) AND non-zero exit AND no sentinel.
Empty dir fixture. Assert honest “no deployable project” message naming signals + non-zero exit.
</dev/null /
piped stdout). Assert exit 0 and the command still PRINTED (clipboard absence is non-fatal).pbcopy stub that writes a file): assert the
idiomatic command was copied AND the “copied to clipboard” note printed. Fake pbcopy is allowed to
run (it is not a cloud CLI). SKIP-safe if no TTY can be simulated.--json honesty (if implemented)Assert valid JSON; options empty when no CLI installed; non-empty with correct commands when stubs
present. Suppresses the clipboard note.
Run a representative cmd_deploy invocation inside a set -e -o pipefail subshell and assert it
reaches a sentinel “ALIVE” echo after the call (proves no spurious abort), mirroring preview 1b
(lines 304-314).
tests/run-all-tests.shAdd next to the preview registration (tests/run-all-tests.sh:204):
run_test "Deploy Advisory (print-only, non-execution invariant)" "$SCRIPT_DIR/test-deploy.sh"
Also covered transitively by tests/run-shellcheck.sh and the mutation/mock detectors (gates #8/#9).
README.md:447 – change the Deploy “What Works” cell to note advisory-print: e.g.
“Generates configs/Dockerfiles/CI-CD; loki deploy prints the exact deploy command (advisory)”.
Keep “What Doesn’t (Yet)”: “Does not run deploy – human runs the printed command.” Line 452’s
“Human oversight is expected for deployment” stays TRUE and unchanged.CHANGELOG.md – new feature entry (FEAT-DEPLOY): advisory print-only loki deploy; non-execution
invariant; filesystem-only detection; best-effort clipboard; bash-only (Bun parity note).docs/INSTALLATION.md – optional “Deploying your build” note pointing at loki deploy.wiki/CLI-Reference.md – add the deploy command entry + options.skills/production.md (and skills/00-index.md if it indexes commands) – document the advisory
deploy step in the production workflow.docs/PREVIEW-LINK-PLAN.md / docs/BUILD-HUD-PLAN.md
– this file (docs/DEPLOY-PLAN.md) is the matching plan artifact.