carlos / platform Public

deploy: --assets, --no-assets and the saved assets default (edge asset cache plan 1, task 3)

carlos deploy takes the repeatable --assets (the shared EAC-1 help) and
--no-assets (EAC-2). deployAssets resolves them: both together is EAC-20,
--no-assets declares none, --assets replaces the saved list, and a static
release refuses a flag (EAC-8) but drops a saved list with a notice (EAC-9).
The saved list is cliConfig.Assets, the first list-valued config key: trimmed
on load, laid over by a destination block (an empty list included), and kept
by the other config rewrites. A zero-arg deploy reads it; a --assets run at a
terminal offers to save it. Copy gate: EAC-2, EAC-9, EAC-20.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6e83e79dec7bb5d3e6feeb2d884a66d6384809dd parent 3ea939d
5 files changed, +476 −2
  • approved_asset_cache_copy_test.go +1 −0
  • config.go +20 −0
  • config_test.go +70 −0
  • deploy.go +85 −2
  • deploy_assets_test.go +300 −0
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go
index 43273793a..9e93f0f85 100644
--- a/approved_asset_cache_copy_test.go
+++ b/approved_asset_cache_copy_test.go
@@ -47,6 +47,7 @@ var assetCacheCopyBuilt = []string{
"EAC-8@internal/release/release.go",
"EAC-1", "EAC-8@cli.go", "EAC-8@internal/console/apiship_assets.go",
"EAC-21", "EAC-22",
+ "EAC-2", "EAC-9", "EAC-20",
}
// assetCacheBuiltRows turns built entries into the rows to check: one per
diff --git a/config.go b/config.go
index 5dd1c58c1..3192ee065 100644
--- a/config.go
+++ b/config.go
@@ -63,6 +63,11 @@ type cliConfig struct {
Kind string `json:"kind"`
Artifact string `json:"artifact"`
Target string `json:"target"`
+ // Assets is the saved asset folders a zero-arg deploy declares on every
+ // release (edge asset cache spec): the first list-valued key. loadConfig
+ // trims each entry and drops empties; the grammar is checked at deploy,
+ // where a bad entry can be refused. Absent means declare nothing.
+ Assets []string `json:"assets,omitempty"`
// UpdateCheck: "off" disables the once-a-day newer-release hint
// (update.go). Any other value — including absent — leaves it on.
UpdateCheck string `json:"update_check"`
@@ -290,6 +295,15 @@ func loadConfig(path string) (cliConfig, error) {
cfg.Kind = strings.TrimSpace(cfg.Kind)
cfg.Artifact = strings.TrimSpace(cfg.Artifact)
cfg.Target = strings.TrimSpace(cfg.Target)
+ if cfg.Assets != nil {
+ trimmed := make([]string, 0, len(cfg.Assets))
+ for _, a := range cfg.Assets {
+ if a = strings.TrimSpace(a); a != "" {
+ trimmed = append(trimmed, a)
+ }
+ }
+ cfg.Assets = trimmed
+ }
return cfg, nil
}
@@ -312,6 +326,12 @@ func overlayDestination(top, block cliConfig) cliConfig {
*kv.dst = kv.v
}
}
+ // The list is the one key that is not a string. A block that names it,
+ // even as [], replaces the top level's: an empty list is how a
+ // destination says it declares nothing.
+ if block.Assets != nil {
+ top.Assets = block.Assets
+ }
return top
}
diff --git a/config_test.go b/config_test.go
index 0dc9e251a..9f8b8ec0d 100644
--- a/config_test.go
+++ b/config_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -462,3 +463,72 @@ func TestConfigRewritesPreserveUnknownFields(t *testing.T) {
t.Fatal("saving an invalid console must fail")
}
}
+
+// TestConfigLoadsAssetsList: "assets" is the first list-valued key. Entries
+// are trimmed and empties dropped, a destination's list replaces the top
+// level's (an empty list included), and a destination the file does not define
+// carries none.
+func TestConfigLoadsAssetsList(t *testing.T) {
+ useTempCredentials(t)
+ write := func(body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "config")
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+ }
+ cfg, err := loadConfig(write(`{"console":"https://tito.example","assets":[" /assets/ ","","/img/"]}`))
+ if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/assets/", "/img/"}) {
+ t.Fatalf("flat = %#v, %v", cfg.Assets, err)
+ }
+
+ blocks := write(`{"console":"https://tito.example","assets":["/top/"],"destinations":{` +
+ `"prod":{"assets":["/prod/"]},"plain":{"app":"shop"},"none":{"assets":[]}}}`)
+ for dest, want := range map[string][]string{"prod": {"/prod/"}, "plain": {"/top/"}, "none": nil} {
+ t.Setenv("CARLOS_DESTINATION", dest)
+ cfg, err := loadConfig(blocks)
+ if err != nil || len(cfg.Assets) != len(want) || (len(want) > 0 && !reflect.DeepEqual(cfg.Assets, want)) {
+ t.Fatalf("destination %s = %#v, %v, want %#v", dest, cfg.Assets, err, want)
+ }
+ }
+ t.Setenv("CARLOS_DESTINATION", "elsewhere")
+ if cfg, err := loadConfig(blocks); err != nil || len(cfg.Assets) != 0 {
+ t.Fatalf("an undefined destination = %#v, %v, want none", cfg.Assets, err)
+ }
+ t.Setenv("CARLOS_DESTINATION", "")
+
+ if _, err := loadConfig(write(`{"assets":"/assets/"}`)); err == nil {
+ t.Fatal("a string where the list belongs was accepted")
+ }
+}
+
+// TestConfigRewritesPreserveAssets: the other rewrites keep a saved list, and
+// writeConfigKeys writes one that loadConfig reads back.
+func TestConfigRewritesPreserveAssets(t *testing.T) {
+ useTempCredentials(t)
+ path := filepath.Join(t.TempDir(), ".carlos", "config")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","assets":["/assets/","/img/"]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := saveConfigAccount(path, "https://tito.example", "abc", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeConfigKeys(path, map[string]any{"app": "shop"}, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadConfig(path)
+ if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/assets/", "/img/"}) || cfg.App != "shop" || cfg.Account != "abc" {
+ t.Fatalf("after rewrites = %+v, %v", cfg, err)
+ }
+ if err := writeConfigKeys(path, map[string]any{"assets": []string{"/static/"}}, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err = loadConfig(path)
+ if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/static/"}) || cfg.App != "shop" {
+ t.Fatalf("after writing assets = %+v, %v", cfg, err)
+ }
+}
diff --git a/deploy.go b/deploy.go
index edcf2f4b7..faf3a1779 100644
--- a/deploy.go
+++ b/deploy.go
@@ -18,6 +18,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"sort"
"strings"
"sync"
@@ -55,6 +56,54 @@ func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked strin
return errors.New(deployRegionMismatch(app, res.AppDefault.Name))
}
+// The deploy-only asset sentences (edge asset cache copy record). The --assets
+// help (EAC-1) and the static refusal (EAC-8) are cli.go's shared constants.
+const (
+ deployNoAssetsFlagHelp = "ship with no asset folders, ignoring any saved in .carlos/config" // EAC-2
+ deployAssetsSavedStatic = "ignoring the asset folders saved in .carlos/config, because a static site is already served from every region." // EAC-9
+ deployAssetsBoth = "--assets and --no-assets cannot be used together. pick one." // EAC-20
+)
+
+// deployAssets resolves the asset folders a deploy declares. flagSet says
+// --assets was given (flag holds its values); noAssets is --no-assets; saved
+// is the project's saved list; kind is the release kind, final by now.
+//
+// - both flags together are refused (EAC-20), whatever the kind;
+// - --no-assets declares none, and a saved list is not even looked at;
+// - --assets replaces the saved list for the run, and is refused on a static
+// release (EAC-8);
+// - otherwise the saved list answers, except on a static release, where it is
+// dropped with a notice (EAC-9) rather than refused: the person typed
+// nothing wrong.
+//
+// Every list that will be declared meets release.ValidAssetPaths. The result
+// is nil, never empty, when nothing is declared, so the ship meta carries no
+// assets key.
+func deployAssets(flagSet bool, flag []string, noAssets bool, saved []string, kind string) (paths []string, notice string, err error) {
+ if flagSet && noAssets {
+ return nil, "", errors.New(deployAssetsBoth)
+ }
+ if noAssets {
+ return nil, "", nil
+ }
+ if flagSet {
+ if err := checkShipAssets(kind, flag); err != nil {
+ return nil, "", err
+ }
+ return flag, "", nil
+ }
+ if len(saved) == 0 {
+ return nil, "", nil
+ }
+ if kind == "static" {
+ return nil, deployAssetsSavedStatic, nil
+ }
+ if err := release.ValidAssetPaths(saved); err != nil {
+ return nil, "", err
+ }
+ return saved, "", nil
+}
+
// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket
// operators keep ship + promote as separate acts; deploy is the member path
// that ships, promotes to the channel the app's instances already follow,
@@ -87,11 +136,14 @@ func runDeploy(args []string) {
ttlFlag := fs.String("ttl", "", "how long a temporary app lives, 1h to 30d (default 7d); implies --temporary") // C11.22
regionFlag := fs.String("region", "", flagRegionDeploy)
swap := fs.String("swap", "", shipSwapFlagHelp)
+ var assets assetPathsFlag
+ fs.Var(&assets, "assets", shipAssetsFlagHelp)
+ noAssets := fs.Bool("no-assets", false, deployNoAssetsFlagHelp)
fs.Usage = flagUsage(fs, "")
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> | -d <destination>] [--capabilities <file.json>] " +
- "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
+ "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--assets <folder>]... [--no-assets] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
"or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]"
temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg())
if terr != nil {
@@ -100,6 +152,14 @@ func runDeploy(args []string) {
if temp {
*kind = "static"
}
+ // The two asset flags are refused together, and a bad folder or any
+ // folder on a static release is refused, before the console is asked
+ // anything. The saved list is only known (and the kind only final) in the
+ // zero-arg branch below, which resolves again.
+ flagAssets := flagWasSet(fs, "assets")
+ if _, _, aerr := deployAssets(flagAssets, assets, *noAssets, nil, *kind); aerr != nil {
+ log.Fatalf("deploy: %v", aerr)
+ }
if *ver == "" {
*ver = gitShortHead()
}
@@ -196,7 +256,12 @@ func runDeploy(args []string) {
printTemporaryExpiry(ctx, sess, acct, *app)
}
- if fs.NArg() == 0 {
+ // The project's saved asset folders belong to the zero-arg deploy, like
+ // the rest of the saved deploy block: set below, from the config that
+ // answered.
+ var savedAssets []string
+ zeroArg := fs.NArg() == 0
+ if zeroArg {
// Zero-arg: the saved deploy block answers, else the first-run
// wizard names the artifact once — never a silent guess.
artifact := ""
@@ -221,6 +286,7 @@ func runDeploy(args []string) {
if !flagWasSet(fs, "target") && dcfg.Target != "" {
*target = dcfg.Target
}
+ savedAssets = dcfg.Assets
from := dsrc
if dcfg.Destination != "" {
from = "the " + dcfg.Destination + " destination in " + dsrc
@@ -262,6 +328,23 @@ func runDeploy(args []string) {
// the deploy's shape instead of being shipped (or refused).
fs.Parse([]string{"--", artifact})
}
+ // The kind is final here, whether a flag, the config or the wizard chose
+ // it, so this is where a static release drops the saved list (EAC-9).
+ assetPaths, assetsNotice, aerr := deployAssets(flagAssets, assets, *noAssets, savedAssets, *kind)
+ if aerr != nil {
+ log.Fatalf("deploy: %v", aerr)
+ }
+ if assetsNotice != "" {
+ mutedf(stdout, "%s", assetsNotice)
+ }
+ sess.shipAssets = assetPaths
+ // --assets is a choice made on the command line, but it is the one flag
+ // worth keeping: a later zero-arg deploy that forgot it would turn the
+ // cache off for the release. Offer it, on the zero-arg path (the only one
+ // that reads the saved list), when it differs from what is saved.
+ if zeroArg && flagAssets && len(assetPaths) > 0 && !slices.Equal(assetPaths, savedAssets) {
+ newly["assets"] = assetPaths
+ }
if len(newly) > 0 && interactiveTerminal() {
offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly,
diff --git a/deploy_assets_test.go b/deploy_assets_test.go
new file mode 100644
index 000000000..c083f0367
--- /dev/null
+++ b/deploy_assets_test.go
@@ -0,0 +1,300 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// The approved copy rows this file pins (docs/superpowers/plans/
+// 2026-10-24-edge-asset-cache-copy.md). EAC-1 and EAC-8 are cli.go's shared
+// constants, used here by name.
+const (
+ eac2Text = "ship with no asset folders, ignoring any saved in .carlos/config"
+ eac9Text = "ignoring the asset folders saved in .carlos/config, because a static site is already served from every region."
+ eac20Text = "--assets and --no-assets cannot be used together. pick one."
+)
+
+// TestDeployAssetsResolution: the flag replaces the saved list, --no-assets
+// declares none, the two flags together are EAC-20, the saved list answers
+// when neither is given, and a static release refuses a flag (EAC-8) but only
+// ignores a saved list, saying so (EAC-9).
+func TestDeployAssetsResolution(t *testing.T) {
+ saved := []string{"/saved/"}
+ flagged := []string{"/assets/", "/img/"}
+ cases := []struct {
+ name string
+ flagSet bool
+ flag []string
+ noAssets bool
+ saved []string
+ kind string
+ wantPaths []string
+ wantNotice string
+ wantErr string
+ }{
+ {name: "flag beats saved", flagSet: true, flag: flagged, saved: saved, kind: "binary", wantPaths: flagged},
+ {name: "flag with nothing saved", flagSet: true, flag: flagged, kind: "binary", wantPaths: flagged},
+ {name: "no-assets beats saved", noAssets: true, saved: saved, kind: "binary"},
+ {name: "both flags together", flagSet: true, flag: flagged, noAssets: true, saved: saved, kind: "binary", wantErr: eac20Text},
+ {name: "both flags together on static", flagSet: true, flag: flagged, noAssets: true, kind: "static", wantErr: eac20Text},
+ {name: "saved list answers", saved: saved, kind: "binary", wantPaths: saved},
+ {name: "nothing anywhere", kind: "binary"},
+ {name: "static refuses a flag", flagSet: true, flag: flagged, saved: saved, kind: "static", wantErr: shipAssetsStatic},
+ {name: "static ignores a saved list and says so", saved: saved, kind: "static", wantNotice: eac9Text},
+ {name: "static with no-assets says nothing", noAssets: true, saved: saved, kind: "static"},
+ {name: "static with nothing saved says nothing", kind: "static"},
+ {name: "a bad flag is the grammar's refusal", flagSet: true, flag: []string{"/"}, kind: "binary",
+ wantErr: "asset path / would cache every page on the site. name the folder your build writes to, for example /assets/."},
+ {name: "a bad saved list is the grammar's refusal", saved: []string{"assets"}, kind: "binary",
+ wantErr: "asset path assets must start and end with /. name the folder your build writes to, for example /assets/."},
+ {name: "a bad saved list does not matter when no-assets is given", noAssets: true, saved: []string{"assets"}, kind: "binary"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ paths, notice, err := deployAssets(c.flagSet, c.flag, c.noAssets, c.saved, c.kind)
+ if c.wantErr != "" {
+ if err == nil || err.Error() != c.wantErr {
+ t.Fatalf("err = %v, want %q", err, c.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("err = %v", err)
+ }
+ if !reflect.DeepEqual(paths, c.wantPaths) {
+ t.Fatalf("paths = %#v, want %#v", paths, c.wantPaths)
+ }
+ if notice != c.wantNotice {
+ t.Fatalf("notice = %q, want %q", notice, c.wantNotice)
+ }
+ })
+ }
+}
+
+// TestDeployAssetsCopyIsApproved: the flag help and the two sentences deploy
+// owns are the approved rows byte for byte.
+func TestDeployAssetsCopyIsApproved(t *testing.T) {
+ if deployNoAssetsFlagHelp != eac2Text {
+ t.Errorf("--no-assets help = %q", deployNoAssetsFlagHelp)
+ }
+ if deployAssetsSavedStatic != eac9Text {
+ t.Errorf("saved-list notice = %q", deployAssetsSavedStatic)
+ }
+ if deployAssetsBoth != eac20Text {
+ t.Errorf("both-flags refusal = %q", deployAssetsBoth)
+ }
+}
+
+// TestDeployWithoutSavedAssetsDeclaresNone: a config written by the CLI
+// before this feature has no assets key, and a deploy under it declares
+// nothing, whatever the destinations around it say.
+func TestDeployWithoutSavedAssetsDeclaresNone(t *testing.T) {
+ dir := t.TempDir()
+ for name, body := range map[string]string{
+ "flat": `{"console":"https://console.example.com","account":"abc","app":"shop","kind":"binary","artifact":"./bin/server","target":"linux-arm64"}`,
+ "destinations": `{"default":"prod","destinations":{"prod":{"console":"https://console.example.com","app":"shop","artifact":"./bin/server"}}}`,
+ } {
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if cfg.Assets != nil {
+ t.Fatalf("%s: assets = %#v, want nil", name, cfg.Assets)
+ }
+ paths, notice, err := deployAssets(false, nil, false, cfg.Assets, "binary")
+ if paths != nil || notice != "" || err != nil {
+ t.Fatalf("%s: deployAssets = %#v %q %v, want nothing", name, paths, notice, err)
+ }
+ }
+}
+
+// deployFixture is a stand-in console just deep enough for a zero-arg deploy
+// to reach its ship: one account, one instance, a release door that keeps the
+// meta part, and a promote that refuses (the test is about what was shipped).
+type deployFixture struct {
+ mu sync.Mutex
+ meta string
+ hits int
+ url string
+}
+
+func newDeployFixture(t *testing.T) *deployFixture {
+ t.Helper()
+ f := &deployFixture{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
+ f.touch()
+ w.Write([]byte(`{"accounts":[{"sqid":"abc","name":"abc@example.com","apps":["shop"]}]}`))
+ })
+ mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) {
+ f.touch()
+ w.Write([]byte(`{"instances":[{"host":"shop.example.com","channel":"stable","kind":"instance"}]}`))
+ })
+ mux.HandleFunc("POST /api/cli/apps/{acct}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
+ f.touch()
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ p, err := mr.NextPart()
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ b, _ := io.ReadAll(p)
+ var in struct {
+ Assets json.RawMessage `json:"assets"`
+ }
+ json.Unmarshal(b, &in)
+ f.mu.Lock()
+ f.meta = string(b)
+ f.mu.Unlock()
+ echo := ""
+ if len(in.Assets) > 0 {
+ echo = `,"assets":` + string(in.Assets)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"result":"shipped","version":"x1"`+echo+`}`)
+ })
+ mux.HandleFunc("POST /api/cli/apps/{acct}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
+ f.touch()
+ http.Error(w, `{"error":"promote is not under test"}`, http.StatusInternalServerError)
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ f.url = ts.URL
+ return f
+}
+
+func (f *deployFixture) touch() {
+ f.mu.Lock()
+ f.hits++
+ f.mu.Unlock()
+}
+
+// runDeploy runs `carlos deploy -version x1 <args>` in a project whose
+// .carlos/config carries extraConfig after the usual defaults, and returns
+// the combined output.
+func (f *deployFixture) runDeploy(t *testing.T, extraConfig string, args ...string) string {
+ t.Helper()
+ bin := buildCarlos(t)
+ project := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(project, ".carlos"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cfg := `{"console":"` + f.url + `","account":"abc@example.com","app":"shop","kind":"binary","artifact":"./bin/server","target":"linux-arm64"` + extraConfig + `}`
+ if err := os.WriteFile(filepath.Join(project, ".carlos", "config"), []byte(cfg), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(project, "bin"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ credPath := filepath.Join(t.TempDir(), "credentials")
+ creds := `{"version":1,"credentials":[{"console":"` + f.url + `","address":"abc@example.com","token":"tok"}]}`
+ if err := os.WriteFile(credPath, []byte(creds), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath)
+ cmd.Dir = project
+ out, _ := cmd.CombinedOutput() // the promote refuses on purpose
+ return string(out)
+}
+
+func (f *deployFixture) shippedMeta() string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.meta
+}
+
+// TestZeroArgDeployCarriesSavedAssets: a zero-arg deploy sends the project's
+// saved asset folders in the ship meta, --assets replaces them for the run,
+// and --no-assets sends none.
+func TestZeroArgDeployCarriesSavedAssets(t *testing.T) {
+ t.Parallel()
+ const saved = `,"assets":["/assets/","/img/"]`
+ for _, c := range []struct {
+ name, config string
+ args []string
+ want string // the assets object the meta must carry; "" means no key at all
+ }{
+ {"saved list", saved, nil, `"assets":{"paths":["/assets/","/img/"]}`},
+ {"flag replaces it", saved, []string{"--assets", "/static/"}, `"assets":{"paths":["/static/"]}`},
+ {"no-assets sends none", saved, []string{"--no-assets"}, ""},
+ {"nothing saved sends none", "", nil, ""},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ f := newDeployFixture(t)
+ out := f.runDeploy(t, c.config, c.args...)
+ meta := f.shippedMeta()
+ if meta == "" {
+ t.Fatalf("the deploy never shipped:\n%s", out)
+ }
+ if c.want == "" && strings.Contains(meta, "assets") {
+ t.Fatalf("meta = %s, want no assets key", meta)
+ }
+ if c.want != "" && !strings.Contains(meta, c.want) {
+ t.Fatalf("meta = %s, want it to carry %s", meta, c.want)
+ }
+ })
+ }
+}
+
+// TestDeployRefusesBothAssetFlagsBeforeTheConsole: --assets with --no-assets
+// is EAC-20 at the terminal, with the console never asked.
+func TestDeployRefusesBothAssetFlagsBeforeTheConsole(t *testing.T) {
+ t.Parallel()
+ f := newDeployFixture(t)
+ out := f.runDeploy(t, "", "--assets", "/assets/", "--no-assets")
+ if !strings.Contains(out, eac20Text) {
+ t.Fatalf("output does not carry the approved refusal %q:\n%s", eac20Text, out)
+ }
+ f.mu.Lock()
+ hits := f.hits
+ f.mu.Unlock()
+ if hits != 0 {
+ t.Fatalf("the console was asked %d times before the refusal", hits)
+ }
+}
+
+// TestDeployStaticSavedAssetsAreIgnoredAloud: a static deploy under a config
+// that saves folders ships none and says EAC-9; --assets on a static deploy is
+// EAC-8 and nothing is shipped.
+func TestDeployStaticSavedAssetsAreIgnoredAloud(t *testing.T) {
+ t.Parallel()
+ f := newDeployFixture(t)
+ out := f.runDeploy(t, `,"assets":["/assets/"]`, "--kind", "static")
+ if !strings.Contains(out, eac9Text) {
+ t.Fatalf("output does not carry the approved notice %q:\n%s", eac9Text, out)
+ }
+ if meta := f.shippedMeta(); strings.Contains(meta, "assets") {
+ t.Fatalf("a static deploy declared folders: %s", meta)
+ }
+ g := newDeployFixture(t)
+ out = g.runDeploy(t, "", "--kind", "static", "--assets", "/assets/")
+ if !strings.Contains(out, shipAssetsStatic) {
+ t.Fatalf("output does not carry the approved refusal %q:\n%s", shipAssetsStatic, out)
+ }
+ if meta := g.shippedMeta(); meta != "" {
+ t.Fatalf("a refused deploy shipped: %s", meta)
+ }
+}