Skip to content

Commit 2f41524

Browse files
Sync Interlock from Intelligence Flow @ 10ef9c03b7e2 (#24)
Co-authored-by: operator-stack-publisher[bot] <operator-stack-publisher[bot]@users.noreply.github.com>
1 parent f929578 commit 2f41524

5 files changed

Lines changed: 306 additions & 3 deletions

File tree

UPSTREAM.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764",
3636
"cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc",
3737
"cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f",
38-
"cmd/interlock/main.go": "0e4088a462c74963ae5c693b3f37e11dcc9ca0ce91fdc5b39ba6fae82d14ecae",
38+
"cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae",
39+
"cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac",
3940
"cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079",
4041
"cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025",
4142
"cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d",
@@ -79,8 +80,9 @@
7980
"derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e",
8081
"doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198",
8182
"docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d",
82-
"e2e/coverage_test.go": "1240b8a56703d3c2b2492ef63049dc4573b769c444549cd693b178c06d3ab136",
83+
"e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5",
8384
"e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f",
85+
"e2e/install_test.go": "a0f65ec294fdc7d032045e214c1f56021f08472f44536b6365df8d66471fd67f",
8486
"e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2",
8587
"e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a",
8688
"emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545",
@@ -117,7 +119,7 @@
117119
"generator": "operatorstack/interlock:project-upstream",
118120
"schema_version": 1,
119121
"source": {
120-
"commit": "0c891b5d90548ec81d5f1bafe9dd63e06a5df2b2",
122+
"commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7",
121123
"path": "labs/21-interlock",
122124
"repository": "operatorstack/intelligence-flow"
123125
}

cmd/interlock/install.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
// The install shortcut fetches the typed client for a language from the project's
13+
// OWN registry, fronted by the public install host — the ONE coordinate this command
14+
// needs. The host is public (it already appears in the shell installer), so embedding
15+
// it here leaks nothing; the private Artifact Registry coordinates live only behind
16+
// that front door (get-service proxies them). Package names are the public client
17+
// package identities. Override the host with INTERLOCK_GET_HOST or --host for staging.
18+
const (
19+
defaultGetHost = "get.operatorstack.systems"
20+
npmScope = "@operatorstack"
21+
npmClientPkg = "@operatorstack/interlock"
22+
pyClientPkg = "interlock-protocol"
23+
)
24+
25+
func resolveGetHost(flag string) string {
26+
if flag != "" {
27+
return flag
28+
}
29+
if env := strings.TrimSpace(os.Getenv("INTERLOCK_GET_HOST")); env != "" {
30+
return env
31+
}
32+
return defaultGetHost
33+
}
34+
35+
// cmdInstall fetches the typed client for a language from the front-door registry,
36+
// configuring the package manager's index so the consumer never hand-edits .npmrc /
37+
// --index-url and never needs GCP credentials. --configure-only writes the registry
38+
// config and stops (no toolchain required); the default also runs the install.
39+
func cmdInstall(args []string) error {
40+
host := ""
41+
dir := "."
42+
force := false
43+
configureOnly := false
44+
var positional []string
45+
46+
i := 0
47+
for i < len(args) {
48+
switch args[i] {
49+
case "--host":
50+
if i+1 >= len(args) {
51+
return fmt.Errorf("install: --host wants a hostname")
52+
}
53+
host = args[i+1]
54+
i += 2
55+
case "--dir":
56+
if i+1 >= len(args) {
57+
return fmt.Errorf("install: --dir wants a path")
58+
}
59+
dir = args[i+1]
60+
i += 2
61+
case "--configure-only":
62+
configureOnly = true
63+
i++
64+
case "--force", "-f":
65+
force = true
66+
i++
67+
default:
68+
if strings.HasPrefix(args[i], "-") {
69+
return fmt.Errorf("install: unexpected flag %q", args[i])
70+
}
71+
positional = append(positional, args[i])
72+
i++
73+
}
74+
}
75+
if len(positional) > 1 {
76+
return fmt.Errorf("install: expected at most one language, got %v", positional)
77+
}
78+
79+
lang := ""
80+
if len(positional) == 1 {
81+
lang = positional[0]
82+
} else {
83+
chosen, err := promptLanguage()
84+
if err != nil {
85+
return err
86+
}
87+
lang = chosen
88+
}
89+
90+
host = resolveGetHost(host)
91+
switch normalizeLang(lang) {
92+
case "ts":
93+
return installNPM(dir, host, force, configureOnly)
94+
case "python":
95+
return installPython(dir, host, force, configureOnly)
96+
default:
97+
return fmt.Errorf("install: unknown language %q (want: ts | python)", lang)
98+
}
99+
}
100+
101+
func normalizeLang(lang string) string {
102+
switch strings.ToLower(strings.TrimSpace(lang)) {
103+
case "ts", "typescript", "js", "javascript", "npm", "node":
104+
return "ts"
105+
case "python", "py", "pip", "uv":
106+
return "python"
107+
default:
108+
return ""
109+
}
110+
}
111+
112+
// promptLanguage renders a numbered picker and reads a choice, mirroring init's
113+
// promptTemplate. On EOF with no input it returns a clear non-interactive error.
114+
func promptLanguage() (string, error) {
115+
fmt.Println("Which typed client do you want to install?")
116+
fmt.Println()
117+
fmt.Println(" 1. TypeScript (" + npmClientPkg + ", via npm)")
118+
fmt.Println(" 2. Python (" + pyClientPkg + ", via uv or pip)")
119+
fmt.Println()
120+
fmt.Print("Choice [1-2]: ")
121+
122+
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
123+
switch strings.TrimSpace(line) {
124+
case "1":
125+
return "ts", nil
126+
case "2":
127+
return "python", nil
128+
case "":
129+
return "", fmt.Errorf("install: name a language when non-interactive: interlock install ts|python")
130+
default:
131+
return "", fmt.Errorf("install: invalid choice %q (want 1 or 2)", strings.TrimSpace(line))
132+
}
133+
}
134+
135+
// upsertLine replaces the first line whose key matches prefix, or appends it,
136+
// keeping the file idempotent across re-runs.
137+
func upsertLine(existing, prefix, line string) string {
138+
out := []string{}
139+
replaced := false
140+
for _, l := range strings.Split(existing, "\n") {
141+
if strings.HasPrefix(strings.TrimSpace(l), prefix) {
142+
if !replaced {
143+
out = append(out, line)
144+
replaced = true
145+
}
146+
continue
147+
}
148+
out = append(out, l)
149+
}
150+
joined := strings.TrimRight(strings.Join(out, "\n"), "\n")
151+
if !replaced {
152+
if joined != "" {
153+
joined += "\n"
154+
}
155+
joined += line
156+
}
157+
return joined + "\n"
158+
}
159+
160+
func installNPM(dir, host string, force, configureOnly bool) error {
161+
registryURL := fmt.Sprintf("https://%s/npm/", host)
162+
line := fmt.Sprintf("%s:registry=%s", npmScope, registryURL)
163+
npmrc := filepath.Join(dir, ".npmrc")
164+
existing := ""
165+
if b, err := os.ReadFile(npmrc); err == nil {
166+
existing = string(b)
167+
} else if !os.IsNotExist(err) {
168+
return err
169+
}
170+
if err := os.WriteFile(npmrc, []byte(upsertLine(existing, npmScope+":registry=", line)), 0o644); err != nil {
171+
return err
172+
}
173+
fmt.Printf("configured %s -> %s\n", npmrc, registryURL)
174+
if configureOnly {
175+
fmt.Printf("run: npm install %s\n", npmClientPkg)
176+
return nil
177+
}
178+
if _, err := exec.LookPath("npm"); err != nil {
179+
return fmt.Errorf("install: npm not found on PATH (config written; run: npm install %s)", npmClientPkg)
180+
}
181+
return runIn(dir, "npm", "install", npmClientPkg)
182+
}
183+
184+
func installPython(dir, host string, force, configureOnly bool) error {
185+
indexURL := fmt.Sprintf("https://%s/pip/simple/", host)
186+
// Persist the index for reuse and print the exact command. uv and pip take the
187+
// same --index-url flag, so the install line is uniform; the .interlock/registry
188+
// file records it so re-runs and CI can source one place.
189+
regFile := filepath.Join(dir, ".interlock", "registry")
190+
if err := os.MkdirAll(filepath.Dir(regFile), 0o755); err != nil {
191+
return err
192+
}
193+
if err := os.WriteFile(regFile, []byte("PIP_INDEX_URL="+indexURL+"\n"), 0o644); err != nil {
194+
return err
195+
}
196+
fmt.Printf("configured %s (PIP_INDEX_URL=%s)\n", regFile, indexURL)
197+
if configureOnly {
198+
fmt.Printf("run: uv pip install --index-url %s %s (or: pip install --index-url %s %s)\n", indexURL, pyClientPkg, indexURL, pyClientPkg)
199+
return nil
200+
}
201+
if _, err := exec.LookPath("uv"); err == nil {
202+
return runIn(dir, "uv", "pip", "install", "--index-url", indexURL, pyClientPkg)
203+
}
204+
if _, err := exec.LookPath("pip"); err == nil {
205+
return runIn(dir, "pip", "install", "--index-url", indexURL, pyClientPkg)
206+
}
207+
return fmt.Errorf("install: neither uv nor pip found on PATH (config written; run with --index-url %s)", indexURL)
208+
}
209+
210+
func runIn(dir, name string, args ...string) error {
211+
cmd := exec.Command(name, args...)
212+
cmd.Dir = dir
213+
cmd.Stdout = os.Stdout
214+
cmd.Stderr = os.Stderr
215+
if err := cmd.Run(); err != nil {
216+
return fmt.Errorf("install: %s %s: %w", name, strings.Join(args, " "), err)
217+
}
218+
return nil
219+
}

cmd/interlock/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ func main() {
3131
switch os.Args[1] {
3232
case "init":
3333
err = cmdInit(os.Args[2:])
34+
case "install":
35+
err = cmdInstall(os.Args[2:])
3436
case "derive":
3537
err = cmdDerive(os.Args[2:])
3638
case "compile":
@@ -78,6 +80,7 @@ usage:
7880
interlock init set up a no-toolchain JSON policy (interactive)
7981
interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock)
8082
interlock init --authoring go <dir> scaffold a programmable Go policy module
83+
interlock install [ts|python] install the typed client from your registry (--configure-only writes config)
8184
interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces)
8285
interlock test [dir] run the policy's tests (dir defaults to .interlock)
8386
interlock demo [name] narrate a built-in policy (default repository-policy; --list)

e2e/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
// commands and are excluded from the dispatch set below.
2323
var covered = map[string]string{
2424
"init": "TestJourney_InitTestTamper",
25+
"install": "TestJourney_Install",
2526
"derive": "TestJourney_Derive",
2627
"compile": "TestJourney_Derive (promotion) + parity fixtures",
2728
"check": "TestSmoke_InfoCommands",

e2e/install_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package e2e
2+
3+
// control-law: shipped-surface-honors-the-core (install)
4+
//
5+
// `interlock install <lang>` configures the consumer's package manager to fetch the
6+
// typed client from the project's front-door registry — no hand-edited .npmrc /
7+
// --index-url, no GCP credentials. --configure-only writes the registry config
8+
// without invoking a toolchain, so this journey is hermetic (no npm/pip/network):
9+
// it asserts the exact config the command writes.
10+
11+
import (
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"testing"
16+
)
17+
18+
func TestJourney_Install(t *testing.T) {
19+
host := "get.operatorstack.systems"
20+
21+
t.Run("typescript writes a scoped npm registry", func(t *testing.T) {
22+
dir := t.TempDir()
23+
_, stderr, code := run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host)
24+
if code != 0 {
25+
t.Fatalf("install ts failed (%d): %s", code, stderr)
26+
}
27+
b, err := os.ReadFile(filepath.Join(dir, ".npmrc"))
28+
if err != nil {
29+
t.Fatalf("read .npmrc: %v", err)
30+
}
31+
want := "@operatorstack:registry=https://" + host + "/npm/"
32+
if !strings.Contains(string(b), want) {
33+
t.Fatalf(".npmrc missing scoped registry\nwant: %s\ngot:\n%s", want, b)
34+
}
35+
if strings.Contains(string(b), "pkg.dev") {
36+
t.Fatalf(".npmrc leaks the private AR host: %s", b)
37+
}
38+
})
39+
40+
t.Run("re-run is idempotent (one registry line)", func(t *testing.T) {
41+
dir := t.TempDir()
42+
run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host)
43+
run(t, "install", "ts", "--dir", dir, "--configure-only", "--host", host)
44+
b, _ := os.ReadFile(filepath.Join(dir, ".npmrc"))
45+
if n := strings.Count(string(b), "@operatorstack:registry="); n != 1 {
46+
t.Fatalf("expected exactly one registry line, got %d:\n%s", n, b)
47+
}
48+
})
49+
50+
t.Run("python writes a pip index pointing at the front door", func(t *testing.T) {
51+
dir := t.TempDir()
52+
_, stderr, code := run(t, "install", "python", "--dir", dir, "--configure-only", "--host", host)
53+
if code != 0 {
54+
t.Fatalf("install python failed (%d): %s", code, stderr)
55+
}
56+
b, err := os.ReadFile(filepath.Join(dir, ".interlock", "registry"))
57+
if err != nil {
58+
t.Fatalf("read .interlock/registry: %v", err)
59+
}
60+
if !strings.Contains(string(b), "PIP_INDEX_URL=https://"+host+"/pip/simple/") {
61+
t.Fatalf("registry file missing pip index:\n%s", b)
62+
}
63+
})
64+
65+
t.Run("no language, non-interactive, fails closed", func(t *testing.T) {
66+
_, _, code := run(t, "install")
67+
if code == 0 {
68+
t.Fatal("install with no language and no stdin should fail closed")
69+
}
70+
})
71+
72+
t.Run("unknown language fails closed", func(t *testing.T) {
73+
_, _, code := run(t, "install", "rust", "--configure-only")
74+
if code == 0 {
75+
t.Fatal("unknown language should fail closed")
76+
}
77+
})
78+
}

0 commit comments

Comments
 (0)