diff --git a/UPSTREAM.json b/UPSTREAM.json index d1a33fd..8f2e3b5 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -34,12 +34,14 @@ "clients/typescript/tsconfig.json": "97647800a147a82d9cdadf00cd2b84d82e07512c9e55971654dd60ba21b4e061", "cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764", "cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc", - "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", - "cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae", - "cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac", + "cmd/interlock/doctor.go": "3d2f439954c2ee2f176cd5ddbe1ce686ddd4f9764765850510b90cef84cb18ee", + "cmd/interlock/init.go": "84d204c6b8bab4175b2ce60422a8379fa8ba319d62b3f3663cd6a7f98ddfb0a7", + "cmd/interlock/install.go": "289bfb4a0b9e6a57c0751de0860d66d9b732e35a6974910eeb7e5513efe69e70", + "cmd/interlock/main.go": "e4b4f98851ed29755840a8094c76e56ca309db3935ce40711b0cc471f95b850e", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", + "cmd/interlock/upgrade.go": "99753fc44d2f1ff98da3c8aaa340d3debcf0341ff333bf12305ad801f4ac484f", "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", - "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", + "cmd/interlock/version.go": "3a579f55abf740b427e61932a783bf99203cbbd9d71ee639d782088975069ccf", "compiler/compiler.go": "50487aaa0c24b3c0d041ab5b9a410fa672de7eb77b1cd3df1ec97cd4cf2bccba", "compiler/compiler_test.go": "6f80886aa1bfdf79bca014ad47c7b48d2a6bc24c86863afa4ebeb93658a790aa", "conformance/compat/compat.go": "5db334201f0d701ba1d7cd73916215d08d00bd566ed35477bf331c055edaca37", @@ -80,11 +82,13 @@ "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", "docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d", - "e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5", + "e2e/coverage_test.go": "92ed6faa9a698bbcb2045b538fc373bed88bcc5fd3f1ceef1071a845ff6d3d42", + "e2e/doctor_test.go": "c2d02f69be3f978496c2d247ba0b951ee0cd94c3440926199226def50f8e2265", "e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f", - "e2e/install_test.go": "a0f65ec294fdc7d032045e214c1f56021f08472f44536b6365df8d66471fd67f", + "e2e/install_test.go": "fa0d032f26a2ffabb4a77b74dd34c6cbe7075db6d23a1efd2c14c6f8197c028c", "e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2", "e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a", + "e2e/upgrade_test.go": "273806d579fe3725063d044098a7006f502cf3e1379805abfffed3925905f39c", "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", "engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f", "engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8", @@ -119,7 +123,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7", + "commit": "05180306152ae51000fc8092a622e53c16ff30a6", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/interlock/doctor.go b/cmd/interlock/doctor.go new file mode 100644 index 0000000..ab05793 --- /dev/null +++ b/cmd/interlock/doctor.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/receipt" +) + +// doctorReport is the one-stop readiness picture: the binary + protocols, whether an +// update is available, which toolchains are present, whether a typed SDK is installed +// and in protocol-sync with this CLI, and whether the project's registry is +// configured. Rendered as text or, with --json, as a machine object. +type doctorReport struct { + Version string `json:"version"` + Protocols map[string]string `json:"protocols"` + Update string `json:"update"` // "up-to-date" | "newer" | "unknown" + Latest string `json:"latest,omitempty"` + Toolchain map[string]bool `json:"toolchain"` + SDK map[string]string `json:"sdk"` // lang -> version | "not installed" + Skew []string `json:"skew"` // human-readable mismatch notes + Registry map[string]bool `json:"registry"` // config artifact -> present +} + +func cmdDoctor(args []string) error { + jsonOut := false + dir := "." + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOut = true + case "--dir": + if i+1 >= len(args) { + return fmt.Errorf("doctor: --dir wants a path") + } + dir = args[i+1] + i++ + default: + return fmt.Errorf("doctor: unexpected argument %q", args[i]) + } + } + + rep := doctorReport{ + Version: releaseVersion(), + Protocols: map[string]string{ + "policy": ir.Protocol, "effect": protocol.EffectRequestProtocol, "receipt": receipt.Schema, + }, + Toolchain: map[string]bool{}, + SDK: map[string]string{}, + Registry: map[string]bool{}, + } + + // Update / connectivity. + if latest, err := latestVersion(resolveGetHost("")); err != nil { + rep.Update = "unknown" + } else { + rep.Latest = latest + if upgradeAvailable(rep.Version, latest) { + rep.Update = "newer" + } else { + rep.Update = "up-to-date" + } + } + + // Toolchains. + for _, tool := range []string{"go", "node", "npm", "python", "python3", "uv", "pip", "pip3"} { + _, err := exec.LookPath(tool) + rep.Toolchain[tool] = err == nil + } + + // Installed SDK versions + protocol skew (compatible == same release). + compat := compatibleSDKVersion() + if v := npmSDKVersion(dir); v != "" { + rep.SDK["ts"] = v + if compat != "" && v != compat { + rep.Skew = append(rep.Skew, fmt.Sprintf("ts SDK %s != CLI %s — run: interlock install ts --upgrade", v, compat)) + } + } else { + rep.SDK["ts"] = "not installed" + } + if v := pySDKVersion(); v != "" { + rep.SDK["python"] = v + if compat != "" && v != compat { + rep.Skew = append(rep.Skew, fmt.Sprintf("python SDK %s != CLI %s — run: interlock install python --upgrade", v, compat)) + } + } else { + rep.SDK["python"] = "not installed" + } + + // Registry config present in this project. + rep.Registry["npm"] = npmRegistryConfigured(dir) + rep.Registry["pip"] = fileExists(filepath.Join(dir, ".interlock", "registry")) + + if jsonOut { + b, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + printDoctorText(rep) + return nil +} + +func printDoctorText(rep doctorReport) { + fmt.Printf("interlock doctor\n") + fmt.Printf(" version : %s\n", rep.Version) + fmt.Printf(" policy protocol : %s\n", rep.Protocols["policy"]) + fmt.Printf(" effect protocol : %s\n", rep.Protocols["effect"]) + fmt.Printf(" receipt schema : %s\n", rep.Protocols["receipt"]) + switch rep.Update { + case "newer": + fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", rep.Version, rep.Latest) + case "up-to-date": + fmt.Printf(" updates : up to date (latest %s)\n", rep.Latest) + default: + fmt.Printf(" updates : could not check (offline?)\n") + } + fmt.Printf(" toolchains : %s\n", toolchainSummary(rep.Toolchain)) + fmt.Printf(" ts SDK : %s\n", rep.SDK["ts"]) + fmt.Printf(" python SDK : %s\n", rep.SDK["python"]) + fmt.Printf(" registry config : npm=%v pip=%v\n", rep.Registry["npm"], rep.Registry["pip"]) + if len(rep.Skew) > 0 { + for _, s := range rep.Skew { + fmt.Printf(" SKEW : %s\n", s) + } + } + fmt.Printf(" note : init --authoring json and test need no toolchain\n") +} + +func toolchainSummary(tc map[string]bool) string { + parts := []string{} + for _, t := range []string{"go", "node", "npm", "python", "python3", "uv", "pip", "pip3"} { + if tc[t] { + parts = append(parts, t) + } + } + if len(parts) == 0 { + return "(none found)" + } + return strings.Join(parts, ", ") +} + +// npmSDKVersion reads the installed typed client's version from node_modules. +func npmSDKVersion(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "node_modules", npmClientPkg, "package.json")) + if err != nil { + return "" + } + var m struct { + Version string `json:"version"` + } + if json.Unmarshal(b, &m) != nil { + return "" + } + return m.Version +} + +// pySDKVersion reports the installed python client version via uv/pip show. +func pySDKVersion() string { + for _, pm := range [][]string{{"uv", "pip", "show", pyClientPkg}, {"pip", "show", pyClientPkg}, {"pip3", "show", pyClientPkg}} { + if _, err := exec.LookPath(pm[0]); err != nil { + continue + } + out, err := exec.Command(pm[0], pm[1:]...).Output() + if err != nil { + continue + } + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "Version:") { + return strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + } + } + } + return "" +} + +// npmRegistryConfigured reports whether the project .npmrc points the scope at us. +func npmRegistryConfigured(dir string) bool { + b, err := os.ReadFile(filepath.Join(dir, ".npmrc")) + if err != nil { + return false + } + return strings.Contains(string(b), npmScope+":registry=") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cmd/interlock/init.go b/cmd/interlock/init.go index d6c744b..c0ede9b 100644 --- a/cmd/interlock/init.go +++ b/cmd/interlock/init.go @@ -82,7 +82,10 @@ func cmdInit(args []string) error { "Bare `interlock init` creates the no-toolchain JSON setup.\n") return initGo(positional[0]) } - return initJSON(defaultInterlockDir, template, path, force) + if err := initJSON(defaultInterlockDir, template, path, force); err != nil { + return err + } + return offerClientInstall() default: return fmt.Errorf("init: unknown --authoring %q (want go|json)", authoring) } @@ -195,6 +198,35 @@ func promptTemplate() (key, path string, err error) { // defaultCustomPathHint mirrors scaffold's default for the interactive prompt. const defaultCustomPathHint = "repo://protected/**" +// offerClientInstall asks, after an interactive init, whether to install a typed +// client. It NEVER fails init: a declined/non-interactive answer (empty stdin) or a +// failed install just returns nil. This is the init -> optional-client-install path. +func offerClientInstall() error { + fmt.Print("\nInstall a typed client for a language now? [y/N]: ") + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + default: + return nil + } + lang, err := promptLanguage() + if err != nil { + return nil + } + host := resolveGetHost("") + var ierr error + switch normalizeLang(lang) { + case "ts": + ierr = installNPM(".", host, false, false) + case "python": + ierr = installPython(".", host, false, false) + } + if ierr != nil { + fmt.Printf("note: client install did not complete: %v (retry: interlock install)\n", ierr) + } + return nil +} + // initGo scaffolds a minimal, deterministic Go policy module (the programmable // authoring surface): arbitrary Go may run in Build(); only the emitted IR decides. func initGo(dir string) error { diff --git a/cmd/interlock/install.go b/cmd/interlock/install.go index b17d544..e0c909c 100644 --- a/cmd/interlock/install.go +++ b/cmd/interlock/install.go @@ -22,6 +22,33 @@ const ( pyClientPkg = "interlock-protocol" ) +// compatibleSDKVersion is the typed-client version this CLI is protocol-compatible +// with. Binary and SDK ship from one tag, so "compatible" is simply "same release". +// A dev/un-stamped build pins nothing (installs latest) so local checkouts work. +func compatibleSDKVersion() string { + v := releaseVersion() + if v == "dev" || v == "" { + return "" + } + return v +} + +// npmClientSpec / pyClientSpec pin the install to the compatible version, preventing +// a fresh SDK from silently outrunning the CLI's protocol. +func npmClientSpec() string { + if v := compatibleSDKVersion(); v != "" { + return npmClientPkg + "@" + v + } + return npmClientPkg +} + +func pyClientSpec() string { + if v := compatibleSDKVersion(); v != "" { + return pyClientPkg + "==" + v + } + return pyClientPkg +} + func resolveGetHost(flag string) string { if flag != "" { return flag @@ -41,6 +68,8 @@ func cmdInstall(args []string) error { dir := "." force := false configureOnly := false + revert := false + example := false var positional []string i := 0 @@ -61,6 +90,16 @@ func cmdInstall(args []string) error { case "--configure-only": configureOnly = true i++ + case "--revert": + revert = true + i++ + case "--example": + example = true + i++ + case "--upgrade": + // --upgrade is ergonomic: a normal install already pins to the compatible + // version, so re-running it after `interlock upgrade` bumps the SDK to match. + i++ case "--force", "-f": force = true i++ @@ -88,14 +127,122 @@ func cmdInstall(args []string) error { } host = resolveGetHost(host) - switch normalizeLang(lang) { + nl := normalizeLang(lang) + if nl == "" { + return fmt.Errorf("install: unknown language %q (want: ts | python)", lang) + } + if revert { + return revertLang(nl, dir) + } + if example { + return writeExample(nl, dir, force) + } + switch nl { case "ts": return installNPM(dir, host, force, configureOnly) - case "python": + default: return installPython(dir, host, force, configureOnly) + } +} + +// revertLang removes the registry config the install wrote — the undo side of the +// install lifecycle. It clears the scoped .npmrc line / the .interlock/registry +// file; it does not uninstall the package (leave that to the package manager). +func revertLang(nl, dir string) error { + switch nl { + case "ts": + npmrc := filepath.Join(dir, ".npmrc") + b, err := os.ReadFile(npmrc) + if os.IsNotExist(err) { + fmt.Printf("nothing to revert: %s not present\n", npmrc) + return nil + } + if err != nil { + return err + } + remaining := removeLine(string(b), npmScope+":registry=") + if strings.TrimSpace(remaining) == "" { + if err := os.Remove(npmrc); err != nil { + return err + } + fmt.Printf("reverted: removed %s\n", npmrc) + return nil + } + if err := os.WriteFile(npmrc, []byte(remaining), 0o644); err != nil { + return err + } + fmt.Printf("reverted: removed the @operatorstack registry line from %s\n", npmrc) + return nil default: - return fmt.Errorf("install: unknown language %q (want: ts | python)", lang) + reg := filepath.Join(dir, ".interlock", "registry") + if err := os.Remove(reg); err != nil { + if os.IsNotExist(err) { + fmt.Printf("nothing to revert: %s not present\n", reg) + return nil + } + return err + } + fmt.Printf("reverted: removed %s\n", reg) + return nil + } +} + +// removeLine drops every line whose trimmed form starts with prefix. +func removeLine(existing, prefix string) string { + out := []string{} + for _, l := range strings.Split(existing, "\n") { + if strings.HasPrefix(strings.TrimSpace(l), prefix) { + continue + } + out = append(out, l) + } + return strings.TrimLeft(strings.Join(out, "\n"), "\n") +} + +// writeExample scaffolds a minimal starter that builds an interlock.effect.v1 request +// with the typed client, mirroring init's scaffolding convention. +func writeExample(nl, dir string, force bool) error { + var name, body string + if nl == "ts" { + name = "interlock-example.ts" + body = `import { EffectRequest } from "@operatorstack/interlock"; + +// The client builds requests; the interlock engine decides them. +const req: EffectRequest = { + protocol: "interlock.effect.v1", + request_id: "example-1", + run_id: "example-run", + actor: "agent", + operation: "artifact.publish", + resource: { kind: "file", uri: "repo://out/result.json" }, +}; +console.log(JSON.stringify(req, null, 2)); +` + } else { + name = "interlock_example.py" + body = `from interlock_protocol.protocol import EffectRequest, TargetResource + +# The client builds requests; the interlock engine decides them. +req: EffectRequest = { + "protocol": "interlock.effect.v1", + "request_id": "example-1", + "run_id": "example-run", + "actor": "agent", + "operation": "artifact.publish", + "resource": {"kind": "file", "uri": "repo://out/result.json"}, +} +print(req) +` + } + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil && !force { + return fmt.Errorf("install: %s already exists (use --force to overwrite)", path) } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf("wrote example: %s\n", path) + return nil } func normalizeLang(lang string) string { @@ -172,13 +319,13 @@ func installNPM(dir, host string, force, configureOnly bool) error { } fmt.Printf("configured %s -> %s\n", npmrc, registryURL) if configureOnly { - fmt.Printf("run: npm install %s\n", npmClientPkg) + fmt.Printf("run: npm install %s\n", npmClientSpec()) 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 fmt.Errorf("install: npm not found on PATH (config written; run: npm install %s)", npmClientSpec()) } - return runIn(dir, "npm", "install", npmClientPkg) + return runIn(dir, "npm", "install", npmClientSpec()) } func installPython(dir, host string, force, configureOnly bool) error { @@ -195,14 +342,14 @@ func installPython(dir, host string, force, configureOnly bool) error { } 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) + fmt.Printf("run: uv pip install --index-url %s %s (or: pip install --index-url %s %s)\n", indexURL, pyClientSpec(), indexURL, pyClientSpec()) return nil } if _, err := exec.LookPath("uv"); err == nil { - return runIn(dir, "uv", "pip", "install", "--index-url", indexURL, pyClientPkg) + return runIn(dir, "uv", "pip", "install", "--index-url", indexURL, pyClientSpec()) } if _, err := exec.LookPath("pip"); err == nil { - return runIn(dir, "pip", "install", "--index-url", indexURL, pyClientPkg) + return runIn(dir, "pip", "install", "--index-url", indexURL, pyClientSpec()) } return fmt.Errorf("install: neither uv nor pip found on PATH (config written; run with --index-url %s)", indexURL) } diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index e2dd4dc..0cc10ec 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -33,6 +33,8 @@ func main() { err = cmdInit(os.Args[2:]) case "install": err = cmdInstall(os.Args[2:]) + case "upgrade": + err = cmdUpgrade(os.Args[2:]) case "derive": err = cmdDerive(os.Args[2:]) case "compile": @@ -81,6 +83,7 @@ usage: interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock) interlock init --authoring go