From ee92aace3982732fab7352092b1b53877bc3a294 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Mon, 27 Jul 2026 11:06:50 +0000 Subject: [PATCH] Sync Interlock from Intelligence Flow @ 10ef9c03b7e2 --- UPSTREAM.json | 8 +- cmd/interlock/install.go | 219 +++++++++++++++++++++++++++++++++++++++ cmd/interlock/main.go | 3 + e2e/coverage_test.go | 1 + e2e/install_test.go | 78 ++++++++++++++ 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 cmd/interlock/install.go create mode 100644 e2e/install_test.go diff --git a/UPSTREAM.json b/UPSTREAM.json index 61ad09a..d1a33fd 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -35,7 +35,8 @@ "cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764", "cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc", "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", - "cmd/interlock/main.go": "0e4088a462c74963ae5c693b3f37e11dcc9ca0ce91fdc5b39ba6fae82d14ecae", + "cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae", + "cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", @@ -79,8 +80,9 @@ "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", "docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d", - "e2e/coverage_test.go": "1240b8a56703d3c2b2492ef63049dc4573b769c444549cd693b178c06d3ab136", + "e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5", "e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f", + "e2e/install_test.go": "a0f65ec294fdc7d032045e214c1f56021f08472f44536b6365df8d66471fd67f", "e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2", "e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a", "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", @@ -117,7 +119,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "0c891b5d90548ec81d5f1bafe9dd63e06a5df2b2", + "commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/interlock/install.go b/cmd/interlock/install.go new file mode 100644 index 0000000..b17d544 --- /dev/null +++ b/cmd/interlock/install.go @@ -0,0 +1,219 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// The install shortcut fetches the typed client for a language from the project's +// OWN registry, fronted by the public install host — the ONE coordinate this command +// needs. The host is public (it already appears in the shell installer), so embedding +// it here leaks nothing; the private Artifact Registry coordinates live only behind +// that front door (get-service proxies them). Package names are the public client +// package identities. Override the host with INTERLOCK_GET_HOST or --host for staging. +const ( + defaultGetHost = "get.operatorstack.systems" + npmScope = "@operatorstack" + npmClientPkg = "@operatorstack/interlock" + pyClientPkg = "interlock-protocol" +) + +func resolveGetHost(flag string) string { + if flag != "" { + return flag + } + if env := strings.TrimSpace(os.Getenv("INTERLOCK_GET_HOST")); env != "" { + return env + } + return defaultGetHost +} + +// cmdInstall fetches the typed client for a language from the front-door registry, +// configuring the package manager's index so the consumer never hand-edits .npmrc / +// --index-url and never needs GCP credentials. --configure-only writes the registry +// config and stops (no toolchain required); the default also runs the install. +func cmdInstall(args []string) error { + host := "" + dir := "." + force := false + configureOnly := false + var positional []string + + i := 0 + for i < len(args) { + switch args[i] { + case "--host": + if i+1 >= len(args) { + return fmt.Errorf("install: --host wants a hostname") + } + host = args[i+1] + i += 2 + case "--dir": + if i+1 >= len(args) { + return fmt.Errorf("install: --dir wants a path") + } + dir = args[i+1] + i += 2 + case "--configure-only": + configureOnly = true + i++ + case "--force", "-f": + force = true + i++ + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("install: unexpected flag %q", args[i]) + } + positional = append(positional, args[i]) + i++ + } + } + if len(positional) > 1 { + return fmt.Errorf("install: expected at most one language, got %v", positional) + } + + lang := "" + if len(positional) == 1 { + lang = positional[0] + } else { + chosen, err := promptLanguage() + if err != nil { + return err + } + lang = chosen + } + + host = resolveGetHost(host) + switch normalizeLang(lang) { + case "ts": + return installNPM(dir, host, force, configureOnly) + case "python": + return installPython(dir, host, force, configureOnly) + default: + return fmt.Errorf("install: unknown language %q (want: ts | python)", lang) + } +} + +func normalizeLang(lang string) string { + switch strings.ToLower(strings.TrimSpace(lang)) { + case "ts", "typescript", "js", "javascript", "npm", "node": + return "ts" + case "python", "py", "pip", "uv": + return "python" + default: + return "" + } +} + +// promptLanguage renders a numbered picker and reads a choice, mirroring init's +// promptTemplate. On EOF with no input it returns a clear non-interactive error. +func promptLanguage() (string, error) { + fmt.Println("Which typed client do you want to install?") + fmt.Println() + fmt.Println(" 1. TypeScript (" + npmClientPkg + ", via npm)") + fmt.Println(" 2. Python (" + pyClientPkg + ", via uv or pip)") + fmt.Println() + fmt.Print("Choice [1-2]: ") + + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + switch strings.TrimSpace(line) { + case "1": + return "ts", nil + case "2": + return "python", nil + case "": + return "", fmt.Errorf("install: name a language when non-interactive: interlock install ts|python") + default: + return "", fmt.Errorf("install: invalid choice %q (want 1 or 2)", strings.TrimSpace(line)) + } +} + +// upsertLine replaces the first line whose key matches prefix, or appends it, +// keeping the file idempotent across re-runs. +func upsertLine(existing, prefix, line string) string { + out := []string{} + replaced := false + for _, l := range strings.Split(existing, "\n") { + if strings.HasPrefix(strings.TrimSpace(l), prefix) { + if !replaced { + out = append(out, line) + replaced = true + } + continue + } + out = append(out, l) + } + joined := strings.TrimRight(strings.Join(out, "\n"), "\n") + if !replaced { + if joined != "" { + joined += "\n" + } + joined += line + } + return joined + "\n" +} + +func installNPM(dir, host string, force, configureOnly bool) error { + registryURL := fmt.Sprintf("https://%s/npm/", host) + line := fmt.Sprintf("%s:registry=%s", npmScope, registryURL) + npmrc := filepath.Join(dir, ".npmrc") + existing := "" + if b, err := os.ReadFile(npmrc); err == nil { + existing = string(b) + } else if !os.IsNotExist(err) { + return err + } + if err := os.WriteFile(npmrc, []byte(upsertLine(existing, npmScope+":registry=", line)), 0o644); err != nil { + return err + } + fmt.Printf("configured %s -> %s\n", npmrc, registryURL) + if configureOnly { + fmt.Printf("run: npm install %s\n", npmClientPkg) + return nil + } + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("install: npm not found on PATH (config written; run: npm install %s)", npmClientPkg) + } + return runIn(dir, "npm", "install", npmClientPkg) +} + +func installPython(dir, host string, force, configureOnly bool) error { + indexURL := fmt.Sprintf("https://%s/pip/simple/", host) + // Persist the index for reuse and print the exact command. uv and pip take the + // same --index-url flag, so the install line is uniform; the .interlock/registry + // file records it so re-runs and CI can source one place. + regFile := filepath.Join(dir, ".interlock", "registry") + if err := os.MkdirAll(filepath.Dir(regFile), 0o755); err != nil { + return err + } + if err := os.WriteFile(regFile, []byte("PIP_INDEX_URL="+indexURL+"\n"), 0o644); err != nil { + return err + } + fmt.Printf("configured %s (PIP_INDEX_URL=%s)\n", regFile, indexURL) + if configureOnly { + fmt.Printf("run: uv pip install --index-url %s %s (or: pip install --index-url %s %s)\n", indexURL, pyClientPkg, indexURL, pyClientPkg) + return nil + } + if _, err := exec.LookPath("uv"); err == nil { + return runIn(dir, "uv", "pip", "install", "--index-url", indexURL, pyClientPkg) + } + if _, err := exec.LookPath("pip"); err == nil { + return runIn(dir, "pip", "install", "--index-url", indexURL, pyClientPkg) + } + return fmt.Errorf("install: neither uv nor pip found on PATH (config written; run with --index-url %s)", indexURL) +} + +func runIn(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("install: %s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index f1f65d8..e2dd4dc 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -31,6 +31,8 @@ func main() { switch os.Args[1] { case "init": err = cmdInit(os.Args[2:]) + case "install": + err = cmdInstall(os.Args[2:]) case "derive": err = cmdDerive(os.Args[2:]) case "compile": @@ -78,6 +80,7 @@ usage: interlock init set up a no-toolchain JSON policy (interactive) interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock) interlock init --authoring go scaffold a programmable Go policy module + interlock install [ts|python] install the typed client from your registry (--configure-only writes config) interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces) interlock test [dir] run the policy's tests (dir defaults to .interlock) interlock demo [name] narrate a built-in policy (default repository-policy; --list) diff --git a/e2e/coverage_test.go b/e2e/coverage_test.go index 33f4d6d..cc0cf67 100644 --- a/e2e/coverage_test.go +++ b/e2e/coverage_test.go @@ -22,6 +22,7 @@ import ( // commands and are excluded from the dispatch set below. var covered = map[string]string{ "init": "TestJourney_InitTestTamper", + "install": "TestJourney_Install", "derive": "TestJourney_Derive", "compile": "TestJourney_Derive (promotion) + parity fixtures", "check": "TestSmoke_InfoCommands", diff --git a/e2e/install_test.go b/e2e/install_test.go new file mode 100644 index 0000000..2032c9b --- /dev/null +++ b/e2e/install_test.go @@ -0,0 +1,78 @@ +package e2e + +// control-law: shipped-surface-honors-the-core (install) +// +// `interlock install ` configures the consumer's package manager to fetch the +// typed client from the project's front-door registry — no hand-edited .npmrc / +// --index-url, no GCP credentials. --configure-only writes the registry config +// without invoking a toolchain, so this journey is hermetic (no npm/pip/network): +// it asserts the exact config the command writes. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestJourney_Install(t *testing.T) { + host := "get.operatorstack.systems" + + t.Run("typescript writes a scoped npm registry", func(t *testing.T) { + dir := t.TempDir() + _, stderr, code := run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host) + if code != 0 { + t.Fatalf("install ts failed (%d): %s", code, stderr) + } + b, err := os.ReadFile(filepath.Join(dir, ".npmrc")) + if err != nil { + t.Fatalf("read .npmrc: %v", err) + } + want := "@operatorstack:registry=https://" + host + "/npm/" + if !strings.Contains(string(b), want) { + t.Fatalf(".npmrc missing scoped registry\nwant: %s\ngot:\n%s", want, b) + } + if strings.Contains(string(b), "pkg.dev") { + t.Fatalf(".npmrc leaks the private AR host: %s", b) + } + }) + + t.Run("re-run is idempotent (one registry line)", func(t *testing.T) { + dir := t.TempDir() + run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host) + run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host) + b, _ := os.ReadFile(filepath.Join(dir, ".npmrc")) + if n := strings.Count(string(b), "@operatorstack:registry="); n != 1 { + t.Fatalf("expected exactly one registry line, got %d:\n%s", n, b) + } + }) + + t.Run("python writes a pip index pointing at the front door", func(t *testing.T) { + dir := t.TempDir() + _, stderr, code := run(t, "install", "python", "--dir", dir, "--configure-only", "--host", host) + if code != 0 { + t.Fatalf("install python failed (%d): %s", code, stderr) + } + b, err := os.ReadFile(filepath.Join(dir, ".interlock", "registry")) + if err != nil { + t.Fatalf("read .interlock/registry: %v", err) + } + if !strings.Contains(string(b), "PIP_INDEX_URL=https://"+host+"/pip/simple/") { + t.Fatalf("registry file missing pip index:\n%s", b) + } + }) + + t.Run("no language, non-interactive, fails closed", func(t *testing.T) { + _, _, code := run(t, "install") + if code == 0 { + t.Fatal("install with no language and no stdin should fail closed") + } + }) + + t.Run("unknown language fails closed", func(t *testing.T) { + _, _, code := run(t, "install", "rust", "--configure-only") + if code == 0 { + t.Fatal("unknown language should fail closed") + } + }) +}