@huynle/hub 0.6.1 → 0.7.1

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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,144 @@
1
+ // Harness presets (design §1.2, preset tier): data-only descriptions of a harness the hub drives through a terminal
2
+ // pane (Herdr/tmux) when it has no spoke of its own. A preset is data, never code: an argv list (no shell syntax,
3
+ // nothing is run through a shell), an optional resume flag, where the harness's native session ID comes from, and
4
+ // fixed delivery "paste" (messages are pasted into the pane, so a deliver is at most "queued") and evidence "screen"
5
+ // (activity is sampled from the screen, which never counts as idle). Unknown fields are refused, so a typo or a field
6
+ // from a newer schema is an error rather than silently ignored. The built-in harness names are reserved.
7
+ //
8
+ // Imports only from wire.ts: index.ts re-exports this module.
9
+
10
+ import { isObj, nonEmpty, token, UNSAFE_CHARS } from "./wire.ts"
11
+
12
+ /** Harnesses with their own spokes; a preset may not take their names. */
13
+ export const RESERVED_HARNESS_NAMES = ["opencode", "claude-code", "codex"] as const
14
+
15
+ /**
16
+ * Where a preset's native session ID comes from: nowhere (it cannot resume), a flag the hub passes a fresh ID with
17
+ * when it starts the harness (e.g. `--session-id <id>`), or the first capture group of a pattern on the screen.
18
+ */
19
+ export type PresetNativeID =
20
+ | { readonly source: "none" }
21
+ | { readonly source: "argv"; readonly flag: string }
22
+ | { readonly source: "screen"; readonly pattern: string }
23
+
24
+ export interface HarnessPreset {
25
+ /** The harness id (a lowercase token, as a hello's `harness`), not one of RESERVED_HARNESS_NAMES. */
26
+ readonly name: string
27
+ readonly description?: string
28
+ /** The argv that starts the harness: the program, then its arguments. Never run through a shell. */
29
+ readonly command: readonly string[]
30
+ /** The flag that resumes a session: the hub appends it and the native ID (`[...command, resumeFlag, id]`). */
31
+ readonly resumeFlag?: string
32
+ readonly nativeID: PresetNativeID
33
+ readonly delivery: "paste"
34
+ readonly evidence: "screen"
35
+ }
36
+
37
+ export const PRESET_FIELDS = ["name", "description", "command", "resumeFlag", "nativeID", "delivery", "evidence"] as const
38
+
39
+ export const PRESET_DESCRIPTION_MAX = 300
40
+ export const PRESET_ARGS_MAX = 64
41
+ export const PRESET_ARG_MAX = 1000
42
+ export const PRESET_PATTERN_MAX = 500
43
+ export const PRESET_FLAG_MAX = 100
44
+
45
+ /** Characters that mean shell syntax (substitution, chaining, redirection, quoting, escapes). */
46
+ const SHELL_SYNTAX = /[`$;|&<>\\'"]/
47
+ const FLAG = new RegExp(`^--?[A-Za-z0-9][A-Za-z0-9-]{0,${PRESET_FLAG_MAX - 3}}$`)
48
+ const isFlag = (v: unknown): v is string => typeof v === "string" && FLAG.test(v)
49
+
50
+ /** Why an argv element is not acceptable, or undefined. */
51
+ function argProblem(v: unknown, i: number): string | undefined {
52
+ const label = `command[${i}]`
53
+ if (!nonEmpty(v) || v.length > PRESET_ARG_MAX) return `${label} must be 1–${PRESET_ARG_MAX} characters`
54
+ if (UNSAFE_CHARS.test(v)) return `${label} must not contain control characters or line breaks`
55
+ if (SHELL_SYNTAX.test(v)) return `${label} must not contain shell syntax (\` $ ; | & < > \\ ' "): command is an argv list, never run through a shell`
56
+ if (v.startsWith("~")) return `${label} must not start with ~ (no shell expansion)`
57
+ if (i === 0 && /\s/.test(v)) return "command[0] is the program alone: put its arguments in the next items"
58
+ if (i === 0 && v.startsWith("-")) return "command[0] must be the program, not a flag"
59
+ return undefined
60
+ }
61
+
62
+ /** The number of capture groups in `pattern`, or undefined when it does not compile. */
63
+ function captureGroups(pattern: string): number | undefined {
64
+ try {
65
+ return new RegExp(`${pattern}|`).exec("")!.length - 1
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ function nativeIDProblem(v: unknown): string | undefined {
72
+ if (!isObj(v)) return "nativeID must be {source: none | argv | screen, …}"
73
+ const allowed: Record<string, readonly string[]> = { none: ["source"], argv: ["source", "flag"], screen: ["source", "pattern"] }
74
+ const keys = typeof v.source === "string" ? allowed[v.source] : undefined
75
+ if (!keys) return "nativeID.source must be none, argv or screen"
76
+ const unknown = Object.keys(v).find((k) => !keys.includes(k))
77
+ if (unknown) return `nativeID.${unknown} is not a field of a ${v.source} nativeID`
78
+ if (v.source === "argv" && !isFlag(v.flag)) return "nativeID.flag must be one flag, e.g. --session-id"
79
+ if (v.source === "screen") {
80
+ if (!nonEmpty(v.pattern) || v.pattern.length > PRESET_PATTERN_MAX) return `nativeID.pattern must be 1–${PRESET_PATTERN_MAX} characters`
81
+ const groups = captureGroups(v.pattern)
82
+ if (groups === undefined) return "nativeID.pattern must be a valid regular expression"
83
+ if (groups < 1) return "nativeID.pattern must capture the ID in a group"
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /** Why `v` is not an acceptable harness preset, or undefined when it is. */
89
+ export function presetProblem(v: unknown): string | undefined {
90
+ if (!isObj(v)) return "a preset must be an object"
91
+ const unknown = Object.keys(v).find((k) => !(PRESET_FIELDS as readonly string[]).includes(k))
92
+ if (unknown) return `${unknown} is not a preset field (fields: ${PRESET_FIELDS.join(", ")})`
93
+ if (!token(v.name, 64)) return "name must be a lowercase token of 1–64 letters, digits and . _ -"
94
+ if ((RESERVED_HARNESS_NAMES as readonly string[]).includes(v.name)) return `name ${v.name} is reserved for a built-in harness`
95
+ if (v.description !== undefined && (typeof v.description !== "string" || v.description.length > PRESET_DESCRIPTION_MAX || UNSAFE_CHARS.test(v.description))) {
96
+ return `description must be at most ${PRESET_DESCRIPTION_MAX} printable characters`
97
+ }
98
+ if (!Array.isArray(v.command) || v.command.length === 0 || v.command.length > PRESET_ARGS_MAX) return `command must be a list of 1–${PRESET_ARGS_MAX} arguments (argv), not a string`
99
+ for (const [i, arg] of v.command.entries()) {
100
+ const problem = argProblem(arg, i)
101
+ if (problem) return problem
102
+ }
103
+ const native = nativeIDProblem(v.nativeID)
104
+ if (native) return native
105
+ if (v.resumeFlag !== undefined) {
106
+ if (!isFlag(v.resumeFlag)) return "resumeFlag must be one flag, e.g. --resume"
107
+ if ((v.nativeID as PresetNativeID).source === "none") return "resumeFlag needs a nativeID source (argv or screen) to resume by"
108
+ }
109
+ if (v.delivery !== "paste") return "delivery must be paste"
110
+ if (v.evidence !== "screen") return "evidence must be screen"
111
+ return undefined
112
+ }
113
+
114
+ /** Whether `v` is an acceptable harness preset (see presetProblem). */
115
+ export const isHarnessPreset = (v: unknown): v is HarnessPreset => presetProblem(v) === undefined
116
+
117
+ /** The preset schema as JSON Schema (draft 2020-12), for packs validated outside TypeScript. */
118
+ export const PRESET_JSON_SCHEMA = {
119
+ $schema: "https://json-schema.org/draft/2020-12/schema",
120
+ title: "Hub harness preset",
121
+ type: "object",
122
+ additionalProperties: false,
123
+ required: ["name", "command", "nativeID", "delivery", "evidence"],
124
+ properties: {
125
+ name: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]*$", maxLength: 64, not: { enum: [...RESERVED_HARNESS_NAMES] } },
126
+ description: { type: "string", maxLength: PRESET_DESCRIPTION_MAX },
127
+ command: {
128
+ type: "array",
129
+ minItems: 1,
130
+ maxItems: PRESET_ARGS_MAX,
131
+ items: { type: "string", minLength: 1, maxLength: PRESET_ARG_MAX, pattern: "^[^`$;|&<>\\\\'\"~][^`$;|&<>\\\\'\"]*$" },
132
+ },
133
+ resumeFlag: { type: "string", pattern: FLAG.source },
134
+ nativeID: {
135
+ oneOf: [
136
+ { type: "object", additionalProperties: false, required: ["source"], properties: { source: { const: "none" } } },
137
+ { type: "object", additionalProperties: false, required: ["source", "flag"], properties: { source: { const: "argv" }, flag: { type: "string", pattern: FLAG.source } } },
138
+ { type: "object", additionalProperties: false, required: ["source", "pattern"], properties: { source: { const: "screen" }, pattern: { type: "string", minLength: 1, maxLength: PRESET_PATTERN_MAX } } },
139
+ ],
140
+ },
141
+ delivery: { const: "paste" },
142
+ evidence: { const: "screen" },
143
+ },
144
+ } as const
@@ -0,0 +1,62 @@
1
+ // Webhook signatures (design §6.1; decision znbg8xa5 #8). Every webhook POST carries
2
+ // Hub-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the registration's secret>
3
+ // plus Hub-Event (the event type) and Hub-Delivery (a delivery ID, the same on every retry, for deduplication).
4
+ // While a secret rotates the header carries one v1 per secret; a receiver accepts any match. Receivers refuse a
5
+ // timestamp outside WEBHOOK_TOLERANCE_SECONDS (replay) and compare signatures in constant time.
6
+ // Only the format lives here (no crypto): the hub signs, the SDK verifies.
7
+
8
+ export const WEBHOOK_SIGNATURE_HEADER = "Hub-Signature"
9
+ export const WEBHOOK_EVENT_HEADER = "Hub-Event"
10
+ export const WEBHOOK_DELIVERY_HEADER = "Hub-Delivery"
11
+ export const WEBHOOK_SIGNATURE_SCHEME = "v1"
12
+ /** The HMAC hash (node:crypto createHmac name). */
13
+ export const WEBHOOK_SIGNATURE_ALGORITHM = "sha256"
14
+ /** Largest gap, either way, between a signature's timestamp and the receiver's clock. */
15
+ export const WEBHOOK_TOLERANCE_SECONDS = 300
16
+ /** Longest Hub-Signature header a receiver parses. */
17
+ export const WEBHOOK_SIGNATURE_MAX = 2048
18
+
19
+ export interface WebhookSignature {
20
+ /** Unix seconds. */
21
+ readonly t: number
22
+ /** Hex HMAC-SHA256 values, one per active secret. */
23
+ readonly v1: readonly string[]
24
+ }
25
+
26
+ const HEX_SHA256 = /^[0-9a-f]{64}$/
27
+
28
+ /** The bytes the signature covers: `<t>.<raw body>`. */
29
+ export function webhookSignedPayload(t: number, body: string): string {
30
+ return `${t}.${body}`
31
+ }
32
+
33
+ /** The Hub-Signature header value for timestamp `t` (unix seconds) and hex signatures. */
34
+ export function formatWebhookSignature(t: number, signatures: readonly string[]): string {
35
+ return [`t=${t}`, ...signatures.map((s) => `${WEBHOOK_SIGNATURE_SCHEME}=${s}`)].join(",")
36
+ }
37
+
38
+ /** A Hub-Signature header's timestamp and v1 signatures, or undefined when it is malformed. Unknown schemes are ignored. */
39
+ export function parseWebhookSignature(header: unknown): WebhookSignature | undefined {
40
+ if (typeof header !== "string" || header.length === 0 || header.length > WEBHOOK_SIGNATURE_MAX) return undefined
41
+ let t: number | undefined
42
+ const v1: string[] = []
43
+ for (const part of header.split(",")) {
44
+ const eq = part.indexOf("=")
45
+ if (eq < 0) return undefined
46
+ const key = part.slice(0, eq).trim()
47
+ const value = part.slice(eq + 1).trim()
48
+ if (key === "t") {
49
+ if (t !== undefined || !/^\d{1,12}$/.test(value)) return undefined
50
+ t = Number(value)
51
+ } else if (key === WEBHOOK_SIGNATURE_SCHEME) {
52
+ if (!HEX_SHA256.test(value)) return undefined
53
+ v1.push(value)
54
+ }
55
+ }
56
+ return t !== undefined && v1.length > 0 ? { t, v1 } : undefined
57
+ }
58
+
59
+ /** Whether timestamp `t` (unix seconds) is within `tolerance` seconds of `nowMs`. */
60
+ export function webhookTimestampFresh(t: number, nowMs: number = Date.now(), tolerance: number = WEBHOOK_TOLERANCE_SECONDS): boolean {
61
+ return Math.abs(nowMs / 1000 - t) <= tolerance
62
+ }
@@ -0,0 +1,54 @@
1
+ // Shared frame primitives for the spoke protocol (index.ts), the node protocol (node.ts) and launch checks
2
+ // (launch.ts). Kept apart so those modules import values from here, never from each other in a cycle.
3
+
4
+ /** Largest frame (in characters) either side accepts; a larger one is a protocol error. */
5
+ export const MAX_FRAME = 1_000_000
6
+
7
+ export const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
8
+ export const str = (v: unknown): v is string => typeof v === "string"
9
+ export const nonEmpty = (v: unknown): v is string => typeof v === "string" && v.length > 0
10
+ export const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
11
+ /** A plain id: letters, digits and `_.:@/+~-`, 1–300 characters. */
12
+ export const ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
13
+ /** A short lowercase token (harness id, evidence level, runtime kind). */
14
+ export const token = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max && /^[a-z0-9][a-z0-9._-]*$/.test(v)
15
+ /** A feature list: at most 64 names of 1–64 characters. */
16
+ export const featureList = (v: unknown): v is string[] => Array.isArray(v) && v.length <= 64 && v.every((f) => nonEmpty(f) && f.length <= 64)
17
+ /** Control (C0, C1), format (bidi, zero-width, BOM) and line/paragraph separator characters. */
18
+ export const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\u2028\u2029]/u
19
+
20
+ export const isResFrame = (v: Record<string, unknown>): boolean => nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
21
+ /** Longest reconnect delay a bye's retryAfterMs may ask for (5 minutes). */
22
+ export const MAX_RETRY_AFTER_MS = 300_000
23
+ /** A bye: a reason, and optionally (e.g. a draining hub) how soon to reconnect, in whole milliseconds. */
24
+ export const isByeFrame = (v: Record<string, unknown>): boolean =>
25
+ str(v.reason) &&
26
+ (v.retryAfterMs === undefined || (Number.isInteger(v.retryAfterMs) && (v.retryAfterMs as number) >= 0 && (v.retryAfterMs as number) <= MAX_RETRY_AFTER_MS))
27
+ export const isWelcomeFrame = (v: Record<string, unknown>): boolean =>
28
+ num(v.v) && nonEmpty(v.machineID) && str(v.publicURL) && (v.features === undefined || featureList(v.features))
29
+
30
+ /** Longest hub URL a sessionTokens request or a start op carries. */
31
+ export const HUB_URL_MAX_CHARS = 2048
32
+
33
+ /** An http(s) URL of at most HUB_URL_MAX_CHARS characters. */
34
+ export function isHubURL(v: unknown): v is string {
35
+ if (!nonEmpty(v) || v.length > HUB_URL_MAX_CHARS) return false
36
+ try {
37
+ const url = new URL(v)
38
+ return url.protocol === "http:" || url.protocol === "https:"
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ /** A frame's JSON object, or undefined when it is too large, not JSON or not an object. */
45
+ export function parseFrame(raw: string | Buffer): Record<string, unknown> | undefined {
46
+ const text = typeof raw === "string" ? raw : raw.toString("utf8")
47
+ if (text.length > MAX_FRAME) return undefined
48
+ try {
49
+ const value = JSON.parse(text)
50
+ return isObj(value) ? value : undefined
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
@@ -0,0 +1,36 @@
1
+ # The hub sandbox image: what an OpenShell sandbox of `hub node` runs (packages/runtime-openshell; spike report
2
+ # gut64rwq). Build it with build.ts, never from the repository root: the context is a staged directory holding
3
+ # bin/opencode the pinned Linux OpenCode build (npm @opencode/cli-linux-<arch>@<OPENCODE_VERSION>)
4
+ # bin/hub-sandbox-start bin/hub-sandbox-entry (entry.sh, start.sh)
5
+ # lib/hub/node_modules/@huynle/hub the hub plugin, installed from this repository's package
6
+ # bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG]
7
+ # The runtime creates the sandbox with no main command (it stays Ready) and execs /usr/local/bin/hub-sandbox-start,
8
+ # which waits for /sandbox/.hub/config.json and .hub/ready and runs `opencode serve --service`. OpenShell runs it as
9
+ # its own user (HOME=/sandbox, the only writable place with /tmp) and injects its proxy CA through the Node and
10
+ # OpenSSL CA environment variables, which nothing here may set or clear.
11
+ FROM debian:bookworm-slim
12
+
13
+ ARG HUB_VERSION=dev
14
+ ARG OPENCODE_VERSION=dev
15
+ LABEL org.opencontainers.image.title="hub-sandbox" \
16
+ org.opencontainers.image.description="Hub sandbox: OpenCode with the hub plugin, for hub node's OpenShell runtime" \
17
+ org.opencontainers.image.licenses="MIT" \
18
+ org.opencontainers.image.source="https://github.com/huynle/hub" \
19
+ org.opencontainers.image.version="${HUB_VERSION}" \
20
+ io.github.huynle.hub.opencode-version="${OPENCODE_VERSION}"
21
+
22
+ RUN apt-get update \
23
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git bash ca-certificates curl procps \
24
+ && rm -rf /var/lib/apt/lists/*
25
+
26
+ COPY bin/ /usr/local/bin/
27
+ COPY lib/hub/ /usr/local/lib/hub/
28
+ RUN chmod 0755 /usr/local/bin/opencode /usr/local/bin/hub-sandbox-start /usr/local/bin/hub-sandbox-entry \
29
+ && chmod -R a+rX /usr/local/lib/hub
30
+
31
+ # The harness stays offline but for the hub and its providers (decision lk6gn3u7 §7); the entry script sets them
32
+ # again, so the bootstrap fallback on another image gets them too.
33
+ ENV OPENCODE_DISABLE_MODELS_FETCH=1 \
34
+ OPENCODE_DISABLE_AUTOUPDATE=1 \
35
+ OPENCODE_DISABLE_PROJECT_CONFIG=1 \
36
+ HUB_SANDBOX_IMAGE=${HUB_VERSION}
@@ -0,0 +1,78 @@
1
+ # hub sandbox image
2
+
3
+ What an OpenShell sandbox of `hub node` runs (design §6.4, decision lk6gn3u7, spike report gut64rwq): Debian
4
+ bookworm-slim with git, bash, ca-certificates, curl and procps, the pinned Linux OpenCode
5
+ (`@opencode/cli-linux-<arch>@OPENCODE_VERSION` from npm, checked against the registry's sha512), the hub plugin
6
+ (this repository's package, installed with its production dependencies) and two scripts.
7
+
8
+ | File | What |
9
+ |---|---|
10
+ | `image.ts` | Names, layout and the commands the runtime and `build.ts` run (pure) |
11
+ | `build.ts` | Stages the layout, builds the image (`docker build`), or packs the bootstrap tarball |
12
+ | `Dockerfile` | The image, built from the staged context only |
13
+ | `start.sh` → `bin/hub-sandbox-start` | Starts the entry detached (`setsid nohup`, log `.hub/harness.log`) and returns |
14
+ | `entry.sh` → `bin/hub-sandbox-entry` | Waits for the start op, runs and supervises `opencode serve --service` |
15
+
16
+ ## Layout
17
+
18
+ The same under both roots, so one entry script serves both:
19
+
20
+ ```
21
+ <root>/bin/opencode IMAGE_ROOT = /usr/local (the image)
22
+ <root>/bin/hub-sandbox-start BOOTSTRAP_ROOT = /sandbox/.hub/opt (the upload fallback)
23
+ <root>/bin/hub-sandbox-entry
24
+ <root>/lib/hub/node_modules/@huynle/hub the plugin
25
+ ```
26
+
27
+ ## Contract with the runtime
28
+
29
+ 1. Create the sandbox **without a main command** (it stays Ready; a main command that ends moves it to Completed and
30
+ exec is refused).
31
+ 2. Exec `startCommand(root)` (`/usr/local/bin/hub-sandbox-start`) at any point; it returns at once and a second call
32
+ does nothing while the entry runs.
33
+ 3. Upload `.hub/config.json` (`{hubURL, token, launchID}`, read by the plugin's spoke, never by the scripts),
34
+ optionally `.hub/env.json` (a JSON object of extra variables), then touch `.hub/ready`. The entry waits for both
35
+ `config.json` and `ready`.
36
+ 4. The entry sets `OPENCODE_DISABLE_MODELS_FETCH/AUTOUPDATE/PROJECT_CONFIG=1`, `XDG_*_HOME=/sandbox/.xdg/*`,
37
+ `HUB_LAUNCH_CONFIG`, a random `OPENCODE_PASSWORD` unless given, and writes the global OpenCode config loading the
38
+ plugin. `env.json` cannot override those, `PATH`, `HOME`, `BUN_*`/`LD_*` or OpenShell's CA variables
39
+ (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, …), which are kept as OpenShell injected them: Bun trusts the proxy CA only
40
+ through them. An invalid `env.json` ends the entry with status 2.
41
+ 5. It runs `opencode serve --service --hostname 127.0.0.1 --port 4096` in `/sandbox/work` (else `/sandbox`) and sends
42
+ one authorized request so the server loads its instance and the plugin.
43
+ 6. `.hub/stop` holding `graceful` (SIGTERM, SIGKILL after `HUB_STOP_GRACE`, 10 s) or `kill` (SIGKILL) stops it;
44
+ `.hub/exited` then holds the exit code. `.hub/entry.pid` and `.hub/harness.pid` exist while they run.
45
+
46
+ ## Build
47
+
48
+ ```sh
49
+ bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG] # docker build, never pushes
50
+ bun packages/runtime-openshell/image/build.ts bootstrap [--arch arm64|x64] [--out FILE.tgz]
51
+ bun packages/runtime-openshell/image/build.ts stage --out DIR [--arch arm64|x64]
52
+ ```
53
+
54
+ The default tag is `hub-sandbox:<hub version>-opencode<OpenCode version>`; the architecture defaults to the host's.
55
+ `HUB_IT_DOCKER=1 bun test packages/runtime-openshell/test/image-docker.it.test.ts` builds it under a throwaway tag,
56
+ checks it and removes it.
57
+
58
+ ## Publishing (deliberate, not automated)
59
+
60
+ OpenShell's vm driver does **not** use local Docker images: it pulls from an **HTTPS registry** (a plain-http local
61
+ registry is refused, rootfs tarballs need the gateway's `allow_driver_config`, and local images only work when the
62
+ gateway itself sees a container engine through `DOCKER_HOST` or `/var/run/docker.sock`, an operator choice). To use the
63
+ image, publish it, for example:
64
+
65
+ ```sh
66
+ bun packages/runtime-openshell/image/build.ts image --arch arm64 --tag ghcr.io/<owner>/hub-sandbox:<tag>
67
+ docker push ghcr.io/<owner>/hub-sandbox:<tag> # or a multi-arch manifest built per architecture
68
+ ```
69
+
70
+ and point the node at it (`openshell.profiles.<name>.image` in node.json, or the runtime's default image).
71
+
72
+ ## Upload fallback (gateways that cannot pull the image)
73
+
74
+ On the gateway's default image (`nvcr.io/nvidia/base/ubuntu:24.04`; `/usr` read-only), the runtime uploads the
75
+ bootstrap tarball under `/sandbox/.hub/`, runs `bootstrapCommands(uploaded)` (unpack into `/sandbox/.hub/opt`, remove
76
+ the tarball) and then `startCommand(BOOTSTRAP_ROOT)`. Two limits: the sandbox policy must allow
77
+ `harnessBinary(BOOTSTRAP_ROOT)` (`/sandbox/.hub/opt/bin/opencode`) instead of `/usr/local/bin/opencode`, and that
78
+ image has no git (the runtime's clone and collect need it), so the fallback needs git from elsewhere.
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bun
2
+ // Builds the hub sandbox image, or the bootstrap tarball of the upload fallback (see README.md next to this file).
3
+ //
4
+ // bun packages/runtime-openshell/image/build.ts image [--arch arm64|x64] [--tag NAME:TAG]
5
+ // bun packages/runtime-openshell/image/build.ts bootstrap [--arch arm64|x64] [--out FILE.tgz]
6
+ // bun packages/runtime-openshell/image/build.ts stage --out DIR [--arch arm64|x64]
7
+ //
8
+ // All three stage the same layout (image.ts): bin/opencode (the pinned Linux OpenCode from the npm registry, checked
9
+ // against the registry's sha512 integrity), bin/hub-sandbox-start and bin/hub-sandbox-entry (start.sh, entry.sh) and
10
+ // lib/hub/node_modules/@huynle/hub (this repository packed with `npm pack --ignore-scripts` and installed with its
11
+ // production dependencies). `image` adds the Dockerfile and runs `docker build` for one platform; it never pushes
12
+ // (OpenShell's vm driver pulls from an HTTPS registry, not from local Docker: publishing is a separate, deliberate
13
+ // step, see README.md). `bootstrap` packs the layout as a .tgz for the runtime to upload into /sandbox/.hub/opt.
14
+ import { createHash } from "node:crypto"
15
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
16
+ import { tmpdir } from "node:os"
17
+ import path from "node:path"
18
+ import {
19
+ ENTRY_SCRIPT,
20
+ OPENCODE_VERSION,
21
+ PLUGIN_SUBPATH,
22
+ START_SCRIPT,
23
+ bootstrapTarballName,
24
+ dockerBuildArgs,
25
+ imageTag,
26
+ linuxArch,
27
+ opencodeMetadataURL,
28
+ opencodeTarballURL,
29
+ type LinuxArch,
30
+ } from "./image.ts"
31
+
32
+ /** Runs a command (argv, no shell) and returns its stdout; throws on a non-zero exit. */
33
+ export type Run = (cmd: readonly string[], cwd?: string) => Promise<string>
34
+
35
+ const HERE = import.meta.dir
36
+ const REPO_ROOT = path.join(HERE, "..", "..", "..")
37
+ const REGISTRY = "https://registry.npmjs.org/"
38
+
39
+ export const run: Run = async (cmd, cwd) => {
40
+ // COPYFILE_DISABLE: macOS tar would add AppleDouble ._ files to the bootstrap tarball.
41
+ const p = Bun.spawn([...cmd], { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "pipe", stderr: "pipe" })
42
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
43
+ if (code !== 0) throw new Error(`${cmd.join(" ")} failed (exit ${code}): ${err.trim().split("\n").slice(-5).join("\n")}`)
44
+ return out
45
+ }
46
+
47
+ /** Throws unless `bytes` match an npm sha512 subresource-integrity string. */
48
+ export function verifyIntegrity(bytes: Uint8Array, integrity: string): void {
49
+ const sha512 = integrity.split(/\s+/).find((s) => s.startsWith("sha512-"))
50
+ if (!sha512) throw new Error(`expected a sha512 integrity, got ${JSON.stringify(integrity)}`)
51
+ const actual = `sha512-${createHash("sha512").update(bytes).digest("base64")}`
52
+ if (actual !== sha512) throw new Error(`integrity mismatch: expected ${sha512}, got ${actual}`)
53
+ }
54
+
55
+ export interface BuildOptions {
56
+ readonly arch: LinuxArch
57
+ /** This repository (default: three levels up from this file). */
58
+ readonly repoRoot?: string
59
+ readonly opencodeVersion?: string
60
+ readonly fetch?: typeof fetch
61
+ readonly run?: Run
62
+ readonly log?: (line: string) => void
63
+ }
64
+
65
+ export interface StageOptions extends BuildOptions {
66
+ /** The context directory: created, or empty. */
67
+ readonly dir: string
68
+ /** Copy the Dockerfile too (default true). */
69
+ readonly dockerfile?: boolean
70
+ }
71
+
72
+ /** Stages the layout into `dir`; returns the hub version packed. */
73
+ export async function stage(o: StageOptions): Promise<{ dir: string; hubVersion: string }> {
74
+ const exec = o.run ?? run
75
+ const get = o.fetch ?? fetch
76
+ const log = o.log ?? (() => {})
77
+ const repoRoot = o.repoRoot ?? REPO_ROOT
78
+ const version = o.opencodeVersion ?? OPENCODE_VERSION
79
+ if (existsSync(o.dir) && readdirSync(o.dir).length > 0) throw new Error(`the context directory ${o.dir} is not empty`)
80
+ const bin = path.join(o.dir, "bin")
81
+ mkdirSync(bin, { recursive: true })
82
+ const work = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "hub-sandbox-stage-"))
83
+ try {
84
+ // The Linux OpenCode build, checked against the registry's integrity.
85
+ log(`fetching OpenCode ${version} for linux-${o.arch}`)
86
+ const meta = await get(opencodeMetadataURL(o.arch, version))
87
+ if (!meta.ok) throw new Error(`npm registry: ${meta.status} for ${opencodeMetadataURL(o.arch, version)}`)
88
+ const dist = ((await meta.json()) as { dist?: { tarball?: unknown; integrity?: unknown } }).dist
89
+ if (typeof dist?.tarball !== "string" || typeof dist.integrity !== "string") throw new Error("npm registry: no dist.tarball/dist.integrity")
90
+ if (!dist.tarball.startsWith(REGISTRY)) throw new Error(`refusing an OpenCode tarball outside the npm registry: ${dist.tarball}`)
91
+ const res = await get(dist.tarball)
92
+ if (!res.ok) throw new Error(`npm registry: ${res.status} for ${dist.tarball}`)
93
+ const bytes = new Uint8Array(await res.arrayBuffer())
94
+ verifyIntegrity(bytes, dist.integrity)
95
+ const tgz = path.join(work, "opencode.tgz")
96
+ writeFileSync(tgz, bytes)
97
+ await exec(["tar", "-xzf", tgz, "-C", work, "package/bin/opencode"])
98
+ copyFileSync(path.join(work, "package/bin/opencode"), path.join(bin, "opencode"))
99
+ chmodSync(path.join(bin, "opencode"), 0o755)
100
+
101
+ // The scripts.
102
+ for (const [name, src] of [
103
+ [START_SCRIPT, "start.sh"],
104
+ [ENTRY_SCRIPT, "entry.sh"],
105
+ ] as const) {
106
+ copyFileSync(path.join(HERE, src), path.join(bin, name))
107
+ chmodSync(path.join(bin, name), 0o755)
108
+ }
109
+
110
+ // The hub plugin: this repository as its npm package, with production dependencies.
111
+ log("packing the hub plugin")
112
+ const packed = JSON.parse(await exec(["npm", "pack", "--ignore-scripts", "--json", "--pack-destination", work], repoRoot)) as Array<{ filename: string }>
113
+ const file = packed[0]?.filename
114
+ if (!file) throw new Error("npm pack reported no file")
115
+ const prefix = path.join(o.dir, "lib/hub")
116
+ mkdirSync(prefix, { recursive: true })
117
+ await exec(["npm", "install", "--prefix", prefix, "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", path.join(work, path.basename(file))], work)
118
+ if (!existsSync(path.join(o.dir, PLUGIN_SUBPATH, "server.ts"))) throw new Error(`the plugin was not installed at ${PLUGIN_SUBPATH}`)
119
+ // The prefix's package.json only names the temporary tarball.
120
+ rmSync(path.join(prefix, "package.json"), { force: true })
121
+
122
+ if (o.dockerfile !== false) copyFileSync(path.join(HERE, "Dockerfile"), path.join(o.dir, "Dockerfile"))
123
+ } finally {
124
+ rmSync(work, { recursive: true, force: true })
125
+ }
126
+ const hubVersion = (JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { version: string }).version
127
+ return { dir: o.dir, hubVersion }
128
+ }
129
+
130
+ function tempDir(prefix: string): string {
131
+ return mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), prefix))
132
+ }
133
+
134
+ /** Builds the image locally (docker build); never pushes. */
135
+ export async function buildImage(o: BuildOptions & { tag?: string }): Promise<{ tag: string }> {
136
+ const exec = o.run ?? run
137
+ const dir = tempDir("hub-sandbox-image-")
138
+ try {
139
+ const { hubVersion } = await stage({ ...o, dir })
140
+ const tag = o.tag ?? imageTag(hubVersion, o.opencodeVersion)
141
+ o.log?.(`docker build ${tag}`)
142
+ await exec(["docker", ...dockerBuildArgs({ tag, arch: o.arch, context: dir, hubVersion, ...(o.opencodeVersion ? { opencodeVersion: o.opencodeVersion } : {}) })])
143
+ return { tag }
144
+ } finally {
145
+ rmSync(dir, { recursive: true, force: true })
146
+ }
147
+ }
148
+
149
+ /** Packs the layout as the bootstrap tarball (default name in the current directory). */
150
+ export async function buildBootstrap(o: BuildOptions & { out?: string }): Promise<{ out: string }> {
151
+ const exec = o.run ?? run
152
+ const dir = tempDir("hub-sandbox-bootstrap-")
153
+ try {
154
+ const { hubVersion } = await stage({ ...o, dir, dockerfile: false })
155
+ const out = path.resolve(o.out ?? bootstrapTarballName(hubVersion, o.arch, o.opencodeVersion))
156
+ await exec(["tar", "-czf", out, "--no-xattrs", "-C", dir, "bin", "lib"])
157
+ return { out }
158
+ } finally {
159
+ rmSync(dir, { recursive: true, force: true })
160
+ }
161
+ }
162
+
163
+ export interface Args {
164
+ readonly command: "image" | "bootstrap" | "stage"
165
+ readonly arch: LinuxArch
166
+ readonly tag?: string
167
+ readonly out?: string
168
+ }
169
+
170
+ const USAGE = "usage: build.ts image [--arch arm64|x64] [--tag NAME:TAG] | bootstrap [--arch arm64|x64] [--out FILE.tgz] | stage --out DIR [--arch arm64|x64]"
171
+
172
+ export function parseArgs(argv: readonly string[]): Args {
173
+ const [command, ...rest] = argv
174
+ if (command !== "image" && command !== "bootstrap" && command !== "stage") throw new Error(USAGE)
175
+ let arch: LinuxArch = linuxArch(process.arch)
176
+ let tag: string | undefined
177
+ let out: string | undefined
178
+ for (let i = 0; i < rest.length; i++) {
179
+ const flag = rest[i]!
180
+ const allowed = flag === "--arch" || (flag === "--tag" && command === "image") || (flag === "--out" && command !== "image")
181
+ if (!allowed) throw new Error(`unknown option ${flag}\n${USAGE}`)
182
+ const value = rest[++i]
183
+ if (value === undefined || value.startsWith("--")) throw new Error(`${flag} needs a value`)
184
+ if (flag === "--arch") arch = linuxArch(value)
185
+ else if (flag === "--tag") tag = value
186
+ else out = value
187
+ }
188
+ if (command === "stage" && !out) throw new Error(`stage needs --out DIR\n${USAGE}`)
189
+ return { command, arch, ...(tag ? { tag } : {}), ...(out ? { out } : {}) }
190
+ }
191
+
192
+ if (import.meta.main) {
193
+ try {
194
+ const args = parseArgs(process.argv.slice(2))
195
+ const log = (line: string) => console.error(`[build] ${line}`)
196
+ if (args.command === "image") console.log((await buildImage({ arch: args.arch, log, ...(args.tag ? { tag: args.tag } : {}) })).tag)
197
+ else if (args.command === "bootstrap") console.log((await buildBootstrap({ arch: args.arch, log, ...(args.out ? { out: args.out } : {}) })).out)
198
+ else console.log((await stage({ arch: args.arch, log, dir: path.resolve(args.out!) })).dir)
199
+ } catch (error) {
200
+ console.error(error instanceof Error ? error.message : String(error))
201
+ process.exit(1)
202
+ }
203
+ }