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
20 changes: 13 additions & 7 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@
"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": "713a253bc206e00c389c004335a3776b19a9732d268664fde46af48ea43e7db3",
"cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079",
"cmd/interlock/upgrade.go": "b9611a7709239d96729efb9cd55a8cd0665b0c95cb3ae706b05b2ea0d328eef7",
"cmd/interlock/upgrade_unix.go": "67acad3679e62cd48819d3ab0624e4a7529f1c00bac6e1b16d6bebeb5fc0d688",
"cmd/interlock/upgrade_windows.go": "f0fdfed54070f9878aa2eeeea1730227e2eaf2dfa52b488ce87cae1ca7bcd1c2",
"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 +84,13 @@
"derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e",
"doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198",
"docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d",
"e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5",
"e2e/coverage_test.go": "c3a9c5e361152da128f977533126702a4b797cb7cea5b07d7b8496cb9d2e7de2",
"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": "431a9e567413997e892410f67ea09912e34016ccff624ea52fc570cd8cc3c7d1",
"emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545",
"engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f",
"engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8",
Expand Down Expand Up @@ -119,7 +125,7 @@
"generator": "operatorstack/interlock:project-upstream",
"schema_version": 1,
"source": {
"commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7",
"commit": "c7c89a11d5f0933d6ff2d8df593b720f70f95af3",
"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
}
34 changes: 33 additions & 1 deletion cmd/interlock/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading