diff --git a/UPSTREAM.json b/UPSTREAM.json
index d1a33fd..a748246 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/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",
@@ -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",
@@ -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"
}
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/install.go b/cmd/interlock/install.go
index b17d544..ac8b6f0 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
@@ -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 {
@@ -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)
}
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
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)
@@ -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:
diff --git a/cmd/interlock/upgrade.go b/cmd/interlock/upgrade.go
new file mode 100644
index 0000000..159f54f
--- /dev/null
+++ b/cmd/interlock/upgrade.go
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// binaryName is the front-door package id and the on-disk executable name — the
+// same "interlock" the shell installer uses.
+const binaryName = "interlock"
+
+// getBaseURL turns a host into a base URL. A bare host gets https:// (the normal
+// case); a host that already carries a scheme is used verbatim, so tests can point
+// the CLI at a local http server. Shared by upgrade and the doctor drift check.
+func getBaseURL(host string) string {
+ if strings.Contains(host, "://") {
+ return strings.TrimRight(host, "/")
+ }
+ return "https://" + host
+}
+
+func httpGetBytes(url string) ([]byte, error) {
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// latestVersion resolves the highest published version from the front door.
+func latestVersion(host string) (string, error) {
+ b, err := httpGetBytes(getBaseURL(host) + "/" + binaryName + "/latest")
+ if err != nil {
+ return "", err
+ }
+ var payload struct {
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(b, &payload); err != nil || payload.Version == "" {
+ return "", fmt.Errorf("could not parse latest version")
+ }
+ return payload.Version, nil
+}
+
+// cmpSemver returns >0 if a>b, <0 if a 0
+}
+
+func cmdUpgrade(args []string) error {
+ host := ""
+ checkOnly := false
+ yes := false
+ i := 0
+ for i < len(args) {
+ switch args[i] {
+ case "--host":
+ if i+1 >= len(args) {
+ return fmt.Errorf("upgrade: --host wants a hostname")
+ }
+ host = args[i+1]
+ i += 2
+ case "--check":
+ checkOnly = true
+ i++
+ case "--yes", "-y":
+ yes = true
+ i++
+ default:
+ return fmt.Errorf("upgrade: unexpected argument %q", args[i])
+ }
+ }
+ host = resolveGetHost(host)
+ current := releaseVersion()
+
+ latest, err := latestVersion(host)
+ if err != nil {
+ return fmt.Errorf("upgrade: resolve latest: %w", err)
+ }
+ if !upgradeAvailable(current, latest) {
+ fmt.Printf("interlock %s is up to date (latest %s)\n", current, latest)
+ return nil
+ }
+ if checkOnly {
+ fmt.Printf("a newer interlock is available: %s -> %s (run: interlock upgrade)\n", current, latest)
+ return nil
+ }
+ if runtime.GOOS == "windows" {
+ return fmt.Errorf("upgrade: automatic upgrade is unix-only; on Windows re-run the install.ps1 installer for %s", latest)
+ }
+ if !yes {
+ fmt.Printf("upgrading interlock %s -> %s\n", current, latest)
+ }
+ return downloadAndReplace(host, latest)
+}
+
+// downloadAndReplace fetches the OS/arch archive + checksums for version, verifies
+// the SHA-256 in-process, and atomically replaces the running executable.
+func downloadAndReplace(host, version string) error {
+ archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", binaryName, version, runtime.GOOS, runtime.GOARCH)
+ base := fmt.Sprintf("%s/%s/dl/%s", getBaseURL(host), binaryName, version)
+
+ archiveBytes, err := httpGetBytes(base + "/" + archive)
+ if err != nil {
+ return fmt.Errorf("upgrade: download %s: %w", archive, err)
+ }
+ checksums, err := httpGetBytes(base + "/checksums.txt")
+ if err != nil {
+ return fmt.Errorf("upgrade: download checksums: %w", err)
+ }
+ want := checksumFor(string(checksums), archive)
+ if want == "" {
+ return fmt.Errorf("upgrade: no checksum listed for %s", archive)
+ }
+ sum := sha256.Sum256(archiveBytes)
+ if got := hex.EncodeToString(sum[:]); got != want {
+ return fmt.Errorf("upgrade: checksum mismatch for %s (want %s, got %s) — refusing", archive, want, got)
+ }
+
+ bin, err := extractBinary(archiveBytes, binaryName)
+ if err != nil {
+ return fmt.Errorf("upgrade: %w", err)
+ }
+
+ self, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil {
+ self = resolved
+ }
+ dir := filepath.Dir(self)
+ tmp := filepath.Join(dir, "."+binaryName+".upgrade")
+ if err := os.WriteFile(tmp, bin, 0o755); err != nil {
+ if os.IsPermission(err) {
+ return fmt.Errorf("upgrade: cannot write to %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", dir, resolveGetHost(host), binaryName)
+ }
+ return err
+ }
+ if err := os.Rename(tmp, self); err != nil {
+ os.Remove(tmp)
+ if os.IsPermission(err) {
+ return fmt.Errorf("upgrade: cannot replace %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", self, resolveGetHost(host), binaryName)
+ }
+ return err
+ }
+ fmt.Printf("upgraded to interlock %s at %s\n", version, self)
+ return nil
+}
+
+// checksumFor returns the hex digest listed for name in a "sha name" manifest.
+func checksumFor(manifest, name string) string {
+ for _, line := range strings.Split(manifest, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == name {
+ return fields[0]
+ }
+ }
+ return ""
+}
+
+// extractBinary returns the named regular file from a .tar.gz archive.
+func extractBinary(data []byte, name string) ([]byte, error) {
+ gzr, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("open archive: %w", err)
+ }
+ defer gzr.Close()
+ tr := tar.NewReader(gzr)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read archive: %w", err)
+ }
+ if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == name {
+ return io.ReadAll(tr)
+ }
+ }
+ return nil, fmt.Errorf("binary %q not found in archive", name)
+}
diff --git a/cmd/interlock/version.go b/cmd/interlock/version.go
index f3c482d..df3f98c 100644
--- a/cmd/interlock/version.go
+++ b/cmd/interlock/version.go
@@ -33,8 +33,13 @@ func releaseVersion() string {
// prebuilt-binary install, it reports the exact tag and commit the artifact was
// built from, alongside the wire contracts.
func cmdVersion(args []string) error {
- if len(args) > 0 {
- return fmt.Errorf("version: unexpected argument %q", args[0])
+ check := false
+ for _, a := range args {
+ if a == "--check" {
+ check = true
+ continue
+ }
+ return fmt.Errorf("version: unexpected argument %q", a)
}
fmt.Printf("interlock %s (%s)\n", releaseVersion(), buildCommit())
if date != "" {
@@ -43,5 +48,15 @@ func cmdVersion(args []string) error {
fmt.Printf(" policy protocol : %s\n", ir.Protocol)
fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol)
fmt.Printf(" receipt schema : %s\n", receipt.Schema)
+ // --check contacts the front door for a newer release; plain `version` stays offline.
+ if check {
+ if latest, err := latestVersion(resolveGetHost("")); err != nil {
+ fmt.Printf(" updates : could not check (offline?)\n")
+ } else if upgradeAvailable(releaseVersion(), latest) {
+ fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", releaseVersion(), latest)
+ } else {
+ fmt.Printf(" updates : up to date (latest %s)\n", latest)
+ }
+ }
return nil
}
diff --git a/e2e/coverage_test.go b/e2e/coverage_test.go
index cc0cf67..1461ed7 100644
--- a/e2e/coverage_test.go
+++ b/e2e/coverage_test.go
@@ -23,6 +23,7 @@ import (
var covered = map[string]string{
"init": "TestJourney_InitTestTamper",
"install": "TestJourney_Install",
+ "upgrade": "TestJourney_Upgrade",
"derive": "TestJourney_Derive",
"compile": "TestJourney_Derive (promotion) + parity fixtures",
"check": "TestSmoke_InfoCommands",
diff --git a/e2e/doctor_test.go b/e2e/doctor_test.go
new file mode 100644
index 0000000..01ce2d2
--- /dev/null
+++ b/e2e/doctor_test.go
@@ -0,0 +1,89 @@
+package e2e
+
+// control-law: shipped-surface-honors-the-core (doctor readiness)
+//
+// `interlock doctor` is the one-stop readiness picture: binary + protocols, update
+// drift, toolchains, installed typed-SDK version + protocol skew, and registry
+// config. These assert the machine (--json) surface + SDK detection hermetically.
+// (Skew itself fires only on a released binary, where the CLI pins a concrete
+// compatible version; the test binary is "dev" and pins nothing.)
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestJourney_Doctor(t *testing.T) {
+ t.Run("--json reports protocols, toolchains, sdk, registry", func(t *testing.T) {
+ dir := t.TempDir()
+ stdout, stderr, code := run(t, "doctor", "--json", "--dir", dir)
+ if code != 0 {
+ t.Fatalf("doctor --json failed (%d): %s", code, stderr)
+ }
+ var rep struct {
+ Version string `json:"version"`
+ Protocols map[string]string `json:"protocols"`
+ Toolchain map[string]bool `json:"toolchain"`
+ SDK map[string]string `json:"sdk"`
+ Registry map[string]bool `json:"registry"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &rep); err != nil {
+ t.Fatalf("doctor --json not valid JSON: %v\n%s", err, stdout)
+ }
+ if rep.Protocols["effect"] != "interlock.effect.v1" {
+ t.Fatalf("missing effect protocol: %+v", rep.Protocols)
+ }
+ if rep.SDK["ts"] != "not installed" || rep.Registry["npm"] {
+ t.Fatalf("empty project should report no SDK / no registry: %+v %+v", rep.SDK, rep.Registry)
+ }
+ })
+
+ t.Run("detects an installed ts SDK version", func(t *testing.T) {
+ dir := t.TempDir()
+ pkgDir := filepath.Join(dir, "node_modules", "@operatorstack", "interlock")
+ if err := os.MkdirAll(pkgDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"name":"@operatorstack/interlock","version":"9.9.9"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ stdout, _, code := run(t, "doctor", "--json", "--dir", dir)
+ if code != 0 {
+ t.Fatal("doctor failed")
+ }
+ var rep struct {
+ SDK map[string]string `json:"sdk"`
+ }
+ json.Unmarshal([]byte(stdout), &rep)
+ if rep.SDK["ts"] != "9.9.9" {
+ t.Fatalf("expected ts SDK 9.9.9, got %q", rep.SDK["ts"])
+ }
+ })
+
+ t.Run("reports registry config after install --configure-only", func(t *testing.T) {
+ dir := t.TempDir()
+ if _, stderr, code := run(t, "install", "ts", "--dir", dir, "--configure-only"); code != 0 {
+ t.Fatalf("install ts failed: %s", stderr)
+ }
+ stdout, _, _ := run(t, "doctor", "--json", "--dir", dir)
+ var rep struct {
+ Registry map[string]bool `json:"registry"`
+ }
+ json.Unmarshal([]byte(stdout), &rep)
+ if !rep.Registry["npm"] {
+ t.Fatalf("registry.npm should be true after install: %s", stdout)
+ }
+ })
+
+ t.Run("text output has the readiness lines", func(t *testing.T) {
+ stdout, _, _ := run(t, "doctor", "--dir", t.TempDir())
+ for _, want := range []string{"toolchains", "ts SDK", "python SDK", "registry config"} {
+ if !strings.Contains(stdout, want) {
+ t.Fatalf("doctor text missing %q:\n%s", want, stdout)
+ }
+ }
+ })
+}
diff --git a/e2e/upgrade_test.go b/e2e/upgrade_test.go
new file mode 100644
index 0000000..31f3218
--- /dev/null
+++ b/e2e/upgrade_test.go
@@ -0,0 +1,109 @@
+package e2e
+
+// control-law: shipped-surface-honors-the-core (upgrade)
+//
+// `interlock upgrade` self-updates the binary from the front door: resolve /latest,
+// download the OS/arch archive + checksums, verify SHA-256, atomically replace the
+// running executable. Hermetic — a local stub server stands in for the front door
+// (via --host http://…), a throwaway copy of the binary is the upgrade target so the
+// shared test binary is never clobbered, and the "fake" payload is asserted in place.
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// stubFrontDoor serves /interlock/latest and /interlock/dl//{archive,checksums.txt}
+// for the given version, with a tar.gz whose "interlock" entry contains payload.
+func stubFrontDoor(t *testing.T, version string, payload []byte) *httptest.Server {
+ t.Helper()
+ archive := fmt.Sprintf("interlock_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH)
+
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gz)
+ if err := tw.WriteHeader(&tar.Header{Name: "interlock", Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil {
+ t.Fatal(err)
+ }
+ tw.Write(payload)
+ tw.Close()
+ gz.Close()
+ tgz := buf.Bytes()
+ sum := sha256.Sum256(tgz)
+ checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), archive)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/interlock/latest", func(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintf(w, `{"version":%q}`, version)
+ })
+ mux.HandleFunc("/interlock/dl/"+version+"/"+archive, func(w http.ResponseWriter, _ *http.Request) {
+ w.Write(tgz)
+ })
+ mux.HandleFunc("/interlock/dl/"+version+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
+ w.Write([]byte(checksums))
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestJourney_Upgrade(t *testing.T) {
+ // The test binary is built without ldflags, so its version is "dev" — always
+ // upgrade-eligible against any published version.
+ srv := stubFrontDoor(t, "9.9.9", []byte("FAKE-UPGRADED-INTERLOCK\n"))
+
+ t.Run("--check reports a newer version", func(t *testing.T) {
+ stdout, stderr, code := run(t, "upgrade", "--check", "--host", srv.URL)
+ if code != 0 {
+ t.Fatalf("upgrade --check failed (%d): %s", code, stderr)
+ }
+ if !strings.Contains(stdout, "9.9.9") || !strings.Contains(stdout, "newer") {
+ t.Fatalf("--check did not report the newer version: %s", stdout)
+ }
+ })
+
+ t.Run("real upgrade replaces the binary atomically", func(t *testing.T) {
+ // Copy the test binary so os.Executable() points at a throwaway target.
+ dir := t.TempDir()
+ target := filepath.Join(dir, "interlock")
+ src, err := os.ReadFile(interlockBin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(target, src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(target, "upgrade", "--host", srv.URL, "--yes")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("upgrade failed: %v\n%s", err, out)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "FAKE-UPGRADED-INTERLOCK\n" {
+ t.Fatalf("binary not replaced with the fetched payload; got %q", string(got))
+ }
+ })
+
+ t.Run("offline resolve fails closed", func(t *testing.T) {
+ // A host with no server → resolve error, non-zero exit, no partial write.
+ _, _, code := run(t, "upgrade", "--check", "--host", "http://127.0.0.1:1")
+ if code == 0 {
+ t.Fatal("upgrade against an unreachable host should fail")
+ }
+ })
+}