@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,31 @@
|
|
|
1
|
+
// Symlink facts for the paths a plan would write, read through the probe (lstat and realpath only).
|
|
2
|
+
import { executionContextOf, rootPathIn, type DiscoverySnapshot } from "../contracts/index.ts"
|
|
3
|
+
import type { DiscoveryProbe } from "../discovery/probe.ts"
|
|
4
|
+
import { join, normalizeAbs } from "../discovery/util.ts"
|
|
5
|
+
import type { LinkInspector } from "./plan.ts"
|
|
6
|
+
|
|
7
|
+
/** Link facts read through the probe (lstat/realpath only), against each root as this hubctl sees it (v1.1 rootPathIn). */
|
|
8
|
+
export function inspectLinks(probe: DiscoveryProbe, snapshot: DiscoverySnapshot): LinkInspector {
|
|
9
|
+
const context = executionContextOf(snapshot)
|
|
10
|
+
return (ref) => {
|
|
11
|
+
const root = snapshot.roots.find((r) => r.id === ref.root)
|
|
12
|
+
if (!root) return undefined
|
|
13
|
+
const base = rootPathIn(root, context)
|
|
14
|
+
const parts = ref.rel.split("/")
|
|
15
|
+
let current = base
|
|
16
|
+
for (let i = 0; i < parts.length; i++) {
|
|
17
|
+
current = join(current, parts[i]!)
|
|
18
|
+
const kind = probe.lstat(current)
|
|
19
|
+
if (kind === "missing") return undefined
|
|
20
|
+
if (kind !== "symlink") continue
|
|
21
|
+
// The edit belongs in the file the link leads to: the deepest existing part, resolved, plus the rest.
|
|
22
|
+
const rest = parts.slice(i + 1)
|
|
23
|
+
for (let k = rest.length; k >= 0; k--) {
|
|
24
|
+
const real = normalizeAbs(probe.realpath([current, ...rest.slice(0, k)].join("/")))
|
|
25
|
+
if (real) return { linkPath: current, resolvedPath: [real, ...rest.slice(k)].join("/").replace(/^\/\//, "/") }
|
|
26
|
+
}
|
|
27
|
+
return { linkPath: current, resolvedPath: [current, ...rest].join("/") }
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// planSetup: a pure function of (snapshot, profile, link facts, planners), with no clock and no randomness, so
|
|
2
|
+
// the same host and choices always give the same plan and planDigest. Harness adapters and other contributors
|
|
3
|
+
// propose typed actions; planSetup adds the steps every plan needs (the state directory, real verification of
|
|
4
|
+
// every chosen harness, opt-in runtime checks), drops exact duplicates, refuses conflicting ones, never implies
|
|
5
|
+
// escalation, puts the actions in dependency order, and replaces any config write behind a symlink with a
|
|
6
|
+
// ManualEdit blocker (decision Q8). Writes that cannot become a manual edit are refused.
|
|
7
|
+
//
|
|
8
|
+
// v1.1: the snapshot digest is the contract's snapshotDigest; `enrollment: "separate"` (from the caller or the
|
|
9
|
+
// profile) leaves enroll-machine out, so the direct and Ansible paths plan the same digest; a merge into a format
|
|
10
|
+
// hubctl cannot edit losslessly (TOML) becomes an unmergeable-format ManualEdit for the file in this execution
|
|
11
|
+
// context; refusals use escalation_required and no_service_manager.
|
|
12
|
+
import {
|
|
13
|
+
actionPaths,
|
|
14
|
+
canonicalJSON,
|
|
15
|
+
checkTarget,
|
|
16
|
+
enrollmentModeOf,
|
|
17
|
+
executionContextOf,
|
|
18
|
+
fail,
|
|
19
|
+
HARNESS_ORDER,
|
|
20
|
+
manualEditFor,
|
|
21
|
+
ok,
|
|
22
|
+
REQUIRED_PROBES,
|
|
23
|
+
resolvePathRef,
|
|
24
|
+
snapshotDigest,
|
|
25
|
+
unmergeableEditFor,
|
|
26
|
+
validateAction,
|
|
27
|
+
validateDiscoverySnapshot,
|
|
28
|
+
validateProfile,
|
|
29
|
+
validateSetupPlan,
|
|
30
|
+
type Action,
|
|
31
|
+
type ActionKind,
|
|
32
|
+
type AdapterRegistry,
|
|
33
|
+
type DiscoverySnapshot,
|
|
34
|
+
type EnrollmentMode,
|
|
35
|
+
type HarnessID,
|
|
36
|
+
type Issue,
|
|
37
|
+
type ManualEdit,
|
|
38
|
+
type PathRef,
|
|
39
|
+
type PlanInput,
|
|
40
|
+
type PreviewEvidence,
|
|
41
|
+
type Profile,
|
|
42
|
+
type Result,
|
|
43
|
+
type SetupPlan,
|
|
44
|
+
} from "../contracts/index.ts"
|
|
45
|
+
|
|
46
|
+
/** What a contributor plans from: the adapters' input, and the actions planned before it (adapters first). */
|
|
47
|
+
export type ContributorInput = PlanInput & { readonly planned?: readonly Action[] }
|
|
48
|
+
|
|
49
|
+
/** Proposes actions for a plan; must be deterministic in its input (like HarnessAdapter.plan). */
|
|
50
|
+
export type PlanContributor = (input: ContributorInput) => readonly Action[]
|
|
51
|
+
|
|
52
|
+
/** A symlink found between a root and a path the plan would write. */
|
|
53
|
+
export interface LinkFact {
|
|
54
|
+
readonly linkPath: string
|
|
55
|
+
/** Where the target really is (the file the edit belongs in). */
|
|
56
|
+
readonly resolvedPath: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The first symlink under the root on the way to `ref`, if any. */
|
|
60
|
+
export type LinkInspector = (ref: PathRef) => LinkFact | undefined
|
|
61
|
+
|
|
62
|
+
export interface PlanSources {
|
|
63
|
+
/** Harness adapters; each chosen harness's adapter plans for it, in HARNESS_ORDER. */
|
|
64
|
+
readonly adapters?: AdapterRegistry
|
|
65
|
+
/** Further planners (services, enrollment, launches), after the adapters. */
|
|
66
|
+
readonly contributors?: readonly PlanContributor[]
|
|
67
|
+
/**
|
|
68
|
+
* v1.1: "separate" leaves enroll-machine out of the plan (the machine is enrolled by its own `hubctl machine
|
|
69
|
+
* enroll`); default: the profile's enrollment mode, else "inline". Apply must re-plan with the same option.
|
|
70
|
+
*/
|
|
71
|
+
readonly enrollment?: EnrollmentMode
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PlannedSetup {
|
|
75
|
+
readonly plan: SetupPlan
|
|
76
|
+
readonly blockers: readonly ManualEdit[]
|
|
77
|
+
readonly evidence: readonly PreviewEvidence[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The contract's canonical snapshot digest (v1.1: defined once, in contracts/discovery.ts), re-exported. */
|
|
81
|
+
export { snapshotDigest }
|
|
82
|
+
|
|
83
|
+
/** Execution phases: each action kind runs after everything it depends on. */
|
|
84
|
+
const PHASE: Readonly<Record<ActionKind, number>> = {
|
|
85
|
+
"ensure-directory": 0,
|
|
86
|
+
"install-artifact": 1,
|
|
87
|
+
"link-executable": 2,
|
|
88
|
+
"install-harness": 3,
|
|
89
|
+
// v1.1: a freshly installed version is made current right after it is installed (and before it is verified).
|
|
90
|
+
"select-harness-version": 3.5,
|
|
91
|
+
"write-file": 4,
|
|
92
|
+
"merge-config": 4,
|
|
93
|
+
"register-mcp-server": 4,
|
|
94
|
+
// v1.1: a removed component's service is unloaded, then its unit removed, before new services are installed.
|
|
95
|
+
"deactivate-service": 4.8,
|
|
96
|
+
"remove-service": 4.9,
|
|
97
|
+
"install-service": 5,
|
|
98
|
+
"enroll-machine": 6,
|
|
99
|
+
"verify-harness": 7,
|
|
100
|
+
"verify-runtime": 8,
|
|
101
|
+
"activate-service": 9,
|
|
102
|
+
"launch-session": 10,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const HARNESS_LABEL: Readonly<Record<HarnessID, string>> = { opencode: "OpenCode", "claude-code": "Claude Code", codex: "Codex" }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The bootstrap state a committed apply writes (setup/state.ts). Its presence means the state directory exists, so a
|
|
109
|
+
* rerun leaves state-dir out. Taking the apply lock creates the directory but not this file, so the preview and
|
|
110
|
+
* apply's locked re-plan agree on a fresh host.
|
|
111
|
+
*/
|
|
112
|
+
export const APPLIED_STATE: PathRef = { root: "state", rel: "hub/bootstrap/state.json" }
|
|
113
|
+
|
|
114
|
+
const STATE_DIR: Action = { id: "state-dir", kind: "ensure-directory", escalation: "user", summary: "Create the hub state directory", path: { root: "state", rel: "hub" }, mode: 0o700 }
|
|
115
|
+
|
|
116
|
+
const verifyHarness = (harness: HarnessID): Action => ({
|
|
117
|
+
id: `verify-${harness}`,
|
|
118
|
+
kind: "verify-harness",
|
|
119
|
+
escalation: "user",
|
|
120
|
+
summary: `Verify ${HARNESS_LABEL[harness]} really runs, reaches its provider and connects to the hub`,
|
|
121
|
+
harness,
|
|
122
|
+
probes: REQUIRED_PROBES[harness].filter((p): p is Exclude<typeof p, "presence"> => p !== "presence"),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** link-executable and select-harness-version replace their own final symlink atomically: it is not written through. */
|
|
126
|
+
const replacesFinalLink = (action: Action) => action.kind === "link-executable" || action.kind === "select-harness-version"
|
|
127
|
+
const parentOf = (ref: PathRef): PathRef | undefined => (ref.rel.includes("/") ? { root: ref.root, rel: ref.rel.slice(0, ref.rel.lastIndexOf("/")) } : undefined)
|
|
128
|
+
|
|
129
|
+
export const issuesAt = (prefix: string, r: Result<unknown>): Issue[] => (r.ok ? [] : r.issues.map((i) => ({ ...i, path: `${prefix}${i.path}` })))
|
|
130
|
+
const failed = <T>(issues: readonly Issue[]): Result<T> => ({ ok: false, issues: [...issues] })
|
|
131
|
+
|
|
132
|
+
/** Plans a setup. Pure: the same inputs always give the same plan. */
|
|
133
|
+
export function planSetup(
|
|
134
|
+
input: {
|
|
135
|
+
readonly snapshot: DiscoverySnapshot
|
|
136
|
+
readonly profile: Profile
|
|
137
|
+
readonly links: LinkInspector
|
|
138
|
+
/** Whether a regular file exists at a path (read-only; preview passes the probe's). */
|
|
139
|
+
readonly fileExists?: (ref: PathRef) => boolean
|
|
140
|
+
} & PlanSources,
|
|
141
|
+
): Result<PlannedSetup> {
|
|
142
|
+
const snap = validateDiscoverySnapshot(input.snapshot)
|
|
143
|
+
const prof = validateProfile(input.profile)
|
|
144
|
+
if (!snap.ok || !prof.ok) return failed([...issuesAt("/snapshot", snap), ...issuesAt("/profile", prof)])
|
|
145
|
+
const snapshot = snap.value
|
|
146
|
+
const profile = prof.value
|
|
147
|
+
const target = checkTarget(snapshot.host.target)
|
|
148
|
+
if (!target.ok || !snapshot.host.supported) return failed(issuesAt("/snapshot/host/target", target.ok ? fail("", "unsupported_platform", "unsupported host") : target))
|
|
149
|
+
if (profile.services.length > 0 && snapshot.managers.service === "none")
|
|
150
|
+
return fail("/profile/services", "no_service_manager", `the ${profile.id} profile installs services, but this host has no user service manager (launchd or systemd --user)`)
|
|
151
|
+
|
|
152
|
+
// Collect: the state dir, then each chosen harness's adapter actions, then the other contributors.
|
|
153
|
+
const planInput: PlanInput = { snapshot, profile }
|
|
154
|
+
const groups: Action[][] = [input.fileExists?.(APPLIED_STATE) ? [] : [STATE_DIR]]
|
|
155
|
+
const harnessGroup = new Map<HarnessID, Action[]>()
|
|
156
|
+
for (const id of HARNESS_ORDER) {
|
|
157
|
+
if (!profile.harnesses.includes(id)) continue
|
|
158
|
+
const group = [...(input.adapters?.get(id)?.plan(planInput) ?? [])]
|
|
159
|
+
harnessGroup.set(id, group)
|
|
160
|
+
groups.push(group)
|
|
161
|
+
}
|
|
162
|
+
for (const contribute of input.contributors ?? []) groups.push([...contribute({ ...planInput, planned: groups.flat() })])
|
|
163
|
+
// v1.1: a separate enrollment is its own step; the plan never enrolls.
|
|
164
|
+
if ((input.enrollment ?? enrollmentModeOf(profile)) === "separate") for (const group of groups) group.splice(0, group.length, ...group.filter((a) => a.kind !== "enroll-machine"))
|
|
165
|
+
// Every chosen harness is verified by real probes (presence is never readiness); runtimes are checked, never installed.
|
|
166
|
+
const all = () => groups.flat()
|
|
167
|
+
for (const [id, group] of harnessGroup) if (!all().some((a) => a.kind === "verify-harness" && a.harness === id)) group.push(verifyHarness(id))
|
|
168
|
+
groups.push(profile.runtimes.map((runtime) => ({ id: `verify-${runtime}`, kind: "verify-runtime", escalation: "user", summary: `Check ${runtime} (opt-in)`, runtime }) as Action))
|
|
169
|
+
|
|
170
|
+
// Validate every proposed action; drop exact duplicates, refuse conflicting ones.
|
|
171
|
+
const issues: Issue[] = []
|
|
172
|
+
const byID = new Map<string, Action>()
|
|
173
|
+
const actions: Action[] = []
|
|
174
|
+
all().forEach((action, i) => {
|
|
175
|
+
const checked = validateAction(action)
|
|
176
|
+
if (!checked.ok) return void issues.push(...issuesAt(`/actions/${i}`, checked))
|
|
177
|
+
const seen = byID.get(action.id)
|
|
178
|
+
if (seen) {
|
|
179
|
+
if (canonicalJSON(seen) !== canonicalJSON(action)) issues.push({ path: `/actions/${i}/id`, code: "duplicate", message: `two different actions are both named ${action.id}` })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
byID.set(action.id, action)
|
|
183
|
+
actions.push(action)
|
|
184
|
+
})
|
|
185
|
+
if (issues.length > 0) return failed(issues)
|
|
186
|
+
|
|
187
|
+
// Escalation is never implied; services must have their manager's root; writes never go through a symlink.
|
|
188
|
+
const blockers: ManualEdit[] = []
|
|
189
|
+
const kept: Action[] = []
|
|
190
|
+
for (const action of actions) {
|
|
191
|
+
if (action.escalation === "elevated" && profile.escalation !== "explicit") {
|
|
192
|
+
issues.push({ path: `/actions/${action.id}`, code: "escalation_required", message: `${action.id} needs escalation, which the ${profile.id} profile never allows` })
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
const unmapped = actionPaths(action).find((ref) => !snapshot.roots.some((r) => r.id === ref.root))
|
|
196
|
+
if (unmapped) {
|
|
197
|
+
issues.push({ path: `/actions/${action.id}`, code: "unmapped_root", message: `this host has no ${unmapped.root} root` })
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
const link = "path" in action ? input.links(replacesFinalLink(action) ? parentOf(action.path) ?? action.path : action.path) : undefined
|
|
201
|
+
if (replacesFinalLink(action) && !parentOf(action.path)) {
|
|
202
|
+
// A link directly in its root: only the root itself could be a link (the applier refuses that), not the
|
|
203
|
+
// link the action replaces.
|
|
204
|
+
kept.push(action)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (!link && action.kind === "merge-config" && action.format === "toml") {
|
|
208
|
+
// v1.1: hubctl cannot edit TOML losslessly; the user gets the exact edit for the file as this hubctl sees it.
|
|
209
|
+
const where = resolvePathRef(action.path, snapshot.roots, executionContextOf(snapshot))
|
|
210
|
+
const edit = where.ok ? unmergeableEditFor(action, where.value) : undefined
|
|
211
|
+
if (edit) blockers.push(edit)
|
|
212
|
+
else issues.push({ path: `/actions/${action.id}`, code: "unmapped_root", message: `this host has no ${action.path.root} root` })
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (!link) {
|
|
216
|
+
kept.push(action)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const edit = manualEditFor(action, link)
|
|
220
|
+
if (edit) blockers.push(edit)
|
|
221
|
+
else issues.push({ path: `/actions/${action.id}`, code: "symlink", message: `${link.linkPath} is a symbolic link; refusing to write through it` })
|
|
222
|
+
}
|
|
223
|
+
if (issues.length > 0) return failed(issues)
|
|
224
|
+
|
|
225
|
+
const ordered = kept.map((a, i) => ({ a, i })).sort((x, y) => PHASE[x.a.kind] - PHASE[y.a.kind] || x.i - y.i).map((x) => x.a)
|
|
226
|
+
const plan: SetupPlan = {
|
|
227
|
+
schema: "hub.setup-plan",
|
|
228
|
+
version: 1,
|
|
229
|
+
hostFingerprint: snapshot.host.fingerprint,
|
|
230
|
+
snapshotDigest: snapshotDigest(snapshot),
|
|
231
|
+
target: snapshot.host.target,
|
|
232
|
+
profile: { id: profile.id, version: profile.version },
|
|
233
|
+
requiresEscalation: ordered.some((a) => a.escalation === "elevated"),
|
|
234
|
+
activatesServices: ordered.some((a) => a.kind === "activate-service"),
|
|
235
|
+
actions: ordered,
|
|
236
|
+
}
|
|
237
|
+
const valid = validateSetupPlan(plan)
|
|
238
|
+
if (!valid.ok) return failed(issuesAt("/plan", valid))
|
|
239
|
+
// Upstream evidence for a pinned harness download is checked on the downloaded bytes, at apply.
|
|
240
|
+
const evidence: PreviewEvidence[] = ordered.flatMap((a) => (a.kind === "install-harness" ? a.evidence.map((kind) => ({ actionID: a.id, kind, status: "deferred" as const })) : []))
|
|
241
|
+
return ok({ plan, blockers, evidence })
|
|
242
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Previews (`hubctl setup preview`) and their revalidation at apply time. previewSetup discovers the host, plans
|
|
2
|
+
// (planSetup) and issues a preview token bound to the host fingerprint, the snapshot digest and the plan digest,
|
|
3
|
+
// valid for at most 15 minutes, with a single-use nonce. revalidateApply never trusts that token: it rediscovers,
|
|
4
|
+
// re-plans and compares (revalidatePreview), then refuses while a manual edit or failed evidence remains. Both only
|
|
5
|
+
// read, through the probe.
|
|
6
|
+
import {
|
|
7
|
+
executionContextOf,
|
|
8
|
+
fail,
|
|
9
|
+
ok,
|
|
10
|
+
planDigest,
|
|
11
|
+
PREVIEW_MAX_TTL_MS,
|
|
12
|
+
previewApplicable,
|
|
13
|
+
revalidatePreview,
|
|
14
|
+
validatePreviewToken,
|
|
15
|
+
validateSetupPreview,
|
|
16
|
+
resolvePathRef,
|
|
17
|
+
type DiscoverySnapshot,
|
|
18
|
+
type PathRef,
|
|
19
|
+
type PreviewToken,
|
|
20
|
+
type Profile,
|
|
21
|
+
type Result,
|
|
22
|
+
type SetupPreview,
|
|
23
|
+
} from "../contracts/index.ts"
|
|
24
|
+
import { discover } from "../discovery/discover.ts"
|
|
25
|
+
import type { DiscoveryProbe } from "../discovery/probe.ts"
|
|
26
|
+
import { inspectLinks } from "./links.ts"
|
|
27
|
+
import { planSetup, type PlannedSetup, type PlanSources } from "./plan.ts"
|
|
28
|
+
|
|
29
|
+
const randomNonce = (): string => {
|
|
30
|
+
const bytes = new Uint8Array(18)
|
|
31
|
+
crypto.getRandomValues(bytes)
|
|
32
|
+
return Buffer.from(bytes).toString("base64url")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreviewRequest extends PlanSources {
|
|
36
|
+
readonly probe: DiscoveryProbe
|
|
37
|
+
readonly profile: Profile
|
|
38
|
+
readonly now: number
|
|
39
|
+
/** Injected for tests; otherwise 18 random bytes, base64url. */
|
|
40
|
+
readonly nonce?: string
|
|
41
|
+
/** Token lifetime, at most (and by default) 15 minutes. */
|
|
42
|
+
readonly ttlMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function discoverAndPlan(probe: DiscoveryProbe, profile: Profile, now: number, sources: PlanSources): Result<{ snapshot: DiscoverySnapshot } & PlannedSetup> {
|
|
46
|
+
const snap = discover(probe, { now })
|
|
47
|
+
if (!snap.ok) return snap
|
|
48
|
+
const fileExists = (ref: PathRef) => {
|
|
49
|
+
const where = resolvePathRef(ref, snap.value.roots, executionContextOf(snap.value))
|
|
50
|
+
return where.ok && probe.lstat(where.value) === "file"
|
|
51
|
+
}
|
|
52
|
+
const planned = planSetup({ snapshot: snap.value, profile, links: inspectLinks(probe, snap.value), fileExists, adapters: sources.adapters, contributors: sources.contributors, enrollment: sources.enrollment })
|
|
53
|
+
return planned.ok ? ok({ snapshot: snap.value, ...planned.value }) : planned
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Discovers, plans and issues a preview. Writes nothing. */
|
|
57
|
+
export function previewSetup(request: PreviewRequest): Result<SetupPreview> {
|
|
58
|
+
const ttl = request.ttlMs ?? PREVIEW_MAX_TTL_MS
|
|
59
|
+
if (!Number.isInteger(ttl) || ttl <= 0 || ttl > PREVIEW_MAX_TTL_MS) return fail("/ttlMs", "invalid_value", `a preview lives 1 ms..${PREVIEW_MAX_TTL_MS / 60_000} minutes`)
|
|
60
|
+
const planned = discoverAndPlan(request.probe, request.profile, request.now, request)
|
|
61
|
+
if (!planned.ok) return planned
|
|
62
|
+
const { plan, blockers, evidence } = planned.value
|
|
63
|
+
const digest = planDigest(plan)
|
|
64
|
+
const token: PreviewToken = {
|
|
65
|
+
schema: "hub.preview-token",
|
|
66
|
+
version: 1,
|
|
67
|
+
planDigest: digest,
|
|
68
|
+
hostFingerprint: plan.hostFingerprint,
|
|
69
|
+
snapshotDigest: plan.snapshotDigest,
|
|
70
|
+
issuedAt: request.now,
|
|
71
|
+
expiresAt: request.now + ttl,
|
|
72
|
+
nonce: request.nonce ?? randomNonce(),
|
|
73
|
+
}
|
|
74
|
+
return validateSetupPreview({ schema: "hub.setup-preview", version: 1, plan, planDigest: digest, token, blockers, evidence })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ApplyCheck extends PlanSources {
|
|
78
|
+
readonly token: unknown
|
|
79
|
+
readonly probe: DiscoveryProbe
|
|
80
|
+
readonly profile: Profile
|
|
81
|
+
readonly now: number
|
|
82
|
+
/** Nonces consumed by earlier applies (from the journals). */
|
|
83
|
+
readonly usedNonces: ReadonlySet<string>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether apply may run the previewed plan: rediscovers and re-plans now, then checks the token against the fresh
|
|
88
|
+
* host fingerprint, snapshot and plan (revalidatePreview), and refuses while a manual edit or failed evidence
|
|
89
|
+
* remains. On success, returns the freshly made plan (never one read back from the preview).
|
|
90
|
+
*/
|
|
91
|
+
export function revalidateApply(check: ApplyCheck): Result<{ readonly token: PreviewToken; readonly snapshot: DiscoverySnapshot } & PlannedSetup> {
|
|
92
|
+
const token = validatePreviewToken(check.token)
|
|
93
|
+
if (!token.ok) return token
|
|
94
|
+
const fresh = discoverAndPlan(check.probe, check.profile, check.now, check)
|
|
95
|
+
if (!fresh.ok) return fresh
|
|
96
|
+
const valid = revalidatePreview(token.value, { now: check.now, hostFingerprint: fresh.value.snapshot.host.fingerprint, plan: fresh.value.plan, usedNonces: check.usedNonces })
|
|
97
|
+
if (!valid.ok) return valid
|
|
98
|
+
const gate = previewApplicable({ schema: "hub.setup-preview", version: 1, plan: fresh.value.plan, planDigest: planDigest(fresh.value.plan), token: valid.value, blockers: fresh.value.blockers, evidence: fresh.value.evidence })
|
|
99
|
+
if (!gate.ok) return gate
|
|
100
|
+
return ok({ token: valid.value, ...fresh.value })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A preview document read back from disk or another process: validated, never cast. */
|
|
104
|
+
export const parsePreview = (raw: unknown): Result<SetupPreview> => validateSetupPreview(raw)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The preview for people. Paths under the home directory are shown as ~/…; the token's nonce is never printed.
|
|
2
|
+
import type { PathRef, SetupPreview } from "../contracts/index.ts"
|
|
3
|
+
|
|
4
|
+
const tilde = (path: string, home?: string) => (home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path)
|
|
5
|
+
const ref = (r: PathRef) => `${r.root}:${r.rel}`
|
|
6
|
+
|
|
7
|
+
/** The preview for people: the steps, the consents apply will ask for, and each manual edit with the file it belongs in. */
|
|
8
|
+
export function renderPreview(preview: SetupPreview, home?: string): string[] {
|
|
9
|
+
const { plan } = preview
|
|
10
|
+
const lines = [`Setup plan for ${plan.target} (${plan.profile.id}), ${plan.actions.length} step(s):`]
|
|
11
|
+
plan.actions.forEach((a, i) => lines.push(` ${i + 1}. ${a.summary}${a.escalation === "elevated" ? " [needs --escalate]" : ""}`))
|
|
12
|
+
if (plan.requiresEscalation) lines.push("Apply needs --escalate: some steps write system locations.")
|
|
13
|
+
if (plan.activatesServices) lines.push("Apply needs --confirm-activation: it loads user services.")
|
|
14
|
+
for (const e of preview.evidence) lines.push(`Evidence ${e.kind} for ${e.actionID}: ${e.status}`)
|
|
15
|
+
if (preview.blockers.length > 0) {
|
|
16
|
+
lines.push(`${preview.blockers.length} change(s) must be made by hand, because hubctl will not write the config itself:`)
|
|
17
|
+
for (const b of preview.blockers) {
|
|
18
|
+
const why = b.linkPath ? `linked from ${tilde(b.linkPath, home)}` : "hubctl cannot edit this format losslessly"
|
|
19
|
+
lines.push(` - in ${tilde(b.resolvedPath, home)} (${why}, ${ref(b.path)}):`)
|
|
20
|
+
const c = b.change
|
|
21
|
+
if (c.op === "merge") for (const s of c.set) lines.push(` set ${s.pointer} = ${JSON.stringify(s.value)}`)
|
|
22
|
+
else if (c.op === "register-mcp-server") lines.push(` register the hub MCP server "${c.name}" for ${c.harness} (hubctl mcp)`)
|
|
23
|
+
else lines.push(` write asset ${c.asset} (${c.sha256}, mode ${c.mode.toString(8)})`)
|
|
24
|
+
}
|
|
25
|
+
lines.push("Make these edits, then run the preview again.")
|
|
26
|
+
}
|
|
27
|
+
lines.push(`Plan ${preview.planDigest}; the preview expires at ${new Date(preview.token.expiresAt).toISOString()}.`)
|
|
28
|
+
return lines
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Audit-only verification. `hubctl setup verify` (or any check that must not change the machine) audits the host
|
|
2
|
+
// against a profile: it reports each harness and runtime the profile names, and plans only read-only probe actions
|
|
3
|
+
// (verify-harness, verify-runtime) for what is there. The plan is a valid contract SetupPlan, so the same applier
|
|
4
|
+
// runs it; checkReadOnly lets that applier prove, before it starts, that an audit plan writes nothing.
|
|
5
|
+
import {
|
|
6
|
+
Checker,
|
|
7
|
+
ok,
|
|
8
|
+
PLAN_SCHEMA,
|
|
9
|
+
PLAN_VERSION,
|
|
10
|
+
snapshotDigest,
|
|
11
|
+
validateSetupPlan,
|
|
12
|
+
type ActionKind,
|
|
13
|
+
type DiscoverySnapshot,
|
|
14
|
+
type Result,
|
|
15
|
+
type SetupPlan,
|
|
16
|
+
type VerifyHarness,
|
|
17
|
+
type VerifyRuntime,
|
|
18
|
+
} from "../contracts/index.ts"
|
|
19
|
+
import { desiredState, type DesiredState } from "./desired.ts"
|
|
20
|
+
|
|
21
|
+
/** The only action kinds an audit may contain: probes that change nothing on disk or in the session manager. */
|
|
22
|
+
export const READ_ONLY_KINDS: readonly ActionKind[] = Object.freeze(["verify-harness", "verify-runtime"])
|
|
23
|
+
|
|
24
|
+
export interface AuditFinding {
|
|
25
|
+
/** "harness:<id>" or "runtime:<id>". */
|
|
26
|
+
readonly subject: string
|
|
27
|
+
/** missing: not installed (an audit never installs). unverified/verified/failed: discovery's health. present: a runtime found. */
|
|
28
|
+
readonly status: "missing" | "unverified" | "verified" | "failed" | "present"
|
|
29
|
+
readonly owner?: "hub" | "user"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuditReport {
|
|
33
|
+
readonly mode: "audit"
|
|
34
|
+
/** Always zero: an audit never writes. */
|
|
35
|
+
readonly writes: 0
|
|
36
|
+
readonly desired: DesiredState
|
|
37
|
+
/** Probe-only plan for this snapshot (checkReadOnly accepts it). */
|
|
38
|
+
readonly plan: SetupPlan
|
|
39
|
+
readonly findings: readonly AuditFinding[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function auditProfile(profile: unknown, snapshotDoc: unknown): Result<AuditReport> {
|
|
43
|
+
const desired = desiredState(profile, snapshotDoc)
|
|
44
|
+
if (!desired.ok) return desired
|
|
45
|
+
const snapshot = snapshotDoc as DiscoverySnapshot
|
|
46
|
+
const findings: AuditFinding[] = []
|
|
47
|
+
const actions: (VerifyHarness | VerifyRuntime)[] = []
|
|
48
|
+
for (const harness of desired.value.harnesses) {
|
|
49
|
+
const found = snapshot.harnesses.find((h) => h.id === harness.id)
|
|
50
|
+
if (harness.presence === "missing" || !found) {
|
|
51
|
+
findings.push({ subject: `harness:${harness.id}`, status: "missing", owner: harness.owner })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
findings.push({ subject: `harness:${harness.id}`, status: found.health.state, owner: harness.owner })
|
|
55
|
+
actions.push({ id: `audit-${harness.id}`, kind: "verify-harness", escalation: "user", summary: `Verify ${harness.id} (audit, read-only)`, harness: harness.id, probes: harness.verify })
|
|
56
|
+
}
|
|
57
|
+
for (const runtime of desired.value.runtimes) {
|
|
58
|
+
findings.push({ subject: `runtime:${runtime.id}`, status: "present" })
|
|
59
|
+
actions.push({ id: `audit-${runtime.id}`, kind: "verify-runtime", escalation: "user", summary: `Check ${runtime.id} (audit, read-only)`, runtime: runtime.id })
|
|
60
|
+
}
|
|
61
|
+
const plan: SetupPlan = {
|
|
62
|
+
schema: PLAN_SCHEMA,
|
|
63
|
+
version: PLAN_VERSION,
|
|
64
|
+
hostFingerprint: snapshot.host.fingerprint,
|
|
65
|
+
snapshotDigest: snapshotDigest(snapshot),
|
|
66
|
+
target: desired.value.target,
|
|
67
|
+
profile: desired.value.profile,
|
|
68
|
+
requiresEscalation: false,
|
|
69
|
+
activatesServices: false,
|
|
70
|
+
actions,
|
|
71
|
+
}
|
|
72
|
+
const checked = validateSetupPlan(plan)
|
|
73
|
+
if (!checked.ok) return checked
|
|
74
|
+
return ok({ mode: "audit", writes: 0, desired: desired.value, plan, findings })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Refuses a plan with any action that is not a read-only probe (each one is named). A valid plan's escalation and
|
|
79
|
+
* activation flags are exact (validateSetupPlan), so a probe-only plan never escalates or activates.
|
|
80
|
+
*/
|
|
81
|
+
export function checkReadOnly(plan: SetupPlan): Result<SetupPlan> {
|
|
82
|
+
const c = new Checker()
|
|
83
|
+
plan.actions.forEach((action, i) => {
|
|
84
|
+
if (!READ_ONLY_KINDS.includes(action.kind)) c.add(`/actions/${i}`, "inconsistent", `${action.kind} changes the machine; an audit only probes`)
|
|
85
|
+
})
|
|
86
|
+
return c.result(plan)
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The built-in profiles hubctl offers, as contract Profile documents. The catalog has a revision: a later hubctl may
|
|
2
|
+
// ship different defaults under a new revision (profile upgrades then keep every field the user chose; see
|
|
3
|
+
// upgrade.ts). Defaults are always the safe ones: no escalation, OpenShell only when asked for, launches bounded.
|
|
4
|
+
// v1.1: every catalog profile records the revision it comes from (Profile.revision), so a rebase knows its base.
|
|
5
|
+
import { fail, HARNESS_ORDER, HARNESS_SERVICE, PROFILE_IDS, validateProfile, type HarnessID, type Profile, type ProfileID, type Result, type ServiceID } from "../contracts/index.ts"
|
|
6
|
+
|
|
7
|
+
export interface ProfileCatalog {
|
|
8
|
+
readonly revision: number
|
|
9
|
+
readonly profiles: Readonly<Record<ProfileID, Profile>>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The bound on a launch when the user chose none. */
|
|
13
|
+
export const DEFAULT_LAUNCH_S = 3600
|
|
14
|
+
|
|
15
|
+
const base = { schema: "hub.bootstrap-profile", version: 2, revision: 1, runtimes: [], escalation: "never" } as const
|
|
16
|
+
|
|
17
|
+
export const PROFILE_CATALOG: ProfileCatalog = Object.freeze({
|
|
18
|
+
revision: 1,
|
|
19
|
+
profiles: Object.freeze({
|
|
20
|
+
"session-only": { ...base, id: "session-only", harnesses: ["opencode"], services: [], launch: { enabled: false } },
|
|
21
|
+
"launch-node": { ...base, id: "launch-node", harnesses: ["opencode"], services: ["node"], launch: { enabled: true, maxDurationS: DEFAULT_LAUNCH_S } },
|
|
22
|
+
"full-harness": {
|
|
23
|
+
...base,
|
|
24
|
+
id: "full-harness",
|
|
25
|
+
harnesses: [...HARNESS_ORDER],
|
|
26
|
+
services: ["node", "claude-code", "codex-spoke"],
|
|
27
|
+
launch: { enabled: true, maxDurationS: DEFAULT_LAUNCH_S },
|
|
28
|
+
},
|
|
29
|
+
} satisfies Record<ProfileID, Profile>),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export interface ProfileChoices {
|
|
33
|
+
/** The harnesses to set up (any order; they are put in HARNESS_ORDER). Default: the profile's. */
|
|
34
|
+
readonly harnesses?: readonly HarnessID[]
|
|
35
|
+
/** Opt in to OpenShell discovery and verification (never installed or configured). */
|
|
36
|
+
readonly openshell?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every spoke service the chosen harnesses run (full-harness only), after the node service. */
|
|
40
|
+
const spokeServices = (harnesses: readonly HarnessID[]): ServiceID[] => harnesses.flatMap((h) => HARNESS_SERVICE[h] ?? [])
|
|
41
|
+
|
|
42
|
+
/** A built-in profile with the user's choices applied, validated against the contract. */
|
|
43
|
+
export function defaultProfile(id: ProfileID, choices: ProfileChoices = {}, catalog: ProfileCatalog = PROFILE_CATALOG): Result<Profile> {
|
|
44
|
+
if (!(PROFILE_IDS as readonly string[]).includes(id) || !catalog.profiles[id]) return fail("/id", "invalid_value", `expected one of ${PROFILE_IDS.join(", ")}`)
|
|
45
|
+
const preset = catalog.profiles[id]
|
|
46
|
+
const harnesses = choices.harnesses ? [...choices.harnesses].sort((a, b) => HARNESS_ORDER.indexOf(a) - HARNESS_ORDER.indexOf(b)) : preset.harnesses
|
|
47
|
+
const services = choices.harnesses && id === "full-harness" ? (["node", ...spokeServices(harnesses)] as ServiceID[]) : preset.services
|
|
48
|
+
return validateProfile({ ...preset, harnesses, services, runtimes: choices.openshell ? ["openshell"] : preset.runtimes })
|
|
49
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Profile → desired state: what this host should end up with, as a pure function of (profile, discovery snapshot).
|
|
2
|
+
// It is a description, never a write: the planner turns it into actions, the audit mode only verifies it. Hosts it
|
|
3
|
+
// cannot serve fail explicitly (an unsupported target, services without a user service manager, OpenShell that is
|
|
4
|
+
// absent or was not discovered) instead of being half-configured. OpenShell is verify-only: hubctl never installs
|
|
5
|
+
// or configures it. A harness the user installed some other way stays theirs (owner "user"): hub verifies it and
|
|
6
|
+
// never replaces it.
|
|
7
|
+
import {
|
|
8
|
+
Checker,
|
|
9
|
+
fail,
|
|
10
|
+
REQUIRED_PROBES,
|
|
11
|
+
validateDiscoverySnapshot,
|
|
12
|
+
validateProfile,
|
|
13
|
+
type DiscoverySnapshot,
|
|
14
|
+
type HarnessID,
|
|
15
|
+
type Profile,
|
|
16
|
+
type ProbeKind,
|
|
17
|
+
type ProfileID,
|
|
18
|
+
type Result,
|
|
19
|
+
type RuntimeID,
|
|
20
|
+
type ServiceID,
|
|
21
|
+
type ServiceManagerID,
|
|
22
|
+
} from "../contracts/index.ts"
|
|
23
|
+
|
|
24
|
+
export interface DesiredHarness {
|
|
25
|
+
readonly id: HarnessID
|
|
26
|
+
readonly presence: "installed" | "missing"
|
|
27
|
+
/** hub: installed (or to be installed) by hubctl from a pinned artifact. user: installed another way; never replaced. */
|
|
28
|
+
readonly owner: "hub" | "user"
|
|
29
|
+
/** The version discovery found, when installed. */
|
|
30
|
+
readonly version?: string
|
|
31
|
+
/** The real probes readiness needs. */
|
|
32
|
+
readonly verify: readonly Exclude<ProbeKind, "presence">[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DesiredService {
|
|
36
|
+
readonly id: ServiceID
|
|
37
|
+
readonly manager: ServiceManagerID
|
|
38
|
+
/** Services are only ever activated on explicit confirmation. */
|
|
39
|
+
readonly activation: "explicit"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DesiredRuntime {
|
|
43
|
+
readonly id: RuntimeID
|
|
44
|
+
readonly mode: "verify-only"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DesiredState {
|
|
48
|
+
readonly profile: { readonly id: ProfileID; readonly version: Profile["version"] }
|
|
49
|
+
readonly target: string
|
|
50
|
+
readonly harnesses: readonly DesiredHarness[]
|
|
51
|
+
readonly services: readonly DesiredService[]
|
|
52
|
+
readonly runtimes: readonly DesiredRuntime[]
|
|
53
|
+
readonly launch: Profile["launch"]
|
|
54
|
+
readonly escalation: Profile["escalation"]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function desiredState(profileDoc: unknown, snapshotDoc: unknown): Result<DesiredState> {
|
|
58
|
+
const c = new Checker()
|
|
59
|
+
const checkedProfile = validateProfile(profileDoc)
|
|
60
|
+
const checkedSnapshot = validateDiscoverySnapshot(snapshotDoc)
|
|
61
|
+
c.merge("/profile", checkedProfile)
|
|
62
|
+
c.merge("/snapshot", checkedSnapshot)
|
|
63
|
+
if (!checkedProfile.ok || !checkedSnapshot.ok) return c.result(undefined)
|
|
64
|
+
const profile = checkedProfile.value
|
|
65
|
+
const snapshot: DiscoverySnapshot = checkedSnapshot.value
|
|
66
|
+
|
|
67
|
+
if (!snapshot.host.supported) return fail("/snapshot/host/target", "unsupported_platform", `cannot set up ${snapshot.host.target}`)
|
|
68
|
+
const manager = snapshot.managers.service
|
|
69
|
+
if (profile.services.length > 0 && manager === "none")
|
|
70
|
+
c.add("/profile/services", "no_service_manager", `${profile.id} runs services, but this host has no user service manager (launchd or systemd --user)`)
|
|
71
|
+
const runtimes = profile.runtimes.map((id, i): DesiredRuntime => {
|
|
72
|
+
const found = snapshot.runtimes.find((r) => r.id === id)
|
|
73
|
+
if (!found) c.add(`/profile/runtimes/${i}`, "unsupported_platform", `discovery did not check ${id}; it is verified only after discovery finds it`)
|
|
74
|
+
else if (!found.present) c.add(`/profile/runtimes/${i}`, "unsupported_platform", `${id} is not present on this host; hubctl verifies it but never installs it`)
|
|
75
|
+
return { id, mode: "verify-only" }
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const harnesses = profile.harnesses.map((id): DesiredHarness => {
|
|
79
|
+
const found = snapshot.harnesses.find((h) => h.id === id)
|
|
80
|
+
const verify = REQUIRED_PROBES[id].filter((k): k is Exclude<ProbeKind, "presence"> => k !== "presence")
|
|
81
|
+
if (!found?.installed) return { id, presence: "missing", owner: "hub", verify }
|
|
82
|
+
return { id, presence: "installed", owner: found.installMethod === "hub" ? "hub" : "user", ...(found.version ? { version: found.version } : {}), verify }
|
|
83
|
+
})
|
|
84
|
+
const services = manager === "none" ? [] : profile.services.map((id): DesiredService => ({ id, manager, activation: "explicit" }))
|
|
85
|
+
const launch = profile.launch.enabled ? { enabled: true, maxDurationS: profile.launch.maxDurationS } : { enabled: false }
|
|
86
|
+
|
|
87
|
+
return c.result<DesiredState>({
|
|
88
|
+
profile: { id: profile.id, version: profile.version },
|
|
89
|
+
target: snapshot.host.target,
|
|
90
|
+
harnesses,
|
|
91
|
+
services,
|
|
92
|
+
runtimes,
|
|
93
|
+
launch,
|
|
94
|
+
escalation: profile.escalation,
|
|
95
|
+
})
|
|
96
|
+
}
|