@huynle/hub 0.7.1 → 0.7.3
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 +87 -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/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- 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 +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -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/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The messaging verbs' helpers (design §3): text arguments (or "-" for stdin) and the short text of what the hub
|
|
2
|
+
// answers to msg, ask, reply, decline, inbox and mute. The shapes are packages/sdk's types (message-routes.ts).
|
|
3
|
+
import type { AgentMessage, Asked, Inbox, MessageDelivery, Party, Replied, Sent } from "../../sdk/src/index.ts"
|
|
4
|
+
import { UsageError } from "./args"
|
|
5
|
+
import { oneLine } from "./format"
|
|
6
|
+
|
|
7
|
+
/** A text argument: the value, or all of stdin for "-" (one trailing newline dropped); never empty. */
|
|
8
|
+
export async function textArg(value: string, stdin: (() => Promise<string>) | undefined, what = "text"): Promise<string> {
|
|
9
|
+
let text = value
|
|
10
|
+
if (value === "-") {
|
|
11
|
+
if (!stdin) throw new UsageError(`cannot read the ${what} from stdin here`)
|
|
12
|
+
text = (await stdin()).replace(/\r?\n$/, "")
|
|
13
|
+
}
|
|
14
|
+
if (!text.trim()) throw new UsageError(`the ${what} is empty`)
|
|
15
|
+
return text
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `@name`, the session ID, `@me`, or the app token. */
|
|
19
|
+
export function partyText(party: Party): string {
|
|
20
|
+
if (party.kind === "session") return party.name ? `@${party.name.replace(/^@+/, "")}` : party.sessionID
|
|
21
|
+
if (party.kind === "user") return "@me"
|
|
22
|
+
return `app ${party.label ?? party.tokenID}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DELIVERY: Record<MessageDelivery, string> = {
|
|
26
|
+
turn: "delivered (started a turn)",
|
|
27
|
+
steer: "delivered (into the running turn)",
|
|
28
|
+
queued: "queued (read at its next turn)",
|
|
29
|
+
retained: "retained (held until its open question is answered)",
|
|
30
|
+
pending: "pending (offline; delivered when it reconnects, for 24 h)",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const deliveryText = (delivery: MessageDelivery | undefined): string => (delivery ? (DELIVERY[delivery] ?? delivery) : "stored (in its inbox)")
|
|
34
|
+
|
|
35
|
+
/** `to @@reviewer → @api` when the address is not the recipient's own name. */
|
|
36
|
+
function toText(message: AgentMessage): string {
|
|
37
|
+
const recipient = partyText(message.recipient)
|
|
38
|
+
return message.to === recipient || message.to === (message.recipient as { sessionID?: string }).sessionID ? recipient : `${message.to} → ${recipient}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `msg_… to @web-work: delivered (started a turn)` */
|
|
42
|
+
export const sentText = ({ message, delivery }: Sent): string => `${message.kind} ${message.id} to ${toText(message)}: ${deliveryText(delivery)}`
|
|
43
|
+
|
|
44
|
+
/** How long an ask stays open when --expires is not given (the hub's default). */
|
|
45
|
+
export const ASK_EXPIRES_DEFAULT_S = 24 * 60 * 60
|
|
46
|
+
|
|
47
|
+
const UNIT_S: Record<string, number> = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }
|
|
48
|
+
|
|
49
|
+
/** Seconds from `90`, `30s`, `5m`, `1.5h` or `1d`. */
|
|
50
|
+
export function durationArg(value: string, flag: string): number {
|
|
51
|
+
const match = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(value)
|
|
52
|
+
if (!match) throw new UsageError(`${flag} takes seconds or a duration like 30s, 5m, 2h, 1d (got "${value}")`)
|
|
53
|
+
return Number(match[1]) * UNIT_S[match[2]!]!
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
57
|
+
|
|
58
|
+
/** What an ask came to, its text, and the exit code: answered 0, declined 1, open after a wait 1, expired 8. */
|
|
59
|
+
export function askOutcome(asked: Asked, waited: number | undefined): { text: string; exit: number; note?: string } {
|
|
60
|
+
const { message, answer } = asked
|
|
61
|
+
const head = sentText(asked)
|
|
62
|
+
const by = answer ? partyText(answer.from) : partyText(message.recipient)
|
|
63
|
+
switch (message.state) {
|
|
64
|
+
case "answered":
|
|
65
|
+
return { text: `${head}\nanswer ${answer?.id ?? "?"} from ${by}:\n${answer?.text ?? ""}`, exit: 0 }
|
|
66
|
+
case "declined":
|
|
67
|
+
return { text: `${head}\ndeclined by ${by}: ${answer?.text ?? ""}`, exit: 1 }
|
|
68
|
+
case "expired":
|
|
69
|
+
return { text: `${head}\nexpired without an answer`, exit: 8 }
|
|
70
|
+
default: {
|
|
71
|
+
const until = message.expiresAt !== undefined ? `open until ${iso(message.expiresAt)}` : "open"
|
|
72
|
+
const text = `${head}\n${until}; the answer shows in \`hub inbox\``
|
|
73
|
+
if (waited === undefined) return { text, exit: 0 }
|
|
74
|
+
return { text, exit: 1, note: `no answer within ${waited} s; ${message.id} is still open (see \`hub inbox\`)` }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `reply msg_r to @api: delivered (started a turn) (answers msg_1)` */
|
|
80
|
+
export const repliedText = (replied: Replied): string => `${sentText(replied)} (answers ${replied.original.id})`
|
|
81
|
+
|
|
82
|
+
/** `declined msg_1 (decline msg_d to @api: queued …)` */
|
|
83
|
+
export const declinedText = (replied: Replied): string => `declined ${replied.original.id} (${sentText(replied)})`
|
|
84
|
+
|
|
85
|
+
/** Open asks to answer (with how), the caller's own open asks, recent messages, mutes. */
|
|
86
|
+
export function inboxText(inbox: Inbox): string {
|
|
87
|
+
const lines: string[] = []
|
|
88
|
+
const until = (m: AgentMessage) => (m.expiresAt !== undefined ? ` (open until ${iso(m.expiresAt)})` : "")
|
|
89
|
+
if (inbox.asks.length) {
|
|
90
|
+
lines.push("asks to answer:")
|
|
91
|
+
for (const m of inbox.asks) {
|
|
92
|
+
lines.push(` ${m.id} from ${partyText(m.from)} ${iso(m.at)}: ${oneLine(m.text, 200)}${until(m)}`)
|
|
93
|
+
lines.push(` ↩ hub reply ${m.id} "…" · hub decline ${m.id} --why "…"`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (inbox.waiting.length) {
|
|
97
|
+
lines.push("waiting for answers:")
|
|
98
|
+
for (const m of inbox.waiting) lines.push(` ${m.id} to ${toText(m)} ${iso(m.at)}: ${oneLine(m.text, 200)}${until(m)}`)
|
|
99
|
+
}
|
|
100
|
+
if (inbox.messages.length) {
|
|
101
|
+
lines.push("messages:")
|
|
102
|
+
for (const m of inbox.messages) lines.push(` ${m.id} ${m.kind} from ${partyText(m.from)} ${iso(m.at)}${m.replyTo ? ` (re ${m.replyTo})` : ""}: ${oneLine(m.text, 200)}`)
|
|
103
|
+
}
|
|
104
|
+
if (inbox.muted.length) lines.push(mutedText(inbox.muted))
|
|
105
|
+
return lines.length ? lines.join("\n") : "inbox empty"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const mutedText = (muted: readonly string[]): string => `muted: ${muted.length ? muted.join(", ") : "nobody"}`
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// `hub node run | install | uninstall | status` (design §6.3; decision znbg8xa5 #6): `run` is the node daemon
|
|
2
|
+
// (packages/node runNode) until SIGINT/SIGTERM. `install` renders the service template embedded in this program (a
|
|
3
|
+
// launchd agent on macOS, a systemd user unit on Linux; renderServiceUnit, the same renderer `hubctl setup` uses)
|
|
4
|
+
// with the argv of what runs this command (programLayout: the standalone hubctl itself, or the runtime and the CLI
|
|
5
|
+
// entry), writes the file, and prints the launchctl / systemctl commands that load it. Nothing here starts a process:
|
|
6
|
+
// loading, unloading and inspecting the service are always left to the user (printed, never run). --dry-run
|
|
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
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
10
|
+
import path from "node:path"
|
|
11
|
+
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 type { Env } from "./credentials"
|
|
15
|
+
import { CliError, EXIT } from "./http"
|
|
16
|
+
|
|
17
|
+
export const NODE_LABEL = "io.github.huynle.hub-node"
|
|
18
|
+
export const NODE_UNIT = "hub-node.service"
|
|
19
|
+
|
|
20
|
+
/** A per-user hub service installed from the embedded templates (hub node, and the harness spokes' services). */
|
|
21
|
+
export interface ServiceSpec {
|
|
22
|
+
/** The service (its embedded template and the subcommand the unit runs). */
|
|
23
|
+
readonly service: ServiceID
|
|
24
|
+
/** The CLI command that installs it ("hub node"), for the written files' header and messages. */
|
|
25
|
+
readonly command: string
|
|
26
|
+
/** The launchd label; the macOS template is scripts/<label>.plist. */
|
|
27
|
+
readonly label: string
|
|
28
|
+
/** The systemd unit name; the Linux template is scripts/<unit>. */
|
|
29
|
+
readonly unit: string
|
|
30
|
+
/** Its log file in ~/.local/state/hub (launchd). */
|
|
31
|
+
readonly log: string
|
|
32
|
+
/** Settings the service must see as this shell does (where its config and state live). */
|
|
33
|
+
readonly carried: readonly string[]
|
|
34
|
+
/** What to tell users on platforms without launchd or systemd. */
|
|
35
|
+
readonly elsewhere: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const NODE_SERVICE: ServiceSpec = {
|
|
39
|
+
service: "node",
|
|
40
|
+
command: "hub node",
|
|
41
|
+
label: NODE_LABEL,
|
|
42
|
+
unit: NODE_UNIT,
|
|
43
|
+
log: "node.log",
|
|
44
|
+
carried: ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME"],
|
|
45
|
+
elsewhere: "Run `hub node run` under your own service manager",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What runs the CLI: the runtime binary and the CLI's entry file. */
|
|
49
|
+
export interface Program {
|
|
50
|
+
readonly execPath: string
|
|
51
|
+
readonly entry: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NodeIo {
|
|
55
|
+
readonly env: Env
|
|
56
|
+
readonly home: string
|
|
57
|
+
readonly platform: NodeJS.Platform
|
|
58
|
+
readonly uid: number
|
|
59
|
+
readonly program: Program
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ServiceKind = "launchd" | "systemd"
|
|
63
|
+
|
|
64
|
+
export interface NodeService {
|
|
65
|
+
readonly kind: ServiceKind
|
|
66
|
+
/** Where the unit file goes. */
|
|
67
|
+
readonly file: string
|
|
68
|
+
/** Its content, as install writes it. */
|
|
69
|
+
readonly content: string
|
|
70
|
+
/** Directories the service needs (its log). */
|
|
71
|
+
readonly dirs: readonly string[]
|
|
72
|
+
/** Commands that load (or reload) it, for the user to run. */
|
|
73
|
+
readonly load: readonly string[]
|
|
74
|
+
/** Commands that stop it once the file is removed. */
|
|
75
|
+
readonly unload: readonly string[]
|
|
76
|
+
/** How to see its state and logs. */
|
|
77
|
+
readonly check: readonly string[]
|
|
78
|
+
/** Settings this shell has that the unit cannot carry (not an absolute path). */
|
|
79
|
+
readonly skipped: readonly string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Extra settings a service's unit carries beyond the shell's (spoke-services.ts: the hub-installed Codex). */
|
|
83
|
+
export type ExtraEnvironment = (io: NodeIo) => Readonly<Record<string, string>>
|
|
84
|
+
|
|
85
|
+
/** This process as a Program: the runtime and the real path of the entry it runs. */
|
|
86
|
+
export function currentProgram(): Program {
|
|
87
|
+
const entry = process.argv[1] ?? ""
|
|
88
|
+
let real = entry
|
|
89
|
+
try {
|
|
90
|
+
real = realpathSync(entry)
|
|
91
|
+
} catch {}
|
|
92
|
+
return { execPath: process.execPath, entry: real }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A path quoted for sh (printed commands). */
|
|
96
|
+
const sh = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
|
|
97
|
+
|
|
98
|
+
/** The settings this shell has that the unit carries (absolute, single-line paths only), and those it cannot. */
|
|
99
|
+
function carried(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): { environment: Record<string, string>; skipped: string[] } {
|
|
100
|
+
const environment: Record<string, string> = { ...(extra?.(io) ?? {}) }
|
|
101
|
+
const skipped: string[] = []
|
|
102
|
+
for (const key of spec.carried) {
|
|
103
|
+
const value = io.env[key]
|
|
104
|
+
if (!value) continue
|
|
105
|
+
if (isAbsolutePath(value)) environment[key] = value
|
|
106
|
+
else skipped.push(key)
|
|
107
|
+
}
|
|
108
|
+
return { environment, skipped }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The unit's text: the embedded template rendered with this program's argv (one word per <string> / ExecStart word). */
|
|
112
|
+
function render(spec: ServiceSpec, io: NodeIo, manager: ServiceManagerID, extra?: ExtraEnvironment): { content: string; skipped: string[] } {
|
|
113
|
+
const { environment, skipped } = carried(spec, io, extra)
|
|
114
|
+
const r = renderServiceUnit({ service: spec.service, manager, layout: programLayout(io.program), home: io.home, environment, writtenBy: spec.command })
|
|
115
|
+
if (!r.ok) throw new CliError(EXIT.usage, `cannot write the ${spec.command} service: ${r.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ")}`)
|
|
116
|
+
return { content: r.value.text, skipped }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function launchd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
|
|
120
|
+
const file = path.join(io.home, "Library", "LaunchAgents", `${spec.label}.plist`)
|
|
121
|
+
const domain = `gui/${io.uid}`
|
|
122
|
+
const { content, skipped } = render(spec, io, "launchd", extra)
|
|
123
|
+
const log = path.join(io.home, ".local", "state", "hub")
|
|
124
|
+
return {
|
|
125
|
+
kind: "launchd",
|
|
126
|
+
file,
|
|
127
|
+
content,
|
|
128
|
+
skipped,
|
|
129
|
+
dirs: [log],
|
|
130
|
+
load: [`launchctl bootout ${domain}/${spec.label} 2>/dev/null || true`, `launchctl bootstrap ${domain} ${sh(file)}`],
|
|
131
|
+
unload: [`launchctl bootout ${domain}/${spec.label}`],
|
|
132
|
+
check: [`launchctl print ${domain}/${spec.label}`, `tail -f ${sh(path.join(log, spec.log))}`],
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function systemd(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
|
|
137
|
+
const unit = spec.unit
|
|
138
|
+
const dir = path.join(io.env.XDG_CONFIG_HOME || path.join(io.home, ".config"), "systemd", "user")
|
|
139
|
+
const file = path.join(dir, unit)
|
|
140
|
+
const { content, skipped } = render(spec, io, "systemd-user", extra)
|
|
141
|
+
return {
|
|
142
|
+
kind: "systemd",
|
|
143
|
+
file,
|
|
144
|
+
content,
|
|
145
|
+
skipped,
|
|
146
|
+
dirs: [],
|
|
147
|
+
load: ["systemctl --user daemon-reload", `systemctl --user enable --now ${unit}`, `systemctl --user restart ${unit} # when it was already running`, 'loginctl enable-linger "$USER" # optional: keep it running while you are logged out'],
|
|
148
|
+
unload: [`systemctl --user stop ${unit}`, `rm -f ${sh(path.join(dir, "default.target.wants", unit))}`, "systemctl --user daemon-reload"],
|
|
149
|
+
check: [`systemctl --user status ${unit}`, `journalctl --user -u ${unit} -f`],
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `spec`'s service for this platform; unsupported (exit 7) elsewhere. */
|
|
154
|
+
export function serviceFor(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): NodeService {
|
|
155
|
+
if (io.platform === "darwin") return launchd(spec, io, extra)
|
|
156
|
+
if (io.platform === "linux") return systemd(spec, io, extra)
|
|
157
|
+
throw new CliError(EXIT.unavailable, `${spec.command} install supports macOS (launchd) and Linux (systemd), not ${io.platform}`, spec.elsewhere)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The node's service for this platform; unsupported (exit 7) elsewhere. */
|
|
161
|
+
export const nodeService = (io: NodeIo): NodeService => serviceFor(NODE_SERVICE, io)
|
|
162
|
+
|
|
163
|
+
const readText = (file: string): string | undefined => {
|
|
164
|
+
try {
|
|
165
|
+
return readFileSync(file, "utf8")
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Why the installed service would not do anything yet (never the token). */
|
|
172
|
+
export function readinessProblems(env: Env, home: string): string[] {
|
|
173
|
+
const problems: string[] = []
|
|
174
|
+
if (!readSpokeCredentials(env, home)) problems.push("this machine is not enrolled with a hub yet (no machine token in config.json): enroll it with `hubctl machine enroll --server <hub URL>`")
|
|
175
|
+
const file = nodeConfigPath(env, home)
|
|
176
|
+
const { config, problems: found } = readNodeConfig(file)
|
|
177
|
+
problems.push(...found.map((p) => `${file}: ${p}`))
|
|
178
|
+
if (config.workspaceRoots.length === 0) problems.push(`${file} lists no workspaceRoots, so the service would exit 78; add e.g. {"workspaceRoots": ["${home}/projects"]}`)
|
|
179
|
+
return problems
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const commands = (title: string, lines: readonly string[]) => [title, ...lines.map((l) => ` ${l}`)].join("\n")
|
|
183
|
+
const NOT_RUN = "(not run by hub; run them yourself)"
|
|
184
|
+
|
|
185
|
+
export interface NodeCommandOutput {
|
|
186
|
+
readonly stdout: string
|
|
187
|
+
readonly warnings: readonly string[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function installNode(io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
191
|
+
return installService(NODE_SERVICE, io, options, readinessProblems(io.env, io.home))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Writes `spec`'s service file (or prints it, dry run) and prints the commands that load it; `warnings` go to stderr. */
|
|
195
|
+
export function installService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }, given: readonly string[], extra?: ExtraEnvironment): NodeCommandOutput {
|
|
196
|
+
const service = serviceFor(spec, io, extra)
|
|
197
|
+
const warnings = [...given, ...service.skipped.map((key) => `${key} is not an absolute path, so the service does not carry it`)]
|
|
198
|
+
const load = commands(`Load it ${NOT_RUN}:`, service.load)
|
|
199
|
+
if (options.dryRun) return { stdout: `would write ${service.file}:\n\n${service.content}\n${load}`, warnings }
|
|
200
|
+
const current = readText(service.file)
|
|
201
|
+
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 }
|
|
203
|
+
mkdirSync(path.dirname(service.file), { recursive: true })
|
|
204
|
+
writeFileSync(service.file, service.content, { mode: 0o644 })
|
|
205
|
+
return { stdout: `${current === undefined ? "wrote" : "updated"} ${service.file}\n${load}`, warnings }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function uninstallNode(io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
209
|
+
return uninstallService(NODE_SERVICE, io, options)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Removes `spec`'s service file (dry run: says it would) and prints the commands that stop it. */
|
|
213
|
+
export function uninstallService(spec: ServiceSpec, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
214
|
+
const service = serviceFor(spec, io)
|
|
215
|
+
if (!existsSync(service.file)) return { stdout: `not installed (no ${service.file})`, warnings: [] }
|
|
216
|
+
const stop = commands(`Stop the service ${NOT_RUN}:`, service.unload)
|
|
217
|
+
if (options.dryRun) return { stdout: `would remove ${service.file}\n${stop}`, warnings: [] }
|
|
218
|
+
rmSync(service.file)
|
|
219
|
+
return { stdout: `removed ${service.file}\n${stop}`, warnings: [] }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export type ServiceState = { readonly kind: ServiceKind; readonly file: string; readonly installed: boolean; readonly current: boolean; readonly check: readonly string[] } | { readonly kind: "unsupported"; readonly platform: string }
|
|
223
|
+
|
|
224
|
+
/** Whether `spec`'s service file is there and is what install would write now. */
|
|
225
|
+
export function serviceState(spec: ServiceSpec, io: NodeIo, extra?: ExtraEnvironment): ServiceState {
|
|
226
|
+
try {
|
|
227
|
+
const s = serviceFor(spec, io, extra)
|
|
228
|
+
const text = readText(s.file)
|
|
229
|
+
return { kind: s.kind, file: s.file, installed: text !== undefined, current: text === s.content, check: s.check }
|
|
230
|
+
} catch {
|
|
231
|
+
return { kind: "unsupported", platform: io.platform }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The status lines about `spec`'s service file. */
|
|
236
|
+
export function serviceStateLine(spec: ServiceSpec, s: ServiceState): string {
|
|
237
|
+
if (s.kind === "unsupported") return `service: ${spec.command} install does not support ${s.platform}`
|
|
238
|
+
if (!s.installed) return `service: not installed (${spec.command} install)`
|
|
239
|
+
return `service: installed, ${s.current ? "up to date" : `differs from what this CLI writes (${spec.command} install updates it)`} (${s.file})`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** How to see an installed service (not run). */
|
|
243
|
+
export const serviceCheckLines = (s: ServiceState): string[] => (s.kind !== "unsupported" && s.installed ? [commands(`See the service ${NOT_RUN}:`, s.check)] : [])
|
|
244
|
+
|
|
245
|
+
export interface NodeStatus {
|
|
246
|
+
readonly service: ServiceState
|
|
247
|
+
readonly enrolled?: { readonly server: string; readonly machineID?: string }
|
|
248
|
+
readonly config: { readonly file: string; readonly workspaceRoots: readonly string[]; readonly slots: number; readonly labels: Readonly<Record<string, string>>; readonly problems: readonly string[] }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function nodeStatus(io: NodeIo): NodeStatus {
|
|
252
|
+
const service = serviceState(NODE_SERVICE, io)
|
|
253
|
+
const credentials = readSpokeCredentials(io.env, io.home)
|
|
254
|
+
const file = nodeConfigPath(io.env, io.home)
|
|
255
|
+
const { config, problems } = readNodeConfig(file)
|
|
256
|
+
return {
|
|
257
|
+
service,
|
|
258
|
+
...(credentials ? { enrolled: { server: credentials.server, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) } } : {}),
|
|
259
|
+
config: { file, workspaceRoots: config.workspaceRoots, slots: config.slots, labels: config.labels, problems },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function nodeStatusText(status: NodeStatus): string {
|
|
264
|
+
const s = status.service
|
|
265
|
+
const lines: string[] = [serviceStateLine(NODE_SERVICE, s)]
|
|
266
|
+
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>`)")
|
|
267
|
+
const c = status.config
|
|
268
|
+
lines.push(`node.json: ${c.file}`, ` workspace roots: ${c.workspaceRoots.length ? c.workspaceRoots.join(", ") : "none (the node would exit 78)"}`, ` slots: ${c.slots}`)
|
|
269
|
+
const labels = Object.entries(c.labels).map(([k, v]) => `${k}=${v}`)
|
|
270
|
+
if (labels.length) lines.push(` labels: ${labels.join(" ")}`)
|
|
271
|
+
for (const p of c.problems) lines.push(` problem: ${p}`)
|
|
272
|
+
lines.push(...serviceCheckLines(s))
|
|
273
|
+
return lines.join("\n")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** `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
|
+
return runNode({ env: io.env, home: io.home, signal: io.signal ?? new AbortController().signal, log: (line) => io.stderr(`[hub node] ${line}\n`) })
|
|
279
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `hub node openshell check` (design §6.4, decision lk6gn3u7, spike report gut64rwq): can this node run sandboxed
|
|
2
|
+
// launches? It reads node.json's `openshell` section and asks the openshell CLI only read-only questions (--version,
|
|
3
|
+
// status, workspace list, provider list), in the order a launch needs them. It never creates or changes anything:
|
|
4
|
+
// for a missing workspace it prints the command to run. A `fail` is something that stops sandboxed launches (exit 1);
|
|
5
|
+
// a `warn` is something that likely makes them useless (no model provider, an image the gateway cannot pull).
|
|
6
|
+
import { nodeConfigPath, readNodeConfig } from "../../node/src/index.ts"
|
|
7
|
+
import { OpenShellCli, OpenShellError, OPENSHELL_WORKSPACE, SUPPORTED_VERSIONS, type OpenShellClient } from "../../runtime-openshell/src/cli.ts"
|
|
8
|
+
import { DEFAULT_PROFILE } from "../../runtime-openshell/src/profiles.ts"
|
|
9
|
+
import { DEFAULT_SANDBOX_IMAGE } from "../../runtime-openshell/src/runtime.ts"
|
|
10
|
+
import type { Env } from "./credentials"
|
|
11
|
+
|
|
12
|
+
export type CheckStatus = "ok" | "warn" | "fail" | "skip"
|
|
13
|
+
|
|
14
|
+
export interface CheckItem {
|
|
15
|
+
/** config, profiles, default-providers, image, cli, gateway, workspace, providers */
|
|
16
|
+
readonly check: string
|
|
17
|
+
readonly status: CheckStatus
|
|
18
|
+
readonly message: string
|
|
19
|
+
readonly hint?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpenShellCheckReport {
|
|
23
|
+
/** No `fail` (warnings allowed). */
|
|
24
|
+
readonly ok: boolean
|
|
25
|
+
/** The node.json read. */
|
|
26
|
+
readonly file: string
|
|
27
|
+
readonly checks: readonly CheckItem[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OpenShellCheckOptions {
|
|
31
|
+
readonly env: Env
|
|
32
|
+
readonly home: string
|
|
33
|
+
/** The openshell executable (default "openshell" on PATH). */
|
|
34
|
+
readonly binary?: string
|
|
35
|
+
/** Instead of the CLI (tests). */
|
|
36
|
+
readonly client?: OpenShellClient
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EXAMPLE = `{"openshell": {"enabled": true, "image": "ghcr.io/<owner>/hub-sandbox:<tag>", "profiles": {"default": {"providers": ["<provider>"]}}}}`
|
|
40
|
+
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
41
|
+
const item = (check: string, status: CheckStatus, message: string, hint?: string): CheckItem => ({ check, status, message, ...(hint ? { hint } : {}) })
|
|
42
|
+
|
|
43
|
+
/** Why an image reference likely cannot be pulled by OpenShell's vm driver (HTTPS registries only); undefined: fine. */
|
|
44
|
+
export function imageConcern(image: string): string | undefined {
|
|
45
|
+
const slash = image.indexOf("/")
|
|
46
|
+
const first = slash < 0 ? "" : image.slice(0, slash)
|
|
47
|
+
const hasHost = first === "localhost" || first.includes(".") || first.includes(":")
|
|
48
|
+
if (!hasHost) return `${image} has no registry host, so the gateway looks for it on Docker Hub; OpenShell's vm driver does not use local Docker images, so it must be published to an HTTPS registry`
|
|
49
|
+
const host = first.replace(/:\d+$/, "")
|
|
50
|
+
if (host === "localhost" || /^127\./.test(host) || host === "[::1]" || host.endsWith(".local")) return `${image} is on a local registry; OpenShell's vm driver pulls over HTTPS only and refuses plain-http local registries`
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The checks node.json alone can answer. */
|
|
55
|
+
function configChecks(env: Env, home: string): { file: string; checks: CheckItem[]; providers: Map<string, string[]>; usable: boolean; gateway?: string } {
|
|
56
|
+
const file = nodeConfigPath(env, home)
|
|
57
|
+
const { config, problems } = readNodeConfig(file)
|
|
58
|
+
const checks: CheckItem[] = []
|
|
59
|
+
const providers = new Map<string, string[]>()
|
|
60
|
+
const openshell = config.openshell
|
|
61
|
+
const configProblems = problems.filter((p) => !/^openshell\.profiles\.[^:]+: unsupported \(/.test(p))
|
|
62
|
+
for (const p of configProblems) checks.push(item("config", "fail", p))
|
|
63
|
+
if (!openshell) {
|
|
64
|
+
checks.push(item("config", "fail", `${file} has no openshell section`, `add one, e.g. ${EXAMPLE}`))
|
|
65
|
+
return { file, checks, providers, usable: false }
|
|
66
|
+
}
|
|
67
|
+
if (configProblems.length === 0) {
|
|
68
|
+
if (openshell.enabled) checks.push(item("config", "ok", `${file}: openshell enabled${openshell.gateway ? ` (gateway ${openshell.gateway})` : ""}`))
|
|
69
|
+
else checks.push(item("config", "warn", `${file}: openshell.enabled is not true, so the node does not offer sandboxes`, 'set "enabled": true in the openshell section'))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const set = openshell.profiles
|
|
73
|
+
const names = Object.keys(set.profiles).sort()
|
|
74
|
+
checks.push(item("profiles", "ok", `usable profiles: ${names.join(", ") || "none"}`))
|
|
75
|
+
for (const [name, reason] of Object.entries(set.unsupported)) checks.push(item("profiles", "fail", `profile ${name} is unsupported: ${reason}`, "remove mounts, cpu and memory from it"))
|
|
76
|
+
|
|
77
|
+
const fallback = set.profiles[DEFAULT_PROFILE]
|
|
78
|
+
if (!fallback) checks.push(item("default-providers", "skip", `no usable ${DEFAULT_PROFILE} profile`))
|
|
79
|
+
else if (fallback.providers.length === 0)
|
|
80
|
+
checks.push(item("default-providers", "warn", `the ${DEFAULT_PROFILE} profile names no providers, so a sandboxed agent cannot reach a model`, `create a provider in workspace ${OPENSHELL_WORKSPACE} (openshell --workspace ${OPENSHELL_WORKSPACE} provider create --name <name> --type <type> …) and list it in openshell.profiles.${DEFAULT_PROFILE}.providers`))
|
|
81
|
+
else checks.push(item("default-providers", "ok", `the ${DEFAULT_PROFILE} profile uses ${fallback.providers.join(", ")}`))
|
|
82
|
+
|
|
83
|
+
const images = new Map<string, string[]>()
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const profile = set.profiles[name]!
|
|
86
|
+
const image = profile.image ?? openshell.image ?? DEFAULT_SANDBOX_IMAGE
|
|
87
|
+
images.set(image, [...(images.get(image) ?? []), name])
|
|
88
|
+
for (const provider of profile.providers) providers.set(provider, [...(providers.get(provider) ?? []), name])
|
|
89
|
+
}
|
|
90
|
+
for (const [image, users] of images) {
|
|
91
|
+
const concern = imageConcern(image)
|
|
92
|
+
const who = `profile${users.length > 1 ? "s" : ""} ${users.join(", ")}`
|
|
93
|
+
if (image === DEFAULT_SANDBOX_IMAGE && !openshell.image && !users.some((u) => set.profiles[u]!.image))
|
|
94
|
+
checks.push(item("image", "warn", `no image configured (${who} get ${image}, a local build name)`, "publish the sandbox image to an HTTPS registry and set openshell.image"))
|
|
95
|
+
else if (concern) checks.push(item("image", "warn", `${concern} (${who})`, "publish it to an HTTPS registry (e.g. ghcr.io) and point openshell.image at it"))
|
|
96
|
+
else checks.push(item("image", "ok", `${image} (${who})`))
|
|
97
|
+
}
|
|
98
|
+
return { file, checks, providers, usable: true, ...(openshell.gateway ? { gateway: openshell.gateway } : {}) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function checkOpenShell(options: OpenShellCheckOptions): Promise<OpenShellCheckReport> {
|
|
102
|
+
const local = configChecks(options.env, options.home)
|
|
103
|
+
const checks = [...local.checks]
|
|
104
|
+
const client: OpenShellClient =
|
|
105
|
+
options.client ?? new OpenShellCli({ ...(options.binary ? { binary: options.binary } : {}), ...(local.gateway ? { gateway: local.gateway } : {}), env: options.env })
|
|
106
|
+
const skip = (check: string, why: string) => checks.push(item(check, "skip", why))
|
|
107
|
+
|
|
108
|
+
let reachable = false
|
|
109
|
+
try {
|
|
110
|
+
const version = await client.version()
|
|
111
|
+
checks.push(item("cli", "ok", `openshell ${version} (supported ${SUPPORTED_VERSIONS})`))
|
|
112
|
+
try {
|
|
113
|
+
const status = await client.status()
|
|
114
|
+
if (status.connected && status.authenticated) {
|
|
115
|
+
reachable = true
|
|
116
|
+
checks.push(item("gateway", "ok", `gateway ${status.gateway || "(active)"} at ${status.server} is connected`))
|
|
117
|
+
} else checks.push(item("gateway", "fail", `gateway ${status.gateway || "(active)"} is ${status.connected ? "connected but not authenticated" : "not connected"}`, "see `openshell status` and `openshell gateway --help`"))
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
checks.push(item("gateway", "fail", errorText(cause), "see `openshell status`"))
|
|
120
|
+
}
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
const notInstalled = cause instanceof OpenShellError && cause.code === "not_installed"
|
|
123
|
+
checks.push(item("cli", "fail", errorText(cause), notInstalled ? "install the NVIDIA OpenShell CLI (and its gateway) on this machine" : `this hub speaks openshell ${SUPPORTED_VERSIONS}`))
|
|
124
|
+
skip("gateway", "the openshell CLI is not usable")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!reachable) {
|
|
128
|
+
skip("workspace", "the gateway is not reachable")
|
|
129
|
+
skip("providers", "the gateway is not reachable")
|
|
130
|
+
} else {
|
|
131
|
+
try {
|
|
132
|
+
const workspaces = client.workspaces ? await client.workspaces() : []
|
|
133
|
+
if (workspaces.includes(OPENSHELL_WORKSPACE)) checks.push(item("workspace", "ok", `${OPENSHELL_WORKSPACE} exists`))
|
|
134
|
+
else checks.push(item("workspace", "fail", `the gateway has no workspace ${OPENSHELL_WORKSPACE} (the hub's sandboxes live there)`, `create it (not run by hub): openshell workspace create --name ${OPENSHELL_WORKSPACE}`))
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
checks.push(item("workspace", "fail", errorText(cause)))
|
|
137
|
+
}
|
|
138
|
+
if (local.providers.size === 0) skip("providers", "no profile names a provider")
|
|
139
|
+
else {
|
|
140
|
+
try {
|
|
141
|
+
const existing = new Set(client.providers ? await client.providers() : [])
|
|
142
|
+
const missing = [...local.providers].filter(([name]) => !existing.has(name))
|
|
143
|
+
for (const [name, users] of missing)
|
|
144
|
+
checks.push(item("providers", "fail", `provider ${name} (profile${users.length > 1 ? "s" : ""} ${users.join(", ")}) does not exist in workspace ${OPENSHELL_WORKSPACE}`, `create it: openshell --workspace ${OPENSHELL_WORKSPACE} provider create --name ${name} --type <type> …`))
|
|
145
|
+
if (missing.length === 0) checks.push(item("providers", "ok", `found ${[...local.providers.keys()].join(", ")}`))
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
checks.push(item("providers", "fail", errorText(cause)))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { ok: !checks.some((c) => c.status === "fail"), file: local.file, checks }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MARK: Record<CheckStatus, string> = { ok: "ok ", warn: "warn", fail: "FAIL", skip: "skip" }
|
|
155
|
+
|
|
156
|
+
export function openShellCheckText(report: OpenShellCheckReport): string {
|
|
157
|
+
const lines: string[] = []
|
|
158
|
+
for (const c of report.checks) {
|
|
159
|
+
lines.push(`${MARK[c.status]} ${c.check}: ${c.message}`)
|
|
160
|
+
if (c.hint && c.status !== "ok") lines.push(` hint: ${c.hint}`)
|
|
161
|
+
}
|
|
162
|
+
const fails = report.checks.filter((c) => c.status === "fail").length
|
|
163
|
+
const warns = report.checks.filter((c) => c.status === "warn").length
|
|
164
|
+
lines.push(fails ? `${fails} problem${fails > 1 ? "s" : ""}${warns ? `, ${warns} warning${warns > 1 ? "s" : ""}` : ""}` : `no problems${warns ? `, ${warns} warning${warns > 1 ? "s" : ""}` : ""}`)
|
|
165
|
+
return lines.join("\n")
|
|
166
|
+
}
|