@huynle/hub 0.0.0-stage → 0.6.0
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 +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -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 +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Hub configuration: HUB_* environment variables (legacy VOICE_* still read, see ../env.ts) over the
|
|
2
|
+
// optional hub config file (~/.config/hub/hub.json or $HUB_CONFIG) over built-in defaults. The file's speech
|
|
3
|
+
// settings sit between the phone's saved settings and the env (see speech/settings.ts).
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { hubEnv } from "../env"
|
|
8
|
+
import { isLoopbackHost } from "../loopback"
|
|
9
|
+
import { APP_DIR, configBase, legacyPaths, migrateFirst, stateBase } from "../migrate"
|
|
10
|
+
import { updateSpeech, type StoredSpeech } from "./speech/settings"
|
|
11
|
+
|
|
12
|
+
export const GOOGLE_ISSUER = "https://accounts.google.com"
|
|
13
|
+
|
|
14
|
+
/** How browsers get access: pairing codes only (one owner), or OpenID Connect sign-in (several users). */
|
|
15
|
+
export type AuthConfig =
|
|
16
|
+
| { readonly mode: "pairing" }
|
|
17
|
+
| {
|
|
18
|
+
readonly mode: "oidc"
|
|
19
|
+
/** Without a trailing slash; discovery at <issuer>/.well-known/openid-configuration. */
|
|
20
|
+
readonly issuer: string
|
|
21
|
+
readonly clientId: string
|
|
22
|
+
/** Non-enumerable: never serialized or printed with the config. */
|
|
23
|
+
readonly clientSecret: string
|
|
24
|
+
/** Lowercased emails and @domain entries. */
|
|
25
|
+
readonly allowed: readonly string[]
|
|
26
|
+
/** Lowercased admin emails (also allowed). */
|
|
27
|
+
readonly admins: readonly string[]
|
|
28
|
+
/** Dev only (HUB_OIDC_INSECURE_ISSUER=1): the issuer is plain http on loopback. */
|
|
29
|
+
readonly insecureIssuer?: true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type OidcConfig = Extract<AuthConfig, { mode: "oidc" }>
|
|
33
|
+
|
|
34
|
+
/** hub.json `auth`, validated (lists already split and lowercased). */
|
|
35
|
+
export interface HubAuth {
|
|
36
|
+
readonly mode?: "pairing" | "oidc"
|
|
37
|
+
readonly issuer?: string
|
|
38
|
+
readonly clientId?: string
|
|
39
|
+
readonly clientSecret?: string
|
|
40
|
+
readonly allowed?: readonly string[]
|
|
41
|
+
readonly admins?: readonly string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CentralConfig {
|
|
45
|
+
readonly host: string
|
|
46
|
+
readonly port: number
|
|
47
|
+
/** Origin used in links, cookies (Secure when https) and CSRF checks; no trailing slash. */
|
|
48
|
+
readonly publicURL: string
|
|
49
|
+
readonly stateDir: string
|
|
50
|
+
readonly apiKey: string | undefined
|
|
51
|
+
readonly sttModel: string
|
|
52
|
+
readonly ttsModel: string
|
|
53
|
+
readonly voice: string
|
|
54
|
+
readonly maxAudioBytes: number
|
|
55
|
+
readonly maxSpeechChars: number
|
|
56
|
+
readonly turnTimeoutMs: number
|
|
57
|
+
/** VAPID subject (mailto: or https: URL) sent to push services. */
|
|
58
|
+
readonly vapidSubject: string
|
|
59
|
+
/** Speech settings from hub.json (below the phone's saved settings, above the env). */
|
|
60
|
+
readonly speech?: StoredSpeech
|
|
61
|
+
readonly auth: AuthConfig
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** hub.json, validated. */
|
|
65
|
+
export interface HubFile {
|
|
66
|
+
readonly host?: string
|
|
67
|
+
readonly port?: number
|
|
68
|
+
readonly publicURL?: string
|
|
69
|
+
readonly stateDir?: string
|
|
70
|
+
readonly speech?: StoredSpeech
|
|
71
|
+
readonly auth?: HubAuth
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
75
|
+
|
|
76
|
+
const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth"])
|
|
77
|
+
const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
|
|
78
|
+
const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
|
|
79
|
+
const DOMAIN = /^@[^\s@,]+\.[^\s@,]+$/
|
|
80
|
+
|
|
81
|
+
/** Splits a comma list (or takes an array), trims and lowercases; empty entries dropped. */
|
|
82
|
+
function emailList(value: string | readonly string[] | undefined): string[] {
|
|
83
|
+
const items = typeof value === "string" ? value.split(",") : (value ?? [])
|
|
84
|
+
return items.map((item) => item.trim().toLowerCase()).filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True when the (verified) email is an admin or on the allow-list: exact email or `@domain` (no subdomains). */
|
|
88
|
+
export function emailAllowed(auth: { readonly allowed: readonly string[]; readonly admins: readonly string[] }, email: string): boolean {
|
|
89
|
+
const e = email.trim().toLowerCase()
|
|
90
|
+
if (!EMAIL.test(e)) return false
|
|
91
|
+
const domain = e.slice(e.lastIndexOf("@"))
|
|
92
|
+
return auth.admins.includes(e) || auth.allowed.includes(e) || auth.allowed.includes(domain)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function int(value: string | undefined, fallback: number, min = 1): number {
|
|
96
|
+
if (value === undefined || value.trim() === "") return fallback
|
|
97
|
+
const n = Number(value)
|
|
98
|
+
return Number.isInteger(n) && n >= min ? n : fallback
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const expandHome = (p: string, home: string) => (p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p)
|
|
102
|
+
|
|
103
|
+
export function hubConfigPath(env: Env = process.env, home: string = homedir()): string {
|
|
104
|
+
const explicit = hubEnv(env).HUB_CONFIG
|
|
105
|
+
if (explicit) return explicit
|
|
106
|
+
return path.join(configBase(env, home), APP_DIR, "hub.json")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
110
|
+
|
|
111
|
+
/** Removes apiKey from both speech sections; true when there was one. */
|
|
112
|
+
function stripKeys(speech: Record<string, unknown>): boolean {
|
|
113
|
+
let found = false
|
|
114
|
+
for (const kind of ["stt", "tts"]) {
|
|
115
|
+
const section = speech[kind]
|
|
116
|
+
if (isObj(section) && "apiKey" in section) {
|
|
117
|
+
found = true
|
|
118
|
+
const { apiKey: _drop, ...rest } = section
|
|
119
|
+
speech[kind] = rest
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return found
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reads and validates hub.json. A missing file yields no settings. Invalid JSON or values throw (with
|
|
127
|
+
* the file name). API keys are refused, with a warning, when the file is readable by group or others.
|
|
128
|
+
*/
|
|
129
|
+
export function readHubFile(file: string, home: string = homedir()): { file?: HubFile; warnings: string[] } {
|
|
130
|
+
if (!existsSync(file)) return { warnings: [] }
|
|
131
|
+
const fail = (message: string): never => {
|
|
132
|
+
throw new Error(`${file}: ${message}`)
|
|
133
|
+
}
|
|
134
|
+
let raw: unknown
|
|
135
|
+
try {
|
|
136
|
+
raw = JSON.parse(readFileSync(file, "utf8"))
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
return fail(`not valid JSON (${cause instanceof Error ? cause.message : cause})`)
|
|
139
|
+
}
|
|
140
|
+
if (!isObj(raw)) return fail("expected a JSON object")
|
|
141
|
+
|
|
142
|
+
const warnings: string[] = []
|
|
143
|
+
for (const key of Object.keys(raw)) if (!HUB_KEYS.has(key)) warnings.push(`${file}: unknown key "${key}" ignored`)
|
|
144
|
+
|
|
145
|
+
const hub: { -readonly [K in keyof HubFile]: HubFile[K] } = {}
|
|
146
|
+
if (raw.host !== undefined) {
|
|
147
|
+
if (typeof raw.host !== "string" || !raw.host.trim()) fail("host must be a non-empty string")
|
|
148
|
+
hub.host = (raw.host as string).trim()
|
|
149
|
+
}
|
|
150
|
+
if (raw.port !== undefined) {
|
|
151
|
+
const port = raw.port
|
|
152
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) fail("port must be an integer from 0 to 65535")
|
|
153
|
+
hub.port = port as number
|
|
154
|
+
}
|
|
155
|
+
if (raw.publicURL !== undefined) {
|
|
156
|
+
if (typeof raw.publicURL !== "string" || !raw.publicURL.trim()) fail("publicURL must be a string")
|
|
157
|
+
hub.publicURL = (raw.publicURL as string).trim()
|
|
158
|
+
}
|
|
159
|
+
if (raw.stateDir !== undefined) {
|
|
160
|
+
if (typeof raw.stateDir !== "string" || !raw.stateDir.trim()) fail("stateDir must be a non-empty string")
|
|
161
|
+
hub.stateDir = expandHome((raw.stateDir as string).trim(), home)
|
|
162
|
+
}
|
|
163
|
+
if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
|
|
164
|
+
if (raw.speech !== undefined) {
|
|
165
|
+
if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
|
|
166
|
+
const speech = { ...(raw.speech as Record<string, unknown>) }
|
|
167
|
+
const shared = (statSync(file).mode & 0o044) !== 0
|
|
168
|
+
if (shared && stripKeys(speech)) {
|
|
169
|
+
warnings.push(`${file} is readable by other users, so its API keys are ignored. Fix it with: chmod 600 ${file}`)
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
hub.speech = updateSpeech({}, speech)
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
fail(`speech: ${cause instanceof Error ? cause.message : cause}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { file: hub, warnings }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readHubAuth(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubAuth {
|
|
181
|
+
if (!isObj(raw)) fail("auth must be an object")
|
|
182
|
+
const input = raw as Record<string, unknown>
|
|
183
|
+
for (const key of Object.keys(input)) if (!AUTH_KEYS.has(key)) warnings.push(`${file}: unknown key "auth.${key}" ignored`)
|
|
184
|
+
const auth: { -readonly [K in keyof HubAuth]: HubAuth[K] } = {}
|
|
185
|
+
if (input.mode !== undefined) {
|
|
186
|
+
if (input.mode !== "pairing" && input.mode !== "oidc") fail('auth.mode must be "pairing" or "oidc"')
|
|
187
|
+
auth.mode = input.mode as "pairing" | "oidc"
|
|
188
|
+
}
|
|
189
|
+
for (const key of ["issuer", "clientId", "clientSecret"] as const) {
|
|
190
|
+
const value = input[key]
|
|
191
|
+
if (value === undefined) continue
|
|
192
|
+
if (typeof value !== "string" || !value.trim()) fail(`auth.${key} must be a non-empty string`)
|
|
193
|
+
auth[key] = (value as string).trim()
|
|
194
|
+
}
|
|
195
|
+
for (const key of ["allowed", "admins"] as const) {
|
|
196
|
+
const value = input[key]
|
|
197
|
+
if (value === undefined) continue
|
|
198
|
+
const ok = typeof value === "string" || (Array.isArray(value) && value.every((v) => typeof v === "string"))
|
|
199
|
+
if (!ok) fail(`auth.${key} must be a list of emails (array of strings or a comma list)`)
|
|
200
|
+
auth[key] = emailList(value as string | string[])
|
|
201
|
+
}
|
|
202
|
+
if (auth.clientSecret !== undefined && (statSync(file).mode & 0o044) !== 0) {
|
|
203
|
+
delete auth.clientSecret
|
|
204
|
+
warnings.push(`${file} is readable by other users, so its auth.clientSecret is ignored. Fix it with: chmod 600 ${file}`)
|
|
205
|
+
}
|
|
206
|
+
return auth
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Validates the OIDC issuer: https, or plain http on loopback when the dev flag is set. No trailing slash. */
|
|
210
|
+
function issuerURL(raw: string, insecure: boolean): string {
|
|
211
|
+
let url: URL
|
|
212
|
+
try {
|
|
213
|
+
url = new URL(raw)
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error(`HUB_OIDC_ISSUER (auth.issuer) must be a URL, got ${JSON.stringify(raw)}`)
|
|
216
|
+
}
|
|
217
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
218
|
+
throw new Error("HUB_OIDC_ISSUER (auth.issuer) must not contain credentials, a query or a fragment")
|
|
219
|
+
}
|
|
220
|
+
if (url.protocol === "http:" && insecure) {
|
|
221
|
+
if (!isLoopbackHost(url.hostname)) throw new Error("HUB_OIDC_INSECURE_ISSUER only allows a plain http issuer on loopback (dev only)")
|
|
222
|
+
} else if (url.protocol !== "https:") {
|
|
223
|
+
throw new Error(`HUB_OIDC_ISSUER (auth.issuer) must be an https:// URL, got ${JSON.stringify(raw)}`)
|
|
224
|
+
}
|
|
225
|
+
return url.toString().replace(/\/+$/, "")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Auth settings from env over hub.json. Throws (without echoing secrets) when oidc is incomplete. */
|
|
229
|
+
function authConfig(env: Env, file: HubAuth = {}): AuthConfig {
|
|
230
|
+
const mode = (env.HUB_AUTH?.trim() || file.mode || "pairing").toLowerCase()
|
|
231
|
+
if (mode === "pairing") return { mode: "pairing" }
|
|
232
|
+
if (mode !== "oidc") throw new Error(`HUB_AUTH must be "pairing" or "oidc", got ${JSON.stringify(env.HUB_AUTH)}`)
|
|
233
|
+
const insecure = env.HUB_OIDC_INSECURE_ISSUER === "1"
|
|
234
|
+
const issuer = issuerURL(env.HUB_OIDC_ISSUER?.trim() || file.issuer || GOOGLE_ISSUER, insecure)
|
|
235
|
+
const required = (value: string | undefined, name: string): string => {
|
|
236
|
+
if (!value) throw new Error(`${name} is required for HUB_AUTH=oidc`)
|
|
237
|
+
return value
|
|
238
|
+
}
|
|
239
|
+
const clientId = required(env.HUB_OIDC_CLIENT_ID?.trim() || file.clientId, "HUB_OIDC_CLIENT_ID (auth.clientId)")
|
|
240
|
+
const clientSecret = required(env.HUB_OIDC_CLIENT_SECRET?.trim() || file.clientSecret, "HUB_OIDC_CLIENT_SECRET (auth.clientSecret)")
|
|
241
|
+
const allowed = emailList(env.HUB_OIDC_ALLOWED ?? file.allowed)
|
|
242
|
+
const admins = emailList(env.HUB_ADMIN_EMAILS ?? file.admins)
|
|
243
|
+
if (allowed.length === 0) throw new Error("HUB_OIDC_ALLOWED (auth.allowed) is required for HUB_AUTH=oidc: list emails and/or @domain entries")
|
|
244
|
+
if (admins.length === 0) throw new Error("HUB_ADMIN_EMAILS (auth.admins) needs at least one admin email for HUB_AUTH=oidc")
|
|
245
|
+
const bad = allowed.find((entry) => !EMAIL.test(entry) && !DOMAIN.test(entry))
|
|
246
|
+
if (bad !== undefined) throw new Error(`HUB_OIDC_ALLOWED (auth.allowed): ${JSON.stringify(bad)} is not an email or @domain`)
|
|
247
|
+
const badAdmin = admins.find((entry) => !EMAIL.test(entry))
|
|
248
|
+
if (badAdmin !== undefined) throw new Error(`HUB_ADMIN_EMAILS (auth.admins): ${JSON.stringify(badAdmin)} is not an email`)
|
|
249
|
+
const auth = { mode: "oidc" as const, issuer, clientId, allowed, admins, ...(insecure ? { insecureIssuer: true as const } : {}) }
|
|
250
|
+
// A getter: printed as [Getter], skipped by JSON.stringify.
|
|
251
|
+
Object.defineProperty(auth, "clientSecret", { get: () => clientSecret, enumerable: false })
|
|
252
|
+
return auth as unknown as OidcConfig
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
|
|
256
|
+
const env = hubEnv(rawEnv)
|
|
257
|
+
const host = env.HUB_HOST || file.host || "127.0.0.1"
|
|
258
|
+
const port = int(env.HUB_PORT, file.port ?? 4097, 0)
|
|
259
|
+
// A wildcard bind address is no origin a browser uses: default to the loopback one (Docker -p 127.0.0.1:…).
|
|
260
|
+
const linkHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host
|
|
261
|
+
const publicURL = (env.HUB_PUBLIC_URL || file.publicURL || `http://${linkHost.includes(":") ? `[${linkHost}]` : linkHost}:${port}`).replace(/\/+$/, "")
|
|
262
|
+
if (!/^https?:\/\/[^\s/]+$/.test(publicURL)) {
|
|
263
|
+
throw new Error(`HUB_PUBLIC_URL must be an http(s) origin without a path, got ${JSON.stringify(publicURL)}`)
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
host,
|
|
267
|
+
port,
|
|
268
|
+
publicURL,
|
|
269
|
+
stateDir: env.HUB_STATE_DIR || file.stateDir || path.join(stateBase(home), APP_DIR),
|
|
270
|
+
apiKey: env.OPENROUTER_API_KEY || undefined,
|
|
271
|
+
sttModel: env.HUB_STT_MODEL || "openai/gpt-4o-mini-transcribe",
|
|
272
|
+
ttsModel: env.HUB_TTS_MODEL || "microsoft/mai-voice-2.1-flash",
|
|
273
|
+
voice: env.HUB_VOICE || "en-US-Harper:MAI-Voice-2.1-Flash",
|
|
274
|
+
maxAudioBytes: int(env.HUB_MAX_AUDIO_BYTES, 10 * 1024 * 1024),
|
|
275
|
+
maxSpeechChars: int(env.HUB_MAX_SPEECH_CHARS, 800),
|
|
276
|
+
turnTimeoutMs: int(env.HUB_TURN_TIMEOUT_MS, 600_000),
|
|
277
|
+
// Push services want a contact: an https URL or mailto. The public URL works for hosted hubs.
|
|
278
|
+
vapidSubject: env.HUB_VAPID_SUBJECT || (publicURL.startsWith("https://") ? publicURL : "mailto:hub@localhost"),
|
|
279
|
+
...(file.speech ? { speech: file.speech } : {}),
|
|
280
|
+
auth: authConfig(env, file.auth),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface LoadOptions {
|
|
285
|
+
/** Where one-time migration notices go (default console). */
|
|
286
|
+
readonly log?: (message: string) => void
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Reads hub.json (if any) and the environment. Throws on an invalid file or public URL. On first start,
|
|
291
|
+
* copies the legacy hub.json and state directory (opencode-hub first, else opencode-voice) to their default
|
|
292
|
+
* Hub locations (never for paths set by env or hub.json; the old copies are kept).
|
|
293
|
+
*/
|
|
294
|
+
export function loadCentralConfig(
|
|
295
|
+
env: Env = process.env,
|
|
296
|
+
home: string = homedir(),
|
|
297
|
+
options: LoadOptions = {},
|
|
298
|
+
): { config: CentralConfig; warnings: string[]; path: string; loaded: boolean } {
|
|
299
|
+
const resolved = hubEnv(env)
|
|
300
|
+
const migrate = options.log ? { log: options.log } : {}
|
|
301
|
+
const file = hubConfigPath(env, home)
|
|
302
|
+
if (!resolved.HUB_CONFIG) migrateFirst(legacyPaths(configBase(env, home), "hub.json"), file, migrate)
|
|
303
|
+
const { file: hub, warnings } = readHubFile(file, home)
|
|
304
|
+
const config = centralConfig(env, home, hub)
|
|
305
|
+
if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
|
|
306
|
+
// hub.pid is the spoke's auto-start lock for the legacy location, not state.
|
|
307
|
+
migrateFirst(legacyPaths(stateBase(home)), config.stateDir, { ...migrate, skip: ["hub.pid"] })
|
|
308
|
+
}
|
|
309
|
+
return { config, warnings, path: file, loaded: hub !== undefined }
|
|
310
|
+
}
|