@huynle/hub 0.7.1 → 0.7.3

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 (194) hide show
  1. package/CHANGELOG.md +87 -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/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,108 @@
1
+ // The messaging verbs' helpers (design §3): text arguments (or "-" for stdin) and the short text of what the hub
2
+ // answers to msg, ask, reply, decline, inbox and mute. The shapes are packages/sdk's types (message-routes.ts).
3
+ import type { AgentMessage, Asked, Inbox, MessageDelivery, Party, Replied, Sent } from "../../sdk/src/index.ts"
4
+ import { UsageError } from "./args"
5
+ import { oneLine } from "./format"
6
+
7
+ /** A text argument: the value, or all of stdin for "-" (one trailing newline dropped); never empty. */
8
+ export async function textArg(value: string, stdin: (() => Promise<string>) | undefined, what = "text"): Promise<string> {
9
+ let text = value
10
+ if (value === "-") {
11
+ if (!stdin) throw new UsageError(`cannot read the ${what} from stdin here`)
12
+ text = (await stdin()).replace(/\r?\n$/, "")
13
+ }
14
+ if (!text.trim()) throw new UsageError(`the ${what} is empty`)
15
+ return text
16
+ }
17
+
18
+ /** `@name`, the session ID, `@me`, or the app token. */
19
+ export function partyText(party: Party): string {
20
+ if (party.kind === "session") return party.name ? `@${party.name.replace(/^@+/, "")}` : party.sessionID
21
+ if (party.kind === "user") return "@me"
22
+ return `app ${party.label ?? party.tokenID}`
23
+ }
24
+
25
+ const DELIVERY: Record<MessageDelivery, string> = {
26
+ turn: "delivered (started a turn)",
27
+ steer: "delivered (into the running turn)",
28
+ queued: "queued (read at its next turn)",
29
+ retained: "retained (held until its open question is answered)",
30
+ pending: "pending (offline; delivered when it reconnects, for 24 h)",
31
+ }
32
+
33
+ export const deliveryText = (delivery: MessageDelivery | undefined): string => (delivery ? (DELIVERY[delivery] ?? delivery) : "stored (in its inbox)")
34
+
35
+ /** `to @@reviewer → @api` when the address is not the recipient's own name. */
36
+ function toText(message: AgentMessage): string {
37
+ const recipient = partyText(message.recipient)
38
+ return message.to === recipient || message.to === (message.recipient as { sessionID?: string }).sessionID ? recipient : `${message.to} → ${recipient}`
39
+ }
40
+
41
+ /** `msg_… to @web-work: delivered (started a turn)` */
42
+ export const sentText = ({ message, delivery }: Sent): string => `${message.kind} ${message.id} to ${toText(message)}: ${deliveryText(delivery)}`
43
+
44
+ /** How long an ask stays open when --expires is not given (the hub's default). */
45
+ export const ASK_EXPIRES_DEFAULT_S = 24 * 60 * 60
46
+
47
+ const UNIT_S: Record<string, number> = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }
48
+
49
+ /** Seconds from `90`, `30s`, `5m`, `1.5h` or `1d`. */
50
+ export function durationArg(value: string, flag: string): number {
51
+ const match = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(value)
52
+ if (!match) throw new UsageError(`${flag} takes seconds or a duration like 30s, 5m, 2h, 1d (got "${value}")`)
53
+ return Number(match[1]) * UNIT_S[match[2]!]!
54
+ }
55
+
56
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
57
+
58
+ /** What an ask came to, its text, and the exit code: answered 0, declined 1, open after a wait 1, expired 8. */
59
+ export function askOutcome(asked: Asked, waited: number | undefined): { text: string; exit: number; note?: string } {
60
+ const { message, answer } = asked
61
+ const head = sentText(asked)
62
+ const by = answer ? partyText(answer.from) : partyText(message.recipient)
63
+ switch (message.state) {
64
+ case "answered":
65
+ return { text: `${head}\nanswer ${answer?.id ?? "?"} from ${by}:\n${answer?.text ?? ""}`, exit: 0 }
66
+ case "declined":
67
+ return { text: `${head}\ndeclined by ${by}: ${answer?.text ?? ""}`, exit: 1 }
68
+ case "expired":
69
+ return { text: `${head}\nexpired without an answer`, exit: 8 }
70
+ default: {
71
+ const until = message.expiresAt !== undefined ? `open until ${iso(message.expiresAt)}` : "open"
72
+ const text = `${head}\n${until}; the answer shows in \`hub inbox\``
73
+ if (waited === undefined) return { text, exit: 0 }
74
+ return { text, exit: 1, note: `no answer within ${waited} s; ${message.id} is still open (see \`hub inbox\`)` }
75
+ }
76
+ }
77
+ }
78
+
79
+ /** `reply msg_r to @api: delivered (started a turn) (answers msg_1)` */
80
+ export const repliedText = (replied: Replied): string => `${sentText(replied)} (answers ${replied.original.id})`
81
+
82
+ /** `declined msg_1 (decline msg_d to @api: queued …)` */
83
+ export const declinedText = (replied: Replied): string => `declined ${replied.original.id} (${sentText(replied)})`
84
+
85
+ /** Open asks to answer (with how), the caller's own open asks, recent messages, mutes. */
86
+ export function inboxText(inbox: Inbox): string {
87
+ const lines: string[] = []
88
+ const until = (m: AgentMessage) => (m.expiresAt !== undefined ? ` (open until ${iso(m.expiresAt)})` : "")
89
+ if (inbox.asks.length) {
90
+ lines.push("asks to answer:")
91
+ for (const m of inbox.asks) {
92
+ lines.push(` ${m.id} from ${partyText(m.from)} ${iso(m.at)}: ${oneLine(m.text, 200)}${until(m)}`)
93
+ lines.push(` ↩ hub reply ${m.id} "…" · hub decline ${m.id} --why "…"`)
94
+ }
95
+ }
96
+ if (inbox.waiting.length) {
97
+ lines.push("waiting for answers:")
98
+ for (const m of inbox.waiting) lines.push(` ${m.id} to ${toText(m)} ${iso(m.at)}: ${oneLine(m.text, 200)}${until(m)}`)
99
+ }
100
+ if (inbox.messages.length) {
101
+ lines.push("messages:")
102
+ for (const m of inbox.messages) lines.push(` ${m.id} ${m.kind} from ${partyText(m.from)} ${iso(m.at)}${m.replyTo ? ` (re ${m.replyTo})` : ""}: ${oneLine(m.text, 200)}`)
103
+ }
104
+ if (inbox.muted.length) lines.push(mutedText(inbox.muted))
105
+ return lines.length ? lines.join("\n") : "inbox empty"
106
+ }
107
+
108
+ export const mutedText = (muted: readonly string[]): string => `muted: ${muted.length ? muted.join(", ") : "nobody"}`
@@ -0,0 +1,279 @@
1
+ // `hub node run | install | uninstall | status` (design §6.3; decision znbg8xa5 #6): `run` is the node daemon
2
+ // (packages/node runNode) until SIGINT/SIGTERM. `install` renders the service template embedded in this program (a
3
+ // launchd agent on macOS, a systemd user unit on Linux; renderServiceUnit, the same renderer `hubctl setup` uses)
4
+ // with the argv of what runs this command (programLayout: the standalone hubctl itself, or the runtime and the CLI
5
+ // entry), writes the file, and prints the launchctl / systemctl commands that load it. Nothing here starts a process:
6
+ // loading, unloading and inspecting the service are always left to the user (printed, never run). --dry-run
7
+ // prints the file instead of writing it. The same machinery (ServiceSpec) installs the harness spokes' services
8
+ // (spoke-services.ts: `hub claude-code install`, `hub codex-spoke install`).
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
10
+ import path from "node:path"
11
+ import { nodeConfigPath, readNodeConfig, readSpokeCredentials, runNode } from "../../node/src/index.ts"
12
+ import { renderServiceUnit } from "./bootstrap/adapters/node-services/render.ts"
13
+ import { isAbsolutePath, programLayout, type ServiceID, type ServiceManagerID } from "./bootstrap/contracts/index.ts"
14
+ import type { Env } from "./credentials"
15
+ import { CliError, EXIT } from "./http"
16
+
17
+ export const NODE_LABEL = "io.github.huynle.hub-node"
18
+ export const NODE_UNIT = "hub-node.service"
19
+
20
+ /** A per-user hub service installed from the embedded templates (hub node, and the harness spokes' services). */
21
+ export interface ServiceSpec {
22
+ /** The service (its embedded template and the subcommand the unit runs). */
23
+ readonly service: ServiceID
24
+ /** The CLI command that installs it ("hub node"), for the written files' header and messages. */
25
+ readonly command: string
26
+ /** The launchd label; the macOS template is scripts/<label>.plist. */
27
+ readonly label: string
28
+ /** The systemd unit name; the Linux template is scripts/<unit>. */
29
+ readonly unit: string
30
+ /** Its log file in ~/.local/state/hub (launchd). */
31
+ readonly log: string
32
+ /** Settings the service must see as this shell does (where its config and state live). */
33
+ readonly carried: readonly string[]
34
+ /** What to tell users on platforms without launchd or systemd. */
35
+ readonly elsewhere: string
36
+ }
37
+
38
+ export const NODE_SERVICE: ServiceSpec = {
39
+ service: "node",
40
+ command: "hub node",
41
+ label: NODE_LABEL,
42
+ unit: NODE_UNIT,
43
+ log: "node.log",
44
+ carried: ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME"],
45
+ elsewhere: "Run `hub node run` under your own service manager",
46
+ }
47
+
48
+ /** What runs the CLI: the runtime binary and the CLI's entry file. */
49
+ export interface Program {
50
+ readonly execPath: string
51
+ readonly entry: string
52
+ }
53
+
54
+ export interface NodeIo {
55
+ readonly env: Env
56
+ readonly home: string
57
+ readonly platform: NodeJS.Platform
58
+ readonly uid: number
59
+ readonly program: Program
60
+ }
61
+
62
+ export type ServiceKind = "launchd" | "systemd"
63
+
64
+ export interface NodeService {
65
+ readonly kind: ServiceKind
66
+ /** Where the unit file goes. */
67
+ readonly file: string
68
+ /** Its content, as install writes it. */
69
+ readonly content: string
70
+ /** Directories the service needs (its log). */
71
+ readonly dirs: readonly string[]
72
+ /** Commands that load (or reload) it, for the user to run. */
73
+ readonly load: readonly string[]
74
+ /** Commands that stop it once the file is removed. */
75
+ readonly unload: readonly string[]
76
+ /** How to see its state and logs. */
77
+ readonly check: readonly string[]
78
+ /** Settings this shell has that the unit cannot carry (not an absolute path). */
79
+ readonly skipped: readonly string[]
80
+ }
81
+
82
+ /** Extra settings a service's unit carries beyond the shell's (spoke-services.ts: the hub-installed Codex). */
83
+ export type ExtraEnvironment = (io: NodeIo) => Readonly<Record<string, string>>
84
+
85
+ /** This process as a Program: the runtime and the real path of the entry it runs. */
86
+ export function currentProgram(): Program {
87
+ const entry = process.argv[1] ?? ""
88
+ let real = entry
89
+ try {
90
+ real = realpathSync(entry)
91
+ } catch {}
92
+ return { execPath: process.execPath, entry: real }
93
+ }
94
+
95
+ /** A path quoted for sh (printed commands). */
96
+ const sh = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
97
+
98
+ /** The settings this shell has that the unit carries (absolute, single-line paths only), and those it cannot. */
99
+ function carried(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): { environment: Record<string, string>; skipped: string[] } {
100
+ const environment: Record<string, string> = { ...(extra?.(io) ?? {}) }
101
+ const skipped: string[] = []
102
+ for (const key of spec.carried) {
103
+ const value = io.env[key]
104
+ if (!value) continue
105
+ if (isAbsolutePath(value)) environment[key] = value
106
+ else skipped.push(key)
107
+ }
108
+ return { environment, skipped }
109
+ }
110
+
111
+ /** The unit's text: the embedded template rendered with this program's argv (one word per <string> / ExecStart word). */
112
+ function render(spec: ServiceSpec, io: NodeIo, manager: ServiceManagerID, extra?: ExtraEnvironment): { content: string; skipped: string[] } {
113
+ const { environment, skipped } = carried(spec, io, extra)
114
+ const r = renderServiceUnit({ service: spec.service, manager, layout: programLayout(io.program), home: io.home, environment, writtenBy: spec.command })
115
+ if (!r.ok) throw new CliError(EXIT.usage, `cannot write the ${spec.command} service: ${r.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ")}`)
116
+ return { content: r.value.text, skipped }
117
+ }
118
+
119
+ function launchd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
120
+ const file = path.join(io.home, "Library", "LaunchAgents", `${spec.label}.plist`)
121
+ const domain = `gui/${io.uid}`
122
+ const { content, skipped } = render(spec, io, "launchd", extra)
123
+ const log = path.join(io.home, ".local", "state", "hub")
124
+ return {
125
+ kind: "launchd",
126
+ file,
127
+ content,
128
+ skipped,
129
+ dirs: [log],
130
+ load: [`launchctl bootout ${domain}/${spec.label} 2>/dev/null || true`, `launchctl bootstrap ${domain} ${sh(file)}`],
131
+ unload: [`launchctl bootout ${domain}/${spec.label}`],
132
+ check: [`launchctl print ${domain}/${spec.label}`, `tail -f ${sh(path.join(log, spec.log))}`],
133
+ }
134
+ }
135
+
136
+ function systemd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
137
+ const unit = spec.unit
138
+ const dir = path.join(io.env.XDG_CONFIG_HOME || path.join(io.home, ".config"), "systemd", "user")
139
+ const file = path.join(dir, unit)
140
+ const { content, skipped } = render(spec, io, "systemd-user", extra)
141
+ return {
142
+ kind: "systemd",
143
+ file,
144
+ content,
145
+ skipped,
146
+ dirs: [],
147
+ load: ["systemctl --user daemon-reload", `systemctl --user enable --now ${unit}`, `systemctl --user restart ${unit} # when it was already running`, 'loginctl enable-linger "$USER" # optional: keep it running while you are logged out'],
148
+ unload: [`systemctl --user stop ${unit}`, `rm -f ${sh(path.join(dir, "default.target.wants", unit))}`, "systemctl --user daemon-reload"],
149
+ check: [`systemctl --user status ${unit}`, `journalctl --user -u ${unit} -f`],
150
+ }
151
+ }
152
+
153
+ /** `spec`'s service for this platform; unsupported (exit 7) elsewhere. */
154
+ export function serviceFor(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
155
+ if (io.platform === "darwin") return launchd(spec, io, extra)
156
+ if (io.platform === "linux") return systemd(spec, io, extra)
157
+ throw new CliError(EXIT.unavailable, `${spec.command} install supports macOS (launchd) and Linux (systemd), not ${io.platform}`, spec.elsewhere)
158
+ }
159
+
160
+ /** The node's service for this platform; unsupported (exit 7) elsewhere. */
161
+ export const nodeService = (io: NodeIo): NodeService => serviceFor(NODE_SERVICE, io)
162
+
163
+ const readText = (file: string): string | undefined => {
164
+ try {
165
+ return readFileSync(file, "utf8")
166
+ } catch {
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ /** Why the installed service would not do anything yet (never the token). */
172
+ export function readinessProblems(env: Env, home: string): string[] {
173
+ const problems: string[] = []
174
+ if (!readSpokeCredentials(env, home)) problems.push("this machine is not enrolled with a hub yet (no machine token in config.json): enroll it with `hubctl machine enroll --server <hub URL>`")
175
+ const file = nodeConfigPath(env, home)
176
+ const { config, problems: found } = readNodeConfig(file)
177
+ problems.push(...found.map((p) => `${file}: ${p}`))
178
+ if (config.workspaceRoots.length === 0) problems.push(`${file} lists no workspaceRoots, so the service would exit 78; add e.g. {"workspaceRoots": ["${home}/projects"]}`)
179
+ return problems
180
+ }
181
+
182
+ const commands = (title: string, lines: readonly string[]) => [title, ...lines.map((l) => ` ${l}`)].join("\n")
183
+ const NOT_RUN = "(not run by hub; run them yourself)"
184
+
185
+ export interface NodeCommandOutput {
186
+ readonly stdout: string
187
+ readonly warnings: readonly string[]
188
+ }
189
+
190
+ export function installNode(io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
191
+ return installService(NODE_SERVICE, io, options, readinessProblems(io.env, io.home))
192
+ }
193
+
194
+ /** Writes `spec`'s service file (or prints it, dry run) and prints the commands that load it; `warnings` go to stderr. */
195
+ export function installService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }, given: readonly string[], extra?: ExtraEnvironment): NodeCommandOutput {
196
+ const service = serviceFor(spec, io, extra)
197
+ const warnings = [...given, ...service.skipped.map((key) => `${key} is not an absolute path, so the service does not carry it`)]
198
+ const load = commands(`Load it ${NOT_RUN}:`, service.load)
199
+ if (options.dryRun) return { stdout: `would write ${service.file}:\n\n${service.content}\n${load}`, warnings }
200
+ const current = readText(service.file)
201
+ for (const dir of service.dirs) mkdirSync(dir, { recursive: true })
202
+ if (current === service.content) return { stdout: `${service.file} is up to date\n${load}`, warnings }
203
+ mkdirSync(path.dirname(service.file), { recursive: true })
204
+ writeFileSync(service.file, service.content, { mode: 0o644 })
205
+ return { stdout: `${current === undefined ? "wrote" : "updated"} ${service.file}\n${load}`, warnings }
206
+ }
207
+
208
+ export function uninstallNode(io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
209
+ return uninstallService(NODE_SERVICE, io, options)
210
+ }
211
+
212
+ /** Removes `spec`'s service file (dry run: says it would) and prints the commands that stop it. */
213
+ export function uninstallService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
214
+ const service = serviceFor(spec, io)
215
+ if (!existsSync(service.file)) return { stdout: `not installed (no ${service.file})`, warnings: [] }
216
+ const stop = commands(`Stop the service ${NOT_RUN}:`, service.unload)
217
+ if (options.dryRun) return { stdout: `would remove ${service.file}\n${stop}`, warnings: [] }
218
+ rmSync(service.file)
219
+ return { stdout: `removed ${service.file}\n${stop}`, warnings: [] }
220
+ }
221
+
222
+ export type ServiceState = { readonly kind: ServiceKind; readonly file: string; readonly installed: boolean; readonly current: boolean; readonly check: readonly string[] } | { readonly kind: "unsupported"; readonly platform: string }
223
+
224
+ /** Whether `spec`'s service file is there and is what install would write now. */
225
+ export function serviceState(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): ServiceState {
226
+ try {
227
+ const s = serviceFor(spec, io, extra)
228
+ const text = readText(s.file)
229
+ return { kind: s.kind, file: s.file, installed: text !== undefined, current: text === s.content, check: s.check }
230
+ } catch {
231
+ return { kind: "unsupported", platform: io.platform }
232
+ }
233
+ }
234
+
235
+ /** The status lines about `spec`'s service file. */
236
+ export function serviceStateLine(spec: ServiceSpec, s: ServiceState): string {
237
+ if (s.kind === "unsupported") return `service: ${spec.command} install does not support ${s.platform}`
238
+ if (!s.installed) return `service: not installed (${spec.command} install)`
239
+ return `service: installed, ${s.current ? "up to date" : `differs from what this CLI writes (${spec.command} install updates it)`} (${s.file})`
240
+ }
241
+
242
+ /** How to see an installed service (not run). */
243
+ export const serviceCheckLines = (s: ServiceState): string[] => (s.kind !== "unsupported" && s.installed ? [commands(`See the service ${NOT_RUN}:`, s.check)] : [])
244
+
245
+ export interface NodeStatus {
246
+ readonly service: ServiceState
247
+ readonly enrolled?: { readonly server: string; readonly machineID?: string }
248
+ readonly config: { readonly file: string; readonly workspaceRoots: readonly string[]; readonly slots: number; readonly labels: Readonly<Record<string, string>>; readonly problems: readonly string[] }
249
+ }
250
+
251
+ export function nodeStatus(io: NodeIo): NodeStatus {
252
+ const service = serviceState(NODE_SERVICE, io)
253
+ const credentials = readSpokeCredentials(io.env, io.home)
254
+ const file = nodeConfigPath(io.env, io.home)
255
+ const { config, problems } = readNodeConfig(file)
256
+ return {
257
+ service,
258
+ ...(credentials ? { enrolled: { server: credentials.server, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) } } : {}),
259
+ config: { file, workspaceRoots: config.workspaceRoots, slots: config.slots, labels: config.labels, problems },
260
+ }
261
+ }
262
+
263
+ export function nodeStatusText(status: NodeStatus): string {
264
+ const s = status.service
265
+ const lines: string[] = [serviceStateLine(NODE_SERVICE, s)]
266
+ lines.push(status.enrolled ? `machine: enrolled with ${status.enrolled.server}${status.enrolled.machineID ? ` as ${status.enrolled.machineID}` : ""}` : "machine: not enrolled (enroll it with `hubctl machine enroll --server <hub URL>`)")
267
+ const c = status.config
268
+ lines.push(`node.json: ${c.file}`, ` workspace roots: ${c.workspaceRoots.length ? c.workspaceRoots.join(", ") : "none (the node would exit 78)"}`, ` slots: ${c.slots}`)
269
+ const labels = Object.entries(c.labels).map(([k, v]) => `${k}=${v}`)
270
+ if (labels.length) lines.push(` labels: ${labels.join(" ")}`)
271
+ for (const p of c.problems) lines.push(` problem: ${p}`)
272
+ lines.push(...serviceCheckLines(s))
273
+ return lines.join("\n")
274
+ }
275
+
276
+ /** `hub node run`: the daemon until `signal` aborts; runNode's exit code. */
277
+ export function runNodeCommand(io: { readonly env: Env; readonly home: string; readonly signal?: AbortSignal; stderr(s: string): void }): Promise<number> {
278
+ return runNode({ env: io.env, home: io.home, signal: io.signal ?? new AbortController().signal, log: (line) => io.stderr(`[hub node] ${line}\n`) })
279
+ }
@@ -0,0 +1,166 @@
1
+ // `hub node openshell check` (design §6.4, decision lk6gn3u7, spike report gut64rwq): can this node run sandboxed
2
+ // launches? It reads node.json's `openshell` section and asks the openshell CLI only read-only questions (--version,
3
+ // status, workspace list, provider list), in the order a launch needs them. It never creates or changes anything:
4
+ // for a missing workspace it prints the command to run. A `fail` is something that stops sandboxed launches (exit 1);
5
+ // a `warn` is something that likely makes them useless (no model provider, an image the gateway cannot pull).
6
+ import { nodeConfigPath, readNodeConfig } from "../../node/src/index.ts"
7
+ import { OpenShellCli, OpenShellError, OPENSHELL_WORKSPACE, SUPPORTED_VERSIONS, type OpenShellClient } from "../../runtime-openshell/src/cli.ts"
8
+ import { DEFAULT_PROFILE } from "../../runtime-openshell/src/profiles.ts"
9
+ import { DEFAULT_SANDBOX_IMAGE } from "../../runtime-openshell/src/runtime.ts"
10
+ import type { Env } from "./credentials"
11
+
12
+ export type CheckStatus = "ok" | "warn" | "fail" | "skip"
13
+
14
+ export interface CheckItem {
15
+ /** config, profiles, default-providers, image, cli, gateway, workspace, providers */
16
+ readonly check: string
17
+ readonly status: CheckStatus
18
+ readonly message: string
19
+ readonly hint?: string
20
+ }
21
+
22
+ export interface OpenShellCheckReport {
23
+ /** No `fail` (warnings allowed). */
24
+ readonly ok: boolean
25
+ /** The node.json read. */
26
+ readonly file: string
27
+ readonly checks: readonly CheckItem[]
28
+ }
29
+
30
+ export interface OpenShellCheckOptions {
31
+ readonly env: Env
32
+ readonly home: string
33
+ /** The openshell executable (default "openshell" on PATH). */
34
+ readonly binary?: string
35
+ /** Instead of the CLI (tests). */
36
+ readonly client?: OpenShellClient
37
+ }
38
+
39
+ const EXAMPLE = `{"openshell": {"enabled": true, "image": "ghcr.io/<owner>/hub-sandbox:<tag>", "profiles": {"default": {"providers": ["<provider>"]}}}}`
40
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
41
+ const item = (check: string, status: CheckStatus, message: string, hint?: string): CheckItem => ({ check, status, message, ...(hint ? { hint } : {}) })
42
+
43
+ /** Why an image reference likely cannot be pulled by OpenShell's vm driver (HTTPS registries only); undefined: fine. */
44
+ export function imageConcern(image: string): string | undefined {
45
+ const slash = image.indexOf("/")
46
+ const first = slash < 0 ? "" : image.slice(0, slash)
47
+ const hasHost = first === "localhost" || first.includes(".") || first.includes(":")
48
+ if (!hasHost) return `${image} has no registry host, so the gateway looks for it on Docker Hub; OpenShell's vm driver does not use local Docker images, so it must be published to an HTTPS registry`
49
+ const host = first.replace(/:\d+$/, "")
50
+ if (host === "localhost" || /^127\./.test(host) || host === "[::1]" || host.endsWith(".local")) return `${image} is on a local registry; OpenShell's vm driver pulls over HTTPS only and refuses plain-http local registries`
51
+ return undefined
52
+ }
53
+
54
+ /** The checks node.json alone can answer. */
55
+ function configChecks(env: Env, home: string): { file: string; checks: CheckItem[]; providers: Map<string, string[]>; usable: boolean; gateway?: string } {
56
+ const file = nodeConfigPath(env, home)
57
+ const { config, problems } = readNodeConfig(file)
58
+ const checks: CheckItem[] = []
59
+ const providers = new Map<string, string[]>()
60
+ const openshell = config.openshell
61
+ const configProblems = problems.filter((p) => !/^openshell\.profiles\.[^:]+: unsupported \(/.test(p))
62
+ for (const p of configProblems) checks.push(item("config", "fail", p))
63
+ if (!openshell) {
64
+ checks.push(item("config", "fail", `${file} has no openshell section`, `add one, e.g. ${EXAMPLE}`))
65
+ return { file, checks, providers, usable: false }
66
+ }
67
+ if (configProblems.length === 0) {
68
+ if (openshell.enabled) checks.push(item("config", "ok", `${file}: openshell enabled${openshell.gateway ? ` (gateway ${openshell.gateway})` : ""}`))
69
+ else checks.push(item("config", "warn", `${file}: openshell.enabled is not true, so the node does not offer sandboxes`, 'set "enabled": true in the openshell section'))
70
+ }
71
+
72
+ const set = openshell.profiles
73
+ const names = Object.keys(set.profiles).sort()
74
+ checks.push(item("profiles", "ok", `usable profiles: ${names.join(", ") || "none"}`))
75
+ for (const [name, reason] of Object.entries(set.unsupported)) checks.push(item("profiles", "fail", `profile ${name} is unsupported: ${reason}`, "remove mounts, cpu and memory from it"))
76
+
77
+ const fallback = set.profiles[DEFAULT_PROFILE]
78
+ if (!fallback) checks.push(item("default-providers", "skip", `no usable ${DEFAULT_PROFILE} profile`))
79
+ else if (fallback.providers.length === 0)
80
+ checks.push(item("default-providers", "warn", `the ${DEFAULT_PROFILE} profile names no providers, so a sandboxed agent cannot reach a model`, `create a provider in workspace ${OPENSHELL_WORKSPACE} (openshell --workspace ${OPENSHELL_WORKSPACE} provider create --name <name> --type <type> …) and list it in openshell.profiles.${DEFAULT_PROFILE}.providers`))
81
+ else checks.push(item("default-providers", "ok", `the ${DEFAULT_PROFILE} profile uses ${fallback.providers.join(", ")}`))
82
+
83
+ const images = new Map<string, string[]>()
84
+ for (const name of names) {
85
+ const profile = set.profiles[name]!
86
+ const image = profile.image ?? openshell.image ?? DEFAULT_SANDBOX_IMAGE
87
+ images.set(image, [...(images.get(image) ?? []), name])
88
+ for (const provider of profile.providers) providers.set(provider, [...(providers.get(provider) ?? []), name])
89
+ }
90
+ for (const [image, users] of images) {
91
+ const concern = imageConcern(image)
92
+ const who = `profile${users.length > 1 ? "s" : ""} ${users.join(", ")}`
93
+ if (image === DEFAULT_SANDBOX_IMAGE && !openshell.image && !users.some((u) => set.profiles[u]!.image))
94
+ checks.push(item("image", "warn", `no image configured (${who} get ${image}, a local build name)`, "publish the sandbox image to an HTTPS registry and set openshell.image"))
95
+ else if (concern) checks.push(item("image", "warn", `${concern} (${who})`, "publish it to an HTTPS registry (e.g. ghcr.io) and point openshell.image at it"))
96
+ else checks.push(item("image", "ok", `${image} (${who})`))
97
+ }
98
+ return { file, checks, providers, usable: true, ...(openshell.gateway ? { gateway: openshell.gateway } : {}) }
99
+ }
100
+
101
+ export async function checkOpenShell(options: OpenShellCheckOptions): Promise<OpenShellCheckReport> {
102
+ const local = configChecks(options.env, options.home)
103
+ const checks = [...local.checks]
104
+ const client: OpenShellClient =
105
+ options.client ?? new OpenShellCli({ ...(options.binary ? { binary: options.binary } : {}), ...(local.gateway ? { gateway: local.gateway } : {}), env: options.env })
106
+ const skip = (check: string, why: string) => checks.push(item(check, "skip", why))
107
+
108
+ let reachable = false
109
+ try {
110
+ const version = await client.version()
111
+ checks.push(item("cli", "ok", `openshell ${version} (supported ${SUPPORTED_VERSIONS})`))
112
+ try {
113
+ const status = await client.status()
114
+ if (status.connected && status.authenticated) {
115
+ reachable = true
116
+ checks.push(item("gateway", "ok", `gateway ${status.gateway || "(active)"} at ${status.server} is connected`))
117
+ } else checks.push(item("gateway", "fail", `gateway ${status.gateway || "(active)"} is ${status.connected ? "connected but not authenticated" : "not connected"}`, "see `openshell status` and `openshell gateway --help`"))
118
+ } catch (cause) {
119
+ checks.push(item("gateway", "fail", errorText(cause), "see `openshell status`"))
120
+ }
121
+ } catch (cause) {
122
+ const notInstalled = cause instanceof OpenShellError && cause.code === "not_installed"
123
+ checks.push(item("cli", "fail", errorText(cause), notInstalled ? "install the NVIDIA OpenShell CLI (and its gateway) on this machine" : `this hub speaks openshell ${SUPPORTED_VERSIONS}`))
124
+ skip("gateway", "the openshell CLI is not usable")
125
+ }
126
+
127
+ if (!reachable) {
128
+ skip("workspace", "the gateway is not reachable")
129
+ skip("providers", "the gateway is not reachable")
130
+ } else {
131
+ try {
132
+ const workspaces = client.workspaces ? await client.workspaces() : []
133
+ if (workspaces.includes(OPENSHELL_WORKSPACE)) checks.push(item("workspace", "ok", `${OPENSHELL_WORKSPACE} exists`))
134
+ else checks.push(item("workspace", "fail", `the gateway has no workspace ${OPENSHELL_WORKSPACE} (the hub's sandboxes live there)`, `create it (not run by hub): openshell workspace create --name ${OPENSHELL_WORKSPACE}`))
135
+ } catch (cause) {
136
+ checks.push(item("workspace", "fail", errorText(cause)))
137
+ }
138
+ if (local.providers.size === 0) skip("providers", "no profile names a provider")
139
+ else {
140
+ try {
141
+ const existing = new Set(client.providers ? await client.providers() : [])
142
+ const missing = [...local.providers].filter(([name]) => !existing.has(name))
143
+ for (const [name, users] of missing)
144
+ checks.push(item("providers", "fail", `provider ${name} (profile${users.length > 1 ? "s" : ""} ${users.join(", ")}) does not exist in workspace ${OPENSHELL_WORKSPACE}`, `create it: openshell --workspace ${OPENSHELL_WORKSPACE} provider create --name ${name} --type <type> …`))
145
+ if (missing.length === 0) checks.push(item("providers", "ok", `found ${[...local.providers.keys()].join(", ")}`))
146
+ } catch (cause) {
147
+ checks.push(item("providers", "fail", errorText(cause)))
148
+ }
149
+ }
150
+ }
151
+ return { ok: !checks.some((c) => c.status === "fail"), file: local.file, checks }
152
+ }
153
+
154
+ const MARK: Record<CheckStatus, string> = { ok: "ok ", warn: "warn", fail: "FAIL", skip: "skip" }
155
+
156
+ export function openShellCheckText(report: OpenShellCheckReport): string {
157
+ const lines: string[] = []
158
+ for (const c of report.checks) {
159
+ lines.push(`${MARK[c.status]} ${c.check}: ${c.message}`)
160
+ if (c.hint && c.status !== "ok") lines.push(` hint: ${c.hint}`)
161
+ }
162
+ const fails = report.checks.filter((c) => c.status === "fail").length
163
+ const warns = report.checks.filter((c) => c.status === "warn").length
164
+ lines.push(fails ? `${fails} problem${fails > 1 ? "s" : ""}${warns ? `, ${warns} warning${warns > 1 ? "s" : ""}` : ""}` : `no problems${warns ? `, ${warns} warning${warns > 1 ? "s" : ""}` : ""}`)
165
+ return lines.join("\n")
166
+ }