| 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 |