@huynle/hub 0.8.7 → 0.9.1

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.
Files changed (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -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: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user">> = Object.freeze({ launchd: "launch-agents", "systemd-user": "systemd-user" })
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 or the systemd unit name (what the service manager calls it). */
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: ServiceManagerID): string {
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<ServiceManagerID, RegExp>> = Object.freeze({
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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 !== "/" && (input.manager !== "systemd-user" || SYSTEMD_HOME.test(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
- input.manager === "systemd-user"
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[input.manager], "")
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, input.manager),
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) => path === dir || path.startsWith(dir === "/" ? "/" : `${dir}/`)
57
- const parentOf = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/"
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 type ServiceManagerID = "launchd" | "systemd-user"
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
- const SERVICE_ROOT: Record<ServiceManagerID, string> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
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, ["launchd", "systemd-user"] as const)
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, ["launchd", "systemd-user"] as const)
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, ["launchd", "systemd-user"] as const)
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, ["launchd", "systemd-user"] as const)
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
- export const ROOT_IDS = ["home", "config", "state", "data", "user-bin", "launch-agents", "systemd-user", "system-bin"] as const
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 yet (Windows until Phase 3). */
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): the session-only profile; user services
35
- // (Task Scheduler) arrive in Phase 3.
36
- { target: "windows-x64", platform: "windows", arch: "x64", libc: "none", serviceManager: "none", bunTarget: "bun-windows-x64-baseline" },
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 has no user service manager hubctl drives yet (PHASE2-CONTRACT.md): "none".
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 (windows-x64) runs session-only; preview refuses the rest. */
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 === "launchd" ? "launch-agents" : "systemd-user"
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: UNIT_ROOT[facts.manager], rel: asset.installAs },
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 six service templates of EMBEDDED_ASSETS (contracts §3.7, I2), imported as text so
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 {