@huynle/hub 0.9.0 → 0.9.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 +36 -0
- package/README.md +7 -4
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1126 -479
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +3 -1
- package/packages/protocol/src/launch.ts +39 -4
- 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/spoke-sdk/package.json +1 -1
- package/src/central/agent/launch-spec.ts +4 -2
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/config.ts +24 -2
- package/src/central/http.ts +1 -0
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/push-target.ts +11 -2
- package/web/dist/assets/{ArtifactPage-4GoBqSpU.js → ArtifactPage-BKISN-Qj.js} +1 -1
- package/web/dist/assets/{ArtifactPane-Dx_0iWwH.js → ArtifactPane-CKOiAUNH.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-CuHgARMk.js → ArtifactWorkbench-d7R1o-rs.js} +1 -1
- package/web/dist/assets/{Diagram-CZM_R7MG.js → Diagram-azw2B_QH.js} +1 -1
- package/web/dist/assets/{Library-TwvrwqSk.js → Library-CcjZXsfF.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-rKNDpLjR.js → ProfilesDestination-DdzbzmeK.js} +1 -1
- package/web/dist/assets/{TeamsPage-DtJylLrt.js → TeamsPage-CDyjrK-I.js} +1 -1
- package/web/dist/assets/{Watchtower-Bmgdyfjb.js → Watchtower-DYwoUWME.js} +2 -2
- package/web/dist/assets/{index-D-xG9nrc.js → index-LNMZCqgE.js} +15 -15
- package/web/dist/assets/{live-7q72Lyj2.js → live-ycZ_aQZO.js} +1 -1
- package/web/dist/assets/{renderer2d-hfZxM6Xl.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-Km9vipTM.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-BAbgBiFk.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
// binary run hub-cli.js, so only the script layout keeps it. A standalone hubctl with it would run `bun node run`
|
|
10
10
|
// (a missing module) and crash-loop, so its units never mention it.
|
|
11
11
|
//
|
|
12
|
+
// Task Scheduler (windows-x64, PHASE3-CONTRACT.md) gets a Task XML instead: a logon task for one user (their SID,
|
|
13
|
+
// InteractiveToken, LeastPrivilege: no admin, no stored password) whose action is the hidden console host
|
|
14
|
+
// (`conhost.exe --headless`, verified on Windows 11 to open no window, where starting hubctl.exe directly opens a
|
|
15
|
+
// console or Windows Terminal) running `hubctl.exe node run`: native paths, quoted by the MSVCRT argv rules. A task has
|
|
16
|
+
// no environment of its own, so it carries none (the planner names what it leaves out), and only the standalone
|
|
17
|
+
// hubctl.exe can be its program. schtasks /XML refuses UTF-8 ("unable to switch the encoding"), so the file is
|
|
18
|
+
// UTF-16LE with a BOM. The hidden console host does not pass its child's exit code on (always 0), so RestartOnFailure
|
|
19
|
+
// never sees a crash: the logon trigger repeats every minute instead, and IgnoreNew makes that a no-op while the
|
|
20
|
+
// node runs, so a node that exited for any reason is started again within a minute.
|
|
21
|
+
//
|
|
12
22
|
// Rendering is pure: the same input always gives the same bytes, so a plan can pin the unit's digest
|
|
13
23
|
// (install-service.sha256) and apply can render it again and compare.
|
|
14
24
|
import {
|
|
@@ -19,7 +29,10 @@ import {
|
|
|
19
29
|
pointer,
|
|
20
30
|
serviceArgv,
|
|
21
31
|
SERVICE_COMMANDS,
|
|
32
|
+
SERVICE_ROOT,
|
|
22
33
|
sha256Hex,
|
|
34
|
+
toNativePath,
|
|
35
|
+
isWindowsPath,
|
|
23
36
|
type PathRef,
|
|
24
37
|
type ProgramLayout,
|
|
25
38
|
type Result,
|
|
@@ -36,7 +49,47 @@ export const SERVICE_ENVIRONMENT: Readonly<Record<ServiceID, readonly string[]>>
|
|
|
36
49
|
})
|
|
37
50
|
|
|
38
51
|
/** The named root each manager's units live in. */
|
|
39
|
-
export const UNIT_ROOT
|
|
52
|
+
export const UNIT_ROOT = SERVICE_ROOT
|
|
53
|
+
|
|
54
|
+
/** A Task Scheduler task hubctl registers: `hub-<service>-<user>`, or a test task `hub-test-<x>-<user>`. */
|
|
55
|
+
export const TASK_NAME = /^hub-(?:node|claude-code|codex-spoke|test-[a-z0-9-]{1,32})-[A-Za-z0-9._-]{1,64}$/
|
|
56
|
+
const TEST_PREFIX = /^hub-test-[a-z0-9-]{1,32}$/
|
|
57
|
+
const SID = /^S-1-[0-9]{1,12}(?:-[0-9]{1,10}){1,15}$/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The task name of `service` for the Windows user `user` (%USERNAME%; characters other than [A-Za-z0-9._-] become
|
|
61
|
+
* "_"). HUBCTL_TEST_TASK_PREFIX (tests on a real machine only) replaces "hub-<service>", and must start with hub-test-.
|
|
62
|
+
*/
|
|
63
|
+
export function taskNameFor(service: ServiceID, user: string, env: Readonly<Record<string, string | undefined>>): string {
|
|
64
|
+
const safe = user.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64)
|
|
65
|
+
if (!safe) throw new Error("the Windows user name is empty; set USERNAME")
|
|
66
|
+
const prefix = env.HUBCTL_TEST_TASK_PREFIX
|
|
67
|
+
if (prefix !== undefined && !TEST_PREFIX.test(prefix)) throw new Error("HUBCTL_TEST_TASK_PREFIX must start with hub-test- (lower-case letters, digits and -)")
|
|
68
|
+
return `${prefix ?? `hub-${service}`}-${safe}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One word of a Windows command line, quoted as the MSVCRT (CommandLineToArgvW) rules read it back. */
|
|
72
|
+
export function quoteWindowsArg(word: string): string {
|
|
73
|
+
if (word !== "" && !/[\s"]/.test(word)) return word
|
|
74
|
+
let out = '"'
|
|
75
|
+
let slashes = 0
|
|
76
|
+
for (const ch of word) {
|
|
77
|
+
if (ch === "\\") slashes++
|
|
78
|
+
else {
|
|
79
|
+
out += "\\".repeat(ch === '"' ? slashes * 2 + 1 : slashes) + ch
|
|
80
|
+
slashes = 0
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${out}${"\\".repeat(slashes * 2)}"`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The logon task a Task XML registers (task-scheduler only). */
|
|
87
|
+
export interface TaskIdentity {
|
|
88
|
+
/** The task's name (taskNameFor). */
|
|
89
|
+
readonly name: string
|
|
90
|
+
/** The user's SID: the trigger's and the principal's UserId. */
|
|
91
|
+
readonly userSid: string
|
|
92
|
+
}
|
|
40
93
|
|
|
41
94
|
export interface RenderInput {
|
|
42
95
|
readonly service: ServiceID
|
|
@@ -49,6 +102,8 @@ export interface RenderInput {
|
|
|
49
102
|
readonly environment?: Readonly<Record<string, string>>
|
|
50
103
|
/** The command that writes the unit by hand ("hub node"): its header then names its install/uninstall (default: hubctl setup). */
|
|
51
104
|
readonly writtenBy?: string
|
|
105
|
+
/** task-scheduler: the task and its user. */
|
|
106
|
+
readonly task?: TaskIdentity
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
export interface RenderedUnit {
|
|
@@ -58,7 +113,7 @@ export interface RenderedUnit {
|
|
|
58
113
|
readonly manager: ServiceManagerID
|
|
59
114
|
/** Where it is installed: the manager's root and the asset's file name. */
|
|
60
115
|
readonly path: PathRef
|
|
61
|
-
/** The launchd label
|
|
116
|
+
/** The launchd label, the systemd unit name or the task name (what the service manager calls it). */
|
|
62
117
|
readonly name: string
|
|
63
118
|
readonly argv: readonly string[]
|
|
64
119
|
readonly text: string
|
|
@@ -68,8 +123,8 @@ export interface RenderedUnit {
|
|
|
68
123
|
|
|
69
124
|
export const assetIDFor = (service: ServiceID, manager: ServiceManagerID): string => `service/${manager}/${service}`
|
|
70
125
|
|
|
71
|
-
/** The launchd label or systemd unit name of a service under a manager. */
|
|
72
|
-
export function serviceName(service: ServiceID, manager:
|
|
126
|
+
/** The launchd label or systemd unit name of a service under a manager (a task's name depends on its user: taskNameFor). */
|
|
127
|
+
export function serviceName(service: ServiceID, manager: "launchd" | "systemd-user"): string {
|
|
73
128
|
const file = EMBEDDED_ASSETS[assetIDFor(service, manager)]!.installAs
|
|
74
129
|
return manager === "launchd" ? file.replace(/\.plist$/, "") : file
|
|
75
130
|
}
|
|
@@ -87,7 +142,7 @@ const SYSTEMD_HOME = /^\/[A-Za-z0-9_./@+,:-]*$/
|
|
|
87
142
|
const PLACEHOLDER = /__[A-Z]+__/
|
|
88
143
|
|
|
89
144
|
/** The template's BUN_BE_BUN=1 setting, per manager (removed for the standalone layout). */
|
|
90
|
-
const BUN_BE_BUN: Readonly<Record<
|
|
145
|
+
const BUN_BE_BUN: Readonly<Record<"launchd" | "systemd-user", RegExp>> = Object.freeze({
|
|
91
146
|
"systemd-user": /^Environment=BUN_BE_BUN=1\n/m,
|
|
92
147
|
launchd: /\n[ \t]*<key>BUN_BE_BUN<\/key>\s*<string>1<\/string>/,
|
|
93
148
|
})
|
|
@@ -136,6 +191,39 @@ function renderLaunchd(c: Checker, asset: string, template: string, argv: readon
|
|
|
136
191
|
.replace(/(<key>PATH<\/key>\s*<string>[^<]*<\/string>)/, (line) => `${line}${extra}`)
|
|
137
192
|
}
|
|
138
193
|
|
|
194
|
+
/** XML character data (quotes stay as they are: an <Arguments> value is a command line). */
|
|
195
|
+
const xmlText = (text: string) => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
196
|
+
|
|
197
|
+
function renderTask(c: Checker, input: RenderInput, asset: string, template: string): string | undefined {
|
|
198
|
+
if (input.layout.kind !== "standalone") c.add("/layout", "unsupported_platform", "a Windows logon task starts the standalone hubctl.exe only, not a script under a runtime")
|
|
199
|
+
if (Object.keys(input.environment ?? {}).length > 0) c.add("/environment", "invalid_value", "a Task Scheduler task carries no environment of its own")
|
|
200
|
+
const task = input.task
|
|
201
|
+
if (!task) c.add("/task", "missing_field", "a Task Scheduler task needs its name and the user's SID")
|
|
202
|
+
else {
|
|
203
|
+
if (!TASK_NAME.test(task.name)) c.add("/task/name", "invalid_value", "expected a hub task name (hub-<service>-<user>)")
|
|
204
|
+
if (!SID.test(task.userSid)) c.add("/task/userSid", "invalid_value", "expected the user's SID (S-1-…)")
|
|
205
|
+
}
|
|
206
|
+
if (!isAbsolutePath(input.home) || !isWindowsPath(input.home)) c.add("/home", "invalid_path", "a Windows logon task needs a Windows home directory (C:/…)")
|
|
207
|
+
if (!/<Arguments>--headless __ARGV__<\/Arguments>/.test(template)) c.add("/asset", "inconsistent", `${asset} does not start hubctl under the hidden console host`)
|
|
208
|
+
if (c.issues.length > 0) return undefined
|
|
209
|
+
const argv = serviceArgv(input.layout, input.service)
|
|
210
|
+
const words = argv.map((w, i) => (i === 0 ? toNativePath(w, "win32") : w))
|
|
211
|
+
if (words.some((w) => w.includes('"'))) c.add("/layout", "invalid_path", "the program path may not contain a double quote")
|
|
212
|
+
return template
|
|
213
|
+
.replace(/<!--[\s\S]*?-->/, () => `<!-- ${header(asset, input.writtenBy)} -->`)
|
|
214
|
+
.replaceAll("__USER__", () => task!.userSid)
|
|
215
|
+
.replace("__ARGV__", () => xmlText(words.map(quoteWindowsArg).join(" ")))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** UTF-16LE with a byte order mark: what schtasks /XML reads. */
|
|
219
|
+
const utf16le = (text: string) => {
|
|
220
|
+
const body = Buffer.from(text, "utf16le")
|
|
221
|
+
const bytes = new Uint8Array(body.length + 2)
|
|
222
|
+
bytes.set([0xff, 0xfe])
|
|
223
|
+
bytes.set(body, 2)
|
|
224
|
+
return bytes
|
|
225
|
+
}
|
|
226
|
+
|
|
139
227
|
/** The unit for `service` under `manager`, rendered from its embedded template. */
|
|
140
228
|
export function renderServiceUnit(input: RenderInput): Result<RenderedUnit> {
|
|
141
229
|
const c = new Checker()
|
|
@@ -146,24 +234,43 @@ export function renderServiceUnit(input: RenderInput): Result<RenderedUnit> {
|
|
|
146
234
|
c.add("/service", "invalid_value", `no embedded template for ${input.service} under ${input.manager}`)
|
|
147
235
|
return c.result(undefined)
|
|
148
236
|
}
|
|
237
|
+
if (input.manager === "task-scheduler") {
|
|
238
|
+
const text = renderTask(c, input, asset, template)
|
|
239
|
+
if (text === undefined || c.issues.length > 0) return c.result(undefined)
|
|
240
|
+
if (PLACEHOLDER.test(text)) c.add("/asset", "inconsistent", `${asset} has a placeholder this hubctl does not fill`)
|
|
241
|
+
if (c.issues.length > 0) return c.result(undefined)
|
|
242
|
+
const bytes = utf16le(text)
|
|
243
|
+
return c.result<RenderedUnit>({
|
|
244
|
+
asset,
|
|
245
|
+
service: input.service,
|
|
246
|
+
manager: input.manager,
|
|
247
|
+
path: { root: UNIT_ROOT[input.manager], rel: entry.installAs },
|
|
248
|
+
name: input.task!.name,
|
|
249
|
+
argv: serviceArgv(input.layout, input.service),
|
|
250
|
+
text,
|
|
251
|
+
bytes,
|
|
252
|
+
sha256: `sha256:${sha256Hex(bytes)}`,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
const manager = input.manager
|
|
149
256
|
let argv: string[] = []
|
|
150
257
|
try {
|
|
151
258
|
argv = serviceArgv(input.layout, input.service)
|
|
152
259
|
} catch (error) {
|
|
153
260
|
c.add("/layout", "invalid_path", (error as Error).message)
|
|
154
261
|
}
|
|
155
|
-
const homeOK = isAbsolutePath(input.home) && input.home !== "/" && (
|
|
262
|
+
const homeOK = isAbsolutePath(input.home) && input.home !== "/" && (manager !== "systemd-user" || SYSTEMD_HOME.test(input.home))
|
|
156
263
|
if (!homeOK) c.add("/home", "invalid_path", "the service's home must be an absolute, normalized directory other than /")
|
|
157
264
|
const env = checkEnvironment(c, input.service, input.environment ?? {})
|
|
158
265
|
if (c.issues.length > 0) return c.result(undefined)
|
|
159
266
|
|
|
160
267
|
const command = SERVICE_COMMANDS[input.service]
|
|
161
268
|
const raw =
|
|
162
|
-
|
|
269
|
+
manager === "systemd-user"
|
|
163
270
|
? renderSystemd(c, asset, template, argv, command, input.home, env, input.writtenBy)
|
|
164
271
|
: renderLaunchd(c, asset, template, argv, command, input.home, env, input.writtenBy)
|
|
165
272
|
const asBun = input.layout.kind === "script"
|
|
166
|
-
const text = asBun ? raw : raw.replace(BUN_BE_BUN[
|
|
273
|
+
const text = asBun ? raw : raw.replace(BUN_BE_BUN[manager], "")
|
|
167
274
|
if (!asBun && text.includes("BUN_BE_BUN")) c.add("/asset", "inconsistent", `${asset} sets BUN_BE_BUN, which a standalone hubctl must not get`)
|
|
168
275
|
if (PLACEHOLDER.test(text)) c.add("/asset", "inconsistent", `${asset} has a placeholder this hubctl does not fill`)
|
|
169
276
|
if (isSecretValue(text)) c.add("/environment", "secret", "service units never carry credentials")
|
|
@@ -174,7 +281,7 @@ export function renderServiceUnit(input: RenderInput): Result<RenderedUnit> {
|
|
|
174
281
|
service: input.service,
|
|
175
282
|
manager: input.manager,
|
|
176
283
|
path: { root: UNIT_ROOT[input.manager], rel: entry.installAs },
|
|
177
|
-
name: serviceName(input.service,
|
|
284
|
+
name: serviceName(input.service, manager),
|
|
178
285
|
argv,
|
|
179
286
|
text,
|
|
180
287
|
bytes,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
fail,
|
|
20
20
|
findSecrets,
|
|
21
21
|
isAbsolutePath,
|
|
22
|
+
isWindowsPath,
|
|
22
23
|
isRecord,
|
|
23
24
|
pointer,
|
|
24
25
|
rootPathIn,
|
|
@@ -52,9 +53,14 @@ export interface NodePlaces {
|
|
|
52
53
|
readonly snapshot: Pick<DiscoverySnapshot, "roots" | "topology">
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Windows paths compare case-insensitively (NTFS names are); POSIX paths exactly. */
|
|
57
|
+
const folded = (p: string) => (isWindowsPath(p) ? p.toLowerCase() : p)
|
|
55
58
|
/** Whether `path` is `dir` or inside it. */
|
|
56
|
-
export const inside = (path: string, dir: string) =>
|
|
57
|
-
const
|
|
59
|
+
export const inside = (path: string, dir: string) => {
|
|
60
|
+
const [p, d] = [folded(path), folded(dir)]
|
|
61
|
+
return p === d || p.startsWith(d.endsWith("/") ? d : `${d}/`)
|
|
62
|
+
}
|
|
63
|
+
const parentOf = (path: string) => folded(path.slice(0, path.lastIndexOf("/")) || "/")
|
|
58
64
|
|
|
59
65
|
/** Mount prefixes as the *other* context sees them (a path under one is not valid where the service runs). */
|
|
60
66
|
function otherViews(roots: readonly RootMapping[], context: ExecutionContext): string[] {
|
|
@@ -16,7 +16,8 @@ export type Escalation = "user" | "elevated"
|
|
|
16
16
|
export type ConfigOwner = "user" | "hub" | `harness:${HarnessID}`
|
|
17
17
|
export const CONFIG_OWNERS: readonly ConfigOwner[] = ["user", "hub", ...HARNESS_ORDER.map((h) => `harness:${h}` as const)]
|
|
18
18
|
export type ConfigFormat = "json" | "jsonc" | "toml"
|
|
19
|
-
export
|
|
19
|
+
export const SERVICE_MANAGERS = ["launchd", "systemd-user", "task-scheduler"] as const
|
|
20
|
+
export type ServiceManagerID = (typeof SERVICE_MANAGERS)[number]
|
|
20
21
|
|
|
21
22
|
/** What the target file must look like for an edit to apply; anything else is a conflicting user edit. */
|
|
22
23
|
export interface Precondition {
|
|
@@ -246,7 +247,8 @@ const ASSET_ID = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/
|
|
|
246
247
|
const MACHINE_NAME = /^[A-Za-z0-9._-]{1,64}$/
|
|
247
248
|
const MCP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
248
249
|
const JSON_POINTER = /^(?:\/(?:[^~/]|~[01])*)+$/
|
|
249
|
-
|
|
250
|
+
/** The named root each manager's unit files live in (the Task XML hubctl registers, on Windows). */
|
|
251
|
+
export const SERVICE_ROOT: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user" | "task-defs">> = Object.freeze({ launchd: "launch-agents", "systemd-user": "systemd-user", "task-scheduler": "task-defs" })
|
|
250
252
|
const EXEC = new Set<string>(EXEC_KEYS)
|
|
251
253
|
|
|
252
254
|
/** JSON pointers of every key named like EXEC_KEYS inside `value`. */
|
|
@@ -413,7 +415,7 @@ export function checkAction(c: Checker, path: string, value: unknown): void {
|
|
|
413
415
|
break
|
|
414
416
|
case "install-service": {
|
|
415
417
|
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
416
|
-
oneOf(c, at("manager"), r.manager,
|
|
418
|
+
oneOf(c, at("manager"), r.manager, SERVICE_MANAGERS)
|
|
417
419
|
ref("path")
|
|
418
420
|
digest(c, at("sha256"), r.sha256)
|
|
419
421
|
const asset = typeof r.asset === "string" ? EMBEDDED_ASSETS[r.asset] : undefined
|
|
@@ -424,18 +426,18 @@ export function checkAction(c: Checker, path: string, value: unknown): void {
|
|
|
424
426
|
}
|
|
425
427
|
case "deactivate-service":
|
|
426
428
|
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
427
|
-
oneOf(c, at("manager"), r.manager,
|
|
429
|
+
oneOf(c, at("manager"), r.manager, SERVICE_MANAGERS)
|
|
428
430
|
break
|
|
429
431
|
case "remove-service":
|
|
430
432
|
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
431
|
-
oneOf(c, at("manager"), r.manager,
|
|
433
|
+
oneOf(c, at("manager"), r.manager, SERVICE_MANAGERS)
|
|
432
434
|
ref("path")
|
|
433
435
|
digest(c, at("sha256"), r.sha256)
|
|
434
436
|
checkServiceUnitPath(c, at, r)
|
|
435
437
|
break
|
|
436
438
|
case "activate-service":
|
|
437
439
|
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
438
|
-
oneOf(c, at("manager"), r.manager,
|
|
440
|
+
oneOf(c, at("manager"), r.manager, SERVICE_MANAGERS)
|
|
439
441
|
if (r.confirm !== "explicit") c.add(at("confirm"), "invalid_value", "services are only activated on explicit confirmation")
|
|
440
442
|
break
|
|
441
443
|
case "enroll-machine":
|
|
@@ -71,7 +71,7 @@ export interface RuntimePresence {
|
|
|
71
71
|
export interface ConfigFile {
|
|
72
72
|
readonly path: string
|
|
73
73
|
readonly owner: ConfigOwner | "package-manager"
|
|
74
|
-
readonly format: "json" | "jsonc" | "toml" | "plist" | "unit"
|
|
74
|
+
readonly format: "json" | "jsonc" | "toml" | "plist" | "unit" | "xml"
|
|
75
75
|
/** Digest of its bytes now; null when it does not exist. Contents are never recorded. */
|
|
76
76
|
readonly sha256: string | null
|
|
77
77
|
}
|
|
@@ -180,10 +180,11 @@ export function validateDiscoverySnapshot(value: unknown): Result<DiscoverySnaps
|
|
|
180
180
|
semver(c, pointer(p, "version"), rt.version)
|
|
181
181
|
absPath(c, pointer(p, "path"), rt.path)
|
|
182
182
|
})
|
|
183
|
-
oneOf(c, "/managers/service", managers.service, ["launchd", "systemd-user", "none"] as const)
|
|
183
|
+
oneOf(c, "/managers/service", managers.service, ["launchd", "systemd-user", "task-scheduler", "none"] as const)
|
|
184
184
|
const platform = host?.platform
|
|
185
185
|
if (managers.service === "launchd" && platform !== "darwin") c.add("/managers/service", "inconsistent", "launchd is macOS only")
|
|
186
186
|
if (managers.service === "systemd-user" && platform !== "linux") c.add("/managers/service", "inconsistent", "systemd is Linux only")
|
|
187
|
+
if (managers.service === "task-scheduler" && platform !== "win32") c.add("/managers/service", "inconsistent", "Task Scheduler is Windows only")
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
list(c, "/harnesses", r.harnesses, HARNESS_ORDER.length, (p, v) => checkHarness(c, p, v), (v) => (v as { id?: string })?.id)
|
|
@@ -199,7 +200,7 @@ export function validateDiscoverySnapshot(value: unknown): Result<DiscoverySnaps
|
|
|
199
200
|
if (!cf) return
|
|
200
201
|
absPath(c, pointer(p, "path"), cf.path)
|
|
201
202
|
oneOf(c, pointer(p, "owner"), cf.owner, [...CONFIG_OWNERS, "package-manager"])
|
|
202
|
-
oneOf(c, pointer(p, "format"), cf.format, ["json", "jsonc", "toml", "plist", "unit"] as const)
|
|
203
|
+
oneOf(c, pointer(p, "format"), cf.format, ["json", "jsonc", "toml", "plist", "unit", "xml"] as const)
|
|
203
204
|
if (cf.sha256 !== null) digest(c, pointer(p, "sha256"), cf.sha256)
|
|
204
205
|
}, (v) => (v as { path?: string })?.path)
|
|
205
206
|
list(c, "/roots", r.roots, ROOT_IDS.length, (p, v) => {
|
|
@@ -51,12 +51,12 @@ export function serviceArgv(layout: ProgramLayout, service: ServiceID): string[]
|
|
|
51
51
|
return [...program, ...command]
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export type AssetKind = "launchd-plist" | "systemd-unit"
|
|
54
|
+
export type AssetKind = "launchd-plist" | "systemd-unit" | "task-xml"
|
|
55
55
|
|
|
56
56
|
export interface EmbeddedAsset {
|
|
57
57
|
readonly kind: AssetKind
|
|
58
58
|
readonly service: ServiceID
|
|
59
|
-
readonly manager: "launchd" | "systemd-user"
|
|
59
|
+
readonly manager: "launchd" | "systemd-user" | "task-scheduler"
|
|
60
60
|
/** The repository file it is embedded from (the template the source install reads today). */
|
|
61
61
|
readonly source: string
|
|
62
62
|
/** The file name it is installed as. */
|
|
@@ -71,4 +71,6 @@ export const EMBEDDED_ASSETS: Readonly<Record<string, EmbeddedAsset>> = Object.f
|
|
|
71
71
|
"service/systemd-user/claude-code": { kind: "systemd-unit", service: "claude-code", manager: "systemd-user", source: "scripts/hub-claude-code.service", installAs: "hub-claude-code.service" },
|
|
72
72
|
"service/launchd/codex-spoke": { kind: "launchd-plist", service: "codex-spoke", manager: "launchd", source: "scripts/io.github.huynle.hub-codex-spoke.plist", installAs: "io.github.huynle.hub-codex-spoke.plist" },
|
|
73
73
|
"service/systemd-user/codex-spoke": { kind: "systemd-unit", service: "codex-spoke", manager: "systemd-user", source: "scripts/hub-codex-spoke.service", installAs: "hub-codex-spoke.service" },
|
|
74
|
+
// Phase 3: the node as a per-user logon task on Windows. The spokes have no task yet (Phase 4).
|
|
75
|
+
"service/task-scheduler/node": { kind: "task-xml", service: "node", manager: "task-scheduler", source: "scripts/hub-node.task.xml", installAs: "hub-node.xml" },
|
|
74
76
|
})
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// symlink between the root and the target (checkNoSymlinkEscape), so a planted link cannot redirect a write.
|
|
5
5
|
import { Checker, fail, isAbsolutePath, ok, oneOf, pointer, shape, type Result } from "./validate.ts"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** `task-defs` (Phase 3, Windows): `state/hub/tasks`, where hubctl writes the Task XML it registers with Task Scheduler. */
|
|
8
|
+
export const ROOT_IDS = ["home", "config", "state", "data", "user-bin", "launch-agents", "systemd-user", "system-bin", "task-defs"] as const
|
|
8
9
|
export type RootID = (typeof ROOT_IDS)[number]
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -7,8 +7,8 @@ export type Platform = "darwin" | "linux" | "windows"
|
|
|
7
7
|
export type Arch = "arm64" | "x64"
|
|
8
8
|
/** "none" on macOS; "unknown" when a Linux host's C library could not be identified. */
|
|
9
9
|
export type Libc = "none" | "glibc" | "musl" | "unknown"
|
|
10
|
-
export type ServiceManager = "launchd" | "systemd-user"
|
|
11
|
-
/** A target's service manager: "none" where hubctl installs no user services
|
|
10
|
+
export type ServiceManager = "launchd" | "systemd-user" | "task-scheduler"
|
|
11
|
+
/** A target's service manager: "none" where hubctl installs no user services (no target today; Windows until Phase 3). */
|
|
12
12
|
export type TargetServiceManager = ServiceManager | "none"
|
|
13
13
|
|
|
14
14
|
export interface SupportedTarget {
|
|
@@ -31,9 +31,9 @@ export const SUPPORTED_TARGETS: readonly SupportedTarget[] = Object.freeze([
|
|
|
31
31
|
{ target: "darwin-x64", platform: "darwin", arch: "x64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-x64-baseline" },
|
|
32
32
|
{ target: "linux-x64", platform: "linux", arch: "x64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-x64-baseline" },
|
|
33
33
|
{ target: "linux-arm64", platform: "linux", arch: "arm64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-arm64" },
|
|
34
|
-
// Phase 2 (docs/plans/2026-11-05-machine-setup-installer-design.md §3)
|
|
35
|
-
// (
|
|
36
|
-
{ target: "windows-x64", platform: "windows", arch: "x64", libc: "none", serviceManager: "
|
|
34
|
+
// Phase 2 (docs/plans/2026-11-05-machine-setup-installer-design.md §3) brought session-only; Phase 3 the node as a
|
|
35
|
+
// per-user logon task (PHASE3-CONTRACT.md). The harness spokes have no task yet (Phase 4).
|
|
36
|
+
{ target: "windows-x64", platform: "windows", arch: "x64", libc: "none", serviceManager: "task-scheduler", bunTarget: "bun-windows-x64-baseline" },
|
|
37
37
|
] as const)
|
|
38
38
|
|
|
39
39
|
export interface UnsupportedTarget {
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { classifyExecutable, HARNESS_BINARIES, installOf, pathDirs, which } from "./executables.ts"
|
|
30
30
|
import { discoverContainer, discoverLibc, discoverOS, discoverRoots, mapContainerRoots, parseMountinfo } from "./host.ts"
|
|
31
31
|
import type { DiscoveryProbe } from "./probe.ts"
|
|
32
|
-
import { decode, join, normalizeAbs } from "./util.ts"
|
|
32
|
+
import { decode, hostAbs, join, normalizeAbs } from "./util.ts"
|
|
33
33
|
|
|
34
34
|
export interface DiscoverOptions {
|
|
35
35
|
readonly now: number
|
|
@@ -65,6 +65,7 @@ export function configCandidates(platform: string, roots: readonly RootMapping[]
|
|
|
65
65
|
for (const asset of Object.values(EMBEDDED_ASSETS)) {
|
|
66
66
|
if (platform === "darwin" && asset.manager === "launchd") add("launch-agents", asset.installAs, "hub", "plist")
|
|
67
67
|
if (platform === "linux" && asset.manager === "systemd-user") add("systemd-user", asset.installAs, "hub", "unit")
|
|
68
|
+
if (platform === "win32" && asset.manager === "task-scheduler") add("task-defs", asset.installAs, "hub", "xml")
|
|
68
69
|
}
|
|
69
70
|
return list.filter((c): c is ConfigCandidate => c !== undefined)
|
|
70
71
|
}
|
|
@@ -74,6 +75,9 @@ export function configCandidates(platform: string, roots: readonly RootMapping[]
|
|
|
74
75
|
/** Where each harness's binary is inside a hub install root (the adapters' unpackers). */
|
|
75
76
|
const HARNESS_BIN_DIR: Readonly<Record<(typeof HARNESS_ORDER)[number], string>> = { opencode: "/bin", "claude-code": "", codex: "/bin" }
|
|
76
77
|
|
|
78
|
+
/** %SystemRoot% in canonical form (C:/Windows when unset or not a drive path). */
|
|
79
|
+
export const systemRootOf = (probe: Pick<DiscoveryProbe, "platform" | "env">): string => hostAbs("win32", probe.env.SystemRoot ?? probe.env.SYSTEMROOT) ?? "C:/Windows"
|
|
80
|
+
|
|
77
81
|
/** Discovers this host through `probe`. Fails (instead of recording) if anything found looks like a credential. */
|
|
78
82
|
export function discover(probe: DiscoveryProbe, options: DiscoverOptions): Result<DiscoverySnapshot> {
|
|
79
83
|
let roots = discoverRoots(probe)
|
|
@@ -92,10 +96,11 @@ export function discover(probe: DiscoveryProbe, options: DiscoverOptions): Resul
|
|
|
92
96
|
const managerDirs = [...dirs, ...extra.filter((d) => !dirs.includes(d))]
|
|
93
97
|
const packages: PackageManager[] = PACKAGE_MANAGERS.filter((m) => which(probe, managerDirs, m) !== undefined)
|
|
94
98
|
const runtime: RuntimeInfo[] = probe.runtime && SEMVER.test(probe.runtime.version) && normalizeAbs(probe.runtime.path) ? [{ ...probe.runtime }] : []
|
|
95
|
-
// Windows
|
|
99
|
+
// Windows (PHASE3-CONTRACT.md): Task Scheduler, when %SystemRoot%\System32\schtasks.exe is there.
|
|
96
100
|
let service: DiscoverySnapshot["managers"]["service"] = "none"
|
|
97
101
|
if (probe.platform === "darwin" && probe.executable("/bin/launchctl")) service = "launchd"
|
|
98
102
|
if (probe.platform === "linux" && probe.lstat("/run/systemd/system") === "directory" && which(probe, [...dirs, "/usr/bin", "/bin"], "systemctl")) service = "systemd-user"
|
|
103
|
+
if (probe.platform === "win32" && probe.executable(join(systemRootOf(probe), "System32/schtasks.exe"))) service = "task-scheduler"
|
|
99
104
|
|
|
100
105
|
const shims: Shim[] = []
|
|
101
106
|
const harnesses: HarnessPresence[] = HARNESS_ORDER.map((id) => {
|
|
@@ -27,6 +27,8 @@ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined
|
|
|
27
27
|
}
|
|
28
28
|
if (probe.platform === "darwin") paths["launch-agents"] = join(home, "Library/LaunchAgents")
|
|
29
29
|
if (probe.platform === "linux") paths["systemd-user"] = join(config, "systemd/user")
|
|
30
|
+
// Phase 3: where hubctl writes the Task XML it registers with Task Scheduler.
|
|
31
|
+
if (windows) paths["task-defs"] = join(paths.state!, "hub/tasks")
|
|
30
32
|
return ROOT_IDS.flatMap((id) => (paths[id] ? [{ id, path: paths[id]! }] : []))
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
type Result,
|
|
43
43
|
type SetupPlan,
|
|
44
44
|
} from "../contracts/index.ts"
|
|
45
|
-
import { NO_SERVICES_ON_TARGET } from "../profiles/desired.ts"
|
|
45
|
+
import { NO_SERVICES_ON_TARGET, servicesWithoutTemplate } from "../profiles/desired.ts"
|
|
46
46
|
|
|
47
47
|
/** What a contributor plans from: the adapters' input, and the actions planned before it (adapters first). */
|
|
48
48
|
export type ContributorInput = PlanInput & { readonly planned?: readonly Action[] }
|
|
@@ -149,6 +149,10 @@ export function planSetup(
|
|
|
149
149
|
if (!target.ok || !snapshot.host.supported) return failed(issuesAt("/snapshot/host/target", target.ok ? fail("", "unsupported_platform", "unsupported host") : target))
|
|
150
150
|
if (profile.services.length > 0 && target.value.serviceManager === "none")
|
|
151
151
|
return fail("/profile/services", "unsupported_platform", `${profile.id} ${NO_SERVICES_ON_TARGET}`)
|
|
152
|
+
if (target.value.serviceManager !== "none") {
|
|
153
|
+
const missing = servicesWithoutTemplate(profile, target.value.serviceManager)
|
|
154
|
+
if (missing.length > 0) return failed(missing)
|
|
155
|
+
}
|
|
152
156
|
if (profile.services.length > 0 && snapshot.managers.service === "none")
|
|
153
157
|
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)`)
|
|
154
158
|
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
import {
|
|
8
8
|
checkTarget,
|
|
9
9
|
Checker,
|
|
10
|
+
EMBEDDED_ASSETS,
|
|
10
11
|
fail,
|
|
11
12
|
REQUIRED_PROBES,
|
|
12
13
|
validateDiscoverySnapshot,
|
|
13
14
|
validateProfile,
|
|
14
15
|
type DiscoverySnapshot,
|
|
16
|
+
type Issue,
|
|
15
17
|
type HarnessID,
|
|
16
18
|
type Profile,
|
|
17
19
|
type ProbeKind,
|
|
@@ -22,8 +24,17 @@ import {
|
|
|
22
24
|
type ServiceManagerID,
|
|
23
25
|
} from "../contracts/index.ts"
|
|
24
26
|
|
|
25
|
-
/** PHASE2-CONTRACT.md: a target without a service manager
|
|
27
|
+
/** PHASE2-CONTRACT.md: a target without a service manager runs session-only; preview refuses the rest. */
|
|
26
28
|
export const NO_SERVICES_ON_TARGET = "needs user services, which hubctl does not install on Windows yet"
|
|
29
|
+
/** PHASE3-CONTRACT.md: a service with no template for the target's manager (the Claude Code / Codex spokes on Windows). */
|
|
30
|
+
export const NO_SERVICE_ON_TARGET = "needs a user service hubctl does not install on Windows yet"
|
|
31
|
+
|
|
32
|
+
/** One unsupported_platform issue per profile service the target's manager has no template for. */
|
|
33
|
+
export function servicesWithoutTemplate(profile: Pick<Profile, "services">, manager: ServiceManagerID, at = "/profile/services"): Issue[] {
|
|
34
|
+
return profile.services.flatMap((service, i) =>
|
|
35
|
+
EMBEDDED_ASSETS[`service/${manager}/${service}`] ? [] : [{ path: `${at}/${i}`, code: "unsupported_platform" as const, message: `${service} ${NO_SERVICE_ON_TARGET}` }],
|
|
36
|
+
)
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
export interface DesiredHarness {
|
|
29
40
|
readonly id: HarnessID
|
|
@@ -73,6 +84,10 @@ export function desiredState(profileDoc: unknown, snapshotDoc: unknown): Result<
|
|
|
73
84
|
const target = checkTarget(snapshot.host.target)
|
|
74
85
|
if (profile.services.length > 0 && target.ok && target.value.serviceManager === "none")
|
|
75
86
|
return fail("/profile/services", "unsupported_platform", `${profile.id} ${NO_SERVICES_ON_TARGET}`)
|
|
87
|
+
if (target.ok && target.value.serviceManager !== "none") {
|
|
88
|
+
const missing = servicesWithoutTemplate(profile, target.value.serviceManager)
|
|
89
|
+
if (missing.length > 0) return { ok: false, issues: missing }
|
|
90
|
+
}
|
|
76
91
|
if (profile.services.length > 0 && manager === "none")
|
|
77
92
|
c.add("/profile/services", "no_service_manager", `${profile.id} runs services, but this host has no user service manager (launchd or systemd --user)`)
|
|
78
93
|
const runtimes = profile.runtimes.map((id, i): DesiredRuntime => {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type JournalEntry,
|
|
29
29
|
type SelectHarnessVersion,
|
|
30
30
|
type ServiceID,
|
|
31
|
+
SERVICE_ROOT,
|
|
31
32
|
type ServiceManagerID,
|
|
32
33
|
type PathRef,
|
|
33
34
|
type RecoveryDecision,
|
|
@@ -176,7 +177,7 @@ export function installedHarnesses(history: readonly TransactionRecord[]): Parti
|
|
|
176
177
|
*/
|
|
177
178
|
export function installedServiceUnits(history: readonly TransactionRecord[], manager: ServiceManagerID): Partial<Record<ServiceID, string>> {
|
|
178
179
|
const byFile = new Map(Object.values(EMBEDDED_ASSETS).filter((a) => a.manager === manager).map((a) => [a.installAs, a.service]))
|
|
179
|
-
const root = manager
|
|
180
|
+
const root = SERVICE_ROOT[manager]
|
|
180
181
|
const units: Partial<Record<ServiceID, string>> = {}
|
|
181
182
|
for (const tx of history) {
|
|
182
183
|
if (tx.outcome !== "committed") continue
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
canonicalJSON,
|
|
27
27
|
decideHarnessInstall,
|
|
28
28
|
EMBEDDED_ASSETS,
|
|
29
|
+
SERVICE_ROOT,
|
|
29
30
|
fail,
|
|
30
31
|
HARNESS_ORDER,
|
|
31
32
|
ok,
|
|
@@ -67,7 +68,6 @@ export interface ServiceFacts {
|
|
|
67
68
|
readonly installed: Partial<Record<ServiceID, string>>
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
const UNIT_ROOT: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user">> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
|
|
71
71
|
|
|
72
72
|
/** Deactivates, then removes (when hub knows the unit it installed), one service of a removed component. */
|
|
73
73
|
function removeServiceActions(service: ServiceID, facts: ServiceFacts): Action[] {
|
|
@@ -84,7 +84,7 @@ function removeServiceActions(service: ServiceID, facts: ServiceFacts): Action[]
|
|
|
84
84
|
summary: `Remove the hub ${service} ${facts.manager} unit (profile downgrade)`,
|
|
85
85
|
service,
|
|
86
86
|
manager: facts.manager,
|
|
87
|
-
path: { root:
|
|
87
|
+
path: { root: SERVICE_ROOT[facts.manager], rel: asset.installAs },
|
|
88
88
|
sha256,
|
|
89
89
|
})
|
|
90
90
|
return actions
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The embedded-asset bundle: the
|
|
1
|
+
// The embedded-asset bundle: the seven service templates of EMBEDDED_ASSETS (contracts §3.7, I2), imported as text so
|
|
2
2
|
// `bun build --compile` (and the npm bundle) carry them inside the program. A standalone hubctl never reads
|
|
3
3
|
// scripts/ at run time; node.ts / spoke-services.ts render from embeddedAsset(id).text (wired by HBS-13).
|
|
4
4
|
//
|
|
@@ -11,6 +11,7 @@ import launchdClaude from "../../../../../scripts/io.github.huynle.hub-claude-co
|
|
|
11
11
|
import systemdClaude from "../../../../../scripts/hub-claude-code.service" with { type: "text" }
|
|
12
12
|
import launchdCodex from "../../../../../scripts/io.github.huynle.hub-codex-spoke.plist" with { type: "text" }
|
|
13
13
|
import systemdCodex from "../../../../../scripts/hub-codex-spoke.service" with { type: "text" }
|
|
14
|
+
import taskNode from "../../../../../scripts/hub-node.task.xml" with { type: "text" }
|
|
14
15
|
|
|
15
16
|
export type AssetID = keyof typeof EMBEDDED_ASSETS
|
|
16
17
|
|
|
@@ -22,6 +23,8 @@ export const EMBEDDED_TEXT: Readonly<Record<string, string>> = Object.freeze({
|
|
|
22
23
|
"service/systemd-user/claude-code": systemdClaude,
|
|
23
24
|
"service/launchd/codex-spoke": launchdCodex,
|
|
24
25
|
"service/systemd-user/codex-spoke": systemdCodex,
|
|
26
|
+
// `*.xml` is declared as a DOM Document elsewhere; imported `with { type: "text" }` it is the file's text.
|
|
27
|
+
"service/task-scheduler/node": taskNode as unknown as string,
|
|
25
28
|
})
|
|
26
29
|
|
|
27
30
|
export interface EmbeddedText {
|