@huynle/hub 0.8.4 → 0.8.5

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 (94) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/runtime-openshell/package.json +1 -1
  53. package/packages/runtime-sdk/package.json +1 -1
  54. package/packages/sdk/package.json +1 -1
  55. package/packages/spoke-sdk/package.json +1 -1
  56. package/src/central/config.ts +12 -0
  57. package/src/central/http.ts +10 -0
  58. package/src/central/install/CONTRACT.md +66 -0
  59. package/src/central/install/install.ps1 +349 -0
  60. package/src/central/install/install.sh +266 -0
  61. package/src/central/install/render.ts +146 -0
  62. package/src/central/install/templates.ts +6 -0
  63. package/src/central/install/text-assets.d.ts +9 -0
  64. package/src/config.ts +12 -7
  65. package/src/spoke/env-inject.ts +17 -2
  66. package/src/spoke/launchers.ts +145 -9
  67. package/src/win-acl.ts +154 -0
  68. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  69. package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
  70. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  71. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  72. package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  73. package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
  74. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  75. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  76. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  77. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  78. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  79. package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
  80. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  81. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  82. package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
  83. package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
  84. package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
  85. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  86. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  87. package/web/dist/index.html +6 -4
  88. package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
  89. package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
  90. package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
  91. package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
  92. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  93. package/web/dist/assets/index-DVSkeAvn.css +0 -1
  94. package/web/dist/assets/index-DifgVuKS.js +0 -34
@@ -34,19 +34,46 @@ import {
34
34
  type PlanInput,
35
35
  type Precondition,
36
36
  type VerifiedManifest,
37
+ toNativePath,
37
38
  } from "../../contracts/index.ts"
38
39
  import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
39
40
  import { sha256Of } from "../../transaction/state.ts"
41
+ import { isWindowsTarget } from "./archive.ts"
40
42
  import { inspectOpencodeConfig, pluginSets, pluginSpec, type ConfigInspection, type Finding } from "./config.ts"
41
43
 
42
44
  /** The Hub plugin is an OpenCode 2 plugin (the @opencode/plugin 2 API): no older OpenCode is ever installed for it. */
43
45
  export const MIN_OPENCODE_VERSION = "2.0.0"
44
46
 
45
- /** The stable hubctl authority: the standalone hubctl hub installs (contract install-artifact, user-bin:hubctl). */
47
+ /**
48
+ * The stable hubctl authority: the standalone hubctl hub installs (contract install-artifact, user-bin:hubctl;
49
+ * user-bin:hubctl.exe on Windows, authorityFor).
50
+ */
46
51
  export const DEFAULT_AUTHORITY: PathRef = { root: "user-bin", rel: "hubctl" }
47
52
  /** Authorities the MCP server may run: hubctl or its hub alias (one command surface), in a bin root. */
48
53
  const AUTHORITY_ROOTS = new Set<PathRef["root"]>(["user-bin", "system-bin"])
49
- const AUTHORITY_NAMES = new Set(["hubctl", "hub"])
54
+ const AUTHORITY_NAMES = new Set(["hubctl", "hub", "hubctl.exe"])
55
+
56
+ /**
57
+ * The authority the MCP server runs on `target`. POSIX: as given (hubctl or hub; a Windows name is refused).
58
+ * Windows (PHASE2-CONTRACT.md): hubctl.exe itself, for `hubctl` or `hubctl.exe`. The hub alias is a cmd shim there
59
+ * (`user-bin:hub.cmd`), and a .cmd file is no program OpenCode can start without a shell, so it is refused.
60
+ */
61
+ export function authorityFor(ref: PathRef, target: string): TxResult<PathRef> {
62
+ if (isWindowsTarget(target)) {
63
+ if (ref.rel === "hubctl" || ref.rel === "hubctl.exe") return txOk({ root: ref.root, rel: "hubctl.exe" })
64
+ return txFail("invalid", `on Windows the MCP server runs hubctl.exe itself, not ${ref.root}:${ref.rel} (the hub alias is a cmd shim, which OpenCode cannot start without a shell)`)
65
+ }
66
+ if (ref.rel.endsWith(".exe")) return txFail("invalid", `${ref.root}:${ref.rel} is a Windows name; on ${target} the MCP server runs ${ref.rel.slice(0, -4)}`)
67
+ return txOk(ref)
68
+ }
69
+
70
+ /**
71
+ * The MCP command as OpenCode's config holds it: on Windows the native path (C:\Users\…\hubctl.exe, toNativePath).
72
+ * Windows' CreateProcess accepts C:/… as well, but the native form is what OpenCode's own Windows docs and users write,
73
+ * what cmd.exe accepts should anything ever pass it through a shell (`/` starts a switch there), and what a user
74
+ * reading the config recognises. Comparisons accept either form (config.ts samePath). Elsewhere: as is.
75
+ */
76
+ export const mcpCommandPath = (canonical: string, target: string): string => toNativePath(canonical, isWindowsTarget(target) ? "win32" : "posix")
50
77
 
51
78
  /** The config files OpenCode reads from its global config dir, in the order hubctl prefers to edit them. */
52
79
  export const CONFIG_REFS: readonly { readonly ref: PathRef; readonly format: ConfigFormat }[] = [
@@ -172,8 +199,9 @@ function readConfigs(snapshot: DiscoverySnapshot, options: OpencodePlanOptions,
172
199
  }
173
200
 
174
201
  function planConfig(snapshot: DiscoverySnapshot, options: OpencodePlanOptions, authorityRef: PathRef, findings: Finding[]): TxResult<Action[]> {
175
- const authority = resolvePathRef(authorityRef, snapshot.roots, executionContextOf(snapshot))
176
- if (!authority.ok) return txFail("invalid", `this host has no ${authorityRef.root} root for hubctl`)
202
+ const resolved = resolvePathRef(authorityRef, snapshot.roots, executionContextOf(snapshot))
203
+ if (!resolved.ok) return txFail("invalid", `this host has no ${authorityRef.root} root for hubctl`)
204
+ const authority = { value: mcpCommandPath(resolved.value, snapshot.host.target) }
177
205
  const views = readConfigs(snapshot, options, authority.value)
178
206
  if (!views.ok) return views
179
207
  const fresh: ConfigView = { ...NEW_CONFIG, precondition: { exists: false }, inspection: { plugin: { state: "absent" }, mcp: { state: "absent" }, findings: [] } }
@@ -227,12 +255,15 @@ const verifyAction = (): Action => ({
227
255
  /** The OpenCode actions for this snapshot and profile, with the findings worth showing; or the refusal. */
228
256
  export function planOpencode(input: PlanInput, options: OpencodePlanOptions): TxResult<OpencodePlan> {
229
257
  if (!input.profile.harnesses.includes("opencode")) return txOk({ actions: [], findings: [] })
230
- const authority = options.authority ?? DEFAULT_AUTHORITY
258
+ const given = options.authority ?? DEFAULT_AUTHORITY
231
259
  try {
232
- checkAuthority(authority)
260
+ checkAuthority(given)
233
261
  } catch (error) {
234
262
  return txFail("invalid", (error as Error).message)
235
263
  }
264
+ const forTarget = authorityFor(given, input.snapshot.host.target)
265
+ if (!forTarget.ok) return forTarget
266
+ const authority = forTarget.value
236
267
  const findings: Finding[] = []
237
268
  const install = planInstall(input.snapshot, options, findings)
238
269
  if (!install.ok) return install
@@ -3,25 +3,29 @@
3
3
  // version-exec the binary itself (hub's `current` version, else what the PATH entry resolves to; never a
4
4
  // container shim, which would test another environment) runs `opencode --version` and reports
5
5
  // OpenCode 2 (and the pinned version, when hub installed it).
6
- // provider-auth `opencode models` answers with at least one provider/model: the user's providers load and
7
- // list models with the user's own, user-local configuration and credentials. It sends no prompt
8
- // (it costs nothing). A stronger probe (a real completion) can be injected.
6
+ // provider-auth `opencode models --standalone` answers with at least one provider/model: the user's providers
7
+ // load and list models with the user's own, user-local configuration and credentials. It sends no
8
+ // prompt (it costs nothing). --standalone runs a private server inside the probe, so it tests
9
+ // exactly this binary and starts no background service (`opencode serve --service`) that would
10
+ // outlive the probe: on Windows that service kept the hub-installed version directory busy, so a
11
+ // rollback could not remove it. A stronger probe (a real completion) can be injected.
9
12
  // spoke-connected the hub sees this machine's OpenCode spoke (an injected client: the integrator asks the hub with
10
13
  // the machine credential; without one this probe fails, so the harness is never ready by default).
11
14
  //
12
15
  // Every process is a fixed argv of the harness binary (OPENCODE_PROBE_ARGV) run through an injected ProcessRunner:
13
- // no shell, no command from a plan, an absolute file, bounded time and output. Probe details are built here (a
14
- // version, counts, an exit status), never the harness's raw output, and anything credential-shaped is withheld.
15
- import { execFile } from "node:child_process"
16
- import { compareVersions, executionContextOf, isSecretValue, REQUIRED_PROBES, rootPathIn, SEMVER, type AdapterContext, type HarnessVerification, type Probe } from "../../contracts/index.ts"
16
+ // no shell, no command from a plan, an absolute file, bounded time and output, and no process outliving the probe.
17
+ // Probe details are built here (a version, counts, an exit status), never the harness's raw output, and anything
18
+ // credential-shaped is withheld.
19
+ import { compareVersions, executionContextOf, isAbsolutePath, isSecretValue, REQUIRED_PROBES, rootPathIn, SEMVER, toNativePath, type AdapterContext, type HarnessVerification, type Probe } from "../../contracts/index.ts"
17
20
  import { discover } from "../../discovery/discover.ts"
18
21
  import type { DiscoveryProbe } from "../../discovery/probe.ts"
19
22
  import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
20
- import { OPENCODE_BINARY_REL } from "./archive.ts"
23
+ import { runBounded, type BoundedOptions } from "../process-tree.ts"
24
+ import { opencodeBinaryRel } from "./archive.ts"
21
25
  import { MIN_OPENCODE_VERSION } from "./plan.ts"
22
26
 
23
27
  /** The only argv the adapter ever runs (after the absolute binary path). */
24
- export const OPENCODE_PROBE_ARGV = Object.freeze({ version: Object.freeze(["--version"]), models: Object.freeze(["models"]) }) as {
28
+ export const OPENCODE_PROBE_ARGV = Object.freeze({ version: Object.freeze(["--version"]), models: Object.freeze(["models", "--standalone"]) }) as {
25
29
  readonly version: readonly string[]
26
30
  readonly models: readonly string[]
27
31
  }
@@ -51,26 +55,39 @@ export interface ProcessRunner {
51
55
  run(request: ProcessRequest): Promise<ProcessOutcome>
52
56
  }
53
57
 
54
- /** child_process.execFile with shell: false, bounded output and time; stdin closed. */
55
- export const nodeProcessRunner: ProcessRunner = {
56
- run: (request) =>
57
- new Promise((resolve, reject) => {
58
- if (!request.file.startsWith("/")) return reject(new Error("the harness binary must be an absolute path"))
59
- const env = Object.fromEntries(Object.entries(request.env).filter((e): e is [string, string] => typeof e[1] === "string"))
60
- const child = execFile(
61
- request.file,
62
- [...request.argv],
63
- { shell: false, env, timeout: request.timeoutMs, maxBuffer: request.maxOutputBytes, encoding: "utf8", windowsHide: true, ...(request.signal ? { signal: request.signal } : {}) },
64
- (error, stdout, stderr) => {
65
- const e = error as (NodeJS.ErrnoException & { killed?: boolean; code?: number | string }) | null
66
- if (e && typeof e.code === "string" && e.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return reject(e)
67
- resolve({ exitCode: e ? (typeof e.code === "number" ? e.code : null) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), timedOut: Boolean(e?.killed) })
58
+ /**
59
+ * The bounded runner (process-tree.ts) with shell: false, bounded output and time, stdin closed. Nothing the probe
60
+ * starts outlives it: a timeout, an abort or an overflow ends its whole tree, and what it left running when it exited
61
+ * is ended too (POSIX process group; win32 taskkill /T). `tree` and `spawn` are injectable for tests.
62
+ */
63
+ export function processRunner(options: BoundedOptions = {}): ProcessRunner {
64
+ return {
65
+ run: async (request) => {
66
+ // A canonical absolute path (POSIX, or C:/… on Windows), handed to the OS in its native form.
67
+ if (!isAbsolutePath(request.file)) throw new Error("the harness binary must be an absolute path")
68
+ const r = await runBounded(
69
+ {
70
+ file: toNativePath(request.file, process.platform),
71
+ args: request.argv,
72
+ env: request.env,
73
+ timeoutMs: request.timeoutMs,
74
+ maxOutputBytes: request.maxOutputBytes,
75
+ stderr: true,
76
+ overflow: "kill",
77
+ ...(request.signal ? { signal: request.signal } : {}),
68
78
  },
79
+ options,
69
80
  )
70
- child.stdin?.end()
71
- }),
81
+ if (r.spawnError) throw r.spawnError
82
+ if (r.aborted) throw Object.assign(new Error("the probe was cancelled"), { name: "AbortError", code: "ABORT_ERR" })
83
+ return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, timedOut: r.timedOut }
84
+ },
85
+ }
72
86
  }
73
87
 
88
+ /** The CLI's runner: processRunner with the platform's process tree. */
89
+ export const nodeProcessRunner: ProcessRunner = processRunner()
90
+
74
91
  /** A provider probe beyond the default model listing (e.g. a real, tiny completion), injected by the integrator. */
75
92
  export type ProviderProbe = (input: { readonly binary: string; readonly context: AdapterContext; readonly runner: ProcessRunner }) => Promise<{ readonly ok: boolean; readonly detail?: string }>
76
93
 
@@ -196,7 +213,7 @@ export function locateOpencodeBinary(probe: DiscoveryProbe): TxResult<string> {
196
213
  const data = snap.value.roots.find((r) => r.id === "data")
197
214
  const base = data ? rootPathIn(data, executionContextOf(snap.value)) : undefined
198
215
  if (base) {
199
- const current = probe.realpath(`${base}/hub/harnesses/opencode/current/${OPENCODE_BINARY_REL}`)
216
+ const current = probe.realpath(`${base}/hub/harnesses/opencode/current/${opencodeBinaryRel(snap.value.host.target)}`)
200
217
  if (current && probe.executable(current)) return txOk(current)
201
218
  }
202
219
  const presence = snap.value.harnesses.find((h) => h.id === "opencode")
@@ -206,3 +223,9 @@ export function locateOpencodeBinary(probe: DiscoveryProbe): TxResult<string> {
206
223
  const target = shim?.resolvesTo ?? presence.path
207
224
  return probe.executable(target) ? txOk(target) : txFail("not_found", `${target} is not an executable on this host`)
208
225
  }
226
+
227
+ /** The version directory of a hub-managed binary (data:hub/harnesses/opencode/<version>/bin/opencode[.exe]). */
228
+ const HUB_VERSION = /\/hub\/harnesses\/opencode\/([^/]+)\/bin\/opencode(?:\.exe)?$/
229
+
230
+ /** The version hub installed `binary` as, when it is a hub-managed OpenCode; else undefined. */
231
+ export const hubManagedVersion = (binary: string): string | undefined => HUB_VERSION.exec(binary)?.[1]
@@ -0,0 +1,263 @@
1
+ // Probe processes never outlive their probe (Phase 2 Windows end-to-end run). A harness CLI may start helpers or a
2
+ // background service: OpenCode 2's `opencode models` starts `opencode serve --service`, which then keeps the
3
+ // hub-installed version directory busy (Windows refuses to delete a running exe) and its database open. So:
4
+ //
5
+ // - on a timeout, an abort or (overflow: "kill") too much output, the probe's whole tree is ended;
6
+ // - after the probe exits by itself, what it left behind is ended too.
7
+ //
8
+ // POSIX: the probe leads its own process group (spawn detached), so kill(-pid, SIGKILL) reaches every process that
9
+ // stayed in the group (a daemon that called setsid escapes it; probes are run so that none is started). win32 has no
10
+ // process groups: `taskkill.exe /T /F /PID <pid>` from System32 ends a live tree, and after an exit the processes
11
+ // whose parent was the probe and that started since it began (a fixed PowerShell CIM query; a reused PID's older
12
+ // process is spared) have their trees ended. Every helper runs with a fixed argv, no shell, and never throws.
13
+ import { execFile, spawn as nodeSpawn } from "node:child_process"
14
+
15
+ export interface ProcessTree {
16
+ /** Spawn option: POSIX probes lead their own process group so the whole group can be signalled. */
17
+ readonly detached: boolean
18
+ /** Ends `pid` and everything it started (a timeout, an abort). Never throws. */
19
+ killTree(pid: number): Promise<void>
20
+ /** After `pid` exited by itself (it started at `startedAt`, ms): ends what it left running. Never throws. */
21
+ reap(pid: number, startedAt: number): Promise<void>
22
+ }
23
+
24
+ export interface ProcessTreeDeps {
25
+ readonly platform?: NodeJS.Platform
26
+ /** process.kill */
27
+ readonly kill?: (pid: number, signal: NodeJS.Signals) => void
28
+ /** Runs a fixed argv without a shell (win32 tools). */
29
+ readonly exec?: (file: string, args: readonly string[]) => Promise<{ readonly code: number | null; readonly stdout: string }>
30
+ /** %SystemRoot% (default from the environment, else C:\Windows). */
31
+ readonly systemRoot?: string
32
+ }
33
+
34
+ const TOOL_TIMEOUT_MS = 15_000
35
+ /** Clock slack for the "started since the probe began" cut-off. */
36
+ const START_SLACK_MS = 2000
37
+ /** Milliseconds between 1601-01-01 (FILETIME) and 1970-01-01. */
38
+ const FILETIME_EPOCH_MS = 11_644_473_600_000n
39
+
40
+ const defaultExec = (file: string, args: readonly string[]) =>
41
+ new Promise<{ code: number | null; stdout: string }>((resolve) => {
42
+ execFile(file, [...args], { shell: false, windowsHide: true, timeout: TOOL_TIMEOUT_MS, encoding: "utf8", maxBuffer: 1 << 20 }, (error, stdout) => {
43
+ const code = (error as { code?: unknown } | null)?.code
44
+ resolve({ code: error ? (typeof code === "number" ? code : null) : 0, stdout: String(stdout ?? "") })
45
+ })
46
+ })
47
+
48
+ /** A drive-rooted SystemRoot without characters that could matter to anything; else C:\Windows. */
49
+ function systemRootOf(value: string | undefined): string {
50
+ const v = value?.replace(/\\+$/, "")
51
+ return v && /^[A-Za-z]:\\[A-Za-z0-9 ._\\()-]*$/.test(v) ? v : "C:\\Windows"
52
+ }
53
+
54
+ export function processTree(deps: ProcessTreeDeps = {}): ProcessTree {
55
+ const platform = deps.platform ?? process.platform
56
+ if (platform === "win32") {
57
+ const exec = deps.exec ?? defaultExec
58
+ const root = systemRootOf(deps.systemRoot ?? process.env.SystemRoot ?? process.env.SYSTEMROOT)
59
+ const taskkill = `${root}\\System32\\taskkill.exe`
60
+ const powershell = `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
61
+ const killTree = async (pid: number) => {
62
+ if (!Number.isInteger(pid) || pid <= 0) return
63
+ try {
64
+ await exec(taskkill, ["/T", "/F", "/PID", String(pid)])
65
+ } catch {}
66
+ }
67
+ return {
68
+ detached: false,
69
+ killTree,
70
+ async reap(pid, startedAt) {
71
+ if (!Number.isInteger(pid) || pid <= 0) return
72
+ const since = (BigInt(Math.max(0, Math.floor(startedAt) - START_SLACK_MS)) + FILETIME_EPOCH_MS) * 10_000n
73
+ const script =
74
+ `$t=[DateTime]::FromFileTimeUtc(${since}); ` +
75
+ `Get-CimInstance -ClassName Win32_Process -Filter 'ParentProcessId=${pid}' | ` +
76
+ `Where-Object { $_.CreationDate.ToUniversalTime() -ge $t } | ForEach-Object { $_.ProcessId }`
77
+ let stdout = ""
78
+ try {
79
+ stdout = (await exec(powershell, ["-NoProfile", "-NonInteractive", "-Command", script])).stdout
80
+ } catch {
81
+ return
82
+ }
83
+ for (const line of stdout.split(/\r?\n/)) {
84
+ const child = line.trim()
85
+ if (/^[1-9]\d*$/.test(child)) await killTree(Number(child))
86
+ }
87
+ },
88
+ }
89
+ }
90
+ const kill = deps.kill ?? ((pid: number, signal: NodeJS.Signals) => void process.kill(pid, signal))
91
+ const group = (pid: number): boolean => {
92
+ try {
93
+ kill(-pid, "SIGKILL")
94
+ return true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+ return {
100
+ detached: true,
101
+ async killTree(pid) {
102
+ if (!Number.isInteger(pid) || pid <= 0) return
103
+ if (group(pid)) return
104
+ try {
105
+ kill(pid, "SIGKILL")
106
+ } catch {}
107
+ },
108
+ async reap(pid) {
109
+ if (Number.isInteger(pid) && pid > 0) group(pid)
110
+ },
111
+ }
112
+ }
113
+
114
+ export interface BoundedRequest {
115
+ /** The native path of the program (no shell; the argv as is). */
116
+ readonly file: string
117
+ readonly args: readonly string[]
118
+ readonly env: Readonly<Record<string, string | undefined>>
119
+ readonly timeoutMs: number
120
+ readonly maxOutputBytes: number
121
+ readonly signal?: AbortSignal
122
+ /** Read stderr too (bounded the same way); default: dropped. */
123
+ readonly stderr?: boolean
124
+ /** Output past maxOutputBytes: end the tree (default), or keep the first bytes and let it run. */
125
+ readonly overflow?: "kill" | "truncate"
126
+ }
127
+
128
+ export interface BoundedResult {
129
+ /** null when it was ended (timeout, abort, overflow, signal) or never started. */
130
+ readonly exitCode: number | null
131
+ readonly stdout: string
132
+ readonly stderr: string
133
+ readonly timedOut: boolean
134
+ readonly aborted: boolean
135
+ readonly overflowed: boolean
136
+ /** The program could not be started (ENOENT, EACCES, …). */
137
+ readonly spawnError?: NodeJS.ErrnoException
138
+ }
139
+
140
+ export interface BoundedOptions {
141
+ readonly spawn?: typeof nodeSpawn
142
+ readonly tree?: ProcessTree
143
+ readonly now?: () => number
144
+ }
145
+
146
+ /** How long, after the exit and the reap, the output may take to end before it is cut. */
147
+ const DRAIN_GRACE_MS = 1000
148
+
149
+ /** Runs one argv (no shell, stdin closed) with bounded time and output; its tree never outlives it. */
150
+ export function runBounded(request: BoundedRequest, options: BoundedOptions = {}): Promise<BoundedResult> {
151
+ const tree = options.tree ?? processTree()
152
+ const spawn = options.spawn ?? nodeSpawn
153
+ const now = options.now ?? Date.now
154
+ const env = Object.fromEntries(Object.entries(request.env).filter((e): e is [string, string] => typeof e[1] === "string"))
155
+ return new Promise<BoundedResult>((resolve) => {
156
+ let timedOut = false
157
+ let aborted = false
158
+ let overflowed = false
159
+ let ended = false
160
+ const out: Buffer[] = []
161
+ const err: Buffer[] = []
162
+ const size = { out: 0, err: 0 }
163
+ const result = (exitCode: number | null, spawnError?: NodeJS.ErrnoException): BoundedResult => ({
164
+ exitCode,
165
+ stdout: Buffer.concat(out).toString("utf8"),
166
+ stderr: Buffer.concat(err).toString("utf8"),
167
+ timedOut,
168
+ aborted,
169
+ overflowed,
170
+ ...(spawnError ? { spawnError } : {}),
171
+ })
172
+ if (request.signal?.aborted) {
173
+ aborted = true
174
+ return resolve(result(null))
175
+ }
176
+ const startedAt = now()
177
+ let child: ReturnType<typeof nodeSpawn>
178
+ try {
179
+ child = spawn(request.file, [...request.args], {
180
+ shell: false,
181
+ env,
182
+ stdio: ["ignore", "pipe", request.stderr ? "pipe" : "ignore"],
183
+ windowsHide: true,
184
+ detached: tree.detached,
185
+ })
186
+ } catch (error) {
187
+ return resolve(result(null, error as NodeJS.ErrnoException))
188
+ }
189
+ let settled = false
190
+ let exited = false
191
+ const pid = child.pid
192
+ const end = () => {
193
+ if (ended || pid === undefined) return
194
+ ended = true
195
+ void tree.killTree(pid)
196
+ }
197
+ const timer = setTimeout(() => {
198
+ timedOut = true
199
+ end()
200
+ }, request.timeoutMs)
201
+ const onAbort = () => {
202
+ aborted = true
203
+ end()
204
+ }
205
+ request.signal?.addEventListener("abort", onAbort, { once: true })
206
+ const finish = (r: BoundedResult) => {
207
+ if (settled) return
208
+ settled = true
209
+ clearTimeout(timer)
210
+ request.signal?.removeEventListener("abort", onAbort)
211
+ resolve(r)
212
+ }
213
+ const collect = (into: Buffer[], key: "out" | "err") => (chunk: Buffer) => {
214
+ const room = request.maxOutputBytes - size[key]
215
+ if (chunk.length > room) {
216
+ overflowed = true
217
+ if (room > 0) into.push(chunk.subarray(0, room))
218
+ size[key] = request.maxOutputBytes
219
+ if ((request.overflow ?? "kill") === "kill") end()
220
+ return
221
+ }
222
+ into.push(chunk)
223
+ size[key] += chunk.length
224
+ }
225
+ child.stdout?.on("data", collect(out, "out"))
226
+ child.stderr?.on("data", collect(err, "err"))
227
+ const streams = [child.stdout, child.stderr].filter((s): s is NonNullable<typeof s> => Boolean(s))
228
+ const drained = () =>
229
+ new Promise<void>((done) => {
230
+ let open = streams.filter((s) => !s.readableEnded && !s.destroyed).length
231
+ if (open === 0) return done()
232
+ const grace = setTimeout(() => {
233
+ for (const s of streams) s.destroy()
234
+ done()
235
+ }, DRAIN_GRACE_MS)
236
+ for (const s of streams) {
237
+ if (s.readableEnded || s.destroyed) continue
238
+ const one = () => {
239
+ if (--open === 0) {
240
+ clearTimeout(grace)
241
+ done()
242
+ }
243
+ }
244
+ s.once("end", one)
245
+ s.once("close", () => (s.readableEnded ? undefined : one()))
246
+ }
247
+ })
248
+ child.on("error", (error: NodeJS.ErrnoException) => {
249
+ if (!exited) finish(result(null, error))
250
+ })
251
+ child.on("exit", (code) => {
252
+ if (exited) return
253
+ exited = true
254
+ clearTimeout(timer)
255
+ const exitCode = typeof code === "number" && !ended ? code : null
256
+ void (async () => {
257
+ if (pid !== undefined) await tree.reap(pid, startedAt)
258
+ await drained()
259
+ finish(result(exitCode))
260
+ })()
261
+ })
262
+ })
263
+ }
@@ -67,7 +67,7 @@ export function resolvePathRef(ref: PathRef, roots: readonly RootMapping[], cont
67
67
  const root = roots.find((r) => r.id === ref.root)
68
68
  const base = root ? rootPathIn(root, context) : undefined
69
69
  if (!base || !isAbsolutePath(base)) return fail("/root", "unmapped_root", `this host has no ${ref.root} root`)
70
- return ok(base === "/" ? `/${ref.rel}` : `${base}/${ref.rel}`)
70
+ return ok(joinAbs(base, ref.rel))
71
71
  }
72
72
 
73
73
  export type EntryKind = "file" | "directory" | "symlink" | "missing" | "other"
@@ -80,12 +80,15 @@ export function checkNoSymlinkEscape(rootAbs: string, rel: string, lstat: (path:
80
80
  if (!isAbsolutePath(rootAbs)) return fail("", "invalid_path", "the root must be an absolute, normalized path")
81
81
  if (!isSafeRel(rel)) return fail("", "invalid_path", "expected a relative path inside the root")
82
82
  const parts = rel.split("/")
83
- let current = rootAbs === "/" ? "" : rootAbs
83
+ let current = rootAbs
84
84
  for (const part of parts) {
85
- current = `${current}/${part}`
85
+ current = joinAbs(current, part)
86
86
  const kind = lstat(current)
87
87
  if (kind === "symlink") return fail("", "symlink", `${current} is a symbolic link; refusing to write through it`)
88
88
  if (kind === "missing") break
89
89
  }
90
- return ok(rootAbs === "/" ? `/${rel}` : `${rootAbs}/${rel}`)
90
+ return ok(joinAbs(rootAbs, rel))
91
91
  }
92
+
93
+ /** `rel` under the canonical absolute `base` ("/" and "C:/" end with a separator already). */
94
+ const joinAbs = (base: string, rel: string) => (base.endsWith("/") ? `${base}${rel}` : `${base}/${rel}`)
@@ -3,19 +3,21 @@
3
3
  // guess at a nearby target.
4
4
  import { fail, ok, type Result } from "./validate.ts"
5
5
 
6
- export type Platform = "darwin" | "linux"
6
+ export type Platform = "darwin" | "linux" | "windows"
7
7
  export type Arch = "arm64" | "x64"
8
8
  /** "none" on macOS; "unknown" when a Linux host's C library could not be identified. */
9
9
  export type Libc = "none" | "glibc" | "musl" | "unknown"
10
10
  export type ServiceManager = "launchd" | "systemd-user"
11
+ /** A target's service manager: "none" where hubctl installs no user services yet (Windows until Phase 3). */
12
+ export type TargetServiceManager = ServiceManager | "none"
11
13
 
12
14
  export interface SupportedTarget {
13
15
  readonly target: string
14
16
  readonly platform: Platform
15
17
  readonly arch: Arch
16
18
  readonly libc: Libc
17
- /** The per-user service manager services are installed for on this target. */
18
- readonly serviceManager: ServiceManager
19
+ /** The per-user service manager services are installed for on this target; "none": profiles with services are refused. */
20
+ readonly serviceManager: TargetServiceManager
19
21
  /**
20
22
  * The `bun build --compile --target` the release builds this target's hubctl with. x64 targets use Bun's
21
23
  * `-baseline` runtime: the default x64 runtime needs AVX2 and dies with SIGILL (exit 132) on CPUs without it,
@@ -29,6 +31,9 @@ export const SUPPORTED_TARGETS: readonly SupportedTarget[] = Object.freeze([
29
31
  { target: "darwin-x64", platform: "darwin", arch: "x64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-x64-baseline" },
30
32
  { target: "linux-x64", platform: "linux", arch: "x64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-x64-baseline" },
31
33
  { target: "linux-arm64", platform: "linux", arch: "arm64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-arm64" },
34
+ // Phase 2 (docs/plans/2026-11-05-machine-setup-installer-design.md §3): the session-only profile; user services
35
+ // (Task Scheduler) arrive in Phase 3.
36
+ { target: "windows-x64", platform: "windows", arch: "x64", libc: "none", serviceManager: "none", bunTarget: "bun-windows-x64-baseline" },
32
37
  ] as const)
33
38
 
34
39
  export interface UnsupportedTarget {
@@ -37,8 +42,7 @@ export interface UnsupportedTarget {
37
42
  }
38
43
 
39
44
  export const UNSUPPORTED_TARGETS: readonly UnsupportedTarget[] = Object.freeze([
40
- { target: "windows-x64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
41
- { target: "windows-arm64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
45
+ { target: "windows-arm64", reason: "Windows on arm64 has no hubctl build yet (only windows-x64 is built)" },
42
46
  { target: "linux-x64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
43
47
  { target: "linux-arm64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
44
48
  ] as const)
@@ -132,16 +132,32 @@ export const digest = (c: Checker, path: string, value: unknown): value is strin
132
132
  export const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
133
133
  export const semver = (c: Checker, path: string, value: unknown): value is string => text(c, path, value, 64, SEMVER)
134
134
 
135
- /** An absolute, normalized POSIX path: no empty, "." or ".." segments, no trailing slash, no control characters. */
135
+ /**
136
+ * An absolute, normalized path in canonical form: POSIX ("/a/b") or Windows ("C:/a/b": an upper-case drive letter
137
+ * and "/" separators). No empty, "." or ".." segments, no trailing slash (except the roots "/" and "C:/"), no
138
+ * backslashes or control characters. UNC and device paths are not canonical. Native Windows forms ("c:\\x") are
139
+ * accepted only at the edges (discovery's normalizeAbs) and converted back with toNativePath.
140
+ */
136
141
  export function isAbsolutePath(value: unknown): value is string {
137
- if (typeof value !== "string" || !value.startsWith("/") || value.length > 4096 || CONTROL.test(value) || value.includes("\\")) return false
138
- if (value === "/") return true
139
- return value
142
+ if (typeof value !== "string" || value.length > 4096 || CONTROL.test(value) || value.includes("\\")) return false
143
+ const drive = WINDOWS_ROOT.exec(value)
144
+ const rest = drive ? value.slice(2) : value
145
+ if (!rest.startsWith("/") || rest.startsWith("//")) return false
146
+ if (rest === "/") return true
147
+ return rest
140
148
  .slice(1)
141
149
  .split("/")
142
150
  .every((s) => s !== "" && s !== "." && s !== "..")
143
151
  }
144
152
 
153
+ const WINDOWS_ROOT = /^[A-Z]:(?=\/)/
154
+
155
+ /** Whether a canonical absolute path is a Windows one ("C:/…"). */
156
+ export const isWindowsPath = (value: string): boolean => WINDOWS_ROOT.test(value)
157
+
158
+ /** A canonical path in the form a program on `platform` expects: backslashes for Windows paths on win32. */
159
+ export const toNativePath = (value: string, platform: string): string => (platform === "win32" && isWindowsPath(value) ? value.replace(/\//g, "\\") : value)
160
+
145
161
  export function absPath(c: Checker, path: string, value: unknown): value is string {
146
162
  if (isAbsolutePath(value)) return true
147
163
  if (value !== undefined) c.add(path, "invalid_path", "expected an absolute, normalized path")
@@ -87,10 +87,12 @@ export function discover(probe: DiscoveryProbe, options: DiscoverOptions): Resul
87
87
  if (container !== "none") roots = mapContainerRoots(roots, parseMountinfo(decode(probe.readFile("/proc/self/mountinfo", 1024 * 1024))))
88
88
 
89
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"]
90
+ // Package managers off PATH in their usual places; none on Windows (npm and bun are found on PATH there).
91
+ const extra = probe.platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin"] : probe.platform === "win32" ? [] : ["/usr/local/sbin", "/usr/sbin", "/sbin", "/nix/var/nix/profiles/default/bin"]
91
92
  const managerDirs = [...dirs, ...extra.filter((d) => !dirs.includes(d))]
92
93
  const packages: PackageManager[] = PACKAGE_MANAGERS.filter((m) => which(probe, managerDirs, m) !== undefined)
93
94
  const runtime: RuntimeInfo[] = probe.runtime && SEMVER.test(probe.runtime.version) && normalizeAbs(probe.runtime.path) ? [{ ...probe.runtime }] : []
95
+ // Windows has no user service manager hubctl drives yet (PHASE2-CONTRACT.md): "none".
94
96
  let service: DiscoverySnapshot["managers"]["service"] = "none"
95
97
  if (probe.platform === "darwin" && probe.executable("/bin/launchctl")) service = "launchd"
96
98
  if (probe.platform === "linux" && probe.lstat("/run/systemd/system") === "directory" && which(probe, [...dirs, "/usr/bin", "/bin"], "systemctl")) service = "systemd-user"