From c27342b91334e0394515e9d1f05c32edd50c313e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Fri, 23 Oct 2026 17:05:36 +0200 Subject: [PATCH 1/4] Add influxdb --- internal/cli/tunnel.go | 3 ++- internal/cli/tunnel_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 61c6304..57a675c 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -27,6 +27,7 @@ var tunnelPorts = map[string]int{ "rabbitmq": 5672, "vinyl": 6081, "varnish": 6081, + "influxdb": 8086, } // tunnelCommand opens a local port onto one of an environment's services. @@ -48,7 +49,7 @@ connection to the local port runs one SSH session, and the machine relays that session to the service you named — of this environment, on its own port, and nothing else. Services: db (whichever database the stack runs), mariadb, mysql, postgres, redis, valkey, memcached, solr, meilisearch, -rabbitmq, varnish. +rabbitmq, varnish, influxdb. Credentials are the environment's own. For the database: vallic ssh -- printenv DB_NAME DB_USER DB_PASSWORD diff --git a/internal/cli/tunnel_test.go b/internal/cli/tunnel_test.go index 9eaae07..727d33b 100644 --- a/internal/cli/tunnel_test.go +++ b/internal/cli/tunnel_test.go @@ -26,7 +26,7 @@ func TestTunnelNamesAKnownService(t *testing.T) { // Every service has a usual port to listen on, so a client's defaults work. func TestEveryTunnelServiceHasAPort(t *testing.T) { - for _, service := range []string{"db", "mariadb", "postgres", "redis", "valkey", "solr", "meilisearch", "rabbitmq", "memcached", "varnish", "vinyl"} { + for _, service := range []string{"db", "mariadb", "postgres", "redis", "valkey", "solr", "meilisearch", "rabbitmq", "memcached", "varnish", "vinyl", "influxdb"} { if tunnelPorts[service] == 0 { t.Errorf("%s has no default port", service) } From 05b2863498da166b0af1aab5e887c7d30eca0ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Fri, 23 Oct 2026 18:50:43 +0200 Subject: [PATCH 2/4] Support new static site type --- internal/cli/init.go | 222 ++++++++++++++++++++++++++++++++++++-- internal/cli/init_test.go | 120 ++++++++++++++++++++- 2 files changed, 330 insertions(+), 12 deletions(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index 14ff480..3309cd3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -31,6 +31,7 @@ const ( typeDjango = "django" typeRuby = "ruby" typeRust = "rust" + typeStatic = "static" ) // initCommand writes a starting vallic.yaml. @@ -55,7 +56,7 @@ func initCommand() *Command { return &Command{ Name: "init", Summary: "write a starting vallic.yaml for this checkout", - Usage: "init [--type drupal|laravel|wordpress|php|nodejs|go|rust|python|django|ruby] [--force]", + Usage: "init [--type drupal|laravel|wordpress|php|nodejs|go|rust|python|django|ruby|static] [--force]", Long: `Writes vallic.yaml in the working directory, and nothing else. It needs no credential and reaches nothing, so it works in a checkout of a project that does not exist on the platform yet. @@ -66,8 +67,10 @@ core package or a wp-config.php is WordPress, a composer.json on its own is PHP, go.mod is Go, Cargo.toml is Rust, manage.py or a dependency on Django is Django, a uv.lock, pyproject.toml or requirements.txt is otherwise Python, a Gemfile is Ruby, and package.json is Node — run on Bun where a bun.lock sits -beside it. The command says which file decided it, and --type overrides the -answer. +beside it — unless it builds with Astro, Eleventy, Docusaurus, Gatsby, a +static SvelteKit or Vite and starts no server, which is a static site, as is +an index.html with nothing beside it that runs. The command says which file +decided it, and --type overrides the answer. The kind is worth getting right rather than settling for the generic one: it decides the cron command the platform runs, which directories survive a @@ -195,6 +198,16 @@ type starter struct { // server. Start string + // BuildImage is the image every build step runs in, where it is not the + // application's own: a static site's application is nginx, which has no + // npm, so its steps name Node's. + BuildImage string + + // Tools are build tools the steps run in, written as services rather + // than suggested: the build cannot run without them, and a build tool is + // taken on at the first build rather than refused, as a database is. + Tools []string + // Services are suggestions, written commented out. See initCommand. Services []string @@ -265,7 +278,14 @@ func detectProject(dir string) (kind, because string, err error) { return typeRuby, because, nil } - if _, readErr := os.Stat(filepath.Join(dir, "package.json")); readErr == nil { + if raw, readErr := os.ReadFile(filepath.Join(dir, "package.json")); readErr == nil { + // Files a generator writes, which nginx serves with nothing running + // behind them. Before Bun's lockfile, which says how the build + // installs and not that anything is served. + if generator, _ := staticGenerator(raw); generator != "" { + return typeStatic, "package.json, which builds with " + generator + " and starts no server", nil + } + // Still Node: Bun is a runtime of the nodejs type, chosen under // `runtime` and not by the type. Named, because it changes every // step the file writes. @@ -276,11 +296,19 @@ func detectProject(dir string) (kind, because string, err error) { return typeNode, "package.json", nil } + // Files committed as they are, with nothing here that builds or runs + // them. Last, because every project above may carry an index.html. + for _, page := range []string{"public/index.html", "index.html"} { + if _, readErr := os.Stat(filepath.Join(dir, filepath.FromSlash(page))); readErr == nil { + return typeStatic, page + ", with nothing here that runs", nil + } + } + // A usage error rather than a failure: the fix is an argument, and the // help printed with it lists the kinds that work. return "", "", Usagef(nil, - "nothing here says what this project is: no composer.json, wp-config.php, go.mod, Cargo.toml, pyproject.toml, requirements.txt, Gemfile or package.json\n"+ - " name it: vallic init --type drupal|laravel|wordpress|php|nodejs|go|rust|python|django|ruby", + "nothing here says what this project is: no composer.json, wp-config.php, go.mod, Cargo.toml, pyproject.toml, requirements.txt, Gemfile, package.json or index.html\n"+ + " name it: vallic init --type drupal|laravel|wordpress|php|nodejs|go|rust|python|django|ruby|static", ) } @@ -355,11 +383,11 @@ func detectPython(dir string) (kind, because string) { // knownProjectType refuses a --type this writes no file for. func knownProjectType(kind string) error { switch kind { - case typeDrupal, typeLaravel, typeWordPress, typePHP, typeNode, typeGo, typeRust, typePython, typeDjango, typeRuby: + case typeDrupal, typeLaravel, typeWordPress, typePHP, typeNode, typeGo, typeRust, typePython, typeDjango, typeRuby, typeStatic: return nil } - return Usagef(nil, "--type takes drupal, laravel, wordpress, php, nodejs, go, rust, python, django or ruby, not %q", kind) + return Usagef(nil, "--type takes drupal, laravel, wordpress, php, nodejs, go, rust, python, django, ruby or static, not %q", kind) } // starterFor builds the starting file for one kind of project. @@ -375,6 +403,8 @@ func starterFor(kind, dir string) *starter { return pythonStarter(kind, dir) case typeRuby: return rubyStarter(dir) + case typeStatic: + return staticStarter(dir) default: return phpStarter(kind, dir) } @@ -570,6 +600,140 @@ func packageManagerVersion(raw []byte, tool string) string { return version } +// staticStarter is a static site: the files a build writes, which nginx +// serves from public/ with nothing running behind them. +// +// No `runtime:` line. Its key is the server's, `nginx:`, and a checkout +// never says which nginx it was written for, so the platform's default +// serves it. +func staticStarter(dir string) *starter { + manifest := &starter{ + Type: typeStatic, + VersionKey: "nginx", + } + + raw, readErr := os.ReadFile(filepath.Join(dir, "package.json")) + if readErr != nil { + // Files committed as they are, and nothing to build. nginx serves + // public/ and nothing else in the release, so a site whose pages sit + // at the root answers every path with a 404 until they move. + if _, err := os.Stat(filepath.Join(dir, "public", "index.html")); err != nil { + manifest.Notes = append(manifest.Notes, + "nginx serves public/ and nothing else, so move the site's files into it") + } + + return manifest + } + + // In Node's image, which the steps name: the application's own is + // nginx's, with no npm in it. + manifest.BuildImage = "node" + + // The major alone, as for Node. A build tool needs a version under + // services, and with none stated the line is left for somebody to fill + // in rather than pinned to whatever was current when this was built. + if node := firstVersion(packageEngine(raw, "node"), 1); node != "" { + manifest.Tools = []string{"node: '" + node + "'"} + } else { + manifest.Services = []string{"node: '26'"} + manifest.Notes = append(manifest.Notes, + "package.json states no node version, and the build runs in Node: uncomment it under services with the one you build with") + } + + step := "npm ci" + + switch _, output := staticGenerator(raw); { + case !hasScript(raw, "build"): + manifest.Notes = append(manifest.Notes, + "package.json has no build script, so nothing writes the site: add one, or commit its files in public/") + case output == "": + step += " && npm run build" + manifest.Notes = append(manifest.Notes, + "nginx serves public/: if the build writes the site anywhere else, end the step by moving it there") + case output == "public": + // Gatsby's, which is where nginx serves it from already. + step += " && npm run build" + default: + // Into public/, where nginx serves it from. The generator reads its + // own public/ as input and has copied it into what it wrote. + step += " && npm run build && rm -rf public && mv " + output + " public" + } + + // Nothing in a static site's release runs node_modules, and the release + // is copied to every machine. npm's downloads are kept between builds by + // the platform, so there is nothing to cache either. + manifest.Build = []string{step + " && rm -rf node_modules"} + + return manifest +} + +// staticGenerators are the packages that build a static site by default, +// with the directory each writes it to, most specific first: SvelteKit +// depends on Vite, and is static only with this adapter. +var staticGenerators = []struct{ pkg, output string }{ + {"astro", "dist"}, + {"@11ty/eleventy", "_site"}, + {"@docusaurus/core", "build"}, + {"gatsby", "public"}, + {"@sveltejs/adapter-static", "build"}, + {"vite", "dist"}, +} + +// staticServers are packages that make a project a server even where it +// builds with one of the generators above: Astro with its Node adapter, +// SvelteKit without the static one, and the frameworks and servers that +// answer requests themselves. +var staticServers = []string{ + "@astrojs/node", "@sveltejs/kit", "next", "nuxt", "@remix-run/node", + "@react-router/node", "express", "fastify", "koa", "hono", +} + +// staticGenerator names the generator a package.json builds a static site +// with, and where it writes it, or nothing where it does not. +// +// Nothing for one with a start script, which is what serves a Node +// application, or one depending on a server: Vite builds the front end of +// plenty of applications that are their own server. +func staticGenerator(raw []byte) (generator, output string) { + if hasScript(raw, "start") { + return "", "" + } + + adapter := packageDepends(raw, "@sveltejs/adapter-static") + + for _, server := range staticServers { + if packageDepends(raw, server) && !(server == "@sveltejs/kit" && adapter) { + return "", "" + } + } + + for _, known := range staticGenerators { + if packageDepends(raw, known.pkg) { + return known.pkg, known.output + } + } + + return "", "" +} + +// packageDepends reports whether a package.json depends on a package, for +// the build or at runtime. +func packageDepends(raw []byte, name string) bool { + var file struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + + if err := json.Unmarshal(raw, &file); err != nil { + return false + } + + _, runtime := file.Dependencies[name] + _, build := file.DevDependencies[name] + + return runtime || build +} + // goStarter is a compiled Go binary, which serves its own HTTP too. func goStarter(dir string) *starter { raw, readErr := os.ReadFile(filepath.Join(dir, "go.mod")) @@ -1204,7 +1368,35 @@ func renderStarter(s *starter) string { fmt.Fprintf(&b, "\nruntime:\n %s: '%s'\n", s.VersionKey, s.Version) } - b.WriteString(` + switch { + case len(s.Tools) > 0: + b.WriteString(` +# What the build runs in. Nothing runs beside a static site, so the only +# service is the build tool its steps name, at the version they were written +# against. +services: +`) + + for _, tool := range s.Tools { + fmt.Fprintf(&b, " - %s\n", tool) + } + case s.Type == typeStatic && len(s.Services) > 0: + b.WriteString(` +# What the build runs in. Nothing runs beside a static site, so the only +# service is the build tool its steps name, and nothing here says which +# version of it: uncomment it with the one you build with. +# +# services: +`) + + for _, service := range s.Services { + fmt.Fprintf(&b, "# - %s\n", service) + } + case s.Type == typeStatic: + // Files and nothing else: nothing to run beside them, and nothing + // to build them in. + default: + b.WriteString(` # What this application needs beside it, with the version the code was written # against. Nothing is declared here because a checkout does not say, and naming # a database an environment does not run refuses the deploy rather than warning @@ -1214,14 +1406,22 @@ func renderStarter(s *starter) string { # services: `) - for _, service := range s.Services { - fmt.Fprintf(&b, "# - %s\n", service) + for _, service := range s.Services { + fmt.Fprintf(&b, "# - %s\n", service) + } } if len(s.Build) > 0 { b.WriteString("\nbuild:\n steps:\n") for _, step := range s.Build { + // In another image than the application's: a mapping, which is + // the shape that can say which. + if s.BuildImage != "" { + fmt.Fprintf(&b, " - image: %s\n run: %s\n", s.BuildImage, step) + continue + } + fmt.Fprintf(&b, " - %s\n", step) } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index cc791f4..27d5629 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -140,6 +140,59 @@ func TestDetectProjectReadsTheFilesInTheCheckout(t *testing.T) { files: map[string]string{"package.json": `{}`, "bun.lock": "{}"}, want: typeNode, }, + { + name: "an astro site", + files: map[string]string{"package.json": `{"scripts":{"build":"astro build"},"dependencies":{"astro":"^5"}}`}, + want: typeStatic, + }, + { + name: "a single-page application vite builds", + files: map[string]string{"package.json": `{"scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^19"},"devDependencies":{"vite":"^7"}}`}, + want: typeStatic, + }, + { + // Vite builds its front end; express is what answers. + name: "vite in front of an express server", + files: map[string]string{"package.json": `{"scripts":{"build":"vite build"},"dependencies":{"express":"^5"},"devDependencies":{"vite":"^7"}}`}, + want: typeNode, + }, + { + name: "an astro site that is a node server", + files: map[string]string{"package.json": `{"scripts":{"build":"astro build"},"dependencies":{"astro":"^5","@astrojs/node":"^9"}}`}, + want: typeNode, + }, + { + name: "anything with a start script, which is what serves a node application", + files: map[string]string{"package.json": `{"scripts":{"build":"vite build","start":"node server.js"},"devDependencies":{"vite":"^7"}}`}, + want: typeNode, + }, + { + name: "sveltekit with the static adapter", + files: map[string]string{"package.json": `{"scripts":{"build":"vite build"},"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3","vite":"^7"}}`}, + want: typeStatic, + }, + { + name: "sveltekit, which is a server without it", + files: map[string]string{"package.json": `{"scripts":{"build":"vite build"},"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-node":"^5","vite":"^7"}}`}, + want: typeNode, + }, + { + name: "files committed where nginx serves them", + files: map[string]string{"public/index.html": "

hello

"}, + want: typeStatic, + }, + { + name: "files committed at the root", + files: map[string]string{"index.html": "

hello

", "about.html": "

about

"}, + want: typeStatic, + }, + { + // Every project may carry an index.html; it decides nothing + // where something else here says what runs. + name: "a go service with a page beside it", + files: map[string]string{"go.mod": "module example.com/api\n\ngo 1.27\n", "index.html": "

hello

"}, + want: typeGo, + }, } for _, c := range cases { @@ -359,7 +412,7 @@ var manifestKeys = map[string]bool{ } func TestStarterUsesOnlyKeysTheControlPlaneUnderstands(t *testing.T) { - for _, kind := range []string{typeDrupal, typePHP, typeNode, typeGo, typeRust, typePython, typeDjango, typeRuby} { + for _, kind := range []string{typeDrupal, typePHP, typeNode, typeGo, typeRust, typePython, typeDjango, typeRuby, typeStatic} { t.Run(kind, func(t *testing.T) { rendered := renderStarter(starterFor(kind, checkoutWith(t, map[string]string{ "composer.json": `{"require":{"php":"^8.4","drupal/core":"^11"}}`, @@ -932,3 +985,68 @@ func TestStarterCachesWhatTheBuildInstalls(t *testing.T) { }) } } + +// A static site builds in Node's image and is served from public/, with +// nothing beside it. Its build tool is declared rather than suggested, since +// the steps cannot run without it and a build tool is taken on at the first +// build rather than refused. node_modules leaves the release: nothing in it +// runs, and the release is copied to every machine. +func TestStaticStarterBuildsInNodeAndServesPublic(t *testing.T) { + astro := renderStarter(starterFor(typeStatic, checkoutWith(t, map[string]string{ + "package.json": `{"engines":{"node":">=22"},"scripts":{"build":"astro build"},"dependencies":{"astro":"^5"}}`, + }))) + + for _, want := range []string{ + "type: static\n", + "services:\n - node: '22'\n", + "build:\n steps:\n - image: node\n run: npm ci && npm run build && rm -rf public && mv dist public && rm -rf node_modules\n", + } { + if !strings.Contains(astro, want) { + t.Errorf("the file does not say %q:\n%s", want, astro) + } + } + + for _, unwanted := range []string{"runtime:", "cache:", "start:", "deploy:"} { + if strings.Contains(astro, unwanted) { + t.Errorf("the file says %q, which a static site has no use for:\n%s", unwanted, astro) + } + } + + // Gatsby writes public/ itself, so nothing moves. + gatsby := renderStarter(starterFor(typeStatic, checkoutWith(t, map[string]string{ + "package.json": `{"engines":{"node":"22"},"scripts":{"build":"gatsby build"},"dependencies":{"gatsby":"^5"}}`, + }))) + if !strings.Contains(gatsby, " run: npm ci && npm run build && rm -rf node_modules\n") { + t.Errorf("gatsby's build is not left where it writes:\n%s", gatsby) + } + + // No version stated: the build tool is left to fill in, and said so. + unpinned := starterFor(typeStatic, checkoutWith(t, map[string]string{ + "package.json": `{"scripts":{"build":"vite build"},"devDependencies":{"vite":"^7"}}`, + })) + if rendered := renderStarter(unpinned); !strings.Contains(rendered, "# services:\n# - node: '26'\n") || strings.Contains(rendered, "\nservices:") { + t.Errorf("a node version was pinned that nothing here states:\n%s", rendered) + } + if len(unpinned.Notes) == 0 { + t.Error("no note saying the build tool needs its version") + } + + // Files and nothing else: no build, nothing beside them. + files := starterFor(typeStatic, checkoutWith(t, map[string]string{"public/index.html": "

hello

"})) + if rendered := renderStarter(files); strings.Contains(rendered, "build:") || strings.Contains(rendered, "services") { + t.Errorf("files committed as they are were given a build or a service:\n%s", rendered) + } + if len(files.Notes) != 0 { + t.Errorf("notes for a site that needs none: %v", files.Notes) + } + + // At the root, nginx would serve none of them, and that is said. + root := starterFor(typeStatic, checkoutWith(t, map[string]string{"index.html": "

hello

"})) + if len(root.Notes) == 0 || !strings.Contains(root.Notes[0], "public/") { + t.Errorf("pages at the root were not told nginx serves public/: %v", root.Notes) + } + + if err := knownProjectType(typeStatic); err != nil { + t.Errorf("--type static is refused: %v", err) + } +} From 2010b50919919a1f170216b0a38a94f898391353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Sat, 24 Oct 2026 20:24:14 +0200 Subject: [PATCH 3/4] Fix exporting wrong dump when project is not mapped --- README.md | 2 +- USAGE.md | 4 +- internal/cli/link.go | 88 ++++++++++++++++++++------------ internal/cli/target.go | 6 +-- internal/resolve/resolve.go | 40 ++++++++++++--- internal/resolve/resolve_test.go | 77 +++++++++++++++++++++++----- 6 files changed, 158 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 8e7b93f..7fa653f 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ when it is left off. | `cdn show\|clear` · `metrics` | What the CDN caches, and emptying it; CPU, memory and disk over a window | | `validate [--local]` | Checking `vallic.yaml` before you commit it | | `init` | Writes a starting `vallic.yaml` from what the checkout looks like: Drupal, Laravel, WordPress, PHP, Node.js (or Bun), Go, Rust, Python, Django, Ruby and Rails | -| `link` | Records which project this checkout is, when the git remote cannot say | +| `link` | Records a project for directories with no git remote; a checkout whose remote names no project is refused | Upsun's and Platform.sh's spelling works too: `vallic db:dump`, `vallic environment:list` and `vallic user:add` are `db dump`, `env list` and `team invite`. diff --git a/USAGE.md b/USAGE.md index 85f31e5..a9551ff 100644 --- a/USAGE.md +++ b/USAGE.md @@ -28,8 +28,8 @@ vallic ssh-key add vallic ssh-key add ~/.ssh/vallic.pub --label work-laptop vallic ssh-key list -# Record a project for directories that are not a clone of its repository. -# One for the whole machine: a clone's own remote still answers first. +# Record a project for directories with no git remote. One for the whole +# machine, so a checkout never uses it: its remote answers, or it is refused. vallic link acme-site # What this checkout points at: project, environment, branch, last deploy diff --git a/internal/cli/link.go b/internal/cli/link.go index e8167d2..9aafeee 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "flag" "fmt" "os" @@ -12,40 +13,41 @@ import ( "github.com/vallic/vallic-cli/internal/resolve" ) -// linkCommand records which project a checkout is. +// linkCommand records which project a directory with no git remote is. // -// For the one case internal/resolve cannot answer on its own: a checkout -// whose remote matches no project this credential can see, where the -// resolution stops and lists the candidates rather than picking one. A fork, -// a mirror, a repository added under a different host, or no git at all. +// For the one case internal/resolve cannot answer on its own: a directory +// that is not a checkout, or a checkout with no origin, where there is +// nothing to match a project against. // -// What it records is not a property of the checkout, and that is the whole +// What it records is not a property of the directory, and that is the whole // caveat. It goes in the configuration this machine shares between every -// directory, so the resolver reads it only *after* the git remote: a -// checkout that names its own project goes on resolving to it. Read first, -// a project linked once answered for every checkout on the machine, and a -// command run in one project's checkout acted on another project. +// directory, so the resolver never reads it where there is a git remote. Read +// before the remote, a project linked once answered for every checkout on the +// machine; read after a remote that matched nothing, it still answered for a +// checkout of a renamed repository. Both times a command run in one project's +// checkout acted on another project. func linkCommand() *Command { var forget bool return &Command{ Name: "link", - Summary: "record which project this checkout is", + Summary: "record which project a directory with no git remote is", Usage: "link [] [--clear]", Long: `Most checkouts need this and never run it. A project carries its repository's full name, so the git remote is matched against it and the project is worked out with nothing recorded anywhere. -This is for the checkouts where that fails, which is what "cannot tell which -project you mean" is saying: a fork, a mirror, a repository the project was -added under another name, or a directory that is not a checkout at all. +This is for the directories where there is no remote to match: one that is +not a checkout at all, or a checkout with no origin. -With no argument it says what is recorded now, or what commands resolve to -here without it. +A checkout whose remote names no project is refused rather than answered from +here: what is recorded is one project for the whole machine, and the checkout +is most likely another one's. Update the remote if the repository was renamed +or moved, or name the project with --project or VALLIC_PROJECT for a fork or +a mirror. -What it records is machine-wide, not per checkout, so it answers only where -a checkout cannot: a checkout whose git remote names a project you can see -goes on resolving to that project. --clear forgets it. +With no argument it says what is recorded now, or what commands resolve to +here without it. --clear forgets it. It records no environment, and that is deliberate. The environment comes from the branch, which changes while you work. An environment recorded once would @@ -107,10 +109,10 @@ VALLIC_ENVIRONMENT.`, return env.Printer.Value(map[string]any{"project": chosen}) } - env.Printer.Good("Commands act on %s where a checkout cannot say.", chosen.MachineName) + env.Printer.Good("Commands act on %s where there is no git remote to say.", chosen.MachineName) if dir, dirErr := config.Dir(); dirErr == nil { - env.Printer.Say(" recorded in %s, for the checkouts on this machine that cannot say", dir) + env.Printer.Say(" recorded in %s, for the directories on this machine with no git remote", dir) } warnOverridden(ctx, env, projects, chosen) @@ -128,7 +130,7 @@ VALLIC_ENVIRONMENT.`, // for a `$(…)` to capture, and which of the two it is goes to stderr. func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []api.Project) error { recorded := cfg.Project - inferred := inferProject(ctx, projects) + inferred, refused := inferProject(ctx, projects) if env.Printer.Structured() { name := "" @@ -157,12 +159,19 @@ func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []ap env.Printer.Say(" worked out from this checkout") if recorded != "" && recorded != inferred.MachineName { - env.Printer.Say(" %s is recorded too, and answers only where a checkout cannot say", recorded) + env.Printer.Say(" %s is recorded too, and answers only where there is no git remote", recorded) } return nil } + // A checkout whose remote names no project. Commands run here are refused + // and what is recorded does not answer, so printing it would be the wrong + // answer to "which project am I on". + if refused != nil { + return refused + } + if recorded == "" { if len(projects) == 0 { return fmt.Errorf("this credential can see no projects, so there is nothing to link to") @@ -181,7 +190,7 @@ func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []ap env.Printer.Line("%s", recorded) if dir, err := config.Dir(); err == nil { - env.Printer.Say(" recorded in %s, for the checkouts on this machine that cannot say", dir) + env.Printer.Say(" recorded in %s, for the directories on this machine with no git remote", dir) } return nil @@ -216,7 +225,7 @@ func clearLink(env *Env, cfg *config.Config) error { // command reads the same variable again. env.Printer.Good("The configuration file records no project now.") default: - env.Printer.Good("Forgot %s. A checkout that cannot say which project it is will be asked.", was) + env.Printer.Good("Forgot %s. A directory with no git remote will be asked which project it is.", was) } if fromEnv != "" { @@ -237,10 +246,15 @@ func warnOverridden(ctx context.Context, env *Env, projects []api.Project, chose env.Printer.Warn("VALLIC_PROJECT is set to %s, every command reads it first, and it has not changed", fromEnv) } - inferred := inferProject(ctx, projects) + inferred, refused := inferProject(ctx, projects) if inferred != nil && inferred.ID != chosen.ID { - env.Printer.Warn("this checkout goes on resolving to %s, which its git remote names; the link answers only where a checkout cannot say", inferred.MachineName) + env.Printer.Warn("this checkout goes on resolving to %s, which its git remote names; the link answers only where there is no git remote", inferred.MachineName) + } + + var ambiguous *resolve.Ambiguous + if errors.As(refused, &ambiguous) { + env.Printer.Warn("this checkout's remote is %s, which no project builds from, so the link does not answer here; update the remote, or name the project with --project or VALLIC_PROJECT", ambiguous.Remote) } } @@ -251,20 +265,28 @@ func warnOverridden(ctx context.Context, env *Env, projects []api.Project, chose // command's whole job, and a hint would answer with what was recorded a // moment ago. // -// A failure is not one: it means the checkout cannot say, which is the state -// this command is for. -func inferProject(ctx context.Context, projects []api.Project) *api.Project { +// A failure is mostly not one: it means there is no remote to say, which is +// the state this command is for. The exception comes back as the error: a +// checkout whose remote names no project, which commands refuse and which +// nothing recorded answers for. +func inferProject(ctx context.Context, projects []api.Project) (*api.Project, error) { dir, err := os.Getwd() if err != nil { - return nil + return nil, nil } project, err := (&resolve.Resolver{Client: known(projects), Dir: dir}).Project(ctx) + + var ambiguous *resolve.Ambiguous + if errors.As(err, &ambiguous) && ambiguous.Remote != "" { + return nil, err + } + if err != nil { - return nil + return nil, nil } - return project + return project, nil } // known answers a resolver out of the list this command already read, so diff --git a/internal/cli/target.go b/internal/cli/target.go index 12d7175..feff72e 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -37,9 +37,9 @@ func (t *Target) Named() string { // // The precedence is settled here, once, for both nouns: a flag beats the // environment variable, and both beat the checkout. The project `vallic -// link` recorded comes after the checkout, not before it — it is one answer -// for every directory on the machine, and a checkout that names its own -// project is the better one. See internal/resolve for the rules below that. +// link` recorded answers only where there is no git remote — it is one +// answer for every directory on the machine, and a checkout answers for +// itself or not at all. See internal/resolve for the rules below that. func (e *Env) Resolver(client resolve.Client) (*resolve.Resolver, error) { cfg, err := e.Config() if err != nil { diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 66b150f..a531ac4 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -53,6 +53,11 @@ type Ambiguous struct { // Because is why the CLI could not decide, in a clause. Because string + + // Remote is the checkout's repository, as `owner/name`, when it names one + // no project builds from. `vallic link` cannot answer in a checkout, so + // it is not offered there; the remote is what needs fixing. + Remote string } func (e *Ambiguous) Error() string { @@ -79,9 +84,13 @@ func (e *Ambiguous) Error() string { // Only for a project, because `link` records only a project. Offering it // for an environment would point somebody at a command that cannot fix - // what they just hit. - if e.What == "project" { - fmt.Fprint(&b, "\n or record it for this checkout: vallic link ") + // what they just hit — and the same goes for a checkout with a remote, + // where the link never answers. + switch { + case e.Remote != "": + fmt.Fprint(&b, "\n or, if the repository was renamed or moved: git remote set-url origin ") + case e.What == "project": + fmt.Fprint(&b, "\n or record it for this directory: vallic link ") } return b.String() @@ -127,9 +136,10 @@ type Resolver struct { // ProjectRecorded is what `vallic link` wrote in the configuration. It // answers for every directory on the machine, so it is asked only where - // the checkout cannot answer: read before the git remote, a project + // there is no git remote at all: read before the git remote, a project // linked for one checkout sent `vallic drush` and `vallic deploy` run in - // another to the first one's production. + // another to the first one's production, and read for a remote that + // matched nothing, it did the same to a checkout of a renamed repository. ProjectRecorded string } @@ -153,10 +163,26 @@ func (r *Resolver) Project(ctx context.Context) (*api.Project, error) { if match := projectByRepository(projects, remote); match != nil { return match, nil } + + // A checkout that names a repository no project builds from is not + // answered by anything below. What was linked and "the only project" + // are both answers for some other directory: taken here, a dump run + // in one project's checkout, whose remote still had the repository's + // old name, came back with another project's production database. + name := normaliseRemote(remote) + if name == "" { + name = remote + } + + return nil, &Ambiguous{ + What: "project", + Candidates: projectNames(projects), + Because: fmt.Sprintf("this checkout's remote is %s, and no project you can see builds from it", name), + Remote: name, + } } - // What was linked, for a checkout whose remote names no project here: a - // fork, a mirror, a directory with no git at all. + // What was linked, for a directory with no git remote to say. if r.ProjectRecorded != "" { return matchProject(projects, r.ProjectRecorded) } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index b105a3e..29f4fa4 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -230,9 +230,8 @@ func TestACheckoutBeatsTheRecordedProject(t *testing.T) { } } -// The linked project answers where the checkout cannot: no git, or a remote -// naming no project this credential can see. -func TestTheRecordedProjectAnswersWhereTheCheckoutCannot(t *testing.T) { +// The linked project answers where there is no git remote to say. +func TestTheRecordedProjectAnswersWhereThereIsNoRemote(t *testing.T) { client := &fakeClient{ projects: []api.Project{ {ID: 1, MachineName: "blog", Repository: repo("acme/blog")}, @@ -240,17 +239,69 @@ func TestTheRecordedProjectAnswersWhereTheCheckoutCannot(t *testing.T) { }, } - for name, dir := range map[string]string{ - "no git": t.TempDir(), - "a fork's remote": checkout(t, "git@github.com:someone/shop-fork.git"), + got, err := (&Resolver{Client: client, Dir: t.TempDir(), ProjectRecorded: "blog"}).Project(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.MachineName != "blog" { + t.Errorf("resolved %s, want the linked blog", got.MachineName) + } +} + +// A checkout whose remote names no project is refused, whatever is linked +// and however few projects there are. +// +// Both are answers for some other directory. Taking the link here sent a +// database dump, run in a checkout whose remote still had its repository's +// old name, to another project's production. +func TestAnUnmatchedRemoteIsRefused(t *testing.T) { + for name, tc := range map[string]struct { + projects []api.Project + recorded string + }{ + "with a project linked": { + projects: []api.Project{ + {ID: 1, MachineName: "blog", Repository: repo("acme/blog")}, + {ID: 2, MachineName: "webshop", Repository: repo("acme/shop")}, + }, + recorded: "blog", + }, + "with only one project": { + projects: []api.Project{{ID: 1, MachineName: "blog", Repository: repo("acme/blog")}}, + }, } { - got, err := (&Resolver{Client: client, Dir: dir, ProjectRecorded: "blog"}).Project(context.Background()) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if got.MachineName != "blog" { - t.Errorf("%s: resolved %s, want the linked blog", name, got.MachineName) - } + t.Run(name, func(t *testing.T) { + r := &Resolver{ + Client: &fakeClient{projects: tc.projects}, + Dir: checkout(t, "git@github.com:acme/old-name.git"), + ProjectRecorded: tc.recorded, + } + + got, err := r.Project(context.Background()) + + var ambiguous *Ambiguous + if !errors.As(err, &ambiguous) { + t.Fatalf("resolved %v, err %v; want an *Ambiguous", got, err) + } + + if ambiguous.Remote != "acme/old-name" { + t.Errorf("Remote = %q, want acme/old-name", ambiguous.Remote) + } + + msg := err.Error() + if !strings.Contains(msg, "acme/old-name") { + t.Errorf("the error does not name the remote:\n%s", msg) + } + if !strings.Contains(msg, "git remote set-url") { + t.Errorf("the error does not say how to fix the remote:\n%s", msg) + } + + // The link cannot answer in a checkout, so offering it would send + // somebody to a command that changes nothing here. + if strings.Contains(msg, "vallic link") { + t.Errorf("the error offers vallic link, which does not answer in a checkout:\n%s", msg) + } + }) } } From 4237b62786f29d454477f9bab370788d81c189ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Thu, 29 Oct 2026 22:19:04 +0100 Subject: [PATCH 4/4] Add domain ches --- README.md | 2 +- USAGE.md | 5 ++ internal/api/domains_promote_test.go | 57 ++++++++++++++++++ internal/api/models.go | 15 ++++- internal/api/routes.go | 27 +++++++++ internal/cli/domain.go | 87 +++++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 internal/api/domains_promote_test.go diff --git a/README.md b/README.md index 7fa653f..ff55134 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ when it is left off. | `ssh` · `sql` · `db dump\|export\|import\|size` · `mount` | Getting inside. `db dump` writes a file named for the project and environment, all of it or `--table`s; `db size` shows the largest tables; `sql -- ` runs one query | | `drush` | `drush` in the container, over the same forced command `ssh --` uses | | `var list\|get\|set\|delete\|import\|apply` | Variables, at either scope; `import` adds a `.env`, `apply` restarts the site with them now | -| `domain list\|add\|verify\|delete` | Hostnames, and the DNS each one needs | +| `domain list\|add\|verify\|primary\|canonical\|delete` | Hostnames, the DNS each one needs, and which one the site redirects to | | `service list` | The stack, and the versions that go in `vallic.yaml` | | `backup list\|create\|download\|restore` | Copies, and putting one back | | `env create` · `env source` · `env maintenance` · `env delete` | Raising, repointing, taking offline and removing an environment | diff --git a/USAGE.md b/USAGE.md index a9551ff..d5ea17f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -300,6 +300,11 @@ vallic domain add shop.example.com production vallic domain verify shop.example.com production vallic domain list production +# The site's address: the platform hostname redirects to it, other domains +# still serve. Canonical redirects every other hostname to it too +vallic domain primary shop.example.com production +vallic domain canonical shop.example.com production + # Give one up vallic domain delete old.example.com production ``` diff --git a/internal/api/domains_promote_test.go b/internal/api/domains_promote_test.go new file mode 100644 index 0000000..85fbdfb --- /dev/null +++ b/internal/api/domains_promote_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +// primary sends canonical false as well, so a canonical hostname made primary +// stops redirecting the others; canonical sends canonical alone, which makes +// it primary too. +func TestPromoteDomainSaysWhetherTheOthersRedirect(t *testing.T) { + for _, tc := range []struct { + name string + canonical bool + want map[string]any + }{ + {"primary", false, map[string]any{"primary": true, "canonical": false}}, + {"canonical", true, map[string]any{"canonical": true}}, + } { + t.Run(tc.name, func(t *testing.T) { + var method, path string + var body map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&body) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"domain":{"id":5,"hostname":"shop.example.com","is_primary":true},"message":"shop.example.com is now primary."}`)) + })) + defer server.Close() + + client := New(server.URL, anonymous{}, "test") + + updated, err := client.PromoteDomain(context.Background(), 5, tc.canonical) + if err != nil { + t.Fatalf("PromoteDomain: %v", err) + } + + if method != http.MethodPatch || path != Prefix+"/domains/5" { + t.Errorf("sent %s %s, want PATCH %s/domains/5", method, path, Prefix) + } + + if !reflect.DeepEqual(body, tc.want) { + t.Errorf("body = %v, want %v", body, tc.want) + } + + if !updated.Domain.IsPrimary || updated.Message == "" { + t.Errorf("answer = %+v, want the domain and the message", updated) + } + }) + } +} diff --git a/internal/api/models.go b/internal/api/models.go index 4316afa..0acff64 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -692,8 +692,12 @@ type Domain struct { // environment and cannot be removed on its own. Kind string `json:"kind"` - IsPrimary bool `json:"is_primary"` - IsApex bool `json:"is_apex"` + // IsPrimary is the site's address, which the platform hostname redirects + // to; IsCanonical, only ever on a primary custom domain, redirects every + // other hostname to it as well. + IsPrimary bool `json:"is_primary"` + IsCanonical bool `json:"is_canonical"` + IsApex bool `json:"is_apex"` // VerificationState is three values, not two: "unverified" means never // checked, "failed" means checked and the record was wrong. A client with @@ -763,6 +767,13 @@ type VerificationResult struct { Domain Domain `json:"domain"` } +// DomainUpdated is a domain after a change, and what the change did, in +// English, for printing. +type DomainUpdated struct { + Domain Domain `json:"domain"` + Message string `json:"message"` +} + // DomainRemoved is what a delete removed. // // A body rather than an empty 200, so a client prints what the control plane diff --git a/internal/api/routes.go b/internal/api/routes.go index 849f919..f6cdb1e 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -463,6 +463,33 @@ func (c *Client) VerifyDomain(ctx context.Context, domainID int) (*VerificationR return &out, nil } +// PromoteDomain makes a hostname the environment's primary address, and says +// whether every other hostname redirects to it. +// +// Canonical false is sent as well as primary true, rather than primary alone, +// so the one request means "primary and nothing more" whatever the hostname +// was: a canonical one stays primary and stops redirecting the others, which +// primary alone would leave as it is. +func (c *Client) PromoteDomain(ctx context.Context, domainID int, canonical bool) (*DomainUpdated, error) { + patch := map[string]any{"canonical": canonical} + if !canonical { + patch["primary"] = true + } + + body, err := json.Marshal(patch) + if err != nil { + return nil, err + } + + var out DomainUpdated + + if err := c.do(ctx, http.MethodPatch, fmt.Sprintf("/domains/%d", domainID), body, &out); err != nil { + return nil, err + } + + return &out, nil +} + // DeleteDomain removes a hostname. func (c *Client) DeleteDomain(ctx context.Context, domainID int) (*DomainRemoved, error) { var out DomainRemoved diff --git a/internal/cli/domain.go b/internal/cli/domain.go index 0784741..f781485 100644 --- a/internal/cli/domain.go +++ b/internal/cli/domain.go @@ -26,6 +26,8 @@ actually being routed.`, domainListCommand(), domainAddCommand(), domainVerifyCommand(), + domainPrimaryCommand(), + domainCanonicalCommand(), domainDeleteCommand(), }, } @@ -62,7 +64,7 @@ records the platform is looking for when it does not find them.`, } table := output.Table{ - Columns: []string{"hostname", "kind", "state", "canonical", "tls"}, + Columns: []string{"hostname", "kind", "state", "primary", "canonical", "tls"}, Empty: "No hostnames, which should not be possible.", } @@ -74,6 +76,7 @@ records the platform is looking for when it does not find them.`, domain.Kind, domainState(domain), yesNo(domain.IsPrimary), + yesNo(domain.IsCanonical), domain.TLSMode, }) } @@ -228,6 +231,88 @@ pass picks up a record that lands later anyway.`, } } +func domainPrimaryCommand() *Command { + return &Command{ + Name: "primary", + Summary: "make a hostname the site's address", + Usage: "domain primary []", + Long: `Makes it the address the site is: its base URL, and the one the +platform hostname redirects to, path and all. Your other hostnames go on +serving the site as they are; ` + "`domain canonical`" + ` redirects them too. + +On a hostname that is canonical it stops the other hostnames redirecting, and +it stays primary. + +Making the platform hostname primary again is how redirects are turned off: +nothing redirects then. + +Only a verified hostname can be primary.`, + Run: func(ctx context.Context, env *Env, args []string) error { + return promoteDomain(ctx, env, args, false) + }, + } +} + +func domainCanonicalCommand() *Command { + return &Command{ + Name: "canonical", + Summary: "make a hostname primary, and redirect every other one to it", + Usage: "domain canonical []", + Long: `Makes it primary, and redirects every other hostname of the +environment to it, path and all — the platform hostname and your other +domains alike. ` + "`domain primary`" + ` on the same hostname undoes the redirects and +keeps it primary. + +Only one of your own verified hostnames can be canonical. The platform +hostname cannot, because your own domains would redirect to it.`, + Run: func(ctx context.Context, env *Env, args []string) error { + return promoteDomain(ctx, env, args, true) + }, + } +} + +// promoteDomain is domain primary and domain canonical: the same request, +// with or without the other hostnames redirecting. +func promoteDomain(ctx context.Context, env *Env, args []string, canonical bool) error { + if len(args) == 0 { + return Usagef(nil, "name a hostname") + } + + hostname := args[0] + + target, err := env.ResolveEnvironment(ctx, firstOf(args[1:])) + if err != nil { + return err + } + + client, err := env.Client() + if err != nil { + return err + } + + domain, err := findDomain(ctx, client, target.Environment.ID, hostname) + if err != nil { + return err + } + + updated, err := client.PromoteDomain(ctx, domain.ID, canonical) + if err != nil { + if api.Code(err) == "not_verified" { + return fmt.Errorf("%w\n vallic domain verify %s", err, domain.Hostname) + } + + return err + } + + if env.Printer.Structured() { + return env.Printer.Value(updated) + } + + env.Printer.Good("%s", updated.Message) + + return nil +} + func domainDeleteCommand() *Command { var yes bool