Skip to content
Closed
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
14 changes: 9 additions & 5 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@
"clients/typescript/tsconfig.json": "97647800a147a82d9cdadf00cd2b84d82e07512c9e55971654dd60ba21b4e061",
"cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764",
"cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc",
"cmd/interlock/doctor.go": "3d2f439954c2ee2f176cd5ddbe1ce686ddd4f9764765850510b90cef84cb18ee",
"cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f",
"cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae",
"cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac",
"cmd/interlock/install.go": "45bc0c7e6c33139ae76cd8b8e9e080685723ee24b127f2f1aea1c5227d79c1c0",
"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",
Expand Down Expand Up @@ -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/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",
Expand Down Expand Up @@ -119,7 +123,7 @@
"generator": "operatorstack/interlock:project-upstream",
"schema_version": 1,
"source": {
"commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7",
"commit": "ba73399230925b87ee2375c2ecd88003eed4bb22",
"path": "labs/21-interlock",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
197 changes: 197 additions & 0 deletions cmd/interlock/doctor.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 33 additions & 6 deletions cmd/interlock/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -172,13 +199,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 {
Expand All @@ -195,14 +222,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)
}
Expand Down
18 changes: 3 additions & 15 deletions cmd/interlock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -81,6 +83,7 @@ usage:
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 upgrade [--check] update interlock to the latest published version
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 Expand Up @@ -339,21 +342,6 @@ func cmdReplay(args []string) error {
return nil
}

func cmdDoctor(args []string) error {
fmt.Printf("interlock doctor\n")
fmt.Printf(" version : %s\n", releaseVersion())
fmt.Printf(" policy protocol : %s\n", ir.Protocol)
fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol)
fmt.Printf(" receipt schema : %s\n", receipt.Schema)
if _, err := exec.LookPath("go"); err != nil {
fmt.Printf(" go toolchain : NOT FOUND (needed only for compile / init --authoring go)\n")
} else {
fmt.Printf(" go toolchain : available\n")
}
fmt.Printf(" note : init --authoring json and test need no toolchain\n")
return nil
}

// helpers

// decodePolicy turns policy bytes into an ir.Policy, routing on the protocol tag:
Expand Down
Loading
Loading