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/2] 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/2] 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) + } +}