Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
219 changes: 219 additions & 0 deletions cmd/interlock/install.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions cmd/interlock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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 <dir> 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)
Expand Down
1 change: 1 addition & 0 deletions e2e/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions e2e/install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package e2e

// control-law: shipped-surface-honors-the-core (install)
//
// `interlock install <lang>` 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")
}
})
}
Loading