carlos / platform Public

cli: ship/promote/rollback/channels speak the console API when logged in

Each verb now calls resolveMode first: (nil, nil) keeps the existing
direct-bucket body unchanged, a session routes the request through the
console instead, using the raw -account flag value (never acctOf/
resolveAccount, which needs a local registry a developer laptop doesn't
have). API-mode promote/ship print the same noop-parity messages the
brief's amendments specify; -hotfix in API mode refuses with a message
naming the direct path or the console, since the console's promote door
has no hotfix field at all. Output for these four verbs now goes through
a package-level stdout var so tests can assert on it without touching
log.Fatal paths.
Paul Campbell 2bd3d9e6b17d76e0e28e9e60c52315f21791f96a parent e2e007a
2 files changed, +486 −10
  • apimode_test.go +332 −0
  • cli.go +154 −10
diff --git a/apimode_test.go b/apimode_test.go
index 5fdf686d3..95fe4eaa8 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -5,6 +5,7 @@
package main
import (
+ "bytes"
"context"
"encoding/json"
"io"
@@ -13,10 +14,26 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/release"
)
+// captureStdout swaps the package-level stdout var (cli.go — the seam the
+// four API-aware verbs print through) for a buffer, restoring it on cleanup.
+// log.Fatal paths are untouched by this seam on purpose — those still exit
+// the process, exactly as before.
+func captureStdout(t *testing.T) *bytes.Buffer {
+ t.Helper()
+ var buf bytes.Buffer
+ orig := stdout
+ stdout = &buf
+ t.Cleanup(func() { stdout = orig })
+ return &buf
+}
+
// clearDeploymentEnv ensures none of the three env vars resolveMode/pick
// read leak in from the process running the test binary — resolveMode must
// see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE
@@ -544,3 +561,318 @@ func TestAPISessionChannels(t *testing.T) {
t.Fatalf("row 1 = %+v", chans[1])
}
}
+
+// ---- Task 5: the four verbs wired to resolveMode --------------------------
+
+// loginTo saves one live credential for origin/address and returns it, so a
+// verb-level test can log in without going through `carlos auth login`.
+func loginTo(t *testing.T, origin, address string) {
+ t.Helper()
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: origin, Address: address, Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live
+// credential and neither store env set, it must ship through the console
+// (not openStore/release.Ship) and print the first-use "using <origin> as
+// <address>" line ahead of the ordinary "shipped" line — no artifact count,
+// per the noop-parity amendment, since the API-mode message is deliberately
+// its own shape.
+func TestShipVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/cli/apps/acct/titogo/releases", func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ _, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
+ })
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "titogo", "-account", "acct", "-version", "abc1234", "-target", "linux-arm64", bin})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if !strings.Contains(out, "shipped titogo abc1234") {
+ t.Fatalf("stdout = %q, want the shipped line", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+}
+
+// TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a
+// 200 (rather than 201) response is a re-ship of identical content, and the
+// verb must print the specified "already shipped ... nothing to do" line, not
+// the ordinary "shipped" line.
+func TestShipVerbAPIModeNoOp(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("not multipart: %v", err)
+ }
+ for {
+ if _, err := mr.NextPart(); err == io.EOF {
+ break
+ } else if err != nil {
+ t.Fatalf("part: %v", err)
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "titogo", "-account", "acct", "-version", "abc1234", "-target", "linux-arm64", bin})
+
+ out := buf.String()
+ if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if strings.Contains(out, "shipped titogo abc1234\n") {
+ t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out)
+ }
+}
+
+// TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote
+// through the console and print the SAME "promoted" line the direct path
+// prints (cli.go's runPromote default case) — the verb, not the transport,
+// owns that wording.
+func TestPromoteVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if r.URL.Path != "/api/cli/apps/acct/titogo/promote" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ var req struct{ Version, Channel string }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Version != "abc1234" || req.Channel != "canary/x" {
+ t.Errorf("promote body = %+v", req)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", "acct", "abc1234", "canary/x"})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+}
+
+// TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode
+// promote must print the byte-identical "already on X — nothing to do" shape
+// direct mode prints (runPromote's res.NoOp branch), not a differently-worded
+// API-mode-only message.
+func TestPromoteVerbAPIModeNoOp(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", "acct", "abc1234", "canary/x"})
+
+ out := buf.String()
+ if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+}
+
+// TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the
+// ladder through the console and print the same tabwriter table direct mode
+// prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row
+// ERROR behaviour, per apimode.go's doc comment).
+func TestChannelsVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/titogo/channels" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "channels": []map[string]any{
+ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
+ {"channel": "broken", "error": "signature mismatch"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runChannels([]string{"-app", "titogo", "-account", "acct"})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") {
+ t.Fatalf("stdout = %q, want the tabwriter header", out)
+ }
+ if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") {
+ t.Fatalf("stdout = %q, want the canary/a row", out)
+ }
+ if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") {
+ t.Fatalf("stdout = %q, want the broken row's ERROR text", out)
+ }
+}
+
+// TestRollbackVerbAPIMode pins runRollback's API-mode wiring.
+func TestRollbackVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if r.URL.Path != "/api/cli/apps/acct/titogo/rollback" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ var req struct{ Channel string }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Channel != "canary/x" {
+ t.Errorf("rollback body = %+v", req)
+ }
+ json.NewEncoder(w).Encode(map[string]string{"result": "rolled back"})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runRollback([]string{"-app", "titogo", "-account", "acct", "canary/x"})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if want := "rolled back titogo canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+}
+
+// TestPromoteVerbDirectUnchanged pins the other half of resolveMode's
+// contract: with CARLOS_DEPLOYMENT_DIR set, runPromote must take the existing
+// direct-bucket path unchanged and never dial a console at all — even one it
+// holds a live (and, if it mistakenly used it, perfectly usable) credential
+// for. The httptest server's request counter is the proof: it must stay 0.
+func TestPromoteVerbDirectUnchanged(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+ // A live credential pointed AT the counting server: direct mode must win
+ // regardless (CARLOS_DEPLOYMENT_DIR set below), so this must never be
+ // dialed — if it ever were, reqCount would catch it.
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+
+ seed, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_KEY", seed)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", pub)
+
+ bin := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "demoapp", "-version", "abc1234", "-target", "linux-arm64", bin})
+ runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"})
+
+ out := buf.String()
+ if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want the direct-mode promoted line", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 0 {
+ t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got)
+ }
+}
diff --git a/cli.go b/cli.go
index b5adf5c8f..7908d6114 100644
--- a/cli.go
+++ b/cli.go
@@ -11,6 +11,7 @@ import (
"encoding/hex"
"flag"
"fmt"
+ "io"
"log"
"os"
"path/filepath"
@@ -26,6 +27,15 @@ import (
"github.com/carlosframework/platform/internal/version"
)
+// stdout is the print seam for the four release-pipeline verbs that now
+// speak the console API (ship/promote/channels/rollback): a package-level
+// var rather than an os.Stdout literal, so a test can swap it for a buffer
+// and assert on it. Every OTHER verb in this file still writes straight to
+// os.Stdout/fmt.Printf, unchanged — this seam exists only where Task 5 added
+// behaviour worth pinning with an assertion; log.Fatal paths are untouched by
+// it on purpose, exactly as they were before.
+var stdout io.Writer = os.Stdout
+
func dataDir() string {
if d := os.Getenv("CARLOS_DATA_DIR"); d != "" {
return d
@@ -754,13 +764,24 @@ func runShip(args []string) {
fs := flag.NewFlagSet("ship", flag.ExitOnError)
app := fs.String("app", "", "app name")
acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)")
kind := fs.String("kind", "binary", "binary | static")
ver := fs.String("version", "", "version (git short sha)")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
fs.Parse(args)
if *app == "" || *ver == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] <files-or-site-dir>")
+ log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] <files-or-site-dir>")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
}
+ if sess != nil {
+ runShipAPI(sess, fs, *app, *ver, *kind, *target)
+ return
+ }
+
files := map[string][]byte{}
arts := map[string]release.Artifact{}
addFile := func(name string, data []byte) {
@@ -806,7 +827,58 @@ func runShip(args []string) {
if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil {
log.Fatal(err)
}
- fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+ fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+}
+
+// runShipAPI is runShip's API-mode body: the raw -account flag value goes
+// straight to the console (never acctOf/resolveAccount — there is no local
+// registry to resolve against on a developer laptop). Unlike the direct-mode
+// body above, artifacts are streamed from disk by path (apiSession.ship
+// reads them lazily) rather than buffered into memory here first — for a
+// static release, whose bytes only exist in memory (edge.PackDir's tarball),
+// that means writing them to a scratch temp file first so there is a path to
+// hand over at all.
+func runShipAPI(sess *apiSession, fs *flag.FlagSet, app, ver, kind, target string) {
+ acct := fs.Lookup("account").Value.String()
+ files := map[string]string{}
+ if kind == "static" {
+ if fs.NArg() != 1 {
+ log.Fatal("static ship takes exactly one site directory")
+ }
+ data, err := edge.PackDir(fs.Arg(0))
+ if err != nil {
+ log.Fatal(err)
+ }
+ tmp, err := os.CreateTemp("", "carlos-ship-*.tar.gz")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ log.Fatal(err)
+ }
+ if err := tmp.Close(); err != nil {
+ log.Fatal(err)
+ }
+ files["site.tar.gz"] = tmp.Name()
+ } else if fs.NArg() == 1 {
+ files[target] = fs.Arg(0)
+ } else {
+ for _, path := range fs.Args() {
+ files[filepath.Base(path)] = path
+ }
+ }
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ res, err := sess.ship(context.Background(), acct, app, ver, kind, files)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if res.NoOp {
+ fmt.Fprintf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver)
+ } else {
+ fmt.Fprintf(stdout, "shipped %s %s\n", app, ver)
+ }
}
// runPromote points a channel at a shipped version, with the ladder enforced:
@@ -819,11 +891,41 @@ func runPromote(args []string) {
app := fs.String("app", "", "app name")
hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)")
acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 2 {
- log.Fatal("usage: carlos promote -app <a> [-hotfix] <version> <channel>")
+ log.Fatal("usage: carlos promote -app <a> [-hotfix] [-console <origin>] <version> <channel>")
}
version, channel := fs.Arg(0), fs.Arg(1)
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ if *hotfix {
+ // A hotfix bypasses the signed ladder's ordering check entirely —
+ // an operator act, recorded as such (spec §3). The console's own
+ // promote door (handleAPIPromote) has no hotfix field at all, so
+ // there is no way to express this through the API even in
+ // principle; refuse before ever dialing out, rather than let the
+ // flag silently do nothing.
+ log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
+ }
+ acct := fs.Lookup("account").Value.String()
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ res, err := sess.promote(context.Background(), acct, *app, version, channel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if res.NoOp {
+ fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
+ } else {
+ fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
+ }
+ return
+ }
+
res, err := release.PromoteOnLadder(context.Background(), openStore(), signingKey(),
acctOf(), *app, channel, version,
release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()})
@@ -832,11 +934,11 @@ func runPromote(args []string) {
}
switch {
case res.NoOp:
- fmt.Printf("%s %s is already on %s — nothing to do\n", *app, version, channel)
+ fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
case res.Hotfix:
- fmt.Printf("promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel)
+ fmt.Fprintf(stdout, "promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel)
default:
- fmt.Printf("promoted %s %s -> %s\n", *app, version, channel)
+ fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
}
}
@@ -857,10 +959,36 @@ func runChannels(args []string) {
fs := flag.NewFlagSet("channels", flag.ExitOnError)
app := fs.String("app", "", "app name")
acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>]")
+ log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-console <origin>]")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
}
+ if sess != nil {
+ acct := fs.Lookup("account").Value.String()
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ chans, err := sess.channels(context.Background(), acct, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
+ for _, ch := range chans {
+ if ch.Err != "" {
+ fmt.Fprintf(w, "%s\tERROR: %s\t\n", ch.Channel, ch.Err)
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt)
+ }
+ w.Flush()
+ return
+ }
+
acct := acctOf()
s := openStore()
prefix := release.ChannelsPrefix(acct, *app)
@@ -869,7 +997,7 @@ func runChannels(args []string) {
log.Fatal(err)
}
pub := publicKey()
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
for _, k := range keys {
name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
@@ -887,14 +1015,30 @@ func runRollback(args []string) {
fs := flag.NewFlagSet("rollback", flag.ExitOnError)
app := fs.String("app", "", "app name")
acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
- log.Fatal("usage: carlos rollback -app <a> <channel>")
+ log.Fatal("usage: carlos rollback -app <a> [-console <origin>] <channel>")
}
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ acct := fs.Lookup("account").Value.String()
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
+ return
+ }
+
if err := release.Rollback(context.Background(), openStore(), signingKey(), acctOf(), *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
- fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0))
+ fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
}
func runKeygen(args []string) {