diff --git a/internal/builder/builder.go b/internal/builder/builder.go index fdcbb64..4fdfc42 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -587,6 +587,12 @@ func one(ctx context.Context, run Runner, publish Publisher, if err != nil { return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err) } + // **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime + // knows no language; for one that runs through an interpreter the build writes the launcher. + launchers, err := writeLaunchers(compiled, chain, a) + if err != nil { + return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err) + } say("bundle", "compiled, packing") body, err := pack(compiled) if err != nil { @@ -598,7 +604,7 @@ func one(ctx context.Context, run Runner, publish Publisher, if err != nil { return catalogue.Built{}, err } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil + return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil case catalogue.ArtifactPackage: // Built and published on a public base, to the mesh's package registry, by version @@ -1173,3 +1179,45 @@ func binaryName(a catalogue.Artifact) string { } return a.Name } + +// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is +// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the +// bundle's package.json says. +const launcherSuffix = ".serve.mjs" + +// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that +// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's +// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its +// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for +// a language whose build is already executable. +func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) { + if chain.Language != "typescript" { + return nil, nil + } + out := map[string]string{} + for _, entry := range a.Entrypoints { + if !strings.HasSuffix(entry, ".js") { + continue + } + launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix + body := "#!/usr/bin/env node\n" + + "// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" + + "// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" + + "import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" + + "await import(\"./" + filepath.Base(entry) + "\");\n" + + "await serveRegisteredOverStdio();\n" + path := filepath.Join(root, filepath.FromSlash(launcher)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + return nil, err + } + // WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set. + if err := os.Chmod(path, 0o755); err != nil { + return nil, err + } + out[entry] = launcher + } + return out, nil +} diff --git a/internal/builder/launchers_test.go b/internal/builder/launchers_test.go new file mode 100644 index 0000000..8185eff --- /dev/null +++ b/internal/builder/launchers_test.go @@ -0,0 +1,49 @@ +package builder + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no +// language — so a TypeScript bundle carries a launcher beside each entrypoint. +func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) { + root := t.TempDir() + chain, err := ToolchainFor("typescript") + if err != nil { + t.Fatal(err) + } + got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, + Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}}) + if err != nil { + t.Fatal(err) + } + if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" { + t.Fatalf("launchers: %v", got) + } + path := filepath.Join(root, "tools", "index.serve.mjs") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm()) + } + body, _ := os.ReadFile(path) + for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} { + if !strings.Contains(string(body), want) { + t.Errorf("the launcher lacks %q:\n%s", want, body) + } + } + + // A compiled language's build is executable already: no launcher. + goChain, _ := ToolchainFor("go") + none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"}) + if err != nil || len(none) != 0 { + t.Errorf("a Go bundle was given launchers: %v %v", none, err) + } +} diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 68b70b8..88f77ab 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -28,6 +28,9 @@ type Built struct { Reference string // Digest is "sha256:", for an archive. An image reference already ends in one. Digest string + // Launchers are, for a bundle in an interpreted language, the executable the build wrote beside + // each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it. + Launchers map[string]string } // Resolve fills a manifest's resources in from what was built. @@ -92,7 +95,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { out.Bundles = append(out.Bundles, Bundle{ Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest, Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), - Loads: loads, Env: copyWords(a.Env), + Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers), }) } sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 5233a19..8acd381 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -604,6 +604,9 @@ type Bundle struct { Loads []string `json:"loads,omitempty"` // Env is what the artifact said it is given (ADR 0192), as written; composed per machine. Env map[string]string `json:"env,omitempty"` + // Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq + // ADR 0193). A bundle built before them has none, and is served as it was built. + Launchers map[string]string `json:"launchers,omitempty"` } // Build says how to produce this module's artifacts from its source. diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 0722e49..4a33cc7 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -169,6 +169,11 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { continue } for _, load := range b.Loads { + // What the runtime starts: the launcher the build wrote beside the entrypoint, where + // it wrote one (ADR 0193); the entrypoint itself for a build from before them. + if launcher, has := b.Launchers[load]; has { + load = launcher + } served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) } restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 4003fb2..0411124 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -336,3 +336,29 @@ func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { } } } + +// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a +// build from before launchers — so the move needs no flag day. +func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, + Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript", + Entrypoints: []string{"tools/index.js"}}}}} + launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest, + Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}}) + if err != nil { + t.Fatal(err) + } + older := aToolsModule(t, "nftables", "tools/index.js") + out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) + want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js" + if env[RuntimeToolModules] != want { + t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want) + } +}