@huynle/hub 0.7.1 → 0.7.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 +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- 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/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// What `hubctl setup` keeps between commands (HBS-13 integration item 7). Everything is secret-free, validated when
|
|
2
|
+
// read and written, and reached only through symlink-checked paths under the state root (next to the applier's own
|
|
3
|
+
// journal, contract LOCK_PATH):
|
|
4
|
+
//
|
|
5
|
+
// the setup request `setup preview --out <file>`: the SetupPreview plus the profile and choices apply must
|
|
6
|
+
// re-plan with, so `setup apply --preview <file>` needs nothing else (the Ansible role's argv)
|
|
7
|
+
// state:hub/bootstrap/state.json
|
|
8
|
+
// the applied profile, its choices and catalog revision, the last committed transaction and
|
|
9
|
+
// plan digest, and the security floors this machine has seen (hubctl and each harness's
|
|
10
|
+
// pinned minimumVersion), which only rise
|
|
11
|
+
// state:hub/bootstrap/requests/<hex>.json
|
|
12
|
+
// the request each applied plan was made from, by plan digest (resume, drift and verify
|
|
13
|
+
// re-plan from it); the plan itself is the engine's journal/<txID>.plan.json
|
|
14
|
+
//
|
|
15
|
+
// The journals stay the record of used preview nonces (the engine reads them).
|
|
16
|
+
import {
|
|
17
|
+
bool,
|
|
18
|
+
Checker,
|
|
19
|
+
compareVersions,
|
|
20
|
+
digest,
|
|
21
|
+
ENROLLMENT_MODES,
|
|
22
|
+
findSecrets,
|
|
23
|
+
HARNESS_ORDER,
|
|
24
|
+
header,
|
|
25
|
+
int,
|
|
26
|
+
isRecord,
|
|
27
|
+
oneOf,
|
|
28
|
+
semver,
|
|
29
|
+
shape,
|
|
30
|
+
text,
|
|
31
|
+
validatePathRef,
|
|
32
|
+
validateSetupPreview,
|
|
33
|
+
type ArtifactManifest,
|
|
34
|
+
type EnrollmentMode,
|
|
35
|
+
type HarnessID,
|
|
36
|
+
type PathRef,
|
|
37
|
+
type Profile,
|
|
38
|
+
type Result,
|
|
39
|
+
type RootMapping,
|
|
40
|
+
type SetupPreview,
|
|
41
|
+
} from "../bootstrap/contracts/index.ts"
|
|
42
|
+
import { loadProfile } from "../bootstrap/profiles/versions.ts"
|
|
43
|
+
import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
|
|
44
|
+
import { resolveTarget } from "../bootstrap/transaction/resolve.ts"
|
|
45
|
+
import { errnoOf } from "../bootstrap/transaction/result.ts"
|
|
46
|
+
|
|
47
|
+
export const SETUP_REQUEST_SCHEMA = "hub.setup-request"
|
|
48
|
+
export const BOOTSTRAP_STATE_SCHEMA = "hub.bootstrap-state"
|
|
49
|
+
|
|
50
|
+
/** What the user chose at preview, besides the profile; apply re-plans with exactly these. */
|
|
51
|
+
export interface SetupChoices {
|
|
52
|
+
/** Inline (a planned enroll-machine, with `server`) or separate (`hubctl machine enroll`). */
|
|
53
|
+
readonly enrollment?: EnrollmentMode
|
|
54
|
+
/** --confirm-activation at preview: plan loading the services (apply needs the flag again). */
|
|
55
|
+
readonly activate?: boolean
|
|
56
|
+
/** --allow-downgrade at preview (apply needs the flag again). */
|
|
57
|
+
readonly allowDowngrade?: boolean
|
|
58
|
+
/** node.json settings for a launch node. */
|
|
59
|
+
readonly node?: { readonly workspaceRoots: readonly string[]; readonly slots?: number; readonly labels?: Readonly<Record<string, string>> }
|
|
60
|
+
/** One bounded verification launch. */
|
|
61
|
+
readonly launch?: { readonly harness: HarnessID; readonly workspace: PathRef }
|
|
62
|
+
/** The hub to enroll with inline. */
|
|
63
|
+
readonly server?: string
|
|
64
|
+
readonly machineName?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SetupRequest {
|
|
68
|
+
readonly schema: typeof SETUP_REQUEST_SCHEMA
|
|
69
|
+
readonly version: 1
|
|
70
|
+
readonly preview: SetupPreview
|
|
71
|
+
readonly profile: Profile
|
|
72
|
+
readonly choices: SetupChoices
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Floors {
|
|
76
|
+
readonly hubctl?: string
|
|
77
|
+
readonly harnesses: Partial<Record<HarnessID, string>>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BootstrapState {
|
|
81
|
+
readonly schema: typeof BOOTSTRAP_STATE_SCHEMA
|
|
82
|
+
readonly version: 1
|
|
83
|
+
/** The profile of the last committed apply. */
|
|
84
|
+
readonly profile?: Profile
|
|
85
|
+
readonly choices?: SetupChoices
|
|
86
|
+
/** The built-in catalog revision that profile was made from (profile.revision). */
|
|
87
|
+
readonly catalogRevision?: number
|
|
88
|
+
readonly txID?: string
|
|
89
|
+
readonly planDigest?: string
|
|
90
|
+
readonly floors: Floors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const EMPTY: BootstrapState = { schema: BOOTSTRAP_STATE_SCHEMA, version: 1, floors: { harnesses: {} } }
|
|
94
|
+
|
|
95
|
+
function checkChoices(c: Checker, path: string, value: unknown): void {
|
|
96
|
+
const r = shape(c, path, value, [], ["enrollment", "activate", "allowDowngrade", "node", "launch", "server", "machineName"])
|
|
97
|
+
if (!r) return
|
|
98
|
+
oneOf(c, `${path}/enrollment`, r.enrollment, ENROLLMENT_MODES)
|
|
99
|
+
bool(c, `${path}/activate`, r.activate)
|
|
100
|
+
bool(c, `${path}/allowDowngrade`, r.allowDowngrade)
|
|
101
|
+
if (r.node !== undefined) {
|
|
102
|
+
const n = shape(c, `${path}/node`, r.node, ["workspaceRoots"], ["slots", "labels"])
|
|
103
|
+
if (n) {
|
|
104
|
+
if (!Array.isArray(n.workspaceRoots) || n.workspaceRoots.length > 64) c.add(`${path}/node/workspaceRoots`, "invalid_value", "expected a list of at most 64 paths")
|
|
105
|
+
else n.workspaceRoots.forEach((w, i) => text(c, `${path}/node/workspaceRoots/${i}`, w, 4096))
|
|
106
|
+
int(c, `${path}/node/slots`, n.slots, 1, 64)
|
|
107
|
+
if (n.labels !== undefined && (!isRecord(n.labels) || Object.values(n.labels).some((v) => typeof v !== "string"))) c.add(`${path}/node/labels`, "invalid_value", "labels are strings")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (r.launch !== undefined) {
|
|
111
|
+
const l = shape(c, `${path}/launch`, r.launch, ["harness", "workspace"])
|
|
112
|
+
if (l) {
|
|
113
|
+
oneOf(c, `${path}/launch/harness`, l.harness, HARNESS_ORDER)
|
|
114
|
+
c.merge(`${path}/launch/workspace`, validatePathRef(l.workspace))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
text(c, `${path}/server`, r.server, 2048)
|
|
118
|
+
text(c, `${path}/machineName`, r.machineName, 64)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A setup request read back from a file: validated, never cast; the profile is migrated to the current version. */
|
|
122
|
+
export function validateSetupRequest(value: unknown): Result<SetupRequest> {
|
|
123
|
+
const c = new Checker()
|
|
124
|
+
const r = shape(c, "", value, ["schema", "version", "preview", "profile", "choices"])
|
|
125
|
+
if (!r) return c.result(undefined)
|
|
126
|
+
header(c, "", r, SETUP_REQUEST_SCHEMA, 1)
|
|
127
|
+
c.merge("/preview", validateSetupPreview(r.preview))
|
|
128
|
+
const profile = loadProfile(r.profile)
|
|
129
|
+
c.merge("/profile", profile)
|
|
130
|
+
checkChoices(c, "/choices", r.choices)
|
|
131
|
+
// The preview is checked by its own validator (its token field is a preview token, not a credential).
|
|
132
|
+
for (const p of findSecrets({ profile: r.profile, choices: r.choices })) c.add(p, "secret", "setup requests never carry credentials")
|
|
133
|
+
if (c.issues.length > 0) return c.result(undefined)
|
|
134
|
+
return c.result({ ...r, profile: profile.ok ? profile.value.profile : r.profile })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateState(value: unknown): Result<BootstrapState> {
|
|
138
|
+
const c = new Checker()
|
|
139
|
+
const r = shape(c, "", value, ["schema", "version", "floors"], ["profile", "choices", "catalogRevision", "txID", "planDigest"])
|
|
140
|
+
if (!r) return c.result(undefined)
|
|
141
|
+
header(c, "", r, BOOTSTRAP_STATE_SCHEMA, 1)
|
|
142
|
+
if (r.profile !== undefined) c.merge("/profile", loadProfile(r.profile))
|
|
143
|
+
if (r.choices !== undefined) checkChoices(c, "/choices", r.choices)
|
|
144
|
+
int(c, "/catalogRevision", r.catalogRevision, 1, 1_000_000)
|
|
145
|
+
text(c, "/txID", r.txID, 64, /^tx_[A-Za-z0-9_-]+$/)
|
|
146
|
+
if (r.planDigest !== undefined) digest(c, "/planDigest", r.planDigest)
|
|
147
|
+
const f = shape(c, "/floors", r.floors, ["harnesses"], ["hubctl"])
|
|
148
|
+
if (f) {
|
|
149
|
+
if (f.hubctl !== undefined) semver(c, "/floors/hubctl", f.hubctl)
|
|
150
|
+
const h = shape(c, "/floors/harnesses", f.harnesses, [], HARNESS_ORDER)
|
|
151
|
+
if (h) for (const id of HARNESS_ORDER) if (h[id] !== undefined) semver(c, `/floors/harnesses/${id}`, h[id])
|
|
152
|
+
}
|
|
153
|
+
for (const p of findSecrets(value)) c.add(p, "secret", "the bootstrap state never carries credentials")
|
|
154
|
+
return c.result(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const max = (a: string | undefined, b: string | undefined) => (a === undefined ? b : b === undefined ? a : compareVersions(a, b) >= 0 ? a : b)
|
|
158
|
+
|
|
159
|
+
/** The floors after seeing a verified manifest: the highest of what was seen and what it pins for `target`. */
|
|
160
|
+
export function raiseFloors(floors: Floors, manifest: Pick<ArtifactManifest, "minimumVersion" | "harnesses">, target: string): Floors {
|
|
161
|
+
const harnesses: Partial<Record<HarnessID, string>> = { ...floors.harnesses }
|
|
162
|
+
for (const pin of manifest.harnesses) if (pin.target === target && pin.minimumVersion) harnesses[pin.harness] = max(harnesses[pin.harness], pin.minimumVersion)
|
|
163
|
+
const hubctl = max(floors.hubctl, manifest.minimumVersion)
|
|
164
|
+
return { ...(hubctl ? { hubctl } : {}), harnesses }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
|
|
168
|
+
|
|
169
|
+
export class BootstrapStateStore {
|
|
170
|
+
readonly statePath: string
|
|
171
|
+
private readonly requestDir: string
|
|
172
|
+
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly roots: readonly RootMapping[],
|
|
175
|
+
private readonly fs: TxFs = nodeFs,
|
|
176
|
+
) {
|
|
177
|
+
const state = this.resolve("hub/bootstrap/state.json")
|
|
178
|
+
this.statePath = state
|
|
179
|
+
this.requestDir = `${state.slice(0, state.lastIndexOf("/"))}/requests`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private resolve(rel: string): string {
|
|
183
|
+
const r = resolveTarget({ root: "state", rel }, this.roots, this.fs)
|
|
184
|
+
if (!r.ok) throw new Error(`the hub state directory is unusable: ${r.message}`)
|
|
185
|
+
return r.value
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
read(): BootstrapState {
|
|
189
|
+
const info = this.fs.lstat(this.resolve("hub/bootstrap/state.json"))
|
|
190
|
+
if (!info) return EMPTY
|
|
191
|
+
let parsed: unknown
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(decode(this.fs.readFile(this.statePath)))
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`the bootstrap state ${this.statePath} cannot be read; inspect it before setting up again`)
|
|
196
|
+
}
|
|
197
|
+
const checked = validateState(parsed)
|
|
198
|
+
if (!checked.ok) throw new Error(`the bootstrap state ${this.statePath} is not valid (${checked.issues[0]!.message})`)
|
|
199
|
+
return checked.value
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
write(state: BootstrapState): void {
|
|
203
|
+
const checked = validateState(state)
|
|
204
|
+
if (!checked.ok) throw new Error(`refusing to write an invalid bootstrap state: ${checked.issues[0]!.path} ${checked.issues[0]!.message}`)
|
|
205
|
+
this.writeAtomic(this.resolve("hub/bootstrap/state.json"), state)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Records the request a plan was made from, under its plan digest. */
|
|
209
|
+
writeRequest(request: SetupRequest): void {
|
|
210
|
+
const checked = validateSetupRequest(request)
|
|
211
|
+
if (!checked.ok) throw new Error(`refusing to store an invalid setup request: ${checked.issues[0]!.message}`)
|
|
212
|
+
this.writeAtomic(this.resolve(`hub/bootstrap/requests/${request.preview.planDigest.slice("sha256:".length)}.json`), request)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
readRequest(planDigest: string): SetupRequest | undefined {
|
|
216
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) return undefined
|
|
217
|
+
const path = this.resolve(`hub/bootstrap/requests/${planDigest.slice("sha256:".length)}.json`)
|
|
218
|
+
if (!this.fs.lstat(path)) return undefined
|
|
219
|
+
try {
|
|
220
|
+
const r = validateSetupRequest(JSON.parse(decode(this.fs.readFile(path))))
|
|
221
|
+
return r.ok && r.value.preview.planDigest === planDigest ? r.value : undefined
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private writeAtomic(path: string, value: unknown): void {
|
|
228
|
+
const dir = path.slice(0, path.lastIndexOf("/"))
|
|
229
|
+
if (!this.fs.lstat(dir)) this.fs.mkdirp(dir, 0o700)
|
|
230
|
+
else if (this.fs.lstat(dir)?.kind !== "directory") throw new Error(`${dir} is not a directory`)
|
|
231
|
+
const tmp = `${path}.tmp`
|
|
232
|
+
try {
|
|
233
|
+
if (this.fs.lstat(tmp)) this.fs.unlink(tmp)
|
|
234
|
+
this.fs.writeExclusive(tmp, `${JSON.stringify(value, null, 2)}\n`, 0o600)
|
|
235
|
+
this.fs.rename(tmp, path)
|
|
236
|
+
this.fs.fsyncDir(dir)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
try {
|
|
239
|
+
if (this.fs.lstat(tmp)) this.fs.unlink(tmp)
|
|
240
|
+
} catch {}
|
|
241
|
+
throw errnoOf(error) ? new Error(`cannot write ${path} (${errnoOf(error)})`) : error
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The user-bin link rule (contract §7.6 A6, HBS-12 E4), shared by the setup's harness links (contributors.ts) and
|
|
2
|
+
// the OpenCode plugin's `hub` link (src/spoke/launchers.ts): hub links a command into user-bin (~/.local/bin) only
|
|
3
|
+
// when nothing is there or what is there is a link of hub's own, and only when no other command of that name is on
|
|
4
|
+
// PATH. A user's binary is never shadowed or replaced. Pure: callers read the facts (lstat, readlink, PATH).
|
|
5
|
+
|
|
6
|
+
/** What is at the link path now, not following a final symlink. */
|
|
7
|
+
export type UserBinEntry = "missing" | "file" | "directory" | "symlink" | "other"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* - in-place: the link is already there, pointing at the target.
|
|
11
|
+
* - create: nothing there: link it.
|
|
12
|
+
* - replace: a link of hub's own pointing elsewhere: re-point it.
|
|
13
|
+
* - foreign: something of the user's is there: leave it.
|
|
14
|
+
* - shadowed: another command of that name comes first on PATH: leave it.
|
|
15
|
+
*/
|
|
16
|
+
export type UserBinLinkDecision = "in-place" | "create" | "replace" | "foreign" | "shadowed"
|
|
17
|
+
|
|
18
|
+
export interface UserBinLinkFacts {
|
|
19
|
+
readonly entry: UserBinEntry
|
|
20
|
+
/** The absolute path a symlink at the link path points to. */
|
|
21
|
+
readonly pointsTo?: string
|
|
22
|
+
/** The absolute path the link should point to. */
|
|
23
|
+
readonly target: string
|
|
24
|
+
/** The link path itself. */
|
|
25
|
+
readonly linkPath: string
|
|
26
|
+
/** Whether a link pointing here is hub's own (its harness directory, its launchers). */
|
|
27
|
+
readonly hubOwned: (pointsTo: string) => boolean
|
|
28
|
+
/** The first command of that name on PATH, as found (not followed); undefined when none. */
|
|
29
|
+
readonly foundOnPath?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function userBinLinkDecision(facts: UserBinLinkFacts): UserBinLinkDecision {
|
|
33
|
+
const { entry, pointsTo } = facts
|
|
34
|
+
if (entry === "symlink" && pointsTo === facts.target) return "in-place"
|
|
35
|
+
const hubOwned = entry === "symlink" && pointsTo !== undefined && facts.hubOwned(pointsTo)
|
|
36
|
+
if (entry !== "missing" && !hubOwned) return "foreign"
|
|
37
|
+
if (facts.foundOnPath !== undefined && facts.foundOnPath !== facts.linkPath) return "shadowed"
|
|
38
|
+
return entry === "missing" ? "create" : "replace"
|
|
39
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `hub claude-code install | uninstall | status` and `hub codex-spoke install | uninstall | status`: the harness
|
|
2
|
+
// spokes' per-user services (`hub claude-code daemon`, `hub codex-spoke serve`) as a launchd agent (macOS) or a
|
|
3
|
+
// systemd user unit (Linux), rendered from the templates embedded in this program the way `hub node install` writes
|
|
4
|
+
// the node's (node.ts). `hub claude` and `hub codex` start these processes on demand; installed, they keep running, so the hub
|
|
5
|
+
// can place claude-code and codex launches on the machine without a wrapper run first. Like `hub node install`,
|
|
6
|
+
// nothing here starts a process: loading, unloading and inspecting the service are printed for the user, never run.
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { claudeBinary, codexBinary, findExecutable } from "../../node/src/harnesses.ts"
|
|
9
|
+
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
10
|
+
import { socketPath } from "../../claude-code/src/paths.ts"
|
|
11
|
+
import { daemonSocketPath, readServicePid } from "../../codex/src/paths.ts"
|
|
12
|
+
import type { Env } from "./credentials"
|
|
13
|
+
import { installService, serviceCheckLines, serviceState, serviceStateLine, uninstallService, type ExtraEnvironment, type NodeCommandOutput, type NodeIo, type ServiceSpec, type ServiceState } from "./node"
|
|
14
|
+
|
|
15
|
+
/** The subcommands `hub claude-code` and `hub codex-spoke` take for their service (the others are the spokes' own). */
|
|
16
|
+
export const SERVICE_VERBS = ["install", "uninstall", "status"] as const
|
|
17
|
+
export type ServiceVerb = (typeof SERVICE_VERBS)[number]
|
|
18
|
+
export const isServiceVerb = (verb: string | undefined): verb is ServiceVerb => (SERVICE_VERBS as readonly string[]).includes(verb ?? "")
|
|
19
|
+
|
|
20
|
+
/** Where config.json and the hub state dir are (shared by every hub spoke). */
|
|
21
|
+
const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR"] as const
|
|
22
|
+
|
|
23
|
+
export const CLAUDE_CODE_SERVICE: ServiceSpec = {
|
|
24
|
+
service: "claude-code",
|
|
25
|
+
command: "hub claude-code",
|
|
26
|
+
label: "io.github.huynle.hub-claude-code",
|
|
27
|
+
unit: "hub-claude-code.service",
|
|
28
|
+
log: "claude-code.log",
|
|
29
|
+
// The hooks find the daemon by HUB_CLAUDE_SOCKET / HUB_STATE_DIR: the service must listen where they look.
|
|
30
|
+
carried: [...HUB_PLACES, "HUB_CLAUDE_SOCKET", "HUB_CLAUDE_BIN"],
|
|
31
|
+
elsewhere: "Run `hub claude-code daemon` under your own service manager (or let `hub claude` start it)",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const CODEX_SPOKE_SERVICE: ServiceSpec = {
|
|
35
|
+
service: "codex-spoke",
|
|
36
|
+
command: "hub codex-spoke",
|
|
37
|
+
label: "io.github.huynle.hub-codex-spoke",
|
|
38
|
+
unit: "hub-codex-spoke.service",
|
|
39
|
+
log: "codex-spoke.log",
|
|
40
|
+
// HUB_CODEX_CONFIG (one override per line) is not carried: a unit's Environment= cannot hold several lines.
|
|
41
|
+
carried: [...HUB_PLACES, "CODEX_HOME", "HUB_CODEX_BIN", "HUB_CODEX_DAEMON_SOCKET"],
|
|
42
|
+
elsewhere: "Run `hub codex-spoke serve` under your own service manager (or let `hub codex` start it)",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SPECS = { "claude-code": CLAUDE_CODE_SERVICE, "codex-spoke": CODEX_SPOKE_SERVICE } as const
|
|
46
|
+
|
|
47
|
+
/** The codex-spoke unit runs the Codex `hubctl setup` installed (HUB_CODEX_BIN), unless the shell names another. */
|
|
48
|
+
const codexEnvironment: ExtraEnvironment = (io): Record<string, string> => {
|
|
49
|
+
const bin = codexBinary(io.env, io.home)
|
|
50
|
+
return path.isAbsolute(bin) && !io.env.HUB_CODEX_BIN ? { HUB_CODEX_BIN: bin } : {}
|
|
51
|
+
}
|
|
52
|
+
const EXTRA: Partial<Record<keyof typeof SPECS, ExtraEnvironment>> = { "codex-spoke": codexEnvironment }
|
|
53
|
+
export type SpokeServiceName = keyof typeof SPECS
|
|
54
|
+
export const spokeServiceSpec = (name: SpokeServiceName): ServiceSpec => SPECS[name]
|
|
55
|
+
|
|
56
|
+
/** Why the installed service would not do anything yet (never the token). */
|
|
57
|
+
function readiness(env: Env, home: string): string[] {
|
|
58
|
+
return readSpokeCredentials(env, home) ? [] : ["this machine is not enrolled with a hub yet (no machine token in config.json): enroll it with `hubctl machine enroll --server <hub URL>`; until then the service exits and is restarted"]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function installSpokeService(name: SpokeServiceName, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
62
|
+
return installService(SPECS[name], io, options, readiness(io.env, io.home), EXTRA[name])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function uninstallSpokeService(name: SpokeServiceName, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
66
|
+
return uninstallService(SPECS[name], io, options)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SpokeServiceStatus {
|
|
70
|
+
readonly service: ServiceState
|
|
71
|
+
readonly enrolled?: { readonly server: string; readonly machineID?: string }
|
|
72
|
+
/** The harness CLI as this shell finds it (the service looks on its own PATH, see the unit). */
|
|
73
|
+
readonly harness: { readonly bin: string; readonly found?: string }
|
|
74
|
+
/** claude-code: the daemon's socket (hooks look there). */
|
|
75
|
+
readonly socket?: string
|
|
76
|
+
/** codex-spoke: the running service's pid (its pid file), and the Codex daemon socket it attaches to. */
|
|
77
|
+
readonly running?: number
|
|
78
|
+
readonly daemonSocket?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function spokeServiceStatus(name: SpokeServiceName, io: NodeIo): SpokeServiceStatus {
|
|
82
|
+
const credentials = readSpokeCredentials(io.env, io.home)
|
|
83
|
+
const bin = name === "claude-code" ? claudeBinary(io.env, io.home) : codexBinary(io.env, io.home)
|
|
84
|
+
const found = findExecutable(bin, io.env)
|
|
85
|
+
const base = {
|
|
86
|
+
service: serviceState(SPECS[name], io, EXTRA[name]),
|
|
87
|
+
...(credentials ? { enrolled: { server: credentials.server, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) } } : {}),
|
|
88
|
+
harness: { bin, ...(found ? { found } : {}) },
|
|
89
|
+
}
|
|
90
|
+
if (name === "claude-code") return { ...base, socket: socketPath(io.env, io.home) }
|
|
91
|
+
const running = readServicePid(io.env, io.home)
|
|
92
|
+
const daemonSocket = daemonSocketPath(io.env, io.home)
|
|
93
|
+
return { ...base, ...(running !== undefined ? { running } : {}), ...(daemonSocket ? { daemonSocket } : {}) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function spokeServiceStatusText(name: SpokeServiceName, status: SpokeServiceStatus): string {
|
|
97
|
+
const spec = SPECS[name]
|
|
98
|
+
const lines = [serviceStateLine(spec, status.service)]
|
|
99
|
+
lines.push(status.enrolled ? `machine: enrolled with ${status.enrolled.server}${status.enrolled.machineID ? ` as ${status.enrolled.machineID}` : ""}` : "machine: not enrolled (enroll it with `hubctl machine enroll --server <hub URL>`)")
|
|
100
|
+
const what = name === "claude-code" ? "Claude Code CLI" : "Codex CLI"
|
|
101
|
+
lines.push(status.harness.found ? `${what}: ${status.harness.found}` : `${what}: ${status.harness.bin} not found on this shell's PATH (no hub launches${name === "claude-code" ? "" : " of new threads"} until it is)`)
|
|
102
|
+
if (status.socket) lines.push(`daemon socket: ${status.socket}`)
|
|
103
|
+
if (name === "codex-spoke") {
|
|
104
|
+
lines.push(status.running !== undefined ? `running: pid ${status.running}` : "running: no")
|
|
105
|
+
lines.push(`Codex daemon socket: ${status.daemonSocket ?? "off"}`)
|
|
106
|
+
}
|
|
107
|
+
lines.push(...serviceCheckLines(status.service))
|
|
108
|
+
return lines.join("\n")
|
|
109
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `hub watch`: GET /api/agent/watch (SSE, through the SDK) printed as JSON lines, one per event, until interrupted.
|
|
2
|
+
// Heartbeats are skipped (the SDK does). The hub closing the stream or the stream breaking is an error.
|
|
3
|
+
import { HubTransportError } from "../../sdk/src/index.ts"
|
|
4
|
+
import type { Credentials } from "./credentials"
|
|
5
|
+
import { CliError, cliError, clientFor, EXIT } from "./http"
|
|
6
|
+
|
|
7
|
+
/** One compact JSON line per event (non-JSON data is printed as is). */
|
|
8
|
+
const line = (data: string) => {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(JSON.parse(data))
|
|
11
|
+
} catch {
|
|
12
|
+
return data
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const AGAIN = "Run `hub watch` again"
|
|
17
|
+
|
|
18
|
+
export async function watch(credentials: Credentials, options: { blocked?: boolean; session?: string }, write: (line: string) => void, signal?: AbortSignal): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
for await (const event of clientFor(credentials).watch({ ...options, ...(signal ? { signal } : {}) })) write(`${line(event.data)}\n`)
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
if (signal?.aborted) return
|
|
23
|
+
if (cause instanceof HubTransportError && cause.kind === "stream") throw new CliError(EXIT.other, cause.message, AGAIN)
|
|
24
|
+
throw cliError(cause, credentials)
|
|
25
|
+
}
|
|
26
|
+
if (signal?.aborted) return
|
|
27
|
+
throw new CliError(EXIT.other, "the watch stream ended (the hub closed it)", AGAIN)
|
|
28
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// token in the spoke's config.json, read-only), keeps a pid file in <hub state dir>/codex/spoke.pid (one service per
|
|
4
4
|
// user: a second one refuses while the first runs) and attaches to the shared Codex daemon's socket when present.
|
|
5
5
|
//
|
|
6
|
-
// Settings (environment): HUB_CODEX_BIN (default `codex` on PATH), HUB_CODEX_DAEMON_SOCKET (another socket, or
|
|
6
|
+
// Settings (environment): HUB_CODEX_BIN (default: the Codex `hubctl setup` installed, else `codex` on PATH), HUB_CODEX_DAEMON_SOCKET (another socket, or
|
|
7
7
|
// "off"), HUB_CODEX_CONFIG (Codex config overrides for the app-server the spoke starts, one key=value per line,
|
|
8
8
|
// passed as -c; Codex's files are never written).
|
|
9
9
|
import { spawnSync } from "node:child_process"
|
|
10
10
|
import { openSync, closeSync, readFileSync, rmSync, writeSync } from "node:fs"
|
|
11
11
|
import { configPath } from "../../../src/config.ts"
|
|
12
12
|
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
13
|
+
import { codexBinary } from "../../node/src/harnesses.ts"
|
|
13
14
|
import pkg from "../package.json" with { type: "json" }
|
|
14
15
|
import { codexStateDir, ensurePrivateDir, daemonSocketPath, pidFile, readServicePid } from "./paths.ts"
|
|
15
16
|
import { startCodexSpoke } from "./spoke.ts"
|
|
@@ -85,7 +86,7 @@ async function serve(io: CodexMainIo): Promise<number> {
|
|
|
85
86
|
return 1
|
|
86
87
|
}
|
|
87
88
|
try {
|
|
88
|
-
const codexBin = io.env.
|
|
89
|
+
const codexBin = codexBinary(io.env, io.home)
|
|
89
90
|
const env: Record<string, string | undefined> = { ...io.env }
|
|
90
91
|
delete env.BUN_BE_BUN
|
|
91
92
|
const daemonSocket = daemonSocketPath(io.env, io.home)
|
|
@@ -17,6 +17,9 @@ import { CodexSessions, type ThreadDescription } from "./sessions.ts"
|
|
|
17
17
|
import { spawnStdioChannel } from "./stdio.ts"
|
|
18
18
|
import { connectUnixWebSocket } from "./websocket.ts"
|
|
19
19
|
|
|
20
|
+
/** The words that start Codex's app-server on stdio (the spoke's own app-server, and `hubctl setup verify`). */
|
|
21
|
+
export const CODEX_APP_SERVER_ARGS: readonly string[] = Object.freeze(["app-server", "--listen", "stdio://"])
|
|
22
|
+
|
|
20
23
|
export const HARNESS = "codex"
|
|
21
24
|
export const DEFAULT_POLL_MS = 2000
|
|
22
25
|
const LIST_PAGES_MAX = 20
|
|
@@ -124,7 +127,7 @@ export function startCodexSpoke(options: CodexSpokeOptions): CodexSpoke {
|
|
|
124
127
|
own ??= (async () => {
|
|
125
128
|
const child = spawnStdioChannel({
|
|
126
129
|
command: options.codexBin,
|
|
127
|
-
args: [...(options.codexBinArgs ?? []),
|
|
130
|
+
args: [...(options.codexBinArgs ?? []), ...CODEX_APP_SERVER_ARGS, ...(options.codexArgs ?? [])],
|
|
128
131
|
env: options.env,
|
|
129
132
|
log: (line) => log(`codex app-server: ${line}`),
|
|
130
133
|
})
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// arguments, its exit code passed through. On a machine that is not enrolled (no machine token) or when the service
|
|
6
6
|
// cannot be started, Codex runs as usual without the hub, and the command says why.
|
|
7
7
|
//
|
|
8
|
-
// Settings (environment): HUB_CODEX_BIN (default `codex` on PATH); see main.ts for the service's.
|
|
8
|
+
// Settings (environment): HUB_CODEX_BIN (default: the Codex `hubctl setup` installed, else `codex` on PATH); see main.ts for the service's.
|
|
9
9
|
import { spawn } from "node:child_process"
|
|
10
10
|
import { closeSync, openSync } from "node:fs"
|
|
11
11
|
import path from "node:path"
|
|
12
12
|
import { configPath } from "../../../src/config.ts"
|
|
13
13
|
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
14
|
+
import { codexBinary } from "../../node/src/harnesses.ts"
|
|
14
15
|
import { codexStateDir, ensurePrivateDir, readServicePid } from "./paths.ts"
|
|
15
16
|
|
|
16
17
|
const SERVICE_START_MS = 8000
|
|
@@ -87,7 +88,10 @@ function spawnCodex(bin: string, args: readonly string[], env: Env, stderr: (s:
|
|
|
87
88
|
})
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
export async function runCodex(args: readonly string[],
|
|
91
|
+
export async function runCodex(args: readonly string[], given: CodexIo): Promise<number> {
|
|
92
|
+
// A hub-installed Codex is what both this run and the spoke it starts use (HUB_CODEX_BIN is passed on).
|
|
93
|
+
const bin = codexBinary(given.env, given.home)
|
|
94
|
+
const io: CodexIo = path.isAbsolute(bin) && !given.env.HUB_CODEX_BIN ? { ...given, env: { ...given.env, HUB_CODEX_BIN: bin } } : given
|
|
91
95
|
let hub = false
|
|
92
96
|
try {
|
|
93
97
|
hub = await ensureService(io)
|
|
@@ -96,5 +100,5 @@ export async function runCodex(args: readonly string[], io: CodexIo): Promise<nu
|
|
|
96
100
|
}
|
|
97
101
|
const env: Record<string, string | undefined> = { ...io.env }
|
|
98
102
|
delete env.BUN_BE_BUN
|
|
99
|
-
return spawnCodex(
|
|
103
|
+
return spawnCodex(bin, hub ? [...DAEMON_ARGS, ...args] : [...args], env, io.stderr)
|
|
100
104
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// runtime starts nothing itself: a host launch's session is created by the machine's service spoke of its harness
|
|
3
3
|
// (placement also needs that spoke connected with "launch"), so this only says what can run here:
|
|
4
4
|
// - opencode: always (its service spoke is the OpenCode plugin, which the node cannot see);
|
|
5
|
-
// - claude-code: when the Claude Code CLI is found: HUB_CLAUDE_BIN
|
|
6
|
-
// PATH; the Claude Code spoke daemon (`hub claude-code daemon`) creates its launch sessions;
|
|
5
|
+
// - claude-code: when the Claude Code CLI is found (claudeBinary: HUB_CLAUDE_BIN, hub's claude-code.json, else `claude` on
|
|
6
|
+
// PATH); the Claude Code spoke daemon (`hub claude-code daemon`) creates its launch sessions;
|
|
7
|
+
// - codex: when the Codex CLI is found (codexBinary); the Codex spoke (`hub codex-spoke serve`) creates them;
|
|
7
8
|
// - node.json `harnesses` overrides detection either way (true offers a harness, false withholds it).
|
|
8
|
-
|
|
9
|
+
//
|
|
10
|
+
// Which binary (claudeBinary, codexBinary): the explicit setting, then the one `hubctl setup` installed, then PATH.
|
|
11
|
+
import { accessSync, constants, readFileSync, statSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
9
13
|
import path from "node:path"
|
|
14
|
+
import { configPath } from "../../../src/config.ts"
|
|
10
15
|
|
|
11
16
|
type Env = Readonly<Record<string, string | undefined>>
|
|
12
17
|
|
|
@@ -18,6 +23,8 @@ export interface DetectHarnessesOptions {
|
|
|
18
23
|
readonly env?: Env
|
|
19
24
|
/** node.json `harnesses`. */
|
|
20
25
|
readonly overrides?: Readonly<Record<string, boolean>>
|
|
26
|
+
/** Where hub's config and data are (default os.homedir()). */
|
|
27
|
+
readonly home?: string
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
function executable(file: string): boolean {
|
|
@@ -47,7 +54,9 @@ export function findExecutable(name: string, env: Env): string | undefined {
|
|
|
47
54
|
export function detectHarnesses(options: DetectHarnessesOptions = {}): string[] {
|
|
48
55
|
const env = options.env ?? process.env
|
|
49
56
|
const found = new Set<string>(["opencode"])
|
|
50
|
-
|
|
57
|
+
const home = options.home ?? homedir()
|
|
58
|
+
if (findExecutable(claudeBinary(env, home), env)) found.add("claude-code")
|
|
59
|
+
if (findExecutable(codexBinary(env, home), env)) found.add("codex")
|
|
51
60
|
for (const [name, on] of Object.entries(options.overrides ?? {})) {
|
|
52
61
|
if (on) found.add(name)
|
|
53
62
|
else found.delete(name)
|
|
@@ -58,3 +67,29 @@ export function detectHarnesses(options: DetectHarnessesOptions = {}): string[]
|
|
|
58
67
|
}
|
|
59
68
|
return [...found].sort((a, b) => rank(a) - rank(b))
|
|
60
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The Claude Code CLI to run: HUB_CLAUDE_BIN, else the absolute `binary` in hub's own claude-code.json (next to
|
|
73
|
+
* config.json, so it follows HUB_SPOKE_CONFIG; `hubctl setup` writes it when it installed Claude Code), else `claude`
|
|
74
|
+
* on PATH. A claude-code.json that is unreadable, or names a relative path, is ignored.
|
|
75
|
+
*/
|
|
76
|
+
export function claudeBinary(env: Env, home: string = homedir()): string {
|
|
77
|
+
if (env.HUB_CLAUDE_BIN) return env.HUB_CLAUDE_BIN
|
|
78
|
+
try {
|
|
79
|
+
const file = path.join(path.dirname(configPath(env as Record<string, string | undefined>, home)), "claude-code.json")
|
|
80
|
+
const binary = (JSON.parse(readFileSync(file, "utf8")) as { binary?: unknown }).binary
|
|
81
|
+
if (typeof binary === "string" && path.isAbsolute(binary)) return binary
|
|
82
|
+
} catch {}
|
|
83
|
+
return "claude"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 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.
|
|
89
|
+
*/
|
|
90
|
+
export function codexBinary(env: Env, home: string = homedir()): string {
|
|
91
|
+
if (env.HUB_CODEX_BIN) return env.HUB_CODEX_BIN
|
|
92
|
+
const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share")
|
|
93
|
+
const current = path.join(data, "hub", "harnesses", "codex", "current", "bin", "codex")
|
|
94
|
+
return executable(current) ? current : "codex"
|
|
95
|
+
}
|
package/packages/node/src/run.ts
CHANGED
|
@@ -79,7 +79,7 @@ export async function runNode(options: RunNodeOptions): Promise<number> {
|
|
|
79
79
|
|
|
80
80
|
if (options.signal.aborted) return 0
|
|
81
81
|
const openshell = config.openshell?.enabled ? await setupOpenShell(config, credentials, options, env, log) : undefined
|
|
82
|
-
const harnesses = detectHarnesses({ env, ...(config.harnesses ? { overrides: config.harnesses } : {}) })
|
|
82
|
+
const harnesses = detectHarnesses({ env, home, ...(config.harnesses ? { overrides: config.harnesses } : {}) })
|
|
83
83
|
const runtimes: Runtime[] = [new HostRuntime({ roots: config.workspaceRoots, harnesses }), ...(openshell ? [openshell.runtime] : [])]
|
|
84
84
|
|
|
85
85
|
const daemon = new NodeDaemon({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|