@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,193 @@
1
+ // Transport contract between the install panel and whoever's hosting it. Home
2
+ // wraps `fetch` to its own /api/apps endpoints; `realm-sdk dev` supplies one
3
+ // over its local dev server, and any future operator surface (Realm Cloud
4
+ // admin, a CLI confirm step) can do the same.
5
+ //
6
+ // The panel does no HTTP itself — it only knows the adapter shape. Adapter
7
+ // methods either resolve with a parsed payload or throw an
8
+ // `InstallAdapterError` carrying a render-ready `InstallErrorShape`. Anything
9
+ // else thrown is surfaced as a network error by the panel.
10
+
11
+ import type { Grants, Violation } from "../../home.ts";
12
+ import type { ConfigFieldTree, SerializablePermission } from "../../sandbox.ts";
13
+
14
+ // ---- Deploy targets ------------------------------------------------
15
+
16
+ /**
17
+ * One worker's id — the box a single app is pinned to. A system app
18
+ * has no deploy target to carry: it runs on every compatible worker by
19
+ * virtue of being one, so the host derives that from the source and the
20
+ * panel sends nothing.
21
+ */
22
+ export type DeployTargets = string;
23
+
24
+ // ---- Render-ready error shape ---------------------------------------
25
+
26
+ export type InstallErrorShape =
27
+ | { kind: "violations"; violations: Violation[] }
28
+ | { kind: "message"; message: string }
29
+ | { kind: "network"; message: string };
30
+
31
+ // ---- Preview + install payloads ------------------------------------
32
+
33
+ /**
34
+ * Image posture row from the preview endpoint. `digest === null`
35
+ * means Home hasn't pinned this ref yet — preview is cache-only,
36
+ * so a brand-new ref shows as unpinned until install commits.
37
+ */
38
+ export interface PreviewImage {
39
+ ref: string;
40
+ digest: string | null;
41
+ runsAs: string | null;
42
+ runsAsRoot: boolean;
43
+ exposes: string[];
44
+ volumes: string[];
45
+ imageCreatedAt: string | null;
46
+ }
47
+
48
+ export interface PreviewResult {
49
+ permissionsFlat: Record<string, SerializablePermission>;
50
+ configFields: ConfigFieldTree;
51
+ images: PreviewImage[] | null;
52
+ /**
53
+ * True when the app's resolved services declare any persistent (bind-mount)
54
+ * volume. Drives the "Install as template" explainer's note that apps created
55
+ * from a template won't keep volumes (Home strips them for preview rows). NOT
56
+ * the same as a `PreviewImage.volumes` (image-VOLUME) posture.
57
+ */
58
+ hasPersistentVolumes: boolean;
59
+ /**
60
+ * App kind. "single" means the app runs on one selected worker
61
+ * (install panel shows a single-pick). "system" means the app fans
62
+ * out across every compatible worker (static caption, no picker).
63
+ * See realm-ts/workers.mdx.
64
+ */
65
+ kind: "single" | "system";
66
+ /**
67
+ * Existing fixture values, if the host has them. `realm-sdk dev`
68
+ * surfaces these from `realm-sdk.dev.ts` so the panel pre-fills the
69
+ * form with the last install's choices, turning the install flow into
70
+ * an edit-and-resave flow on second visit. Home never sets these — its
71
+ * preview reflects "fresh install only".
72
+ */
73
+ currentConfig?: Record<string, unknown>;
74
+ currentGrants?: Grants;
75
+ }
76
+
77
+ export interface InstallPayload {
78
+ source: string;
79
+ /**
80
+ * The single app's chosen worker. Omitted for a system app — placement
81
+ * is derived from the source's kind, not submitted.
82
+ */
83
+ deployed?: DeployTargets;
84
+ config: Record<string, unknown>;
85
+ grants: Grants;
86
+ /**
87
+ * Upstream registry id when the source came from a known store listing
88
+ * (e.g. "realm/counter-fresh"). Lets the host correlate installs with
89
+ * their upstream entry. Absent for hand-pasted source.
90
+ */
91
+ sourceId?: string;
92
+ /**
93
+ * Install as a parked template instead of a persistent app. A template
94
+ * never deploys; a `builder`-scoped app (e.g. Forge) stamps
95
+ * `preview` children from it. Set by the panel's "Install as template"
96
+ * button; the deploy target the operator picks still applies, since
97
+ * previews inherit the template's `deployed`.
98
+ */
99
+ role?: "template";
100
+ }
101
+
102
+ // ---- Silos ----------------------------------------------------------
103
+
104
+ /**
105
+ * A Silo row as the picker renders it. `hostPath` is always shown: it's the
106
+ * one field that says which bytes an operator is about to hand an app.
107
+ */
108
+ export interface SiloOption {
109
+ id: string;
110
+ name: string;
111
+ workerId: string;
112
+ /** The worker's slug, for the surfaces that name the box to the operator. */
113
+ workerSlug: string;
114
+ hostPath: string;
115
+ containerPath: string;
116
+ gid: number;
117
+ /**
118
+ * The command that hands an adopted directory to this Silo's group, or null
119
+ * when there's nothing left to run - a Realm-managed directory (the worker
120
+ * prepares those) or one the operator already confirmed. Non-null means the
121
+ * Silo is pickable but may not be readable yet, which is what the card warns
122
+ * about. Computed by the host, never by the picker: an operator who follows a
123
+ * stale command gets a Silo that looks ready and shares nothing.
124
+ */
125
+ setupCommand: string | null;
126
+ /**
127
+ * Published service directory (name → port). Absent = unknown - the picker
128
+ * keeps the Silo pickable and the server's install gate stays
129
+ * authoritative; `{}` = genuinely memberless. Drives the require-map
130
+ * filtering (a Silo missing a required service renders unpickable).
131
+ */
132
+ offered?: Record<string, number>;
133
+ }
134
+
135
+ // ---- Adapter contract ----------------------------------------------
136
+
137
+ export interface InstallAdapter {
138
+ /** Parse + schema-extract the realm.ts source. */
139
+ preview(source: string): Promise<PreviewResult>;
140
+ /** Persist the candidate app + grants. Resolves with the new app's id. */
141
+ install(payload: InstallPayload): Promise<{ id: string }>;
142
+ /**
143
+ * Every Silo the host knows. Fetched only when the app declares a
144
+ * `host.silo` permission; the picker filters to the chosen worker.
145
+ *
146
+ * Optional, and its absence is the answer to "does this host do Silos at
147
+ * all": a Silo is a Home record on a named worker, and `realm-sdk dev` has
148
+ * neither. A host that omits this gets no picker and no Silo install gate,
149
+ * rather than a picker stuck asking for a worker it will never offer.
150
+ */
151
+ listSilos?(): Promise<SiloOption[]>;
152
+ /**
153
+ * Create one from inside the picker. The worker comes from the deploy
154
+ * target the operator already picked, so it isn't asked for twice.
155
+ *
156
+ * Separately optional from `listSilos`: a host could serve a fixed set it
157
+ * doesn't let you add to. The picker keeps its create card out when this is
158
+ * absent.
159
+ */
160
+ createSilo?(input: {
161
+ name: string;
162
+ workerId: string;
163
+ containerPath?: string;
164
+ /** A directory the operator already has. Omitted = Realm makes one. */
165
+ hostPath?: string;
166
+ }): Promise<SiloOption>;
167
+ /**
168
+ * Record that the operator ran an adopted Silo's setup command, so the
169
+ * warning on its card retires without a trip to the Silos page.
170
+ *
171
+ * Separately optional again: a host that lists Silos it doesn't let you adopt
172
+ * into never needs it, and the card falls back to the warning alone.
173
+ */
174
+ acknowledgeSilo?(id: string): Promise<void>;
175
+ /**
176
+ * Called after `install` resolves, with the new app's id. The side-effect
177
+ * lives outside the call so the adapter owns the navigation. Home lands on
178
+ * the app's summary page; dev mode would mount the app's own UI in place.
179
+ */
180
+ onInstalled(appId: string): void;
181
+ }
182
+
183
+ /**
184
+ * Adapter-side failure carrying a parsed InstallErrorShape. The
185
+ * panel catches this to render the structured error. Anything else
186
+ * thrown from an adapter method is treated as a network error.
187
+ */
188
+ export class InstallAdapterError extends Error {
189
+ constructor(public readonly shape: InstallErrorShape) {
190
+ super(shape.kind === "violations" ? "grant violations" : shape.message);
191
+ this.name = "InstallAdapterError";
192
+ }
193
+ }
@@ -0,0 +1,185 @@
1
+ import { useSignal } from "@preact/signals";
2
+ import { CaretDownIcon } from "../components/icons.tsx";
3
+ import { Label } from "../components/label.tsx";
4
+ import type { HostPermissionKind, WorkerCapability } from "../../home.ts";
5
+ import type { DeployTargets } from "./adapter.ts";
6
+
7
+ export type { DeployTargets };
8
+
9
+ interface Worker {
10
+ id: string;
11
+ slug: string;
12
+ name: string | null;
13
+ capabilities?: WorkerCapability[];
14
+ }
15
+
16
+ interface DeployTargetsPickerProps {
17
+ workers: Worker[];
18
+ /**
19
+ * Current selection: one worker's id (single apps). `null` is
20
+ * "nothing picked" — the picker emits it for a single app with no
21
+ * choice made yet, and it's the standing value for a system app,
22
+ * which shows a static caption instead of a list.
23
+ */
24
+ value: DeployTargets | null;
25
+ onChange: (v: DeployTargets | null) => void;
26
+ disabled?: boolean;
27
+ /**
28
+ * App kind from the `createApp` / `createSystemApp` factory. "single"
29
+ * renders a radio list of workers; "system" renders a caption (no
30
+ * choice).
31
+ */
32
+ kind: "single" | "system";
33
+ /**
34
+ * Required host kinds the candidate app declares. Workers that don't
35
+ * advertise every kind are disabled in the picker. The server-side
36
+ * gate is the authoritative source — this is just a hint.
37
+ */
38
+ requiredKinds?: HostPermissionKind[];
39
+ /**
40
+ * Collapse to a one-line "deploys to X · Change" row once a worker is
41
+ * selected. The install panel sets this: the common case is one capable
42
+ * worker, already pre-selected, and a radio list of one is noise. With
43
+ * nothing selected — no workers, or none that satisfy `requiredKinds` —
44
+ * the list renders expanded regardless, because *why* there's no
45
+ * selection is the thing the operator needs to see.
46
+ */
47
+ collapsible?: boolean;
48
+ }
49
+
50
+ export function DeployTargetsPicker({
51
+ workers,
52
+ value,
53
+ onChange,
54
+ disabled,
55
+ kind,
56
+ requiredKinds,
57
+ collapsible,
58
+ }: DeployTargetsPickerProps) {
59
+ const expanded = useSignal(false);
60
+ function missingFor(w: Worker): HostPermissionKind[] {
61
+ if (!requiredKinds || requiredKinds.length === 0) return [];
62
+ const caps = new Set(w.capabilities ?? []);
63
+ return requiredKinds.filter((k) => !caps.has(k));
64
+ }
65
+ const supportedCount = workers.filter((w) => missingFor(w).length === 0).length;
66
+ const showSupportHint =
67
+ !!requiredKinds && requiredKinds.length > 0 && supportedCount < workers.length;
68
+
69
+ if (kind === "system") {
70
+ return (
71
+ <div class="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
72
+ <div class="flex flex-col gap-0.5">
73
+ <span class="text-sm font-bold text-foreground">Deploys to every worker</span>
74
+ <span class="text-xs text-muted-foreground">
75
+ Every current and future compatible worker runs this app.
76
+ </span>
77
+ {showSupportHint && (
78
+ <span class="text-xs text-muted-foreground">
79
+ Currently {supportedCount} of {workers.length} workers (others lack required
80
+ capabilities).
81
+ </span>
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ const selected = typeof value === "string" ? value : null;
89
+ const pick = (workerId: string) => {
90
+ onChange(workerId);
91
+ // Picking is the whole point of expanding; fold back up so the step
92
+ // reads as one settled line again.
93
+ if (collapsible) expanded.value = false;
94
+ };
95
+
96
+ // The collapsed line is a toggle, not a "Change" shortcut that only appears
97
+ // when there's something to change: opening it on a one-worker Realm and
98
+ // seeing the single radio already ticked is the answer to "what did it pick
99
+ // for me, and did I have a say?". Hiding the control there leaves that
100
+ // unanswerable.
101
+ if (collapsible && selected !== null && !expanded.value) {
102
+ const w = workers.find((x) => x.id === selected);
103
+ return (
104
+ <button
105
+ type="button"
106
+ disabled={disabled}
107
+ onClick={() => (expanded.value = true)}
108
+ class="flex w-fit items-center gap-2 text-xs text-muted-foreground hover:text-foreground cursor-pointer disabled:cursor-not-allowed"
109
+ data-testid="deploy-target-collapsed"
110
+ >
111
+ <span>Deploys to</span>
112
+ <span class="font-mono font-semibold text-foreground">
113
+ {w?.name ?? w?.slug ?? selected}
114
+ </span>
115
+ {w?.name && <span class="font-mono">{w.slug}</span>}
116
+ <CaretDownIcon className="size-3" />
117
+ </button>
118
+ );
119
+ }
120
+
121
+ // Collapsing back needs its own affordance: re-clicking an already-checked
122
+ // radio fires no change event, so on a one-worker Realm the pick can't do it.
123
+ const canCollapse = collapsible && selected !== null;
124
+
125
+ return (
126
+ <div class="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
127
+ <div class="flex flex-col gap-2">
128
+ {canCollapse ? (
129
+ <button
130
+ type="button"
131
+ onClick={() => (expanded.value = false)}
132
+ class="flex w-fit items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground cursor-pointer"
133
+ data-testid="deploy-target-collapse"
134
+ >
135
+ Pick a worker
136
+ <CaretDownIcon className="size-3 rotate-180" />
137
+ </button>
138
+ ) : (
139
+ <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
140
+ Pick a worker
141
+ </span>
142
+ )}
143
+ {workers.length === 0 ? (
144
+ <p class="text-xs text-muted-foreground">
145
+ No workers enrolled yet. Enroll one on the Workers page.
146
+ </p>
147
+ ) : (
148
+ workers.map((w) => {
149
+ const id = `target-${w.slug}`;
150
+ const missing = missingFor(w);
151
+ const unsupported = missing.length > 0;
152
+ const checked = selected === w.id;
153
+ return (
154
+ <div key={w.id} class="flex items-start gap-2">
155
+ <input
156
+ type="radio"
157
+ id={id}
158
+ name="deploy-target-single"
159
+ checked={checked}
160
+ disabled={disabled || unsupported}
161
+ onChange={(e) => {
162
+ if ((e.target as HTMLInputElement).checked) pick(w.id);
163
+ }}
164
+ class="mt-1 cursor-pointer disabled:cursor-not-allowed"
165
+ />
166
+ <Label
167
+ htmlFor={id}
168
+ className={`text-sm font-normal ${unsupported ? "opacity-60" : ""}`}
169
+ >
170
+ <span class="font-mono font-semibold">{w.slug}</span>
171
+ {w.name && <span class="ml-2 text-muted-foreground">{w.name}</span>}
172
+ {unsupported && (
173
+ <span class="block text-xs text-muted-foreground">
174
+ Missing: <span class="font-mono">{missing.join(", ")}</span>
175
+ </span>
176
+ )}
177
+ </Label>
178
+ </div>
179
+ );
180
+ })
181
+ )}
182
+ </div>
183
+ </div>
184
+ );
185
+ }