@huynle/hub 0.8.7 → 0.9.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.
- package/CHANGELOG.md +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- 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/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +22 -3
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-CcjZXsfF.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@ function spawnService(io: CodexIo): void {
|
|
|
39
39
|
ensurePrivateDir(dir)
|
|
40
40
|
const log = openSync(path.join(dir, "spoke.log"), "a", 0o600)
|
|
41
41
|
try {
|
|
42
|
-
const child = spawn(io.program.execPath, [io.program.entry, "codex-spoke", "serve"], { detached: true, stdio: ["ignore", log, log], env: { ...io.env, BUN_BE_BUN: "1" } })
|
|
42
|
+
const child = spawn(io.program.execPath, [io.program.entry, "codex-spoke", "serve"], { detached: true, windowsHide: true, stdio: ["ignore", log, log], env: { ...io.env, BUN_BE_BUN: "1" } })
|
|
43
43
|
child.on("error", () => {})
|
|
44
44
|
child.unref()
|
|
45
45
|
} finally {
|
|
@@ -69,7 +69,7 @@ const SIGNALS: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGK
|
|
|
69
69
|
/** Runs `bin` with the terminal; resolves with its exit code (128 + signal number when a signal ended it). */
|
|
70
70
|
function spawnCodex(bin: string, args: readonly string[], env: Env, stderr: (s: string) => void): Promise<number> {
|
|
71
71
|
return new Promise((resolve) => {
|
|
72
|
-
const child = spawn(bin, args, { stdio: "inherit", env: env as NodeJS.ProcessEnv })
|
|
72
|
+
const child = spawn(bin, args, { stdio: "inherit", windowsHide: true, env: env as NodeJS.ProcessEnv })
|
|
73
73
|
// The terminal sends Ctrl-C to Codex too: it decides what that means; this process keeps waiting.
|
|
74
74
|
const ignore = () => {}
|
|
75
75
|
const forward = () => child.kill("SIGTERM")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
// service (loopback URLs only, the password from a private file), and the project directories to keep loaded on the
|
|
15
15
|
// service (`directories`) or on a server (its own `directories`); absent: the projects of recent sessions under
|
|
16
16
|
// workspaceRoots. `opencode.timeoutSeconds` (1–300, default 30) bounds each warm request.
|
|
17
|
+
// On Windows (Phase 3) paths may be written `C:\\Users\\x\\projects`, `C:/Users/x/projects` or `~/projects`; the node
|
|
18
|
+
// keeps them in canonical form (`C:/…`, protocol canonicalNativePath), so its hello carries canonical paths only.
|
|
17
19
|
import { readFileSync } from "node:fs"
|
|
18
20
|
import { homedir, hostname } from "node:os"
|
|
19
21
|
import path from "node:path"
|
|
20
|
-
import { isAbsolutePath, labelsProblem, NODE_ROOTS_MAX, NODE_SLOTS_MAX } from "../../protocol/src/index.ts"
|
|
22
|
+
import { canonicalNativePath, isAbsolutePath, isWindowsAbsolutePath, labelsProblem, NODE_ROOTS_MAX, NODE_SLOTS_MAX } from "../../protocol/src/index.ts"
|
|
21
23
|
import { normalizeOpenShellProfiles, type OpenShellProfiles } from "../../runtime-openshell/src/profiles.ts"
|
|
22
24
|
import { configPath, readConfig } from "../../../src/config.ts"
|
|
23
25
|
import { WARM_DIRECTORIES_MAX } from "./opencode-warm.ts"
|
|
@@ -94,8 +96,35 @@ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "obj
|
|
|
94
96
|
const whole = (v: unknown, min: number, max: number): v is number => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max
|
|
95
97
|
const show = (v: unknown) => JSON.stringify(v)?.slice(0, 80) ?? String(v)
|
|
96
98
|
|
|
99
|
+
/** Where node.json is read: the platform (default this one) and, for `~/…` on Windows, the user's home. */
|
|
100
|
+
export interface NodeConfigOptions {
|
|
101
|
+
readonly platform?: string
|
|
102
|
+
readonly home?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A node.json path as the node uses it, or undefined when it is not acceptable: on POSIX the path as it is (an
|
|
107
|
+
* absolute path); on Windows (Phase 3) `C:\…`, `C:/…` or `~/…` (`~\…`) in canonical form (`C:/…`), never UNC, device or
|
|
108
|
+
* POSIX paths. Drive and file-system roots are refused by the callers.
|
|
109
|
+
*/
|
|
110
|
+
function configPathOf(raw: unknown, options: NodeConfigOptions): string | undefined {
|
|
111
|
+
if ((options.platform ?? process.platform) !== "win32") return isAbsolutePath(raw) ? raw : undefined
|
|
112
|
+
if (typeof raw !== "string") return undefined
|
|
113
|
+
let native = raw
|
|
114
|
+
if (/^~[\\/]/.test(raw)) {
|
|
115
|
+
const home = canonicalNativePath(options.home ?? homedir())
|
|
116
|
+
if (!home || !isWindowsAbsolutePath(home)) return undefined
|
|
117
|
+
native = `${home}/${raw.slice(2)}`
|
|
118
|
+
}
|
|
119
|
+
const canonical = canonicalNativePath(native)
|
|
120
|
+
return canonical && isWindowsAbsolutePath(canonical) ? canonical : undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A file-system root ("/", "C:/"): never a workspace root or warm directory. */
|
|
124
|
+
const isRoot = (p: string) => p === "/" || /^[A-Z]:\/$/.test(p)
|
|
125
|
+
|
|
97
126
|
/** Normalizes untrusted node.json content, field by field. */
|
|
98
|
-
export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
|
|
127
|
+
export function normalizeNodeConfig(raw: unknown, options: NodeConfigOptions = {}): NodeConfigResult {
|
|
99
128
|
const problems: string[] = []
|
|
100
129
|
let r: Record<string, unknown> = {}
|
|
101
130
|
if (isObject(raw)) r = raw
|
|
@@ -103,9 +132,10 @@ export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
|
|
|
103
132
|
|
|
104
133
|
const workspaceRoots: string[] = []
|
|
105
134
|
if (r.workspaceRoots !== undefined && !Array.isArray(r.workspaceRoots)) problems.push("workspaceRoots must be a list of absolute paths")
|
|
106
|
-
for (const
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
for (const entry of Array.isArray(r.workspaceRoots) ? r.workspaceRoots : []) {
|
|
136
|
+
const root = configPathOf(entry, options)
|
|
137
|
+
if (root === undefined || isRoot(root)) {
|
|
138
|
+
problems.push(`workspaceRoots: ${show(entry)} is not an absolute path (without . or .. segments, and not /)`)
|
|
109
139
|
continue
|
|
110
140
|
}
|
|
111
141
|
const clean = root.replace(/\/+$/, "")
|
|
@@ -137,7 +167,7 @@ export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
|
|
|
137
167
|
|
|
138
168
|
const openshell = r.openshell === undefined ? undefined : normalizeOpenShell(r.openshell, problems)
|
|
139
169
|
const harnesses = r.harnesses === undefined ? undefined : normalizeHarnesses(r.harnesses, problems)
|
|
140
|
-
const opencode = r.opencode === undefined ? undefined : normalizeOpenCode(r.opencode, problems)
|
|
170
|
+
const opencode = r.opencode === undefined ? undefined : normalizeOpenCode(r.opencode, problems, options)
|
|
141
171
|
|
|
142
172
|
return {
|
|
143
173
|
config: { workspaceRoots, labels, slots, ...(memoryMB !== undefined ? { memoryMB } : {}), ...(openshell ? { openshell } : {}), ...(harnesses ? { harnesses } : {}), ...(opencode ? { opencode } : {}) },
|
|
@@ -146,15 +176,16 @@ export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
|
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
/** Absolute directories (not /), trailing slashes dropped, at most WARM_DIRECTORIES_MAX; undefined for a non-list. */
|
|
149
|
-
function normalizeDirectories(raw: unknown, field: string, problems: string[]): string[] | undefined {
|
|
179
|
+
function normalizeDirectories(raw: unknown, field: string, problems: string[], options: NodeConfigOptions): string[] | undefined {
|
|
150
180
|
if (!Array.isArray(raw)) {
|
|
151
181
|
problems.push(`${field} must be a list of absolute paths`)
|
|
152
182
|
return undefined
|
|
153
183
|
}
|
|
154
184
|
const out: string[] = []
|
|
155
|
-
for (const
|
|
156
|
-
|
|
157
|
-
|
|
185
|
+
for (const entry of raw) {
|
|
186
|
+
const dir = configPathOf(entry, options)
|
|
187
|
+
if (dir === undefined || isRoot(dir)) {
|
|
188
|
+
problems.push(`${field}: ${show(entry)} is not an absolute path (without . or .. segments, and not /)`)
|
|
158
189
|
continue
|
|
159
190
|
}
|
|
160
191
|
const clean = dir.replace(/\/+$/, "")
|
|
@@ -184,12 +215,12 @@ function loopbackOrigin(raw: unknown): string | undefined {
|
|
|
184
215
|
return url.origin
|
|
185
216
|
}
|
|
186
217
|
|
|
187
|
-
function normalizeOpenCode(raw: unknown, problems: string[]): OpenCodeNodeConfig | undefined {
|
|
218
|
+
function normalizeOpenCode(raw: unknown, problems: string[], options: NodeConfigOptions): OpenCodeNodeConfig | undefined {
|
|
188
219
|
if (!isObject(raw)) {
|
|
189
220
|
problems.push("opencode must be an object")
|
|
190
221
|
return undefined
|
|
191
222
|
}
|
|
192
|
-
const directories = raw.directories === undefined ? undefined : normalizeDirectories(raw.directories, "opencode.directories", problems)
|
|
223
|
+
const directories = raw.directories === undefined ? undefined : normalizeDirectories(raw.directories, "opencode.directories", problems, options)
|
|
193
224
|
const servers: OpenCodeServerConfig[] = []
|
|
194
225
|
if (raw.servers !== undefined && !Array.isArray(raw.servers)) problems.push("opencode.servers must be a list of {url, passwordFile, directories?}")
|
|
195
226
|
const list = Array.isArray(raw.servers) ? raw.servers : []
|
|
@@ -198,10 +229,11 @@ function normalizeOpenCode(raw: unknown, problems: string[]): OpenCodeNodeConfig
|
|
|
198
229
|
if (!isObject(entry)) return void problems.push(`${field} must be an object {url, passwordFile, directories?}`)
|
|
199
230
|
const url = loopbackOrigin(entry.url)
|
|
200
231
|
if (!url) return void problems.push(`${field}.url must be an http(s) URL on 127.0.0.1, [::1] or localhost, without user info, path or query`)
|
|
201
|
-
|
|
232
|
+
const passwordFile = configPathOf(entry.passwordFile, options)
|
|
233
|
+
if (passwordFile === undefined || isRoot(passwordFile)) return void problems.push(`${field}.passwordFile must be the absolute path of a private (chmod 600) file holding the server's password`)
|
|
202
234
|
if (servers.some((s) => s.url === url)) return void problems.push(`${field}: ${url} is listed twice; the first one is used`)
|
|
203
|
-
const dirs = entry.directories === undefined ? undefined : normalizeDirectories(entry.directories, `${field}.directories`, problems)
|
|
204
|
-
servers.push({ url, passwordFile
|
|
235
|
+
const dirs = entry.directories === undefined ? undefined : normalizeDirectories(entry.directories, `${field}.directories`, problems, options)
|
|
236
|
+
servers.push({ url, passwordFile, ...(dirs ? { directories: dirs } : {}) })
|
|
205
237
|
})
|
|
206
238
|
if (servers.length > OPENCODE_SERVERS_MAX) {
|
|
207
239
|
problems.push(`opencode.servers: at most ${OPENCODE_SERVERS_MAX} servers; the rest are ignored`)
|
|
@@ -256,7 +288,7 @@ function normalizeOpenShell(raw: unknown, problems: string[]): OpenShellNodeConf
|
|
|
256
288
|
}
|
|
257
289
|
|
|
258
290
|
/** Reads node.json; a missing file is the defaults, unreadable or bad JSON the defaults with a problem. */
|
|
259
|
-
export function readNodeConfig(file: string): NodeConfigResult {
|
|
291
|
+
export function readNodeConfig(file: string, options: NodeConfigOptions = {}): NodeConfigResult {
|
|
260
292
|
let text: string
|
|
261
293
|
try {
|
|
262
294
|
text = readFileSync(file, "utf8")
|
|
@@ -265,7 +297,7 @@ export function readNodeConfig(file: string): NodeConfigResult {
|
|
|
265
297
|
return { config: normalizeNodeConfig(undefined).config, problems: [`${file}: cannot read (${(cause as Error).message})`] }
|
|
266
298
|
}
|
|
267
299
|
try {
|
|
268
|
-
return normalizeNodeConfig(JSON.parse(text))
|
|
300
|
+
return normalizeNodeConfig(JSON.parse(text), options)
|
|
269
301
|
} catch {
|
|
270
302
|
return { config: normalizeNodeConfig(undefined).config, problems: [`${file}: not valid JSON`] }
|
|
271
303
|
}
|
package/packages/node/src/git.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// ask), only the https and ssh transports (never ext::, file:: or a local path as a remote), and none of the
|
|
4
4
|
// inherited GIT_* variables that redirect a repository, its config or git's own programs. Every run has a time limit
|
|
5
5
|
// and can be cancelled. node:child_process (not Bun.spawn) so the node also runs bundled for node.
|
|
6
|
+
//
|
|
7
|
+
// Process trees: on POSIX git leads its own process group and a timeout or cancel signals the group; win32 has no
|
|
8
|
+
// groups, so there the shared process-tree helper ends git's tree (taskkill /T /F, Phase 3).
|
|
6
9
|
import { spawn } from "node:child_process"
|
|
10
|
+
import { processTree, type ProcessTree } from "../../cli/src/bootstrap/adapters/process-tree.ts"
|
|
7
11
|
|
|
8
12
|
type Env = Readonly<Record<string, string | undefined>>
|
|
9
13
|
|
|
@@ -48,12 +52,17 @@ export interface GitOptions {
|
|
|
48
52
|
readonly env?: Env
|
|
49
53
|
/** The git executable (default "git" on PATH). */
|
|
50
54
|
readonly binary?: string
|
|
55
|
+
/** The platform (default this one); win32 ends git's tree with `tree`. */
|
|
56
|
+
readonly platform?: string
|
|
57
|
+
/** win32's tree kill (default the shared process-tree helper). */
|
|
58
|
+
readonly tree?: ProcessTree
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/** Runs `git -c protocol.ext.allow=never <args>` and resolves with stdout; rejects with a GitError. */
|
|
54
62
|
export function runGit(args: readonly string[], options: GitOptions): Promise<string> {
|
|
55
63
|
const argv = ["-c", "protocol.ext.allow=never", ...args]
|
|
56
64
|
const what = `git ${args.find((a) => !a.startsWith("-")) ?? ""}`.trim()
|
|
65
|
+
const win32 = (options.platform ?? process.platform) === "win32"
|
|
57
66
|
return new Promise((resolve, reject) => {
|
|
58
67
|
if (options.signal?.aborted) return reject(new GitError(`${what} cancelled`))
|
|
59
68
|
let settled = false
|
|
@@ -65,12 +74,12 @@ export function runGit(args: readonly string[], options: GitOptions): Promise<st
|
|
|
65
74
|
if (error) reject(error)
|
|
66
75
|
else resolve(stdout ?? "")
|
|
67
76
|
}
|
|
68
|
-
// Its own process group, so a timeout or cancel also kills what git started (ssh, git-remote-https).
|
|
77
|
+
// Its own process group (POSIX), so a timeout or cancel also kills what git started (ssh, git-remote-https).
|
|
69
78
|
const child = spawn(options.binary ?? "git", argv, {
|
|
70
79
|
cwd: options.cwd,
|
|
71
80
|
env: gitEnv(options.baseEnv ?? process.env, options.env),
|
|
72
81
|
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
-
detached:
|
|
82
|
+
detached: !win32,
|
|
74
83
|
windowsHide: true,
|
|
75
84
|
})
|
|
76
85
|
const out: Buffer[] = []
|
|
@@ -92,6 +101,11 @@ export function runGit(args: readonly string[], options: GitOptions): Promise<st
|
|
|
92
101
|
// Settles at once: a killed git's children may hold its output pipes open for a while.
|
|
93
102
|
function stop(why: string): void {
|
|
94
103
|
if (settled) return
|
|
104
|
+
if (win32) {
|
|
105
|
+
if (child.pid) void (options.tree ?? processTree({ platform: "win32" })).killTree(child.pid)
|
|
106
|
+
else child.kill("SIGKILL")
|
|
107
|
+
return finish(new GitError(`${what} ${why}`))
|
|
108
|
+
}
|
|
95
109
|
try {
|
|
96
110
|
if (child.pid) process.kill(-child.pid, "SIGKILL")
|
|
97
111
|
} catch {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// - node.json `harnesses` overrides detection either way (true offers a harness, false withholds it).
|
|
9
9
|
//
|
|
10
10
|
// Which binary (claudeBinary, codexBinary): the explicit setting, then the one `hubctl setup` installed, then PATH.
|
|
11
|
+
// On Windows (Phase 3) executables carry an extension: a bare name is tried with PATHEXT's .exe/.com (the ones a
|
|
12
|
+
// program starts without a shell; .cmd/.bat shims are never chosen), PATH splits on `;`, and a regular file counts
|
|
13
|
+
// (no execute bit); hub's Codex is bin/codex.exe.
|
|
11
14
|
import { accessSync, constants, readFileSync, statSync } from "node:fs"
|
|
12
15
|
import { homedir } from "node:os"
|
|
13
16
|
import path from "node:path"
|
|
@@ -25,21 +28,35 @@ export interface DetectHarnessesOptions {
|
|
|
25
28
|
readonly overrides?: Readonly<Record<string, boolean>>
|
|
26
29
|
/** Where hub's config and data are (default os.homedir()). */
|
|
27
30
|
readonly home?: string
|
|
31
|
+
/** The platform (default this one). */
|
|
32
|
+
readonly platform?: string
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
function executable(file: string): boolean {
|
|
35
|
+
function executable(file: string, win32 = false): boolean {
|
|
31
36
|
try {
|
|
32
37
|
if (!statSync(file).isFile()) return false
|
|
33
|
-
accessSync(file, constants.X_OK)
|
|
38
|
+
if (!win32) accessSync(file, constants.X_OK)
|
|
34
39
|
return true
|
|
35
40
|
} catch {
|
|
36
41
|
return false
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/** Extensions a Windows program can be started with without a shell. */
|
|
46
|
+
const WIN_RUNNABLE = [".exe", ".com"]
|
|
47
|
+
|
|
48
|
+
/** The names `name` may be on Windows: as it is when it has a runnable extension, else with each, in PATHEXT order. */
|
|
49
|
+
function windowsNames(name: string, env: Env): string[] {
|
|
50
|
+
if (WIN_RUNNABLE.includes(path.win32.extname(name).toLowerCase())) return [name]
|
|
51
|
+
const listed = (env.PATHEXT ?? "").split(";").map((e) => e.trim().toLowerCase()).filter((e) => WIN_RUNNABLE.includes(e))
|
|
52
|
+
const order = listed.length ? [...new Set(listed)] : WIN_RUNNABLE
|
|
53
|
+
return order.map((ext) => `${name}${ext}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
/** The executable `name` runs as: an absolute path checked as it is, else the first match on PATH. */
|
|
41
|
-
export function findExecutable(name: string, env: Env): string | undefined {
|
|
57
|
+
export function findExecutable(name: string, env: Env, platform: string = process.platform): string | undefined {
|
|
42
58
|
if (!name) return undefined
|
|
59
|
+
if (platform === "win32") return findWindowsExecutable(name, env)
|
|
43
60
|
if (path.isAbsolute(name)) return executable(name) ? name : undefined
|
|
44
61
|
if (name.includes("/")) return undefined
|
|
45
62
|
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
@@ -50,13 +67,27 @@ export function findExecutable(name: string, env: Env): string | undefined {
|
|
|
50
67
|
return undefined
|
|
51
68
|
}
|
|
52
69
|
|
|
70
|
+
function findWindowsExecutable(name: string, env: Env): string | undefined {
|
|
71
|
+
if (path.isAbsolute(name) || path.win32.isAbsolute(name)) return windowsNames(name, env).find((file) => executable(file, true))
|
|
72
|
+
if (/[\\/]/.test(name)) return undefined
|
|
73
|
+
for (const dir of (env.PATH ?? env.Path ?? "").split(";")) {
|
|
74
|
+
if (!dir || !(path.isAbsolute(dir) || path.win32.isAbsolute(dir))) continue
|
|
75
|
+
for (const file of windowsNames(name, env)) {
|
|
76
|
+
const full = path.join(dir, file)
|
|
77
|
+
if (executable(full, true)) return full
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/** The harnesses this machine's host runtime offers. */
|
|
54
84
|
export function detectHarnesses(options: DetectHarnessesOptions = {}): string[] {
|
|
55
85
|
const env = options.env ?? process.env
|
|
56
86
|
const found = new Set<string>(["opencode"])
|
|
57
87
|
const home = options.home ?? homedir()
|
|
58
|
-
|
|
59
|
-
if (findExecutable(
|
|
88
|
+
const platform = options.platform ?? process.platform
|
|
89
|
+
if (findExecutable(claudeBinary(env, home), env, platform)) found.add("claude-code")
|
|
90
|
+
if (findExecutable(codexBinary(env, home, platform), env, platform)) found.add("codex")
|
|
60
91
|
for (const [name, on] of Object.entries(options.overrides ?? {})) {
|
|
61
92
|
if (on) found.add(name)
|
|
62
93
|
else found.delete(name)
|
|
@@ -85,11 +116,25 @@ export function claudeBinary(env: Env, home: string = homedir()): string {
|
|
|
85
116
|
|
|
86
117
|
/**
|
|
87
118
|
* The Codex CLI to run: HUB_CODEX_BIN, else the Codex `hubctl setup` installed (data/hub/harnesses/codex/current/
|
|
88
|
-
* bin/codex, under XDG_DATA_HOME or ~/.local/share) when it is there, else `codex` on PATH.
|
|
119
|
+
* bin/codex, bin/codex.exe on Windows, under XDG_DATA_HOME or ~/.local/share) when it is there, else `codex` on PATH.
|
|
89
120
|
*/
|
|
90
|
-
export function codexBinary(env: Env, home: string = homedir()): string {
|
|
121
|
+
export function codexBinary(env: Env, home: string = homedir(), platform: string = process.platform): string {
|
|
91
122
|
if (env.HUB_CODEX_BIN) return env.HUB_CODEX_BIN
|
|
92
123
|
const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share")
|
|
93
|
-
const
|
|
94
|
-
|
|
124
|
+
const win32 = platform === "win32"
|
|
125
|
+
const current = path.join(data, "hub", "harnesses", "codex", "current", "bin", win32 ? "codex.exe" : "codex")
|
|
126
|
+
return executable(current, win32) ? current : "codex"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The OpenCode CLI (the node starts its background service with it on Windows): HUB_OPENCODE_BIN, else the OpenCode
|
|
131
|
+
* `hubctl setup` installed (data/hub/harnesses/opencode/current/bin/opencode, opencode.exe on Windows) when it is
|
|
132
|
+
* there, else `opencode` on PATH.
|
|
133
|
+
*/
|
|
134
|
+
export function opencodeBinary(env: Env, home: string = homedir(), platform: string = process.platform): string {
|
|
135
|
+
if (env.HUB_OPENCODE_BIN) return env.HUB_OPENCODE_BIN
|
|
136
|
+
const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share")
|
|
137
|
+
const win32 = platform === "win32"
|
|
138
|
+
const current = path.join(data, "hub", "harnesses", "opencode", "current", "bin", win32 ? "opencode.exe" : "opencode")
|
|
139
|
+
return executable(current, win32) ? current : "opencode"
|
|
95
140
|
}
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
15
15
|
import path from "node:path"
|
|
16
16
|
import {
|
|
17
|
+
canonicalNativePath,
|
|
17
18
|
isAbsolutePath,
|
|
18
19
|
isRefName,
|
|
20
|
+
isWindowsAbsolutePath,
|
|
19
21
|
launchWorkspaceProblem,
|
|
20
22
|
preparedWorkspaceProblem,
|
|
21
23
|
type CleanupPolicy,
|
|
@@ -38,41 +40,84 @@ export interface HostRuntimeOptions {
|
|
|
38
40
|
readonly git?: Omit<GitOptions, "cwd" | "signal">
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The file-system calls path resolution needs (tests inject a Windows one). On win32 (Phase 3) realpath reports native
|
|
45
|
+
* paths (`C:\\…`, `\\\\?\\C:\\…`); they are turned canonical (`C:/…`, protocol canonicalNativePath) and compared without
|
|
46
|
+
* case, as Windows compares them; a real path off a local drive (a mapped share's UNC path) is refused.
|
|
47
|
+
*/
|
|
48
|
+
export interface PathOps {
|
|
49
|
+
readonly win32: boolean
|
|
50
|
+
/** dirname and basename for the platform's paths (path.win32 also reads `/`). */
|
|
51
|
+
readonly path: Pick<typeof path, "dirname" | "basename">
|
|
52
|
+
readonly realpath: (p: string) => string
|
|
53
|
+
readonly isSymlink: (p: string) => boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const nativeOps: PathOps = {
|
|
57
|
+
win32: process.platform === "win32",
|
|
58
|
+
path,
|
|
59
|
+
realpath: (p) => realpathSync(p),
|
|
60
|
+
isSymlink: (p) => {
|
|
61
|
+
try {
|
|
62
|
+
return lstatSync(p).isSymbolicLink()
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** realpath in the form the runtime keeps: as it is on POSIX, canonical (or refused) on Windows. */
|
|
70
|
+
function realOf(p: string, ops: PathOps): string {
|
|
71
|
+
const real = ops.realpath(p)
|
|
72
|
+
if (!ops.win32) return real
|
|
73
|
+
const canonical = canonicalNativePath(real)
|
|
74
|
+
if (canonical === undefined || !isWindowsAbsolutePath(canonical)) throw new RuntimeError(`${p} resolves to ${real}, which is not on a local drive`)
|
|
75
|
+
return canonical
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
/** `p` with every existing part resolved through symlinks; refuses a dangling symlink on the way. */
|
|
42
|
-
function resolvePath(p: string): string {
|
|
79
|
+
function resolvePath(p: string, ops: PathOps = nativeOps): string {
|
|
43
80
|
const rest: string[] = []
|
|
44
81
|
let current = p
|
|
45
82
|
for (;;) {
|
|
83
|
+
let real: string
|
|
46
84
|
try {
|
|
47
|
-
|
|
85
|
+
real = realOf(current, ops)
|
|
48
86
|
} catch (cause) {
|
|
87
|
+
if (cause instanceof RuntimeError) throw cause
|
|
49
88
|
if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw new RuntimeError(`cannot resolve ${p}: ${(cause as Error).message}`)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
link = lstatSync(current).isSymbolicLink()
|
|
53
|
-
} catch {}
|
|
54
|
-
if (link) throw new RuntimeError(`${p} goes through a dangling symlink`)
|
|
55
|
-
const parent = path.dirname(current)
|
|
89
|
+
if (ops.isSymlink(current)) throw new RuntimeError(`${p} goes through a dangling symlink`)
|
|
90
|
+
const parent = ops.path.dirname(current)
|
|
56
91
|
if (parent === current) throw new RuntimeError(`cannot resolve ${p}`)
|
|
57
|
-
rest.push(path.basename(current))
|
|
92
|
+
rest.push(ops.path.basename(current))
|
|
58
93
|
current = parent
|
|
94
|
+
continue
|
|
59
95
|
}
|
|
96
|
+
return ops.win32 ? path.posix.join(real, ...rest.reverse()) : path.join(real, ...rest.reverse())
|
|
60
97
|
}
|
|
61
98
|
}
|
|
62
99
|
|
|
100
|
+
/** Whether real path `real` is real root `root` or inside it (without case on Windows). */
|
|
101
|
+
function within(real: string, root: string, ops: PathOps): boolean {
|
|
102
|
+
if (!ops.win32) return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
|
|
103
|
+
const r = real.toLowerCase()
|
|
104
|
+
const base = root.toLowerCase()
|
|
105
|
+
return r === base || r.startsWith(base.endsWith("/") ? base : `${base}/`)
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
/** The real path of `p` when it is (or would be) under one of `roots`; throws a RuntimeError otherwise. */
|
|
64
|
-
export function resolveUnderRoots(p: string, roots: readonly string[]): string {
|
|
109
|
+
export function resolveUnderRoots(p: string, roots: readonly string[], ops: PathOps = nativeOps): string {
|
|
65
110
|
if (roots.length === 0) throw new RuntimeError("this node has no workspaceRoots in node.json, so it prepares no workspaces")
|
|
66
111
|
if (!isAbsolutePath(p)) throw new RuntimeError(`${p} is not an absolute path`)
|
|
67
|
-
const real = resolvePath(p)
|
|
112
|
+
const real = resolvePath(p, ops)
|
|
68
113
|
for (const root of roots) {
|
|
69
114
|
let realRoot: string
|
|
70
115
|
try {
|
|
71
|
-
realRoot =
|
|
116
|
+
realRoot = realOf(root, ops)
|
|
72
117
|
} catch {
|
|
73
118
|
continue
|
|
74
119
|
}
|
|
75
|
-
if (real
|
|
120
|
+
if (within(real, realRoot, ops)) return real
|
|
76
121
|
}
|
|
77
122
|
throw new RuntimeError(`${p} is not under this node's workspace roots`)
|
|
78
123
|
}
|
|
@@ -128,7 +173,7 @@ export class HostRuntime implements Runtime {
|
|
|
128
173
|
const directory = resolveUnderRoots(existing, this.roots)
|
|
129
174
|
return this.prepared(repo, directory, "worktree", branch, base, true, ctx)
|
|
130
175
|
}
|
|
131
|
-
const directory = resolveUnderRoots(path.join(repo, ".worktrees", ...branch.split("/")), this.roots)
|
|
176
|
+
const directory = resolveUnderRoots(path.posix.join(repo, ".worktrees", ...branch.split("/")), this.roots)
|
|
132
177
|
if (existsSync(directory)) throw new RuntimeError(`${directory} exists and is not the worktree of ${branch}`)
|
|
133
178
|
if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
|
|
134
179
|
ctx.progress?.(`creating worktree ${branch}`)
|
|
@@ -154,7 +199,8 @@ export class HostRuntime implements Runtime {
|
|
|
154
199
|
const { branch, base, remote } = input.workspace
|
|
155
200
|
const target = resolveUnderRoots(input.workspace.repo, this.roots)
|
|
156
201
|
if (existsSync(target) || isLink(target)) throw new RuntimeError(`${target} exists; a clone needs a new directory`)
|
|
157
|
-
|
|
202
|
+
// Canonical paths join and split with "/" on every platform (path.posix is path on POSIX).
|
|
203
|
+
const parent = path.posix.dirname(target)
|
|
158
204
|
mkdirSync(parent, { recursive: true })
|
|
159
205
|
// The parent was created through resolved paths, but check again before git writes there.
|
|
160
206
|
if (resolveUnderRoots(parent, this.roots) !== parent) throw new RuntimeError(`${parent} moved while preparing`)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// OpenCode's background service on Windows (Phase 3, PHASE3-CONTRACT.md "The node on Windows"). On macOS and Linux a
|
|
2
|
+
// login item or the user keeps OpenCode's background service running; on Windows nothing does, so the node starts
|
|
3
|
+
// it: when no service is registered (service.json missing, or its process gone), `<opencode> service start` (no shell,
|
|
4
|
+
// hidden window) at most once per warm tick, backing off after a failure (START_BACKOFF_MS doubling up to
|
|
5
|
+
// START_BACKOFF_MAX_MS), and logged. `service start` asks OpenCode's client to ensure the service (it spawns
|
|
6
|
+
// `opencode serve --service` detached, waits until it is ready, prints its URL and exits), so the service outlives
|
|
7
|
+
// the command: only the command itself is ended on a timeout, never its tree. The warmer then warms it as elsewhere.
|
|
8
|
+
import { spawn } from "node:child_process"
|
|
9
|
+
import type { WarmEndpoint } from "./opencode-warm.ts"
|
|
10
|
+
|
|
11
|
+
/** First back-off after a failed start (the warm interval); it doubles up to START_BACKOFF_MAX_MS. */
|
|
12
|
+
export const START_BACKOFF_MS = 30_000
|
|
13
|
+
export const START_BACKOFF_MAX_MS = 10 * 60_000
|
|
14
|
+
/** How long `service start` may take (it waits for the service to be ready). */
|
|
15
|
+
export const START_TIMEOUT_MS = 90_000
|
|
16
|
+
const STDERR_MAX = 2000
|
|
17
|
+
|
|
18
|
+
/** Runs `file args` without a shell; resolves when it exits (or failed to start). */
|
|
19
|
+
export type StartRun = (file: string, args: readonly string[]) => Promise<{ code: number | null; stderr: string; spawnError?: string }>
|
|
20
|
+
|
|
21
|
+
export interface OpenCodeServiceStarterOptions {
|
|
22
|
+
/** The OpenCode executable (an absolute path), or undefined when it is not installed. */
|
|
23
|
+
readonly binary: () => string | undefined
|
|
24
|
+
readonly log: (line: string) => void
|
|
25
|
+
readonly run?: StartRun
|
|
26
|
+
readonly now?: () => number
|
|
27
|
+
/** The environment `service start` runs with (default process.env). */
|
|
28
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The default run: hidden, no shell, stdin and stdout closed, stderr bounded; the command alone ends on a timeout. */
|
|
32
|
+
export function startRun(env: Readonly<Record<string, string | undefined>> = process.env, timeoutMs = START_TIMEOUT_MS): StartRun {
|
|
33
|
+
return (file, args) =>
|
|
34
|
+
new Promise((resolve) => {
|
|
35
|
+
const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => typeof e[1] === "string"))
|
|
36
|
+
let child: ReturnType<typeof spawn>
|
|
37
|
+
try {
|
|
38
|
+
child = spawn(file, [...args], { shell: false, windowsHide: true, env: clean, stdio: ["ignore", "ignore", "pipe"] })
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
return resolve({ code: null, stderr: "", spawnError: (cause as Error).message })
|
|
41
|
+
}
|
|
42
|
+
let stderr = ""
|
|
43
|
+
let done = false
|
|
44
|
+
const finish = (r: { code: number | null; stderr: string; spawnError?: string }) => {
|
|
45
|
+
if (done) return
|
|
46
|
+
done = true
|
|
47
|
+
clearTimeout(timer)
|
|
48
|
+
// The service it started may hold an inherited handle; never wait for the pipe to close.
|
|
49
|
+
child.stderr?.destroy()
|
|
50
|
+
resolve(r)
|
|
51
|
+
}
|
|
52
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
53
|
+
if (stderr.length < STDERR_MAX) stderr += chunk.toString("utf8").slice(0, STDERR_MAX - stderr.length)
|
|
54
|
+
})
|
|
55
|
+
child.on("error", (cause) => finish({ code: null, stderr, spawnError: cause.message }))
|
|
56
|
+
child.on("exit", (code) => finish({ code, stderr }))
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
child.kill()
|
|
59
|
+
finish({ code: null, stderr, spawnError: `timed out after ${Math.round(timeoutMs / 1000)} s` })
|
|
60
|
+
}, timeoutMs)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const oneLine = (text: string) => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, 300)
|
|
65
|
+
|
|
66
|
+
export class OpenCodeServiceStarter {
|
|
67
|
+
private nextAttempt = 0
|
|
68
|
+
private delay = START_BACKOFF_MS
|
|
69
|
+
private missingLogged = false
|
|
70
|
+
|
|
71
|
+
constructor(private readonly options: OpenCodeServiceStarterOptions) {}
|
|
72
|
+
|
|
73
|
+
/** Starts the service unless backing off; true when `service start` succeeded. */
|
|
74
|
+
async start(): Promise<boolean> {
|
|
75
|
+
const now = (this.options.now ?? Date.now)()
|
|
76
|
+
if (now < this.nextAttempt) return false
|
|
77
|
+
const binary = this.options.binary()
|
|
78
|
+
if (!binary) {
|
|
79
|
+
if (!this.missingLogged) this.options.log("OpenCode is not installed (no opencode.exe found), so its background service cannot be started")
|
|
80
|
+
this.missingLogged = true
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
this.missingLogged = false
|
|
84
|
+
this.options.log("OpenCode's background service is not running; starting it (opencode service start)")
|
|
85
|
+
const r = await (this.options.run ?? startRun(this.options.env))(binary, ["service", "start"])
|
|
86
|
+
if (r.code === 0) {
|
|
87
|
+
this.delay = START_BACKOFF_MS
|
|
88
|
+
this.nextAttempt = 0
|
|
89
|
+
this.options.log("started OpenCode's background service")
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
const why = r.spawnError ?? (oneLine(r.stderr) || `exit ${r.code}`)
|
|
93
|
+
this.options.log(`could not start OpenCode's background service (${why}); retrying in ${Math.round(this.delay / 1000)} s`)
|
|
94
|
+
this.nextAttempt = (this.options.now ?? Date.now)() + this.delay
|
|
95
|
+
this.delay = Math.min(this.delay * 2, START_BACKOFF_MAX_MS)
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The warmer's discovery of the background service: on win32 a missing registration starts the service (at most one
|
|
102
|
+
* attempt per call, i.e. per tick) and reads the registration again; elsewhere `read` as it is.
|
|
103
|
+
*/
|
|
104
|
+
export function serviceDiscovery(read: () => Promise<WarmEndpoint | undefined>, starter: OpenCodeServiceStarter, platform: string = process.platform): () => Promise<WarmEndpoint | undefined> {
|
|
105
|
+
if (platform !== "win32") return read
|
|
106
|
+
return async () => {
|
|
107
|
+
const endpoint = await read()
|
|
108
|
+
if (endpoint) return endpoint
|
|
109
|
+
return (await starter.start()) ? read() : undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -248,19 +248,25 @@ export class OpenCodeWarmer {
|
|
|
248
248
|
* OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
|
|
249
249
|
* without any request: undefined when the file is missing or malformed, is not this user's own (a symbolic link, not
|
|
250
250
|
* owned by this uid, or writable by group or others), or its process is gone or another user's (EPERM). The URL is
|
|
251
|
-
* passed on as it is; OpenCodeWarmer.tick decides whether it may be asked.
|
|
251
|
+
* passed on as it is; OpenCodeWarmer.tick decides whether it may be asked. On Windows (no modes, no uid; Phase 3) the
|
|
252
|
+
* file is this user's when its access list allows no one but this user, SYSTEM and Administrators (src/win-acl.ts, as
|
|
253
|
+
* for password files: it holds the service's password); Bun ignores numeric open flags there, so it is opened "r"
|
|
254
|
+
* after the lstat check.
|
|
252
255
|
*/
|
|
253
|
-
export async function readServiceRegistration(file: string, options: { readonly uid?: number } = {}): Promise<WarmEndpoint | undefined> {
|
|
254
|
-
const
|
|
256
|
+
export async function readServiceRegistration(file: string, options: { readonly uid?: number } & SecretFileOptions = {}): Promise<WarmEndpoint | undefined> {
|
|
257
|
+
const win32 = isWin32(options)
|
|
258
|
+
const uid = win32 ? undefined : (options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined))
|
|
255
259
|
let info: unknown
|
|
256
260
|
let fd: number | undefined
|
|
257
261
|
try {
|
|
258
262
|
const before = lstatSync(file)
|
|
259
263
|
if (!before.isFile()) return undefined
|
|
260
|
-
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
264
|
+
fd = win32 ? openSync(file, "r") : openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
261
265
|
const stat = fstatSync(fd)
|
|
262
266
|
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return undefined
|
|
263
|
-
if (
|
|
267
|
+
if (win32) {
|
|
268
|
+
if (!(options.acl ?? defaultWinAcl()).check(nativeWinPath(file)).ok) return undefined
|
|
269
|
+
} else if ((stat.mode & 0o022) !== 0) return undefined
|
|
264
270
|
if (uid !== undefined && stat.uid !== uid) return undefined
|
|
265
271
|
if (stat.size > REGISTRATION_MAX) return undefined
|
|
266
272
|
// Read through the descriptor that was checked (no second open by path).
|