carlos / platform Public

cli: zero-arg carlos deploy — the first-run wizard and saved defaults

With saved project state, carlos deploy alone ships; first run at a
terminal resolves account and app (offering to create one named after
the directory when the account owns nothing), asks for the artifact once
(conventional site dirs pre-fill, never decide), infers kind/target, and
offers one combined save. Channel ambiguity gets a chooser where the
error used to quote -channel back. Non-TTY zero-arg errors naming both
remedies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell be90582b34a7713b19da713d5f9184e7d675542f parent b5b9488
2 files changed, +356 −12
  • deploy.go +239 −12
  • deploy_wizard_test.go +117 −0
diff --git a/deploy.go b/deploy.go
index 8dc81c995..0076b8ee6 100644
--- a/deploy.go
+++ b/deploy.go
@@ -15,12 +15,16 @@ import (
"log"
"net/http"
"net/url"
+ "os"
"os/exec"
+ "path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
+
+ "github.com/carlosframework/platform/internal/caps"
)
// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket
@@ -43,33 +47,126 @@ func runDeploy(args []string) {
fs.Parse(args)
usage := "usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " +
"[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] " +
- "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>"
- if fs.NArg() == 0 {
- log.Fatal(usage)
- }
+ "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>\n" +
+ "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy"
if *ver == "" {
*ver = gitShortHead()
}
-
- // Read and decode the capability declaration before ever dialing the
- // console, exactly like runShip does — a bad file is refused at the
- // operator's terminal, not after the ship half of this verb already ran.
- capsSet, err := readCapabilities(*capsFile, *app)
- if err != nil {
- log.Fatal(err)
+ // A bad capability file is still refused before anything ships — but the
+ // app name may be left to resolution now, so with an omitted -app the
+ // read happens again once the name is known (ship's rule).
+ var capsSet *caps.Set
+ var err error
+ if *app != "" {
+ if capsSet, err = readCapabilities(*capsFile, *app); err != nil {
+ log.Fatal(err)
+ }
}
sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb
printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
- *app = resolvedApp(sess, acct, *app, usage)
ctx := context.Background()
+
+ // What was resolved interactively THIS run — the one combined save
+ // offer at the end covers exactly these, never values that came from
+ // flags or from config (project-defaults spec, 2026-08-13).
+ newly := map[string]any{}
+
+ if *app == "" {
+ cfgApp, _, cfgConsole, cerr := configApp()
+ fromConfig := cerr == nil && cfgApp != "" && cfgConsole == sess.console
+ r, aerr := sess.defaultApp(ctx, acct, false)
+ var na *noAppsError
+ switch {
+ case aerr == nil && r != "":
+ *app = r
+ if !fromConfig {
+ newly["app"] = r
+ }
+ case errors.As(aerr, &na) && interactiveTerminal():
+ // The account owns nothing yet: zero-arg deploy is allowed to
+ // finish the onboarding — offer the claim, named after this
+ // directory, through the same door as `carlos apps create`
+ // (rate limiter and server validation unchanged).
+ *app = offerCreateApp(ctx, sess, acct, na)
+ newly["app"] = *app
+ case aerr != nil:
+ log.Fatal(aerr)
+ default:
+ log.Fatal(usage)
+ }
+ }
+ if capsSet == nil {
+ if capsSet, err = readCapabilities(*capsFile, *app); err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ if fs.NArg() == 0 {
+ // Zero-arg: the saved deploy block answers, else the first-run
+ // wizard names the artifact once — never a silent guess.
+ artifact := ""
+ dcfg, dsrc, derr := configDeploy()
+ if derr != nil {
+ log.Fatal(derr)
+ }
+ if dsrc != "" && dcfg.Console == sess.console {
+ artifact = dcfg.Artifact
+ if !flagWasSet(fs, "kind") && dcfg.Kind != "" {
+ *kind = dcfg.Kind
+ }
+ if !flagWasSet(fs, "target") && dcfg.Target != "" {
+ *target = dcfg.Target
+ }
+ mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, dsrc)
+ } else if interactiveTerminal() {
+ var k, tgt string
+ artifact, k, tgt, err = deployArtifactWizard()
+ if err != nil {
+ log.Fatal(err)
+ }
+ if !flagWasSet(fs, "kind") {
+ *kind = k
+ }
+ if !flagWasSet(fs, "target") && tgt != "" {
+ *target = tgt
+ }
+ newly["artifact"] = artifact
+ newly["kind"] = *kind
+ if *kind == "binary" {
+ newly["target"] = *target
+ }
+ } else {
+ log.Fatal("deploy: nothing to ship — pass -app and a binary or site directory, " +
+ "or run carlos deploy once at a terminal to save this project's defaults")
+ }
+ if _, err := os.Stat(artifact); err != nil {
+ log.Fatalf("deploy: artifact %s: %v (the saved default may be stale — edit .carlos/config)", artifact, err)
+ }
+ fs.Parse([]string{artifact})
+ }
+
+ if len(newly) > 0 && interactiveTerminal() {
+ offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly,
+ "save these as this project's deploy defaults")
+ }
+
recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
if err != nil {
log.Fatalf("deploy: list instances: %v", err)
}
channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag,
func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) })
+ if err != nil && *channelFlag == "" && interactiveTerminal() &&
+ strings.Contains(err.Error(), "-channel") {
+ // The refusal's own advice is "name one with -channel" — at a
+ // terminal, ask instead of quoting the flag back.
+ if picked, ok := chooseDeployChannel(ctx, sess, acct, *app); ok {
+ channel, hosts, staticDeploy, err = deployTargets(recs, *kind, picked, *hostFlag,
+ func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) })
+ }
+ }
if err != nil {
log.Fatalf("deploy: %v", err)
}
@@ -171,6 +268,136 @@ func deployingLine(hosts []string, channel string) string {
return fmt.Sprintf("deploying %d instances on %s", len(hosts), channel)
}
+// flagWasSet reports whether the user typed a flag — the wizard and the
+// saved deploy block must never override an explicit -kind or -target.
+func flagWasSet(fs *flag.FlagSet, name string) bool {
+ set := false
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == name {
+ set = true
+ }
+ })
+ return set
+}
+
+// deployArtifactWizard is first-run zero-arg deploy's one real question:
+// WHAT ships. A conventional site directory (public/, dist/, _site/) only
+// ever pre-fills the prompt — the human confirms; detection never decides.
+// A directory answers static; a regular file answers binary, with the
+// target prompted (linux-arm64 pre-filled, the fleet's shape).
+func deployArtifactWizard() (artifact, kind, target string, err error) {
+ suggestion := ""
+ for _, d := range []string{"public", "dist", "_site"} {
+ if fi, err := os.Stat(d); err == nil && fi.IsDir() {
+ if suggestion != "" {
+ suggestion = "" // two candidates: suggest neither
+ break
+ }
+ suggestion = "./" + d
+ }
+ }
+ prompt := "artifact to deploy (binary or site directory): "
+ if suggestion != "" {
+ prompt = fmt.Sprintf("artifact to deploy [%s]: ", suggestion)
+ }
+ for attempt := 0; attempt < 2; attempt++ {
+ ans := promptLine(stdout, prompt)
+ if ans == "" {
+ ans = suggestion
+ }
+ if ans == "" {
+ // An accidental Enter with nothing pre-filled is a miss, not
+ // the end — the retry below asks once more.
+ continue
+ }
+ fi, serr := os.Stat(ans)
+ if serr != nil {
+ fmt.Fprintf(stdout, "%s does not exist\n", ans)
+ continue
+ }
+ if fi.IsDir() {
+ return ans, "static", "", nil
+ }
+ target = promptLine(stdout, "built for (GOOS-GOARCH) [linux-arm64]: ")
+ if target == "" {
+ target = "linux-arm64"
+ }
+ return ans, "binary", target, nil
+ }
+ return "", "", "", errors.New("deploy: nothing to ship — build the project first, then name its binary or site directory")
+}
+
+// offerCreateApp turns defaultApp's no-apps refusal into zero-arg deploy's
+// onboarding step: claim a name — this directory's, when it is a valid app
+// name — through the createApp door, or die with the refusal the other
+// verbs print. Declining is the refusal too: deploy cannot proceed without
+// an app, and inventing one silently is not on the table.
+func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError) string {
+ name := ""
+ if cwd, err := os.Getwd(); err == nil {
+ base := filepath.Base(cwd)
+ if validAppName(base) == nil {
+ name = base
+ }
+ }
+ fmt.Fprintf(stdout, "account %s has no apps yet.\n", na.Account)
+ if name != "" {
+ if !promptYes(stdout, fmt.Sprintf("create %q (this directory's name)? [y/N] ", name)) {
+ log.Fatal(na)
+ }
+ } else {
+ name = promptLine(stdout, "app name to create (lowercase letters, digits, dashes): ")
+ if name == "" || validAppName(name) != nil {
+ log.Fatal(na)
+ }
+ }
+ res, err := sess.createApp(ctx, acct, name, "")
+ if err != nil {
+ log.Fatalf("deploy: create app: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account)
+ return res.App
+}
+
+// chooseDeployChannel is the interactive form of "say which with -channel":
+// the app's verified channels, numbered. ok=false (no terminal answer, or
+// fewer than two clean channels to choose between) sends the caller back
+// to the original refusal — the chooser must never invent an option the
+// flag could not have named.
+func chooseDeployChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, bool) {
+ chans, err := sess.channels(ctx, acct, app)
+ if err != nil {
+ return "", false
+ }
+ names := make([]string, 0, len(chans))
+ for _, ch := range chans {
+ if ch.Err == "" {
+ names = append(names, ch.Channel)
+ }
+ }
+ if len(names) < 2 {
+ return "", false
+ }
+ picked, err := chooseOne(chooserIn, stdout, "this app releases onto several channels:", "channel",
+ names, errors.New("no channel chosen"))
+ if err != nil {
+ return "", false
+ }
+ return picked, true
+}
+
+// accountName answers what to CALL acct in a saved config: the memoized
+// memberships entry's name when the fetch has happened, else the segment
+// as given — a name resolves on any future run, and a sqid still works.
+func (s *apiSession) accountName(acct apiAcct) string {
+ for i := range s.accts {
+ if s.accts[i].Sqid == acct.Seg || s.accts[i].Name == acct.Seg {
+ return s.accts[i].Name
+ }
+ }
+ return acct.Seg
+}
+
// deployTargets decides what this deploy promotes to and what it then watches,
// and reports whether it took the static path. It is a function, not a stretch
// of runDeploy, so the decision can be tested without a console: `channels` is
diff --git a/deploy_wizard_test.go b/deploy_wizard_test.go
new file mode 100644
index 000000000..94f7e2446
--- /dev/null
+++ b/deploy_wizard_test.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFlagWasSet(t *testing.T) {
+ fs := flag.NewFlagSet("t", flag.ContinueOnError)
+ fs.String("kind", "binary", "")
+ fs.String("target", "linux-arm64", "")
+ if err := fs.Parse([]string{"-kind", "static"}); err != nil {
+ t.Fatal(err)
+ }
+ if !flagWasSet(fs, "kind") || flagWasSet(fs, "target") {
+ t.Fatal("parsed -kind must report set; untyped -target must not")
+ }
+}
+
+func TestDeployArtifactWizard(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+
+ // A lone conventional dir pre-fills; an empty answer takes it.
+ if err := os.MkdirAll("public", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ swapChooserIn(t, "\n")
+ artifact, kind, _, err := deployArtifactWizard()
+ if err != nil || artifact != "./public" || kind != "static" {
+ t.Fatalf("suggested dir = %q %q %v", artifact, kind, err)
+ }
+
+ // Two conventional dirs: no suggestion, the empty answer is a miss and
+ // a real answer on the retry lands.
+ if err := os.MkdirAll("dist", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ swapChooserIn(t, "\npublic\n")
+ if artifact, kind, _, err = deployArtifactWizard(); err != nil || artifact != "public" || kind != "static" {
+ t.Fatalf("ambiguous dirs = %q %q %v", artifact, kind, err)
+ }
+
+ // A regular file is a binary, target pre-filled.
+ if err := os.WriteFile("server", []byte("bin"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ swapChooserIn(t, "server\n\n")
+ artifact, kind, target, err := deployArtifactWizard()
+ if err != nil || artifact != "server" || kind != "binary" || target != "linux-arm64" {
+ t.Fatalf("binary = %q %q %q %v", artifact, kind, target, err)
+ }
+
+ // A missing path re-prompts once, then the wizard gives up with the
+ // build-first message.
+ swapChooserIn(t, "nope\nalso-nope\n")
+ if _, _, _, err = deployArtifactWizard(); err == nil ||
+ !strings.Contains(err.Error(), "nothing to ship") {
+ t.Fatalf("two misses = %v", err)
+ }
+}
+
+// swapChooserIn points the shared prompt reader at a script for one test.
+func swapChooserIn(t *testing.T, script string) {
+ t.Helper()
+ old := chooserIn
+ chooserIn = strings.NewReader(script)
+ t.Cleanup(func() { chooserIn = old })
+}
+
+func TestOfferCreateAppUsesDirectoryName(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ var gotName string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Name string `json:"name"`
+ }
+ json.NewDecoder(r.Body).Decode(&in)
+ gotName = in.Name
+ json.NewEncoder(w).Encode(map[string]any{"app": in.Name, "account": "bdk",
+ "url": "https://x/a/bdk/" + in.Name})
+ }))
+ t.Cleanup(ts.Close)
+
+ // cwd's base name is the suggestion — make it a valid app name.
+ dir := filepath.Join(t.TempDir(), "taskmonger")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(dir)
+ swapChooserIn(t, "y\n")
+ s := newTestSession(ts.URL)
+ app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true},
+ &noAppsError{Account: "tom@keymail.dev"})
+ if app != "taskmonger" || gotName != "taskmonger" {
+ t.Fatalf("created %q (server saw %q), want the directory's name", app, gotName)
+ }
+}
+
+func TestAccountName(t *testing.T) {
+ s := &apiSession{accts: []apiAccountEntry{{Name: "tom@keymail.dev", Sqid: "bdk"}}}
+ if got := s.accountName(apiAcct{Seg: "bdk", Exact: true}); got != "tom@keymail.dev" {
+ t.Fatalf("by sqid = %q", got)
+ }
+ if got := s.accountName(apiAcct{Seg: "acme"}); got != "acme" {
+ t.Fatalf("unknown segment must pass through, got %q", got)
+ }
+}