@huynle/hub 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,146 @@
1
+ // Read-only discovery: what `hubctl setup discover` reports about this host, as a contract DiscoverySnapshot.
2
+ // Everything comes through the injected DiscoveryProbe (reads only). Discovery never runs a harness (running even
3
+ // `--version` may create state), never records file contents or environment values, and never claims health:
4
+ // every harness it finds stays "unverified" until an adapter runs real probes. The snapshot is deterministic: the
5
+ // same host gives the same document, except for capturedAt.
6
+ import {
7
+ checkTarget,
8
+ EMBEDDED_ASSETS,
9
+ fail,
10
+ HARNESS_ORDER,
11
+ hostFingerprint,
12
+ PACKAGE_MANAGERS,
13
+ rootPathIn,
14
+ SEMVER,
15
+ sha256Hex,
16
+ targetOf,
17
+ validateDiscoverySnapshot,
18
+ type ConfigFile,
19
+ type DiscoverySnapshot,
20
+ type ExecutionContext,
21
+ type HarnessPresence,
22
+ type PackageManager,
23
+ type Result,
24
+ type RootID,
25
+ type RootMapping,
26
+ type RuntimeInfo,
27
+ type Shim,
28
+ } from "../contracts/index.ts"
29
+ import { classifyExecutable, HARNESS_BINARIES, installOf, pathDirs, which } from "./executables.ts"
30
+ import { discoverContainer, discoverLibc, discoverOS, discoverRoots, mapContainerRoots, parseMountinfo } from "./host.ts"
31
+ import type { DiscoveryProbe } from "./probe.ts"
32
+ import { decode, join, normalizeAbs } from "./util.ts"
33
+
34
+ export interface DiscoverOptions {
35
+ readonly now: number
36
+ }
37
+
38
+ // ---------- configs ----------
39
+
40
+ interface ConfigCandidate {
41
+ readonly path: string
42
+ readonly owner: ConfigFile["owner"]
43
+ readonly format: ConfigFile["format"]
44
+ }
45
+
46
+ /** The config files a setup plan might touch, with their owners, as seen from `context` (where discovery runs). */
47
+ export function configCandidates(platform: string, roots: readonly RootMapping[], context: ExecutionContext = "host"): ConfigCandidate[] {
48
+ const at = (id: RootID, rel: string) => {
49
+ const r = roots.find((x) => x.id === id)
50
+ return r ? join(rootPathIn(r, context), rel) : undefined
51
+ }
52
+ const list: (ConfigCandidate | undefined)[] = []
53
+ const add = (id: RootID, rel: string, owner: ConfigFile["owner"], format: ConfigFile["format"]) => {
54
+ const path = at(id, rel)
55
+ list.push(path ? { path, owner, format } : undefined)
56
+ }
57
+ add("config", "hub/config.json", "hub", "json")
58
+ add("config", "hub/node.json", "hub", "json")
59
+ add("config", "hub/claude-code.json", "hub", "json")
60
+ add("config", "opencode/opencode.json", "harness:opencode", "json")
61
+ add("config", "opencode/opencode.jsonc", "harness:opencode", "jsonc")
62
+ add("home", ".claude.json", "harness:claude-code", "json")
63
+ add("home", ".claude/settings.json", "harness:claude-code", "json")
64
+ add("home", ".codex/config.toml", "harness:codex", "toml")
65
+ for (const asset of Object.values(EMBEDDED_ASSETS)) {
66
+ if (platform === "darwin" && asset.manager === "launchd") add("launch-agents", asset.installAs, "hub", "plist")
67
+ if (platform === "linux" && asset.manager === "systemd-user") add("systemd-user", asset.installAs, "hub", "unit")
68
+ }
69
+ return list.filter((c): c is ConfigCandidate => c !== undefined)
70
+ }
71
+
72
+ // ---------- snapshot ----------
73
+
74
+ /** Where each harness's binary is inside a hub install root (the adapters' unpackers). */
75
+ const HARNESS_BIN_DIR: Readonly<Record<(typeof HARNESS_ORDER)[number], string>> = { opencode: "/bin", "claude-code": "", codex: "/bin" }
76
+
77
+ /** Discovers this host through `probe`. Fails (instead of recording) if anything found looks like a credential. */
78
+ export function discover(probe: DiscoveryProbe, options: DiscoverOptions): Result<DiscoverySnapshot> {
79
+ let roots = discoverRoots(probe)
80
+ if (!roots) return fail("/roots", "unmapped_root", "this host has no absolute home directory")
81
+ const home = roots[0]!.path
82
+ const libc = discoverLibc(probe)
83
+ const target = targetOf(probe.platform, probe.arch, libc)
84
+ const container = discoverContainer(probe)
85
+ // v1.1: discovery runs where apply will run; inside a container every root is read as containerPath ?? path.
86
+ const context: ExecutionContext = container === "none" ? "host" : "container"
87
+ if (container !== "none") roots = mapContainerRoots(roots, parseMountinfo(decode(probe.readFile("/proc/self/mountinfo", 1024 * 1024))))
88
+
89
+ const dirs = pathDirs(probe)
90
+ const extra = probe.platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin"] : ["/usr/local/sbin", "/usr/sbin", "/sbin", "/nix/var/nix/profiles/default/bin"]
91
+ const managerDirs = [...dirs, ...extra.filter((d) => !dirs.includes(d))]
92
+ const packages: PackageManager[] = PACKAGE_MANAGERS.filter((m) => which(probe, managerDirs, m) !== undefined)
93
+ const runtime: RuntimeInfo[] = probe.runtime && SEMVER.test(probe.runtime.version) && normalizeAbs(probe.runtime.path) ? [{ ...probe.runtime }] : []
94
+ let service: DiscoverySnapshot["managers"]["service"] = "none"
95
+ if (probe.platform === "darwin" && probe.executable("/bin/launchctl")) service = "launchd"
96
+ if (probe.platform === "linux" && probe.lstat("/run/systemd/system") === "directory" && which(probe, [...dirs, "/usr/bin", "/bin"], "systemctl")) service = "systemd-user"
97
+
98
+ const shims: Shim[] = []
99
+ const harnesses: HarnessPresence[] = HARNESS_ORDER.map((id) => {
100
+ const name = HARNESS_BINARIES[id]
101
+ const known = id === "opencode" ? [join(home, ".opencode/bin")] : id === "claude-code" ? [join(home, ".local/bin"), join(home, ".claude/local")] : []
102
+ // A harness hub installed (data:hub/harnesses/<id>/current) is found off PATH too, so a rerun after an install
103
+ // sees it (installMethod "hub") and plans nothing more. PATH still wins.
104
+ const data = roots!.find((r) => r.id === "data")
105
+ if (data) known.push(join(rootPathIn(data, context), `hub/harnesses/${id}/current${HARNESS_BIN_DIR[id]}`))
106
+ const path = which(probe, [...dirs, ...known.filter((d) => !dirs.includes(d))], name)
107
+ if (!path) return { id, installed: false, health: { state: "unverified", probes: [] } }
108
+ const shim = classifyExecutable(probe, name, path)
109
+ shims.push(shim)
110
+ const install = installOf(probe, id, shim, roots!, context)
111
+ return { id, installed: true, path, ...(install.version ? { version: install.version } : {}), installMethod: install.installMethod, health: { state: "unverified", probes: [] } }
112
+ })
113
+
114
+ const configs: ConfigFile[] = []
115
+ for (const c of configCandidates(probe.platform, roots, context)) {
116
+ if (probe.stat(c.path) !== "file") continue
117
+ const bytes = probe.readFile(c.path)
118
+ if (!bytes) return fail("/configs", "invalid_value", `${c.path} exists but cannot be read; discovery will not guess its contents`)
119
+ configs.push({ ...c, sha256: `sha256:${sha256Hex(bytes)}` })
120
+ }
121
+ configs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
122
+
123
+ const machineID = probe.machineID()?.trim() || `hostname:${probe.hostname}`
124
+ const snapshot: DiscoverySnapshot = {
125
+ schema: "hub.discovery-snapshot",
126
+ version: 1,
127
+ capturedAt: options.now,
128
+ host: {
129
+ fingerprint: hostFingerprint({ machineID, platform: probe.platform, arch: probe.arch, uid: probe.uid, home }),
130
+ platform: probe.platform,
131
+ arch: probe.arch,
132
+ libc,
133
+ target,
134
+ supported: checkTarget(target).ok,
135
+ os: discoverOS(probe),
136
+ },
137
+ topology: { container, shims },
138
+ managers: { package: packages, runtime, service },
139
+ harnesses,
140
+ runtimes: [{ id: "openshell", present: which(probe, dirs, "openshell") !== undefined }],
141
+ configs,
142
+ roots,
143
+ }
144
+ return validateDiscoverySnapshot(snapshot)
145
+ }
146
+
@@ -0,0 +1,134 @@
1
+ // Executables on PATH: where a command is, whether it is a native binary, a wrapper script or a shim into a
2
+ // container (docker/podman exec), what it resolves to, and how (and at which version) a harness was installed,
3
+ // read from the install layout. A harness is never run to find out.
4
+ import { rootPathIn, SEMVER, type ExecutionContext, type HarnessID, type InstallMethod, type RootMapping, type Shim, type ShimKind } from "../contracts/index.ts"
5
+ import type { DiscoveryProbe } from "./probe.ts"
6
+ import { decode, join, normalizeAbs } from "./util.ts"
7
+
8
+ /** How much of an executable discovery reads to tell a native binary from a script. */
9
+ const HEAD_BYTES = 4096
10
+ const MAX_PATH_DIRS = 64
11
+
12
+ /** The command each harness is started with. */
13
+ export const HARNESS_BINARIES: Readonly<Record<HarnessID, string>> = Object.freeze({ opencode: "opencode", "claude-code": "claude", codex: "codex" })
14
+
15
+ // ---------- executables ----------
16
+
17
+ export function pathDirs(probe: DiscoveryProbe): string[] {
18
+ const dirs: string[] = []
19
+ for (const raw of (probe.env.PATH ?? "").split(":")) {
20
+ const d = normalizeAbs(raw)
21
+ if (d && !dirs.includes(d)) dirs.push(d)
22
+ if (dirs.length >= MAX_PATH_DIRS) break
23
+ }
24
+ return dirs
25
+ }
26
+
27
+ export function which(probe: DiscoveryProbe, dirs: readonly string[], name: string): string | undefined {
28
+ for (const d of dirs) {
29
+ const candidate = join(d, name)
30
+ if (probe.executable(candidate)) return candidate
31
+ }
32
+ return undefined
33
+ }
34
+
35
+ const CONTAINER_EXEC = /\b(?:docker|podman|nerdctl|distrobox|toolbox|lxc|lxc-attach)\b[^\n]*\b(?:exec|enter|run|attach)\b/
36
+ /** Container-exec options that take a value (the next word is not the program). */
37
+ const VALUE_OPTIONS = new Set(["-w", "--workdir", "-e", "--env", "-u", "--user", "--env-file", "--detach-keys", "--name"])
38
+
39
+ /** The first absolute program path after a container exec (`docker exec -i box /usr/bin/opencode "$@"`). */
40
+ function containerTarget(script: string): string | undefined {
41
+ const line = script.split("\n").find((l) => CONTAINER_EXEC.test(l))
42
+ if (!line) return undefined
43
+ const words = line.trim().split(/\s+/)
44
+ const start = words.findIndex((w, i) => /^(?:exec|enter|run|attach)$/.test(w) && words.slice(0, i).some((x) => /(?:docker|podman|nerdctl|distrobox|toolbox|lxc|lxc-attach)$/.test(x)))
45
+ for (let i = start + 1; start >= 0 && i < words.length; i++) {
46
+ if (VALUE_OPTIONS.has(words[i - 1]!)) continue
47
+ const p = normalizeAbs(words[i])
48
+ if (p) return p
49
+ }
50
+ return undefined
51
+ }
52
+
53
+ /** The absolute program a wrapper script execs (`exec /opt/x/bin/tool "$@"`). */
54
+ function wrapperTarget(script: string): string | undefined {
55
+ for (const line of script.split("\n").slice(1)) {
56
+ const m = /^\s*exec\s+(\S+)/.exec(line)
57
+ const p = m ? normalizeAbs(m[1]) : undefined
58
+ if (p) return p
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ const MAGIC = [
64
+ [0x7f, 0x45, 0x4c, 0x46], // ELF
65
+ [0xcf, 0xfa, 0xed, 0xfe],
66
+ [0xce, 0xfa, 0xed, 0xfe],
67
+ [0xfe, 0xed, 0xfa, 0xcf],
68
+ [0xfe, 0xed, 0xfa, 0xce],
69
+ [0xca, 0xfe, 0xba, 0xbe], // Mach-O (thin and universal)
70
+ ]
71
+
72
+ /** What an executable on PATH is: a native binary, a script wrapper, or a shim into a container. */
73
+ export function classifyExecutable(probe: DiscoveryProbe, name: string, path: string): Shim {
74
+ const isLink = probe.lstat(path) === "symlink"
75
+ const real = isLink ? normalizeAbs(probe.realpath(path)) : undefined
76
+ const head = probe.readFile(path, HEAD_BYTES) ?? new Uint8Array()
77
+ const native = MAGIC.some((m) => m.every((b, i) => head[i] === b))
78
+ let kind: ShimKind = native || isLink ? "host" : "unknown"
79
+ let resolvesTo = real && real !== path ? real : undefined
80
+ if (!native && head[0] === 0x23 && head[1] === 0x21) {
81
+ const script = decode(head)
82
+ if (CONTAINER_EXEC.test(script)) {
83
+ kind = "container-shim"
84
+ resolvesTo = containerTarget(script)
85
+ } else {
86
+ kind = "wrapper"
87
+ resolvesTo ??= wrapperTarget(script)
88
+ }
89
+ }
90
+ return resolvesTo ? { name, path, kind, resolvesTo } : { name, path, kind }
91
+ }
92
+
93
+ function packageVersion(probe: DiscoveryProbe, real: string): string | undefined {
94
+ const m = /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(real)
95
+ if (!m) return undefined
96
+ try {
97
+ const pkg = JSON.parse(decode(probe.readFile(`${m[1]}/package.json`, 64 * 1024))) as { version?: unknown }
98
+ return typeof pkg.version === "string" && SEMVER.test(pkg.version) ? pkg.version : undefined
99
+ } catch {
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /** How a harness was installed, and its version when the install layout states it (never by running it). */
105
+ export function installOf(probe: DiscoveryProbe, id: HarnessID, shim: Shim, roots: readonly RootMapping[], context: ExecutionContext = "host"): { installMethod: InstallMethod; version?: string } {
106
+ if (shim.kind === "container-shim") return { installMethod: "unknown" }
107
+ const real = shim.kind === "host" || shim.kind === "wrapper" ? (shim.resolvesTo ?? shim.path) : shim.path
108
+ const semver = (v: string | undefined) => (v && SEMVER.test(v) ? v : undefined)
109
+ // Paths are read in this (possibly container) view of each root (v1.1 rootPathIn).
110
+ const local = (root: string) => {
111
+ const r = roots.find((x) => x.id === root)
112
+ return r ? rootPathIn(r, context) : undefined
113
+ }
114
+ const data = local("data")
115
+ const home = local("home")
116
+ const hubLayout = data ? new RegExp(`^${escape(data)}/hub/harnesses/${escape(id)}/([^/]+)(?:/|$)`) : undefined
117
+ let hub = hubLayout?.exec(real) ?? null
118
+ // v1.1 layout: `current` is a link to the version directory in use; the version is where it leads.
119
+ if (hub?.[1] === "current") hub = hubLayout!.exec(normalizeAbs(probe.realpath(real)) ?? "") ?? null
120
+ if (hub && semver(hub[1])) return { installMethod: "hub", version: hub[1] }
121
+ const brew = /\/(?:Cellar|Caskroom)\/[^/]+\/([^/]+)\//.exec(real)
122
+ if (brew) return { installMethod: "brew", ...(semver(brew[1]) ? { version: brew[1] } : {}) }
123
+ if (real.includes("/node_modules/")) {
124
+ const version = packageVersion(probe, real)
125
+ return { installMethod: "npm", ...(version ? { version } : {}) }
126
+ }
127
+ const claude = /\/claude\/versions\/([^/]+)$/.exec(real)
128
+ if (id === "claude-code" && claude) return { installMethod: "native", ...(semver(claude[1]) ? { version: claude[1] } : {}) }
129
+ if (home && id === "opencode" && real.startsWith(`${home}/.opencode/bin/`)) return { installMethod: "native" }
130
+ if (/^\/(?:usr\/)?s?bin\//.test(real)) return { installMethod: "package-manager" }
131
+ return { installMethod: "unknown" }
132
+ }
133
+
134
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -0,0 +1,96 @@
1
+ // Host facts: path roots (and their host location inside a container), C library, OS name and version, and
2
+ // container kind. Everything is read through the probe; nothing is executed.
3
+ import { ROOT_IDS, type ContainerKind, type Libc, type RootID, type RootMapping } from "../contracts/index.ts"
4
+ import type { DiscoveryProbe } from "./probe.ts"
5
+ import { decode, join, normalizeAbs, oneLine } from "./util.ts"
6
+
7
+ // ---------- roots ----------
8
+
9
+ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined {
10
+ const home = normalizeAbs(probe.env.HOME) ?? normalizeAbs(probe.home)
11
+ if (!home) return undefined
12
+ const xdg = (key: string, fallback: string) => normalizeAbs(probe.env[key]) ?? join(home, fallback)
13
+ const config = xdg("XDG_CONFIG_HOME", ".config")
14
+ const paths: Partial<Record<RootID, string>> = {
15
+ home,
16
+ config,
17
+ state: xdg("XDG_STATE_HOME", ".local/state"),
18
+ data: xdg("XDG_DATA_HOME", ".local/share"),
19
+ "user-bin": join(home, ".local/bin"),
20
+ "system-bin": "/usr/local/bin",
21
+ }
22
+ if (probe.platform === "darwin") paths["launch-agents"] = join(home, "Library/LaunchAgents")
23
+ if (probe.platform === "linux") paths["systemd-user"] = join(config, "systemd/user")
24
+ return ROOT_IDS.flatMap((id) => (paths[id] ? [{ id, path: paths[id]! }] : []))
25
+ }
26
+
27
+ export interface Mount {
28
+ readonly root: string
29
+ readonly point: string
30
+ }
31
+
32
+ const unescapeMount = (s: string) => s.replace(/\\([0-7]{3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)))
33
+
34
+ export function parseMountinfo(text: string): Mount[] {
35
+ const mounts: Mount[] = []
36
+ for (const line of text.split("\n")) {
37
+ const f = line.split(" ")
38
+ if (f.length < 5) continue
39
+ const root = normalizeAbs(unescapeMount(f[3]!))
40
+ const point = normalizeAbs(unescapeMount(f[4]!))
41
+ if (root && point) mounts.push({ root, point })
42
+ }
43
+ return mounts
44
+ }
45
+
46
+ /** In a container: where each root lives on the host, when it sits in a bind mount of a host directory. */
47
+ export function mapContainerRoots(roots: RootMapping[], mounts: readonly Mount[]): RootMapping[] {
48
+ return roots.map((r) => {
49
+ const mount = mounts
50
+ .filter((m) => r.path === m.point || r.path.startsWith(m.point === "/" ? "/" : `${m.point}/`))
51
+ .sort((a, b) => b.point.length - a.point.length)[0]
52
+ if (!mount || mount.root === "/" || mount.root === mount.point) return r
53
+ const host = normalizeAbs(mount.root + r.path.slice(mount.point.length))
54
+ return host ? { id: r.id, path: host, containerPath: r.path } : r
55
+ })
56
+ }
57
+
58
+ // ---------- host ----------
59
+
60
+ export function discoverLibc(probe: DiscoveryProbe): Libc {
61
+ if (probe.platform === "darwin") return "none"
62
+ if (probe.platform !== "linux") return "unknown"
63
+ const names = ["/lib", "/lib64", "/usr/lib"].flatMap((d) => probe.readdir(d) ?? [])
64
+ if (names.some((n) => n.startsWith("ld-musl-"))) return "musl"
65
+ if (names.some((n) => n.startsWith("ld-linux"))) return "glibc"
66
+ return "unknown"
67
+ }
68
+
69
+ export function discoverOS(probe: DiscoveryProbe): { name: string; version: string } {
70
+ if (probe.platform === "darwin") {
71
+ const plist = decode(probe.readFile("/System/Library/CoreServices/SystemVersion.plist", 64 * 1024))
72
+ const value = (key: string) => oneLine(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(plist)?.[1])
73
+ return { name: value("ProductName") ?? "macOS", version: value("ProductVersion") ?? "unknown" }
74
+ }
75
+ if (probe.platform === "linux") {
76
+ const text = decode(probe.readFile("/etc/os-release", 64 * 1024) ?? probe.readFile("/usr/lib/os-release", 64 * 1024))
77
+ const value = (key: string) => oneLine(new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2"))
78
+ return { name: value("NAME") ?? "Linux", version: value("VERSION_ID") ?? "unknown" }
79
+ }
80
+ return { name: oneLine(probe.platform, 32) ?? "unknown", version: "unknown" }
81
+ }
82
+
83
+ export function discoverContainer(probe: DiscoveryProbe): ContainerKind {
84
+ if (probe.platform !== "linux") return "none"
85
+ if (probe.lstat("/.dockerenv") !== "missing") return "docker"
86
+ if (probe.lstat("/run/.containerenv") !== "missing") return "podman"
87
+ const env = probe.env.container
88
+ if (env === "docker" || env === "podman" || env === "lxc") return env
89
+ if (env) return "unknown"
90
+ const cgroup = decode(probe.readFile("/proc/1/cgroup", 64 * 1024))
91
+ if (/libpod/.test(cgroup)) return "podman"
92
+ if (/docker|containerd/.test(cgroup)) return "docker"
93
+ if (/\blxc\b/.test(cgroup)) return "lxc"
94
+ if (/microsoft|wsl/i.test(decode(probe.readFile("/proc/sys/kernel/osrelease", 4096)))) return "wsl"
95
+ return "none"
96
+ }
@@ -0,0 +1,6 @@
1
+ // Read-only discovery (HBS-03): DiscoveryProbe in, contract DiscoverySnapshot out.
2
+ export * from "./probe.ts"
3
+ export * from "./discover.ts"
4
+ export { classifyExecutable, HARNESS_BINARIES } from "./executables.ts"
5
+ export { normalizeAbs } from "./util.ts"
6
+ export { createNodeProbe, MAX_READ_BYTES, type NodeProbeOptions } from "./node-probe.ts"
@@ -0,0 +1,97 @@
1
+ // The real DiscoveryProbe, over node:fs. Every method is a read: lstat/stat, a bounded read of a file opened
2
+ // read-only, readdir, readlink, realpath and an access(X_OK) check. Errors mean "missing" or "unknown", never a
3
+ // throw. The only process it may start is the macOS machine-identity query (`ioreg`, a fixed read-only argv, no
4
+ // shell), and only when no machine ID was injected; Linux reads /etc/machine-id.
5
+ import childProcess from "node:child_process"
6
+ import fs from "node:fs"
7
+ import os from "node:os"
8
+ import { programLayout, type EntryKind } from "../contracts/index.ts"
9
+ import type { DiscoveryProbe, RuntimeFact } from "./probe.ts"
10
+
11
+ export interface NodeProbeOptions {
12
+ /** Defaults to process.env (read, never recorded). */
13
+ readonly env?: Readonly<Record<string, string | undefined>>
14
+ readonly home?: string
15
+ /** Injected machine identity (tests); otherwise read from the OS. */
16
+ readonly machineID?: string
17
+ /** The running runtime; null when hubctl runs standalone. Detected by default. */
18
+ readonly runtime?: RuntimeFact | null
19
+ }
20
+
21
+ /** Reads without a limit are capped (configs are small; a bigger file is not read at all). */
22
+ export const MAX_READ_BYTES = 16 * 1024 * 1024
23
+
24
+ const kindOf = (st: fs.Stats): EntryKind => (st.isSymbolicLink() ? "symlink" : st.isDirectory() ? "directory" : st.isFile() ? "file" : "other")
25
+
26
+ function attempt<T>(f: () => T): T | undefined {
27
+ try {
28
+ return f()
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ function currentRuntime(): RuntimeFact | undefined {
35
+ const layout = programLayout({ execPath: process.execPath, entry: process.argv[1] ?? "" })
36
+ if (layout.kind === "standalone") return undefined
37
+ const bun = process.versions.bun
38
+ return bun ? { id: "bun", version: bun, path: process.execPath } : { id: "node", version: process.versions.node, path: process.execPath }
39
+ }
40
+
41
+ function osMachineID(): string | undefined {
42
+ if (process.platform === "linux")
43
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
44
+ const id = attempt(() => fs.readFileSync(p, "utf8").trim())
45
+ if (id) return id
46
+ }
47
+ if (process.platform === "darwin") {
48
+ const out = attempt(() => childProcess.execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }))
49
+ return out ? /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1] : undefined
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ export function createNodeProbe(options: NodeProbeOptions = {}): DiscoveryProbe {
55
+ let machineID: string | undefined | null = options.machineID ?? null
56
+ const runtime = options.runtime === null ? undefined : (options.runtime ?? currentRuntime())
57
+ return {
58
+ platform: process.platform,
59
+ arch: process.arch,
60
+ uid: process.getuid?.() ?? 0,
61
+ home: options.home ?? os.homedir(),
62
+ hostname: os.hostname(),
63
+ env: options.env ?? process.env,
64
+ ...(runtime ? { runtime } : {}),
65
+ lstat: (p) => attempt(() => kindOf(fs.lstatSync(p))) ?? "missing",
66
+ stat: (p) => attempt(() => kindOf(fs.statSync(p))) ?? "missing",
67
+ readFile(p, limit) {
68
+ const max = Math.min(limit ?? MAX_READ_BYTES, MAX_READ_BYTES)
69
+ const fd = attempt(() => fs.openSync(p, "r"))
70
+ if (fd === undefined) return undefined
71
+ try {
72
+ const st = fs.fstatSync(fd)
73
+ if (!st.isFile() || (limit === undefined && st.size > MAX_READ_BYTES)) return undefined
74
+ const buffer = Buffer.alloc(Math.min(max, st.size))
75
+ let read = 0
76
+ while (read < buffer.length) {
77
+ const n = fs.readSync(fd, buffer, read, buffer.length - read, read)
78
+ if (n === 0) break
79
+ read += n
80
+ }
81
+ return new Uint8Array(buffer.subarray(0, read))
82
+ } catch {
83
+ return undefined
84
+ } finally {
85
+ attempt(() => fs.closeSync(fd))
86
+ }
87
+ },
88
+ readdir: (p) => attempt(() => fs.readdirSync(p).sort()),
89
+ readlink: (p) => attempt(() => fs.readlinkSync(p)),
90
+ realpath: (p) => attempt(() => fs.realpathSync(p)),
91
+ executable: (p) => attempt(() => fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true)) ?? false,
92
+ machineID() {
93
+ if (machineID === null) machineID = osMachineID()
94
+ return machineID
95
+ },
96
+ }
97
+ }
@@ -0,0 +1,41 @@
1
+ // The read-only probe discovery and preview run through. It is the only way they see the host: a handful of
2
+ // filesystem reads (lstat, stat, readFile, readdir, readlink, realpath, an executable check), facts about this
3
+ // process, and the machine identity. The interface has no method that writes, deletes, connects or spawns an
4
+ // arbitrary command, so discover and preview cannot change the host or reach the network even by mistake. Tests
5
+ // inject an in-memory probe; the CLI injects createNodeProbe().
6
+ import type { EntryKind } from "../contracts/index.ts"
7
+
8
+ export interface RuntimeFact {
9
+ readonly id: "bun" | "node"
10
+ readonly version: string
11
+ readonly path: string
12
+ }
13
+
14
+ export interface DiscoveryProbe {
15
+ /** process.platform and process.arch as found (may be unsupported). */
16
+ readonly platform: string
17
+ readonly arch: string
18
+ readonly uid: number
19
+ /** The user's home directory (os.homedir()). */
20
+ readonly home: string
21
+ readonly hostname: string
22
+ /** Read, never recorded: discovery looks at PATH, HOME, XDG_* and `container` only. */
23
+ readonly env: Readonly<Record<string, string | undefined>>
24
+ /** The runtime running this script, when hubctl runs as a script (absent for a standalone binary). */
25
+ readonly runtime?: RuntimeFact
26
+ /** Does not follow a final symlink. */
27
+ lstat(path: string): EntryKind
28
+ /** Follows symlinks. */
29
+ stat(path: string): EntryKind
30
+ /** Up to `limit` bytes of a file (following symlinks), or undefined when it cannot be read. */
31
+ readFile(path: string, limit?: number): Uint8Array | undefined
32
+ /** Entry names of a directory (sorted), or undefined. */
33
+ readdir(path: string): readonly string[] | undefined
34
+ readlink(path: string): string | undefined
35
+ /** The canonical path (every link resolved), or undefined when it does not exist. */
36
+ realpath(path: string): string | undefined
37
+ /** A regular file (after links) this user may execute. */
38
+ executable(path: string): boolean
39
+ /** /etc/machine-id (Linux) or IOPlatformUUID (macOS); never recorded raw. */
40
+ machineID(): string | undefined
41
+ }
@@ -0,0 +1,13 @@
1
+ // Small path and text helpers shared by discovery and preview.
2
+ import { isAbsolutePath } from "../contracts/index.ts"
3
+
4
+ /** A normalized absolute path (duplicate and trailing slashes removed), or undefined. */
5
+ export function normalizeAbs(value: string | undefined): string | undefined {
6
+ if (typeof value !== "string" || !value.startsWith("/")) return undefined
7
+ const p = value.replace(/\/{2,}/g, "/").replace(/(.)\/+$/, "$1")
8
+ return isAbsolutePath(p) ? p : undefined
9
+ }
10
+
11
+ export const join = (dir: string, rel: string) => (dir === "/" ? `/${rel}` : `${dir}/${rel}`)
12
+ export const decode = (bytes: Uint8Array | undefined) => (bytes ? new TextDecoder("utf-8", { fatal: false }).decode(bytes) : "")
13
+ export const oneLine = (v: string | undefined, max = 64) => (v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v) ? v : undefined)