@huynle/hub 0.8.4 → 0.8.6
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.
- package/CHANGELOG.md +55 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/herdr.ts +3 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/localhub.ts +1 -0
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- 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
|
-
/**
|
|
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
|
|
176
|
-
if (!
|
|
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
|
|
258
|
+
const given = options.authority ?? DEFAULT_AUTHORITY
|
|
231
259
|
try {
|
|
232
|
-
checkAuthority(
|
|
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
|
|
7
|
-
// list models with the user's own, user-local configuration and credentials. It sends no
|
|
8
|
-
// (it costs nothing).
|
|
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
|
|
14
|
-
// version, counts, an exit status), never the harness's raw output, and anything
|
|
15
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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/${
|
|
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
|
|
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
|
|
83
|
+
let current = rootAbs
|
|
84
84
|
for (const part of parts) {
|
|
85
|
-
current =
|
|
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
|
|
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:
|
|
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-
|
|
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
|
-
/**
|
|
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" ||
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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"
|