@huynle/hub 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The transaction journal, ownership records, recovery rules and the apply lock.
|
|
2
|
+
//
|
|
3
|
+
// apply takes the lock (O_EXCL create of state:hub/bootstrap/apply.lock), writes the journal before each action
|
|
4
|
+
// ("pending", with the file's state before and a backup reference), performs the action, then marks it "done"
|
|
5
|
+
// with the state after. The journal holds digests and modes, never contents (backups are separate owner-only
|
|
6
|
+
// files) and never credentials. After a crash, recovery walks the entries newest first and decides per entry
|
|
7
|
+
// (recoveryDecision): undo what is still exactly as we left it, keep anything the user changed since, and never
|
|
8
|
+
// restore a stale credential (a rolled-back enrollment is cleared, not reverted to the previous token).
|
|
9
|
+
import { ACTION_KINDS, type ActionKind } from "./actions.ts"
|
|
10
|
+
import { checkPathRef, type PathRef } from "./paths.ts"
|
|
11
|
+
import { findSecrets } from "./secrets.ts"
|
|
12
|
+
import { bool, Checker, digest, header, instant, int, isRecord, list, oneOf, pointer, shape, text, type Result } from "./validate.ts"
|
|
13
|
+
|
|
14
|
+
export const JOURNAL_SCHEMA = "hub.transaction-journal"
|
|
15
|
+
export const JOURNAL_STATES = ["prepared", "applying", "committed", "rolling-back", "rolled-back", "failed"] as const
|
|
16
|
+
export type JournalState = (typeof JOURNAL_STATES)[number]
|
|
17
|
+
export const ENTRY_CLASSES = ["file", "config", "credential", "service", "artifact", "harness", "link", "directory", "external"] as const
|
|
18
|
+
/** harness: an installed harness binary/version (rollback restores the previous one). external: a step with no local file (verify, launch, service activation); nothing to undo on disk. */
|
|
19
|
+
export type EntryClass = (typeof ENTRY_CLASSES)[number]
|
|
20
|
+
|
|
21
|
+
export interface FileState {
|
|
22
|
+
readonly exists: boolean
|
|
23
|
+
readonly sha256?: string
|
|
24
|
+
readonly mode?: number
|
|
25
|
+
readonly symlink?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One owned change. `before`/`after` are what the file was and what we made it; ownership is "we wrote `after`". */
|
|
29
|
+
export interface JournalEntry {
|
|
30
|
+
readonly seq: number
|
|
31
|
+
readonly actionID: string
|
|
32
|
+
readonly kind: ActionKind
|
|
33
|
+
readonly class: EntryClass
|
|
34
|
+
readonly path?: PathRef
|
|
35
|
+
readonly before: FileState
|
|
36
|
+
readonly after?: FileState
|
|
37
|
+
/** The owner-only copy of `before` (never for credentials). */
|
|
38
|
+
readonly backup?: { readonly id: string }
|
|
39
|
+
readonly status: "pending" | "done" | "undone" | "skipped"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TransactionJournal {
|
|
43
|
+
readonly schema: typeof JOURNAL_SCHEMA
|
|
44
|
+
readonly version: 1
|
|
45
|
+
readonly txID: string
|
|
46
|
+
readonly planDigest: string
|
|
47
|
+
readonly hostFingerprint: string
|
|
48
|
+
/** The preview nonce this apply consumed (replays are refused). */
|
|
49
|
+
readonly nonce: string
|
|
50
|
+
readonly state: JournalState
|
|
51
|
+
readonly startedAt: number
|
|
52
|
+
readonly updatedAt: number
|
|
53
|
+
readonly entries: readonly JournalEntry[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Classes whose previous content must be backed up before they are changed. */
|
|
57
|
+
const BACKED_UP: readonly EntryClass[] = ["file", "config", "service", "artifact", "harness", "link"]
|
|
58
|
+
const TX_ID = /^tx_[A-Za-z0-9]{1,64}$/
|
|
59
|
+
const BACKUP_ID = /^bk_[A-Za-z0-9]{1,64}$/
|
|
60
|
+
const NONCE = /^[A-Za-z0-9_-]{16,128}$/
|
|
61
|
+
|
|
62
|
+
function checkFileState(c: Checker, path: string, v: unknown) {
|
|
63
|
+
const r = shape(c, path, v, ["exists"], ["sha256", "mode", "symlink"])
|
|
64
|
+
if (!r) return
|
|
65
|
+
bool(c, pointer(path, "exists"), r.exists)
|
|
66
|
+
if (r.sha256 !== undefined) digest(c, pointer(path, "sha256"), r.sha256)
|
|
67
|
+
if (r.mode !== undefined) int(c, pointer(path, "mode"), r.mode, 0, 0o7777)
|
|
68
|
+
if (r.symlink !== undefined) bool(c, pointer(path, "symlink"), r.symlink)
|
|
69
|
+
if (r.exists === false && (r.sha256 !== undefined || r.mode !== undefined)) c.add(path, "inconsistent", "a missing file has no digest or mode")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateJournal(value: unknown): Result<TransactionJournal> {
|
|
73
|
+
const c = new Checker()
|
|
74
|
+
const r = shape(c, "", value, ["schema", "version", "txID", "planDigest", "hostFingerprint", "nonce", "state", "startedAt", "updatedAt", "entries"])
|
|
75
|
+
if (!r) return c.result(value)
|
|
76
|
+
header(c, "", r, JOURNAL_SCHEMA, 1)
|
|
77
|
+
text(c, "/txID", r.txID, 67, TX_ID)
|
|
78
|
+
digest(c, "/planDigest", r.planDigest)
|
|
79
|
+
digest(c, "/hostFingerprint", r.hostFingerprint)
|
|
80
|
+
text(c, "/nonce", r.nonce, 128, NONCE)
|
|
81
|
+
oneOf(c, "/state", r.state, JOURNAL_STATES)
|
|
82
|
+
if (instant(c, "/startedAt", r.startedAt) && instant(c, "/updatedAt", r.updatedAt) && (r.updatedAt as number) < (r.startedAt as number))
|
|
83
|
+
c.add("/updatedAt", "inconsistent", "updated before it started")
|
|
84
|
+
list(c, "/entries", r.entries, 256, (p, v) => {
|
|
85
|
+
const e = shape(c, p, v, ["seq", "actionID", "kind", "class", "before", "status"], ["path", "after", "backup"])
|
|
86
|
+
if (!e) return
|
|
87
|
+
const index = Number(p.slice(p.lastIndexOf("/") + 1))
|
|
88
|
+
if (int(c, pointer(p, "seq"), e.seq, 1, 256) && e.seq !== index + 1) c.add(pointer(p, "seq"), "inconsistent", "entries are numbered 1, 2, 3, … in order")
|
|
89
|
+
text(c, pointer(p, "actionID"), e.actionID, 64, /^[a-z0-9][a-z0-9-]{0,63}$/)
|
|
90
|
+
oneOf(c, pointer(p, "kind"), e.kind, ACTION_KINDS)
|
|
91
|
+
oneOf(c, pointer(p, "class"), e.class, ENTRY_CLASSES)
|
|
92
|
+
oneOf(c, pointer(p, "status"), e.status, ["pending", "done", "undone", "skipped"] as const)
|
|
93
|
+
if (e.path !== undefined) checkPathRef(c, pointer(p, "path"), e.path)
|
|
94
|
+
else if (e.class !== "external") c.add(pointer(p, "path"), "missing_field", "a local change names its path")
|
|
95
|
+
checkFileState(c, pointer(p, "before"), e.before)
|
|
96
|
+
if (e.after !== undefined) checkFileState(c, pointer(p, "after"), e.after)
|
|
97
|
+
if (e.backup !== undefined) {
|
|
98
|
+
const b = shape(c, pointer(p, "backup"), e.backup, ["id"])
|
|
99
|
+
if (b) text(c, pointer(pointer(p, "backup"), "id"), b.id, 67, BACKUP_ID)
|
|
100
|
+
}
|
|
101
|
+
const before = isRecord(e.before) ? e.before : {}
|
|
102
|
+
if (e.class === "credential" && e.backup !== undefined) c.add(pointer(p, "backup"), "inconsistent", "credentials are never backed up (a stale credential is never restored)")
|
|
103
|
+
// v1.1: a skipped entry changed nothing, so it has nothing to back up.
|
|
104
|
+
if (BACKED_UP.includes(e.class as EntryClass) && before.exists === true && e.backup === undefined && e.status !== "skipped")
|
|
105
|
+
c.add(pointer(p, "backup"), "inconsistent", "an existing file is backed up before it is changed")
|
|
106
|
+
if ((e.status === "done" || e.status === "undone") && e.after === undefined && e.class !== "external") c.add(pointer(p, "after"), "inconsistent", "a done entry records the state it left")
|
|
107
|
+
})
|
|
108
|
+
for (const p of findSecrets(value)) c.add(p, "secret", "journals never carry credentials")
|
|
109
|
+
return c.result(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type RecoveryDecision = "nothing-to-do" | "remove-created" | "restore-backup" | "clear-credential" | "keep-user-edit"
|
|
113
|
+
|
|
114
|
+
const sameState = (a: FileState, b: FileState) =>
|
|
115
|
+
a.exists === b.exists && (a.sha256 ?? null) === (b.sha256 ?? null) && (a.mode === undefined || b.mode === undefined || a.mode === b.mode) && Boolean(a.symlink) === Boolean(b.symlink)
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What recovery does for one entry, given the file's state now. Anything not exactly as we left it is a user edit
|
|
119
|
+
* and is kept (reported as a conflict), never overwritten.
|
|
120
|
+
*/
|
|
121
|
+
export function recoveryDecision(entry: JournalEntry, current: FileState): RecoveryDecision {
|
|
122
|
+
if (entry.status === "undone" || entry.status === "skipped" || entry.class === "external") return "nothing-to-do"
|
|
123
|
+
const ours = entry.after !== undefined && sameState(current, entry.after)
|
|
124
|
+
if (entry.status === "pending" && sameState(current, entry.before)) return "nothing-to-do"
|
|
125
|
+
// v1.1: an enrollment interrupted before it was journaled done records no `after`. The credential store writes
|
|
126
|
+
// atomically, so a changed file means the interrupted enrollment stored its credential: clear it (never restore).
|
|
127
|
+
if (entry.class === "credential" && entry.status === "pending" && entry.after === undefined) return "clear-credential"
|
|
128
|
+
if (!ours) return "keep-user-edit"
|
|
129
|
+
if (entry.class === "credential") return "clear-credential"
|
|
130
|
+
if (entry.before.exists) return entry.backup ? "restore-backup" : "keep-user-edit"
|
|
131
|
+
return "remove-created"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RecoveryStep {
|
|
135
|
+
readonly seq: number
|
|
136
|
+
readonly actionID: string
|
|
137
|
+
readonly decision: RecoveryDecision
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Recovery for an interrupted transaction, newest entry first; finished transactions need none. */
|
|
141
|
+
export function recoveryPlan(journal: TransactionJournal, stateOf: (entry: JournalEntry) => FileState): RecoveryStep[] {
|
|
142
|
+
if (journal.state === "committed" || journal.state === "rolled-back") return []
|
|
143
|
+
return [...journal.entries].reverse().map((entry) => ({ seq: entry.seq, actionID: entry.actionID, decision: recoveryDecision(entry, stateOf(entry)) }))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hooks the applier calls around each step (logging, progress, tests). They observe; they cannot change actions. */
|
|
147
|
+
export interface JournalHooks {
|
|
148
|
+
beforeAction?(entry: JournalEntry, journal: TransactionJournal): void | Promise<void>
|
|
149
|
+
afterAction?(entry: JournalEntry, journal: TransactionJournal): void | Promise<void>
|
|
150
|
+
onConflict?(entry: JournalEntry, current: FileState): void | Promise<void>
|
|
151
|
+
onRecover?(journal: TransactionJournal, steps: readonly RecoveryStep[]): void | Promise<void>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------- apply lock ----------
|
|
155
|
+
|
|
156
|
+
export const LOCK_SCHEMA = "hub.apply-lock"
|
|
157
|
+
/** The longest an apply may hold the lock before it is considered stale. */
|
|
158
|
+
export const LOCK_MAX_MS = 60 * 60_000
|
|
159
|
+
/** Where the lock lives (state root). */
|
|
160
|
+
export const LOCK_PATH: PathRef = { root: "state", rel: "hub/bootstrap/apply.lock" }
|
|
161
|
+
|
|
162
|
+
export interface ApplyLock {
|
|
163
|
+
readonly schema: typeof LOCK_SCHEMA
|
|
164
|
+
readonly version: 1
|
|
165
|
+
readonly pid: number
|
|
166
|
+
readonly hostFingerprint: string
|
|
167
|
+
readonly txID: string
|
|
168
|
+
readonly acquiredAt: number
|
|
169
|
+
readonly expiresAt: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateApplyLock(value: unknown): Result<ApplyLock> {
|
|
173
|
+
const c = new Checker()
|
|
174
|
+
const r = shape(c, "", value, ["schema", "version", "pid", "hostFingerprint", "txID", "acquiredAt", "expiresAt"])
|
|
175
|
+
if (!r) return c.result(value)
|
|
176
|
+
header(c, "", r, LOCK_SCHEMA, 1)
|
|
177
|
+
int(c, "/pid", r.pid, 1, 2 ** 31)
|
|
178
|
+
digest(c, "/hostFingerprint", r.hostFingerprint)
|
|
179
|
+
text(c, "/txID", r.txID, 67, TX_ID)
|
|
180
|
+
if (instant(c, "/acquiredAt", r.acquiredAt) && instant(c, "/expiresAt", r.expiresAt)) {
|
|
181
|
+
const span = (r.expiresAt as number) - (r.acquiredAt as number)
|
|
182
|
+
if (span <= 0 || span > LOCK_MAX_MS) c.add("/expiresAt", "invalid_value", `a lock is held for 1 ms..${LOCK_MAX_MS / 60_000} minutes`)
|
|
183
|
+
}
|
|
184
|
+
return c.result(value)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type LockState = "free" | "held" | "stale" | "foreign"
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* free: take it. held: another apply runs (refuse: exit locked). stale: same host, the holder died or overran
|
|
191
|
+
* (recover its journal, then take it). foreign: another host's lock (a shared home): never broken automatically.
|
|
192
|
+
*/
|
|
193
|
+
export function lockState(lock: ApplyLock | undefined, context: { readonly now: number; readonly hostFingerprint: string; readonly isAlive: (pid: number) => boolean }): LockState {
|
|
194
|
+
if (!lock) return "free"
|
|
195
|
+
if (lock.hostFingerprint !== context.hostFingerprint) return "foreign"
|
|
196
|
+
if (context.now >= lock.expiresAt || !context.isAlive(lock.pid)) return "stale"
|
|
197
|
+
return "held"
|
|
198
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Where the bootstrap may touch the filesystem. Plans never carry absolute paths: every path is a PathRef, a named
|
|
2
|
+
// root plus a relative path inside it. Discovery maps each root to this host's absolute directory; a relative path
|
|
3
|
+
// can never climb out of its root ("..", absolute, backslashes, NUL), and the applier refuses to write through any
|
|
4
|
+
// symlink between the root and the target (checkNoSymlinkEscape), so a planted link cannot redirect a write.
|
|
5
|
+
import { Checker, fail, isAbsolutePath, ok, oneOf, pointer, shape, type Result } from "./validate.ts"
|
|
6
|
+
|
|
7
|
+
export const ROOT_IDS = ["home", "config", "state", "data", "user-bin", "launch-agents", "systemd-user", "system-bin"] as const
|
|
8
|
+
export type RootID = (typeof ROOT_IDS)[number]
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Roots that need explicit escalation to write. Everything else is the user's own directories; the default
|
|
12
|
+
* install is unprivileged.
|
|
13
|
+
*/
|
|
14
|
+
export const ELEVATED_ROOTS: readonly RootID[] = ["system-bin"]
|
|
15
|
+
|
|
16
|
+
export interface PathRef {
|
|
17
|
+
readonly root: RootID
|
|
18
|
+
/** Relative, "/"-separated, inside the root. */
|
|
19
|
+
readonly rel: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A root as discovery found it on this host (and where a container sees it, when the host is a container). */
|
|
23
|
+
export interface RootMapping {
|
|
24
|
+
readonly id: RootID
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly containerPath?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const BAD_REL = /[\u0000-\u001f\u007f\\]/
|
|
31
|
+
|
|
32
|
+
export function isSafeRel(rel: unknown): rel is string {
|
|
33
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024 || rel.startsWith("/") || BAD_REL.test(rel)) return false
|
|
34
|
+
return rel.split("/").every((s) => s !== "" && s !== "." && s !== ".." && s.length <= 255)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function checkPathRef(c: Checker, path: string, value: unknown): value is PathRef {
|
|
38
|
+
const r = shape(c, path, value, ["root", "rel"])
|
|
39
|
+
if (!r) return false
|
|
40
|
+
const rootOK = oneOf(c, pointer(path, "root"), r.root, ROOT_IDS)
|
|
41
|
+
const relOK = isSafeRel(r.rel)
|
|
42
|
+
if (!relOK && r.rel !== undefined) c.add(pointer(path, "rel"), "invalid_path", "expected a relative path inside the root (no '..', '/', '\\' or control characters)")
|
|
43
|
+
return rootOK && relOK
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validatePathRef(value: unknown): Result<PathRef> {
|
|
47
|
+
const c = new Checker()
|
|
48
|
+
checkPathRef(c, "", value)
|
|
49
|
+
return c.result(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* v1.1: where the code that resolves a path runs. `path` is always the host's view of a root; `containerPath` is
|
|
54
|
+
* what a process inside the container sees (discovery records it only when it ran inside one). hubctl resolves
|
|
55
|
+
* paths for the context it runs in: inside the container, a root is `containerPath ?? path`; on the host, `path`.
|
|
56
|
+
*/
|
|
57
|
+
export const EXECUTION_CONTEXTS = ["host", "container"] as const
|
|
58
|
+
export type ExecutionContext = (typeof EXECUTION_CONTEXTS)[number]
|
|
59
|
+
|
|
60
|
+
/** The absolute directory of `root` as seen from `context`. */
|
|
61
|
+
export const rootPathIn = (root: RootMapping, context: ExecutionContext): string => (context === "container" ? (root.containerPath ?? root.path) : root.path)
|
|
62
|
+
|
|
63
|
+
/** The absolute path of `ref` on a host with these roots, as seen from `context` (default: the host). */
|
|
64
|
+
export function resolvePathRef(ref: PathRef, roots: readonly RootMapping[], context: ExecutionContext = "host"): Result<string> {
|
|
65
|
+
const checked = validatePathRef(ref)
|
|
66
|
+
if (!checked.ok) return checked as Result<string>
|
|
67
|
+
const root = roots.find((r) => r.id === ref.root)
|
|
68
|
+
const base = root ? rootPathIn(root, context) : undefined
|
|
69
|
+
if (!base || !isAbsolutePath(base)) return fail("/root", "unmapped_root", `this host has no ${ref.root} root`)
|
|
70
|
+
return ok(base === "/" ? `/${ref.rel}` : `${base}/${ref.rel}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type EntryKind = "file" | "directory" | "symlink" | "missing" | "other"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Walks from `rootAbs` to `rootAbs/rel` with `lstat` (which must not follow links) and fails at the first symlink. A missing component ends the walk: the rest would be created. The root itself is the
|
|
77
|
+
* mapping discovery reported and is not checked here.
|
|
78
|
+
*/
|
|
79
|
+
export function checkNoSymlinkEscape(rootAbs: string, rel: string, lstat: (path: string) => EntryKind): Result<string> {
|
|
80
|
+
if (!isAbsolutePath(rootAbs)) return fail("", "invalid_path", "the root must be an absolute, normalized path")
|
|
81
|
+
if (!isSafeRel(rel)) return fail("", "invalid_path", "expected a relative path inside the root")
|
|
82
|
+
const parts = rel.split("/")
|
|
83
|
+
let current = rootAbs === "/" ? "" : rootAbs
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
current = `${current}/${part}`
|
|
86
|
+
const kind = lstat(current)
|
|
87
|
+
if (kind === "symlink") return fail("", "symlink", `${current} is a symbolic link; refusing to write through it`)
|
|
88
|
+
if (kind === "missing") break
|
|
89
|
+
}
|
|
90
|
+
return ok(rootAbs === "/" ? `/${rel}` : `${rootAbs}/${rel}`)
|
|
91
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SetupPlan and PreviewToken. A plan is a pure function of (discovery snapshot, profile): no timestamps, no
|
|
2
|
+
// randomness, so the same host and choices always produce the same plan and the same digest. `hubctl setup
|
|
3
|
+
// preview` shows the plan and issues a preview token bound to the host fingerprint, the snapshot digest and the
|
|
4
|
+
// plan digest, with a short expiry and a single-use nonce. `apply` never trusts the token's content: it rediscovers,
|
|
5
|
+
// re-plans, and applies only when the fresh plan's digest equals the previewed one (revalidatePreview), so a stale,
|
|
6
|
+
// foreign, expired or replayed preview applies nothing.
|
|
7
|
+
import { checkAction, type Action } from "./actions.ts"
|
|
8
|
+
import { digestOf } from "./canonical.ts"
|
|
9
|
+
import { PLAN_PROFILE_VERSIONS, PROFILE_IDS, type PlanProfileVersion, type ProfileID } from "./profile.ts"
|
|
10
|
+
import { checkTarget } from "./platform.ts"
|
|
11
|
+
import { bool, Checker, digest, fail, header, instant, isRecord, list, ok, oneOf, shape, text, type Result } from "./validate.ts"
|
|
12
|
+
|
|
13
|
+
export const PLAN_SCHEMA = "hub.setup-plan"
|
|
14
|
+
export const PLAN_VERSION = 1
|
|
15
|
+
export const PLAN_MAX_ACTIONS = 256
|
|
16
|
+
|
|
17
|
+
export interface SetupPlan {
|
|
18
|
+
readonly schema: typeof PLAN_SCHEMA
|
|
19
|
+
readonly version: typeof PLAN_VERSION
|
|
20
|
+
readonly hostFingerprint: string
|
|
21
|
+
/** digestOf the DiscoverySnapshot the plan was made from. */
|
|
22
|
+
readonly snapshotDigest: string
|
|
23
|
+
readonly target: string
|
|
24
|
+
/** v1.1: the profile version it was planned from (1 for plans made by a v1.0 hubctl). */
|
|
25
|
+
readonly profile: { readonly id: ProfileID; readonly version: PlanProfileVersion }
|
|
26
|
+
/** True iff some action is elevated; apply then needs explicit consent (--escalate). */
|
|
27
|
+
readonly requiresEscalation: boolean
|
|
28
|
+
/** True iff the plan activates a service; apply then needs explicit consent. */
|
|
29
|
+
readonly activatesServices: boolean
|
|
30
|
+
/** In execution order. */
|
|
31
|
+
readonly actions: readonly Action[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateSetupPlan(value: unknown): Result<SetupPlan> {
|
|
35
|
+
const c = new Checker()
|
|
36
|
+
const r = shape(c, "", value, ["schema", "version", "hostFingerprint", "snapshotDigest", "target", "profile", "requiresEscalation", "activatesServices", "actions"])
|
|
37
|
+
if (!r) return c.result(value)
|
|
38
|
+
header(c, "", r, PLAN_SCHEMA, PLAN_VERSION)
|
|
39
|
+
digest(c, "/hostFingerprint", r.hostFingerprint)
|
|
40
|
+
digest(c, "/snapshotDigest", r.snapshotDigest)
|
|
41
|
+
if (typeof r.target !== "string" || !checkTarget(r.target).ok) c.add("/target", "unsupported_platform", `cannot plan for ${String(r.target)}`)
|
|
42
|
+
const profile = shape(c, "/profile", r.profile, ["id", "version"])
|
|
43
|
+
if (profile) {
|
|
44
|
+
oneOf(c, "/profile/id", profile.id, PROFILE_IDS)
|
|
45
|
+
if (!(PLAN_PROFILE_VERSIONS as readonly unknown[]).includes(profile.version)) c.add("/profile/version", "unsupported_version", `profile version must be one of ${PLAN_PROFILE_VERSIONS.join(", ")}`)
|
|
46
|
+
}
|
|
47
|
+
bool(c, "/requiresEscalation", r.requiresEscalation)
|
|
48
|
+
bool(c, "/activatesServices", r.activatesServices)
|
|
49
|
+
if (!list(c, "/actions", r.actions, PLAN_MAX_ACTIONS, (p, v) => checkAction(c, p, v), (v) => (isRecord(v) && typeof v.id === "string" ? v.id : undefined))) return c.result(value)
|
|
50
|
+
|
|
51
|
+
const actions = (r.actions as unknown[]).filter(isRecord)
|
|
52
|
+
const elevated = actions.some((a) => a.escalation === "elevated")
|
|
53
|
+
if (r.requiresEscalation !== elevated) c.add("/requiresEscalation", "inconsistent", "requiresEscalation must say exactly whether an action is elevated")
|
|
54
|
+
const activates = actions.some((a) => a.kind === "activate-service")
|
|
55
|
+
if (r.activatesServices !== activates) c.add("/activatesServices", "inconsistent", "activatesServices must say exactly whether a service is activated")
|
|
56
|
+
const installed = new Set<string>()
|
|
57
|
+
const verified = new Set<string>()
|
|
58
|
+
const unverifiedInstalls = new Map<string, number>()
|
|
59
|
+
// v1.1: an installed version is made current (select-harness-version) before it is verified.
|
|
60
|
+
const awaitingSelect = new Map<string, { version: string; index: number }>()
|
|
61
|
+
const removed = new Set<string>()
|
|
62
|
+
const installAt = (harness: string, version: string) => actions.findIndex((a) => a.kind === "install-harness" && a.harness === harness && a.version === version)
|
|
63
|
+
let enrollments = 0
|
|
64
|
+
actions.forEach((a, i) => {
|
|
65
|
+
const at = `/actions/${i}`
|
|
66
|
+
const service = `${String(a.service)}@${String(a.manager)}`
|
|
67
|
+
if (a.kind === "install-service") installed.add(service)
|
|
68
|
+
if (a.kind === "install-harness") {
|
|
69
|
+
unverifiedInstalls.set(String(a.harness), i)
|
|
70
|
+
awaitingSelect.set(String(a.harness), { version: String(a.version), index: i })
|
|
71
|
+
}
|
|
72
|
+
if (a.kind === "select-harness-version") {
|
|
73
|
+
const pending = awaitingSelect.get(String(a.harness))
|
|
74
|
+
if (pending?.version === a.version) awaitingSelect.delete(String(a.harness))
|
|
75
|
+
else if (installAt(String(a.harness), String(a.version)) > i) c.add(at, "inconsistent", "a harness version is installed before it is made current")
|
|
76
|
+
unverifiedInstalls.set(String(a.harness), i)
|
|
77
|
+
}
|
|
78
|
+
if (a.kind === "verify-harness") {
|
|
79
|
+
if (awaitingSelect.has(String(a.harness))) c.add(at, "inconsistent", "a harness is verified after its installed version is made current")
|
|
80
|
+
verified.add(String(a.harness))
|
|
81
|
+
unverifiedInstalls.delete(String(a.harness))
|
|
82
|
+
}
|
|
83
|
+
if (a.kind === "remove-service") removed.add(service)
|
|
84
|
+
if (a.kind === "deactivate-service" && removed.has(service)) c.add(at, "inconsistent", "a service is deactivated before it is removed")
|
|
85
|
+
if (a.kind === "activate-service" && !installed.has(service)) c.add(at, "inconsistent", "a service is installed before it is activated")
|
|
86
|
+
if (a.kind === "launch-session" && !verified.has(String(a.harness))) c.add(at, "inconsistent", "a harness is verified before a session is launched on it")
|
|
87
|
+
if (a.kind === "enroll-machine" && ++enrollments > 1) c.add(at, "duplicate", "a plan enrolls the machine at most once")
|
|
88
|
+
})
|
|
89
|
+
for (const i of unverifiedInstalls.values()) c.add(`/actions/${i}`, "inconsistent", "an installed harness is verified afterwards (install is not readiness)")
|
|
90
|
+
for (const { index } of awaitingSelect.values()) c.add(`/actions/${index}`, "inconsistent", "an installed harness version is made current afterwards (select-harness-version)")
|
|
91
|
+
return c.result(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The digest previews and journals pin. */
|
|
95
|
+
export const planDigest = (plan: SetupPlan): string => digestOf(plan)
|
|
96
|
+
|
|
97
|
+
export const PREVIEW_SCHEMA = "hub.preview-token"
|
|
98
|
+
export const PREVIEW_VERSION = 1
|
|
99
|
+
export const PREVIEW_MAX_TTL_MS = 15 * 60_000
|
|
100
|
+
/** How far a preview's issue time may be ahead of this clock. */
|
|
101
|
+
export const PREVIEW_CLOCK_SKEW_MS = 60_000
|
|
102
|
+
const NONCE = /^[A-Za-z0-9_-]{16,128}$/
|
|
103
|
+
|
|
104
|
+
export interface PreviewToken {
|
|
105
|
+
readonly schema: typeof PREVIEW_SCHEMA
|
|
106
|
+
readonly version: typeof PREVIEW_VERSION
|
|
107
|
+
readonly planDigest: string
|
|
108
|
+
readonly hostFingerprint: string
|
|
109
|
+
readonly snapshotDigest: string
|
|
110
|
+
readonly issuedAt: number
|
|
111
|
+
readonly expiresAt: number
|
|
112
|
+
/** Single use: apply records it in the journal and refuses it again. */
|
|
113
|
+
readonly nonce: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validatePreviewToken(value: unknown): Result<PreviewToken> {
|
|
117
|
+
const c = new Checker()
|
|
118
|
+
const r = shape(c, "", value, ["schema", "version", "planDigest", "hostFingerprint", "snapshotDigest", "issuedAt", "expiresAt", "nonce"])
|
|
119
|
+
if (!r) return c.result(value)
|
|
120
|
+
header(c, "", r, PREVIEW_SCHEMA, PREVIEW_VERSION)
|
|
121
|
+
digest(c, "/planDigest", r.planDigest)
|
|
122
|
+
digest(c, "/hostFingerprint", r.hostFingerprint)
|
|
123
|
+
digest(c, "/snapshotDigest", r.snapshotDigest)
|
|
124
|
+
if (instant(c, "/issuedAt", r.issuedAt) && instant(c, "/expiresAt", r.expiresAt)) {
|
|
125
|
+
const ttl = (r.expiresAt as number) - (r.issuedAt as number)
|
|
126
|
+
if (ttl <= 0 || ttl > PREVIEW_MAX_TTL_MS) c.add("/expiresAt", "invalid_value", `a preview lives 1 ms..${PREVIEW_MAX_TTL_MS / 60_000} minutes`)
|
|
127
|
+
}
|
|
128
|
+
text(c, "/nonce", r.nonce, 128, NONCE)
|
|
129
|
+
return c.result(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RevalidationContext {
|
|
133
|
+
readonly now: number
|
|
134
|
+
/** This host's fingerprint, computed fresh. */
|
|
135
|
+
readonly hostFingerprint: string
|
|
136
|
+
/** The plan made fresh from a new discovery. */
|
|
137
|
+
readonly plan: SetupPlan
|
|
138
|
+
/** Nonces already consumed by earlier applies (from the journals). */
|
|
139
|
+
readonly usedNonces: ReadonlySet<string>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether `apply` may proceed with this preview. Checked in this order; the first failure is the answer. */
|
|
143
|
+
export function revalidatePreview(token: unknown, context: RevalidationContext): Result<PreviewToken> {
|
|
144
|
+
const checked = validatePreviewToken(token)
|
|
145
|
+
if (!checked.ok) return checked
|
|
146
|
+
const t = checked.value
|
|
147
|
+
if (t.hostFingerprint !== context.hostFingerprint || context.plan.hostFingerprint !== context.hostFingerprint)
|
|
148
|
+
return fail("/hostFingerprint", "preview_foreign", "this preview was made for another host or account")
|
|
149
|
+
if (context.now >= t.expiresAt) return fail("/expiresAt", "preview_expired", "the preview expired; run the preview again")
|
|
150
|
+
if (t.issuedAt > context.now + PREVIEW_CLOCK_SKEW_MS) return fail("/issuedAt", "preview_not_yet_valid", "the preview is from the future (clock skew?)")
|
|
151
|
+
if (context.usedNonces.has(t.nonce)) return fail("/nonce", "preview_replayed", "this preview was already applied")
|
|
152
|
+
if (t.snapshotDigest !== context.plan.snapshotDigest || t.planDigest !== planDigest(context.plan))
|
|
153
|
+
return fail("/planDigest", "preview_stale", "the host or the plan changed since the preview; run the preview again")
|
|
154
|
+
return ok(t)
|
|
155
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The supported platform matrix: the only hosts a hubctl artifact is built for and a setup plan may target. Every
|
|
2
|
+
// other host is named explicitly with the reason it is refused, so "unsupported" is a clear answer rather than a
|
|
3
|
+
// guess at a nearby target.
|
|
4
|
+
import { fail, ok, type Result } from "./validate.ts"
|
|
5
|
+
|
|
6
|
+
export type Platform = "darwin" | "linux"
|
|
7
|
+
export type Arch = "arm64" | "x64"
|
|
8
|
+
/** "none" on macOS; "unknown" when a Linux host's C library could not be identified. */
|
|
9
|
+
export type Libc = "none" | "glibc" | "musl" | "unknown"
|
|
10
|
+
export type ServiceManager = "launchd" | "systemd-user"
|
|
11
|
+
|
|
12
|
+
export interface SupportedTarget {
|
|
13
|
+
readonly target: string
|
|
14
|
+
readonly platform: Platform
|
|
15
|
+
readonly arch: Arch
|
|
16
|
+
readonly libc: Libc
|
|
17
|
+
/** The per-user service manager services are installed for on this target. */
|
|
18
|
+
readonly serviceManager: ServiceManager
|
|
19
|
+
/**
|
|
20
|
+
* The `bun build --compile --target` the release builds this target's hubctl with. x64 targets use Bun's
|
|
21
|
+
* `-baseline` runtime: the default x64 runtime needs AVX2 and dies with SIGILL (exit 132) on CPUs without it,
|
|
22
|
+
* including QEMU's default kvm64 model, before hubctl can even print --version.
|
|
23
|
+
*/
|
|
24
|
+
readonly bunTarget: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_TARGETS: readonly SupportedTarget[] = Object.freeze([
|
|
28
|
+
{ target: "darwin-arm64", platform: "darwin", arch: "arm64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-arm64" },
|
|
29
|
+
{ target: "darwin-x64", platform: "darwin", arch: "x64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-x64-baseline" },
|
|
30
|
+
{ target: "linux-x64", platform: "linux", arch: "x64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-x64-baseline" },
|
|
31
|
+
{ target: "linux-arm64", platform: "linux", arch: "arm64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-arm64" },
|
|
32
|
+
] as const)
|
|
33
|
+
|
|
34
|
+
export interface UnsupportedTarget {
|
|
35
|
+
readonly target: string
|
|
36
|
+
readonly reason: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const UNSUPPORTED_TARGETS: readonly UnsupportedTarget[] = Object.freeze([
|
|
40
|
+
{ target: "windows-x64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
|
|
41
|
+
{ target: "windows-arm64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
|
|
42
|
+
{ target: "linux-x64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
|
|
43
|
+
{ target: "linux-arm64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
|
|
44
|
+
] as const)
|
|
45
|
+
|
|
46
|
+
const PLATFORM_NAME: Record<string, string> = { darwin: "darwin", linux: "linux", win32: "windows" }
|
|
47
|
+
|
|
48
|
+
/** The target name of a host as Node reports it (process.platform, process.arch) plus its C library. */
|
|
49
|
+
export function targetOf(platform: string, arch: string, libc: Libc): string {
|
|
50
|
+
const name = PLATFORM_NAME[platform] ?? platform
|
|
51
|
+
return `${name}-${arch}${name === "linux" && libc === "musl" ? "-musl" : ""}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const supportedTarget = (target: string): SupportedTarget | undefined => SUPPORTED_TARGETS.find((t) => t.target === target)
|
|
55
|
+
|
|
56
|
+
/** The target's entry in the matrix, or `unsupported_platform` with the documented reason. */
|
|
57
|
+
export function checkTarget(target: string): Result<SupportedTarget> {
|
|
58
|
+
const supported = supportedTarget(target)
|
|
59
|
+
if (supported) return ok(supported)
|
|
60
|
+
const known = UNSUPPORTED_TARGETS.find((t) => t.target === target)
|
|
61
|
+
return fail("", "unsupported_platform", known ? `${target} is not supported: ${known.reason}` : `${target} is not a supported target (supported: ${SUPPORTED_TARGETS.map((t) => t.target).join(", ")})`)
|
|
62
|
+
}
|