@huynle/hub 0.9.0 → 0.9.2
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 +36 -0
- package/README.md +7 -4
- package/package.json +1 -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 +1126 -479
- 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/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- 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/src/index.ts +3 -1
- package/packages/protocol/src/launch.ts +39 -4
- 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/agent/launch-spec.ts +4 -2
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/config.ts +24 -2
- package/src/central/http.ts +1 -0
- 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/push-target.ts +11 -2
- package/web/dist/assets/{ArtifactPage-4GoBqSpU.js → ArtifactPage-BKISN-Qj.js} +1 -1
- package/web/dist/assets/{ArtifactPane-Dx_0iWwH.js → ArtifactPane-CKOiAUNH.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-CuHgARMk.js → ArtifactWorkbench-d7R1o-rs.js} +1 -1
- package/web/dist/assets/{Diagram-CZM_R7MG.js → Diagram-azw2B_QH.js} +1 -1
- package/web/dist/assets/{Library-TwvrwqSk.js → Library-CcjZXsfF.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-rKNDpLjR.js → ProfilesDestination-DdzbzmeK.js} +1 -1
- package/web/dist/assets/{TeamsPage-DtJylLrt.js → TeamsPage-CDyjrK-I.js} +1 -1
- package/web/dist/assets/{Watchtower-Bmgdyfjb.js → Watchtower-DYwoUWME.js} +2 -2
- package/web/dist/assets/{index-D-xG9nrc.js → index-LNMZCqgE.js} +15 -15
- package/web/dist/assets/{live-7q72Lyj2.js → live-ycZ_aQZO.js} +1 -1
- package/web/dist/assets/{renderer2d-hfZxM6Xl.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-Km9vipTM.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-BAbgBiFk.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/index.html +1 -1
package/packages/cli/src/node.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
// entry), writes the file, and prints the launchctl / systemctl commands that load it. Nothing here starts a process:
|
|
6
6
|
// loading, unloading and inspecting the service are always left to the user (printed, never run). --dry-run
|
|
7
7
|
// prints the file instead of writing it. The same machinery (ServiceSpec) installs the harness spokes' services
|
|
8
|
-
// (spoke-services.ts: `hub claude-code install`, `hub codex-spoke install`).
|
|
9
|
-
|
|
8
|
+
// (spoke-services.ts: `hub claude-code install`, `hub codex-spoke install`). On Windows (PHASE3-CONTRACT.md) the node
|
|
9
|
+
// is a per-user logon task: install writes its Task XML (UTF-16LE) under the hub state dir's tasks/ and prints the
|
|
10
|
+
// schtasks commands that register and start it; the spokes have no task yet.
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
10
12
|
import path from "node:path"
|
|
11
13
|
import { nodeConfigPath, readNodeConfig, readSpokeCredentials, runNode } from "../../node/src/index.ts"
|
|
12
|
-
import { renderServiceUnit } from "./bootstrap/adapters/node-services/render.ts"
|
|
13
|
-
import { isAbsolutePath, programLayout, type ServiceID, type ServiceManagerID } from "./bootstrap/contracts/index.ts"
|
|
14
|
+
import { renderServiceUnit, taskNameFor } from "./bootstrap/adapters/node-services/render.ts"
|
|
15
|
+
import { isAbsolutePath, programLayout, toNativePath, type ProgramLayout, type ServiceID, type ServiceManagerID } from "./bootstrap/contracts/index.ts"
|
|
16
|
+
import { normalizeAbs } from "./bootstrap/discovery/util.ts"
|
|
17
|
+
import { resolveHubStateDir } from "../../../src/migrate.ts"
|
|
18
|
+
import { defaultWinAcl } from "../../../src/win-acl.ts"
|
|
14
19
|
import type { Env } from "./credentials"
|
|
15
20
|
import { CliError, EXIT } from "./http"
|
|
16
21
|
|
|
@@ -57,16 +62,20 @@ export interface NodeIo {
|
|
|
57
62
|
readonly platform: NodeJS.Platform
|
|
58
63
|
readonly uid: number
|
|
59
64
|
readonly program: Program
|
|
65
|
+
/** Windows: the current user's SID (the logon task's principal); default `whoami /user` (win-acl.ts). */
|
|
66
|
+
readonly userSid?: () => string
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
export type ServiceKind = "launchd" | "systemd"
|
|
69
|
+
export type ServiceKind = "launchd" | "systemd" | "task-scheduler"
|
|
63
70
|
|
|
64
71
|
export interface NodeService {
|
|
65
72
|
readonly kind: ServiceKind
|
|
66
73
|
/** Where the unit file goes. */
|
|
67
74
|
readonly file: string
|
|
68
|
-
/** Its content, as install writes it. */
|
|
75
|
+
/** Its content, as install writes it (as text). */
|
|
69
76
|
readonly content: string
|
|
77
|
+
/** Its bytes when they are not the UTF-8 of `content` (a Task XML is UTF-16LE). */
|
|
78
|
+
readonly bytes?: Uint8Array
|
|
70
79
|
/** Directories the service needs (its log). */
|
|
71
80
|
readonly dirs: readonly string[]
|
|
72
81
|
/** Commands that load (or reload) it, for the user to run. */
|
|
@@ -150,16 +159,61 @@ function systemd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeS
|
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
|
|
162
|
+
/** The node as a Windows logon task: the Task XML in <hub state>/tasks and the schtasks commands (printed, not run). */
|
|
163
|
+
function taskScheduler(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
|
|
164
|
+
if (spec.service !== "node")
|
|
165
|
+
throw new CliError(EXIT.unavailable, `${spec.command} install does not support Windows yet: hubctl installs no user service for it there`, spec.elsewhere)
|
|
166
|
+
const home = normalizeAbs(io.home)
|
|
167
|
+
if (!home) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: the home directory ${io.home} is not a drive path`)
|
|
168
|
+
const state = normalizeAbs(resolveHubStateDir(io.env, io.home).dir)
|
|
169
|
+
if (!state) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: the hub state directory is not a drive path`)
|
|
170
|
+
const executable = normalizeAbs(io.program.execPath)
|
|
171
|
+
const found = programLayout(io.program)
|
|
172
|
+
const layout: ProgramLayout = found.kind === "standalone" && executable ? { kind: "standalone", executable } : found
|
|
173
|
+
const { environment, skipped } = carried(spec, io, extra)
|
|
174
|
+
let name: string
|
|
175
|
+
try {
|
|
176
|
+
name = taskNameFor(spec.service, io.env.USERNAME ?? "", io.env)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: ${(error as Error).message}`)
|
|
179
|
+
}
|
|
180
|
+
const r = renderServiceUnit({ service: spec.service, manager: "task-scheduler", layout, home, writtenBy: spec.command, task: { name, userSid: (io.userSid ?? (() => defaultWinAcl().sid()))() } })
|
|
181
|
+
if (!r.ok) throw new CliError(EXIT.usage, `cannot write the ${spec.command} task: ${r.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ")}`)
|
|
182
|
+
const file = toNativePath(`${state}/tasks/${r.value.path.rel}`, "win32")
|
|
183
|
+
const stopNode = `Get-CimInstance Win32_Process -Filter "Name='hubctl.exe'" | Where-Object CommandLine -like '* node run' | Invoke-CimMethod -MethodName Terminate # PowerShell: /End stops only the hidden console host`
|
|
184
|
+
return {
|
|
185
|
+
kind: "task-scheduler",
|
|
186
|
+
file,
|
|
187
|
+
content: r.value.text,
|
|
188
|
+
bytes: r.value.bytes,
|
|
189
|
+
// The task carries no environment: these settings of this shell are not seen by it.
|
|
190
|
+
skipped: [...Object.keys(environment), ...skipped].sort(),
|
|
191
|
+
dirs: [],
|
|
192
|
+
load: [`schtasks /Create /TN ${name} /XML "${file}" /F`, `schtasks /Run /TN ${name}`],
|
|
193
|
+
unload: [`schtasks /End /TN ${name}`, stopNode, `schtasks /Delete /TN ${name} /F`],
|
|
194
|
+
check: [`schtasks /Query /TN ${name} /V /FO LIST`],
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
153
198
|
/** `spec`'s service for this platform; unsupported (exit 7) elsewhere. */
|
|
154
199
|
export function serviceFor(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
|
|
155
200
|
if (io.platform === "darwin") return launchd(spec, io, extra)
|
|
156
201
|
if (io.platform === "linux") return systemd(spec, io, extra)
|
|
202
|
+
if (io.platform === "win32") return taskScheduler(spec, io, extra)
|
|
157
203
|
throw new CliError(EXIT.unavailable, `${spec.command} install supports macOS (launchd) and Linux (systemd), not ${io.platform}`, spec.elsewhere)
|
|
158
204
|
}
|
|
159
205
|
|
|
160
206
|
/** The node's service for this platform; unsupported (exit 7) elsewhere. */
|
|
161
207
|
export const nodeService = (io: NodeIo): NodeService => serviceFor(NODE_SERVICE, io)
|
|
162
208
|
|
|
209
|
+
const readBytes = (file: string): Buffer | undefined => {
|
|
210
|
+
try {
|
|
211
|
+
return readFileSync(file)
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
163
217
|
const readText = (file: string): string | undefined => {
|
|
164
218
|
try {
|
|
165
219
|
return readFileSync(file, "utf8")
|
|
@@ -194,14 +248,15 @@ export function installNode(io: NodeIo, options: { readonly dryRun: boolean }):
|
|
|
194
248
|
/** Writes `spec`'s service file (or prints it, dry run) and prints the commands that load it; `warnings` go to stderr. */
|
|
195
249
|
export function installService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }, given: readonly string[], extra?: ExtraEnvironment): NodeCommandOutput {
|
|
196
250
|
const service = serviceFor(spec, io, extra)
|
|
197
|
-
const
|
|
251
|
+
const why = service.kind === "task-scheduler" ? "is set in this shell, but a logon task has no environment of its own, so the node does not see it" : "is not an absolute path, so the service does not carry it"
|
|
252
|
+
const warnings = [...given, ...service.skipped.map((key) => `${key} ${why}`)]
|
|
198
253
|
const load = commands(`Load it ${NOT_RUN}:`, service.load)
|
|
199
254
|
if (options.dryRun) return { stdout: `would write ${service.file}:\n\n${service.content}\n${load}`, warnings }
|
|
200
|
-
const current = readText(service.file)
|
|
255
|
+
const current = service.bytes ? readBytes(service.file) : readText(service.file)
|
|
201
256
|
for (const dir of service.dirs) mkdirSync(dir, { recursive: true })
|
|
202
|
-
if (current === service.content) return { stdout: `${service.file} is up to date\n${load}`, warnings }
|
|
257
|
+
if (service.bytes ? current !== undefined && Buffer.from(service.bytes).equals(current as Buffer) : current === service.content) return { stdout: `${service.file} is up to date\n${load}`, warnings }
|
|
203
258
|
mkdirSync(path.dirname(service.file), { recursive: true })
|
|
204
|
-
writeFileSync(service.file, service.content, { mode: 0o644 })
|
|
259
|
+
writeFileSync(service.file, service.bytes ?? service.content, { mode: 0o644 })
|
|
205
260
|
return { stdout: `${current === undefined ? "wrote" : "updated"} ${service.file}\n${load}`, warnings }
|
|
206
261
|
}
|
|
207
262
|
|
|
@@ -225,6 +280,10 @@ export type ServiceState = { readonly kind: ServiceKind; readonly file: string;
|
|
|
225
280
|
export function serviceState(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): ServiceState {
|
|
226
281
|
try {
|
|
227
282
|
const s = serviceFor(spec, io, extra)
|
|
283
|
+
if (s.bytes) {
|
|
284
|
+
const bytes = readBytes(s.file)
|
|
285
|
+
return { kind: s.kind, file: s.file, installed: bytes !== undefined, current: bytes !== undefined && Buffer.from(s.bytes).equals(bytes), check: s.check }
|
|
286
|
+
}
|
|
228
287
|
const text = readText(s.file)
|
|
229
288
|
return { kind: s.kind, file: s.file, installed: text !== undefined, current: text === s.content, check: s.check }
|
|
230
289
|
} catch {
|
|
@@ -273,7 +332,30 @@ export function nodeStatusText(status: NodeStatus): string {
|
|
|
273
332
|
return lines.join("\n")
|
|
274
333
|
}
|
|
275
334
|
|
|
335
|
+
/** node.log is rotated to node.log.1 once it grows past this. */
|
|
336
|
+
export const NODE_LOG_MAX_BYTES = 1024 * 1024
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The node's log: stderr everywhere; on win32 also <hub state dir>/node.log, because the logon task runs hubctl
|
|
340
|
+
* headless (conhost --headless) and its stderr goes nowhere. Writing the file never throws.
|
|
341
|
+
*/
|
|
342
|
+
export function nodeLogger(o: { readonly platform: string; readonly stateDir: string; stderr(s: string): void; readonly now?: () => Date }): (line: string) => void {
|
|
343
|
+
const file = path.join(o.stateDir, "node.log")
|
|
344
|
+
return (line) => {
|
|
345
|
+
o.stderr(`[hub node] ${line}\n`)
|
|
346
|
+
if (o.platform !== "win32") return
|
|
347
|
+
try {
|
|
348
|
+
mkdirSync(o.stateDir, { recursive: true })
|
|
349
|
+
try {
|
|
350
|
+
if (statSync(file).size > NODE_LOG_MAX_BYTES) renameSync(file, `${file}.1`)
|
|
351
|
+
} catch {}
|
|
352
|
+
appendFileSync(file, `${(o.now ?? (() => new Date()))().toISOString()} ${line}\n`)
|
|
353
|
+
} catch {}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
276
357
|
/** `hub node run`: the daemon until `signal` aborts; runNode's exit code. */
|
|
277
|
-
export function runNodeCommand(io: { readonly env: Env; readonly home: string; readonly signal?: AbortSignal; stderr(s: string): void }): Promise<number> {
|
|
278
|
-
|
|
358
|
+
export function runNodeCommand(io: { readonly env: Env; readonly home: string; readonly signal?: AbortSignal; readonly platform?: string; stderr(s: string): void }): Promise<number> {
|
|
359
|
+
const log = nodeLogger({ platform: io.platform ?? process.platform, stateDir: resolveHubStateDir(io.env, io.home).dir, stderr: (s) => io.stderr(s) })
|
|
360
|
+
return runNode({ env: io.env, home: io.home, signal: io.signal ?? new AbortController().signal, log })
|
|
279
361
|
}
|
|
@@ -42,7 +42,7 @@ import { OpencodePlanError } from "../bootstrap/adapters/opencode/plan.ts"
|
|
|
42
42
|
import { parseJsonc } from "../bootstrap/transaction/json-merge.ts"
|
|
43
43
|
import { renderRoleVars } from "../bootstrap/ansible/render.ts"
|
|
44
44
|
import { createNodeProbe } from "../bootstrap/discovery/node-probe.ts"
|
|
45
|
-
import { normalizeAbs } from "../bootstrap/discovery/util.ts"
|
|
45
|
+
import { normalizeAbs, underDir } from "../bootstrap/discovery/util.ts"
|
|
46
46
|
import { credentialStoreFor, defaultMachineName, machineEnroll, machineStatus, renderMachineResult, sharingOffWarning } from "../bootstrap/enrollment/index.ts"
|
|
47
47
|
import { previewSetup, renderPreview, revalidateApply } from "../bootstrap/preview/index.ts"
|
|
48
48
|
import { defaultProfile } from "../bootstrap/profiles/catalog.ts"
|
|
@@ -284,25 +284,45 @@ function discovered(io: SetupIo): DiscoverySnapshot {
|
|
|
284
284
|
return r.value
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/** A PathRef for an absolute path under a mapped root (the verification launch's workspace). */
|
|
288
|
-
function refFor(abs: string, snapshot: DiscoverySnapshot): PathRef | undefined {
|
|
287
|
+
/** A PathRef for an absolute path under a mapped root (the verification launch's workspace; Windows paths in any case). */
|
|
288
|
+
export function refFor(abs: string, snapshot: DiscoverySnapshot): PathRef | undefined {
|
|
289
289
|
const ctx = executionContextOf(snapshot)
|
|
290
290
|
for (const id of ["home"] as const) {
|
|
291
291
|
const root = snapshot.roots.find((r) => r.id === id)
|
|
292
292
|
const base = root ? rootPathIn(root, ctx) : undefined
|
|
293
|
-
if (base && abs
|
|
293
|
+
if (base && underDir(abs, base)) return { root: id, rel: abs.slice(base.length + (base.endsWith("/") ? 0 : 1)) }
|
|
294
294
|
}
|
|
295
295
|
return undefined
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A --workspace-root as a canonical absolute path. POSIX: resolved against the working directory, as before. Windows
|
|
300
|
+
* (PHASE3-CONTRACT.md): a native ("C:\\x"), canonical ("C:/x") or lower-case drive path, "~/…" or "~\\…" under
|
|
301
|
+
* the home, or a path relative to the working directory; UNC and device paths are refused (undefined).
|
|
302
|
+
*/
|
|
303
|
+
export function workspaceRootOf(raw: string, where: { readonly platform: string; readonly cwd: string; readonly home: string }): string | undefined {
|
|
304
|
+
if (where.platform !== "win32") return path.resolve(where.cwd, raw)
|
|
305
|
+
if (/^[\\/]{2}/.test(raw)) return undefined
|
|
306
|
+
const home = normalizeAbs(where.home)
|
|
307
|
+
const tilde = /^~(?:[\\/](.*))?$/.exec(raw)
|
|
308
|
+
if (tilde) return home ? normalizeAbs(path.win32.join(home, tilde[1] ?? "")) : undefined
|
|
309
|
+
const resolved = path.win32.resolve(path.win32.normalize(where.cwd), raw)
|
|
310
|
+
return /^[\\/]{2}/.test(resolved) ? undefined : normalizeAbs(resolved)
|
|
311
|
+
}
|
|
312
|
+
|
|
298
313
|
function choicesOf(io: SetupIo, flags: Parsed["flags"], profile: Profile, snapshot: DiscoverySnapshot): SetupChoices {
|
|
299
314
|
const enrollment = str(flags, "enrollment")
|
|
300
315
|
if (enrollment !== undefined && !(ENROLLMENT_MODES as readonly string[]).includes(enrollment)) throw new Refusal("usage", `--enrollment is ${ENROLLMENT_MODES.join(" or ")}`)
|
|
301
|
-
|
|
316
|
+
const where = { platform: snapshot.host.platform, cwd: io.cwd ?? process.cwd(), home: io.home }
|
|
317
|
+
let roots = (flags["workspace-root"] as string[] | undefined)?.map((r) => {
|
|
318
|
+
const root = workspaceRootOf(r, where)
|
|
319
|
+
if (root === undefined) throw new Refusal("usage", `--workspace-root ${r} is not a local absolute path (a drive path such as C:\\Users\\you\\projects, or ~/projects)`)
|
|
320
|
+
return root
|
|
321
|
+
})
|
|
302
322
|
if (!roots?.length && profile.services.includes("node")) {
|
|
303
|
-
// Without --workspace-root, the workspace roots node.json already lists.
|
|
323
|
+
// Without --workspace-root, the workspace roots node.json already lists (canonical on Windows, as the plan holds them).
|
|
304
324
|
const existing = readNodeConfig(nodeConfigPath(io.env as Record<string, string | undefined>, io.home)).config.workspaceRoots
|
|
305
|
-
if (existing.length > 0) roots = [...existing]
|
|
325
|
+
if (existing.length > 0) roots = where.platform === "win32" ? existing.map((r) => workspaceRootOf(r, where) ?? r) : [...existing]
|
|
306
326
|
}
|
|
307
327
|
const launchHarness = str(flags, "verify-launch")
|
|
308
328
|
let launch: SetupChoices["launch"]
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type ProgramLayout,
|
|
33
33
|
type Result,
|
|
34
34
|
type RootMapping,
|
|
35
|
+
type ServiceID,
|
|
35
36
|
type ServiceManagerID,
|
|
36
37
|
type VerifiedManifest,
|
|
37
38
|
} from "../bootstrap/contracts/index.ts"
|
|
@@ -49,10 +50,13 @@ import {
|
|
|
49
50
|
type ManagerRunner,
|
|
50
51
|
type NodeServiceControl,
|
|
51
52
|
type NodeServicesPlan,
|
|
53
|
+
taskNameFor,
|
|
54
|
+
type TaskIdentity,
|
|
52
55
|
} from "../bootstrap/adapters/node-services/index.ts"
|
|
53
56
|
import { createOpencodeHarness, type Download, type ProcessRunner } from "../bootstrap/adapters/opencode/index.ts"
|
|
54
57
|
import type { ReadinessGate } from "../bootstrap/adapters/readiness.ts"
|
|
55
|
-
import { discover } from "../bootstrap/discovery/discover.ts"
|
|
58
|
+
import { discover, systemRootOf } from "../bootstrap/discovery/discover.ts"
|
|
59
|
+
import { defaultWinAcl } from "../../../../src/win-acl.ts"
|
|
56
60
|
import { pathDirs, which } from "../bootstrap/discovery/executables.ts"
|
|
57
61
|
import type { DiscoveryProbe } from "../bootstrap/discovery/probe.ts"
|
|
58
62
|
import { machineEnroll, type EnrollPrompt } from "../bootstrap/enrollment/enroll.ts"
|
|
@@ -89,6 +93,14 @@ export interface RuntimePorts {
|
|
|
89
93
|
readonly hubctlBytes?: () => Uint8Array | undefined
|
|
90
94
|
readonly fs?: TxFs
|
|
91
95
|
readonly uid?: number
|
|
96
|
+
/** Windows: the current user's SID (the logon task's principal); default `whoami /user` (win-acl.ts). */
|
|
97
|
+
readonly userSid?: () => string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The Windows programs the task-scheduler control runs: absolute, under %SystemRoot%\System32 (canonical form). */
|
|
101
|
+
export function windowsServicePrograms(systemRoot: string): { readonly schtasks: string; readonly powershell: string; readonly taskkill: string } {
|
|
102
|
+
const dir = `${systemRoot.replace(/\/+$/, "")}/System32`
|
|
103
|
+
return { schtasks: `${dir}/schtasks.exe`, powershell: `${dir}/WindowsPowerShell/v1.0/powershell.exe`, taskkill: `${dir}/taskkill.exe` }
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
export interface RuntimeDeps {
|
|
@@ -324,6 +336,7 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
|
|
|
324
336
|
...(choices.activate ? { activate: true } : {}),
|
|
325
337
|
environment: deps.env,
|
|
326
338
|
...(choices.launch ? { launch: choices.launch } : {}),
|
|
339
|
+
...(input.snapshot.managers.service === "task-scheduler" && input.profile.services.length > 0 ? { task: taskIdentity() } : {}),
|
|
327
340
|
})
|
|
328
341
|
if (!planned.ok) return planned
|
|
329
342
|
nodePlan = planned.value
|
|
@@ -343,14 +356,28 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
|
|
|
343
356
|
return { sources: { adapters: registry, contributors, ...(choices.enrollment ? { enrollment: choices.enrollment } : {}) }, node: () => nodePlan, history: records }
|
|
344
357
|
}
|
|
345
358
|
|
|
359
|
+
/** The node's logon task for this Windows user: hub-node-<USERNAME> (a hub-test-… name only under test) and the SID. */
|
|
360
|
+
function taskIdentity(): TaskIdentity {
|
|
361
|
+
return { name: taskNameFor("node", deps.env.USERNAME ?? "", deps.env), userSid: (ports.userSid ?? (() => defaultWinAcl().sid()))() }
|
|
362
|
+
}
|
|
363
|
+
|
|
346
364
|
function control(snap: DiscoverySnapshot): NodeServiceControl | undefined {
|
|
347
365
|
if (snap.managers.service === "none") return undefined
|
|
366
|
+
const tasks = snap.managers.service === "task-scheduler"
|
|
348
367
|
let run = ports.serviceRunner
|
|
349
368
|
if (!run) {
|
|
350
|
-
|
|
351
|
-
|
|
369
|
+
if (tasks) run = spawnManagerRunner({ programs: windowsServicePrograms(systemRootOf(deps.probe)), env: deps.env })
|
|
370
|
+
else {
|
|
371
|
+
const systemctl = which(deps.probe, [...pathDirs(deps.probe), "/usr/bin", "/bin"], "systemctl") ?? "/usr/bin/systemctl"
|
|
372
|
+
run = spawnManagerRunner({ programs: { launchctl: "/bin/launchctl", systemctl }, env: deps.env })
|
|
373
|
+
}
|
|
352
374
|
}
|
|
353
|
-
return createServiceControl({
|
|
375
|
+
return createServiceControl({
|
|
376
|
+
run,
|
|
377
|
+
uid: ports.uid ?? deps.probe.uid,
|
|
378
|
+
unitPath: unitPathResolver(snap.roots, executionContextOf(snap)),
|
|
379
|
+
...(tasks ? { taskName: (service: ServiceID) => taskNameFor(service, deps.env.USERNAME ?? "", deps.env) } : {}),
|
|
380
|
+
})
|
|
354
381
|
}
|
|
355
382
|
|
|
356
383
|
function hubctlSource(): HubctlSource | undefined {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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 {
|