@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,510 @@
1
+ // The operator's half of `host.silo`: which Silo this app joins. The author
2
+ // declared affordances (read the shared files, write them); this picks the
3
+ // directory those affordances apply to.
4
+ //
5
+ // Creating one from inside the picker is the point, not a convenience: for an
6
+ // app that joins a Silo on its own, creating IS the install step, and folding
7
+ // it away would tax the common case with a page trip.
8
+
9
+ import type { ComponentChildren, JSX } from "preact";
10
+ import { useId } from "preact/hooks";
11
+ import { useSignal } from "@preact/signals";
12
+ import type { SiloGrants } from "../../schema/host.ts";
13
+ import type { SiloOption } from "../install/adapter.ts";
14
+ import { Badge } from "./badge.tsx";
15
+ import { Button } from "./button.tsx";
16
+ import { Input } from "./input.tsx";
17
+ import { Label } from "./label.tsx";
18
+ import {
19
+ CheckCircleIcon,
20
+ CheckIcon,
21
+ ClipboardIcon,
22
+ FolderSimpleIcon,
23
+ KeyIcon,
24
+ PlusIcon,
25
+ ShareNetworkIcon,
26
+ StackIcon,
27
+ WarningIcon,
28
+ } from "./icons.tsx";
29
+
30
+ // The volume affordance carries its mode, because read-vs-write is the one
31
+ // thing on this screen an operator can judge at a glance.
32
+ type Affordance = "network" | "volume:read" | "volume:readwrite" | "apiKey";
33
+
34
+ const AFFORDANCE_META: Record<Affordance, { label: string; Icon: typeof StackIcon }> = {
35
+ network: { label: "Network", Icon: ShareNetworkIcon },
36
+ "volume:read": { label: "Read shared files", Icon: FolderSimpleIcon },
37
+ "volume:readwrite": { label: "Read + write shared files", Icon: FolderSimpleIcon },
38
+ apiKey: { label: "Shared key", Icon: KeyIcon },
39
+ };
40
+
41
+ function affordancesOf(ask: SiloGrants): Affordance[] {
42
+ const out: Affordance[] = [];
43
+ if (ask.network !== undefined) out.push("network");
44
+ if (ask.volume !== undefined) out.push(`volume:${ask.volume}`);
45
+ if (ask.apiKey !== undefined) out.push("apiKey");
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Require-map entries this Silo doesn't currently offer (name absent, or
51
+ * published on a different port). Only computable when the host sent a
52
+ * directory - an absent `offered` means unknown, and unknown stays pickable;
53
+ * the server's install gate is authoritative either way.
54
+ */
55
+ function missingRequires(ask: SiloGrants, silo: SiloOption): Array<{ name: string; port: number }> {
56
+ if (ask.network === undefined || ask.network === true) return [];
57
+ if (silo.offered === undefined) return [];
58
+ const missing: Array<{ name: string; port: number }> = [];
59
+ for (const [name, port] of Object.entries(ask.network)) {
60
+ if (silo.offered[name] !== port) missing.push({ name, port });
61
+ }
62
+ return missing;
63
+ }
64
+
65
+ interface Props {
66
+ /** The author's declared affordances - the descriptor's `data`. */
67
+ ask: SiloGrants;
68
+ /** Every Silo the Home knows; the picker narrows to `workerId`. */
69
+ silos: SiloOption[];
70
+ /** The worker this app will run on. Null until one is chosen. */
71
+ workerId: string | null;
72
+ kind: "single" | "system";
73
+ /** The chosen Silo's id. */
74
+ value: string | null;
75
+ onChange: (id: string | null) => void;
76
+ /** Absent when the host serves a fixed set - the create card stays out. */
77
+ onCreate?: (input: { name: string; containerPath?: string; hostPath?: string }) => Promise<void>;
78
+ /**
79
+ * Confirm an adopted Silo's setup command from here, so an operator who
80
+ * created one mid-install can finish it without leaving the install.
81
+ */
82
+ onAcknowledge?: (id: string) => Promise<void>;
83
+ disabled?: boolean;
84
+ }
85
+
86
+ export function SiloPicker({
87
+ ask,
88
+ silos,
89
+ workerId,
90
+ kind,
91
+ value,
92
+ onChange,
93
+ onCreate,
94
+ onAcknowledge,
95
+ disabled,
96
+ }: Props) {
97
+ const creating = useSignal(false);
98
+ const newName = useSignal("");
99
+ const newPath = useSignal("");
100
+ // Managed vs adopted, the same choice the Silos page offers. Which one the
101
+ // operator wants is the one question a solo-app install has to ask anyway.
102
+ const adopt = useSignal(false);
103
+ const newHostPath = useSignal("");
104
+ const saving = useSignal(false);
105
+ const createError = useSignal<string | null>(null);
106
+ // Field ids aren't literals: an install screen can show a picker per app,
107
+ // and duplicate ids would point every label at the first input.
108
+ const uid = useId();
109
+
110
+ const chips = (
111
+ <div class="flex flex-wrap gap-1.5">
112
+ {affordancesOf(ask).map((a) => {
113
+ const { label, Icon } = AFFORDANCE_META[a];
114
+ return (
115
+ <Badge key={a} variant="outline" className="gap-1">
116
+ <Icon /> {label}
117
+ </Badge>
118
+ );
119
+ })}
120
+ </div>
121
+ );
122
+
123
+ // A Silo is a directory on one box, so an app that fans out across every
124
+ // worker can never be bound to one. The install refuses this combination
125
+ // server-side; say so here rather than render a control that can't help.
126
+ if (kind === "system") {
127
+ return (
128
+ <Frame chips={chips}>
129
+ <Note>This app runs on every worker, and a Silo lives on one.</Note>
130
+ </Frame>
131
+ );
132
+ }
133
+
134
+ if (workerId === null) {
135
+ return (
136
+ <Frame chips={chips}>
137
+ <Note>Pick a worker first. Silos live on one worker, so the choice narrows by it.</Note>
138
+ </Frame>
139
+ );
140
+ }
141
+
142
+ const onWorker = silos.filter((s) => s.workerId === workerId);
143
+
144
+ async function create() {
145
+ createError.value = null;
146
+ const name = newName.value.trim();
147
+ if (!name) {
148
+ createError.value = "Name is required.";
149
+ return;
150
+ }
151
+ if (newPath.value && !newPath.value.startsWith("/")) {
152
+ createError.value = "Mount path must be absolute.";
153
+ return;
154
+ }
155
+ const folder = newHostPath.value.trim();
156
+ if (adopt.value && !folder.startsWith("/")) {
157
+ createError.value = "Folder path must be absolute.";
158
+ return;
159
+ }
160
+ saving.value = true;
161
+ try {
162
+ await onCreate!({
163
+ name,
164
+ ...(newPath.value ? { containerPath: newPath.value } : {}),
165
+ ...(adopt.value ? { hostPath: folder } : {}),
166
+ });
167
+ creating.value = false;
168
+ newName.value = "";
169
+ newPath.value = "";
170
+ newHostPath.value = "";
171
+ adopt.value = false;
172
+ } catch (err) {
173
+ createError.value = (err as Error).message;
174
+ } finally {
175
+ saving.value = false;
176
+ }
177
+ }
178
+
179
+ return (
180
+ <Frame chips={chips}>
181
+ {onWorker.length === 0 && (
182
+ <Note>
183
+ {onCreate
184
+ ? "No Silos on this worker yet. Create the first one below."
185
+ : "No Silos on this worker."}
186
+ </Note>
187
+ )}
188
+
189
+ {onWorker.map((silo) => {
190
+ const selected = value === silo.id;
191
+ const missing = missingRequires(ask, silo);
192
+ // A selected-but-now-unmet Silo (edit flow, provider since removed)
193
+ // keeps its card active so the operator can switch away; the note
194
+ // still says what's missing.
195
+ const unpickable = missing.length > 0 && !selected;
196
+ return (
197
+ <div
198
+ key={silo.id}
199
+ class={[
200
+ "rounded-xl border transition-colors",
201
+ selected ? "border-text-body bg-surface-light" : "border-border bg-surface-tinted",
202
+ ].join(" ")}
203
+ >
204
+ <button
205
+ type="button"
206
+ disabled={disabled || unpickable}
207
+ data-silo-option={silo.id}
208
+ data-silo-name={silo.name}
209
+ data-silo-missing={
210
+ missing.length > 0 ? missing.map((m) => m.name).join(",") : undefined
211
+ }
212
+ aria-pressed={selected}
213
+ onClick={() => onChange(silo.id)}
214
+ class={[
215
+ "w-full text-left p-3",
216
+ disabled || unpickable ? "cursor-not-allowed opacity-60" : "cursor-pointer",
217
+ ].join(" ")}
218
+ >
219
+ <div class="flex items-start gap-3">
220
+ <StackIcon className="w-5 h-5 mt-0.5 shrink-0 text-text-body" />
221
+ <div class="flex-1 min-w-0">
222
+ <div class="flex items-center gap-2">
223
+ <span class="font-medium text-text-dark">{silo.name}</span>
224
+ {selected && <CheckCircleIcon className="w-4 h-4 text-success" />}
225
+ </div>
226
+ <div class="mt-0.5 font-mono text-xs text-text-muted truncate">
227
+ {silo.hostPath}
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </button>
232
+ {missing.length > 0 && (
233
+ <div
234
+ class="flex items-start gap-2 border-t border-border px-3 py-2 text-xs text-perm"
235
+ data-testid={`silo-missing-${silo.id}`}
236
+ >
237
+ <WarningIcon className="w-4 h-4 mt-0.5 shrink-0" />
238
+ <span>
239
+ {missing.length === 1
240
+ ? `Missing required service: ${missing[0]!.name} (port ${missing[0]!.port})`
241
+ : `Missing required services: ${missing
242
+ .map((m) => `${m.name} (port ${m.port})`)
243
+ .join(", ")}`}
244
+ </span>
245
+ </div>
246
+ )}
247
+ {/* Selectable anyway: the operator may be about to run the command,
248
+ or may have run it somewhere Realm can't see. A warning is
249
+ honest about what we know; a disabled card would claim we
250
+ checked. */}
251
+ {silo.setupCommand && (
252
+ <SetupNotice
253
+ silo={silo}
254
+ command={silo.setupCommand}
255
+ onAcknowledge={onAcknowledge}
256
+ disabled={disabled ?? false}
257
+ />
258
+ )}
259
+ </div>
260
+ );
261
+ })}
262
+
263
+ {onCreate && (
264
+ <div
265
+ class={[
266
+ "rounded-xl border transition-colors",
267
+ creating.value ? "border-text-body bg-surface-light" : "border-dashed border-border",
268
+ ].join(" ")}
269
+ >
270
+ <button
271
+ type="button"
272
+ disabled={disabled}
273
+ data-testid="silo-create-toggle"
274
+ onClick={() => (creating.value = !creating.value)}
275
+ class={`w-full text-left p-3 ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
276
+ >
277
+ <div class="flex items-center gap-3">
278
+ <PlusIcon className="w-5 h-5 shrink-0 text-text-body" />
279
+ <span class="font-medium text-text-dark">Create a new Silo</span>
280
+ </div>
281
+ </button>
282
+
283
+ {creating.value && (
284
+ <div class="px-3 pb-3 flex flex-col gap-4">
285
+ <div class="flex flex-col gap-1.5">
286
+ <Label htmlFor={`${uid}-name`}>Name</Label>
287
+ <Input
288
+ id={`${uid}-name`}
289
+ data-testid="silo-new-name"
290
+ placeholder="Media"
291
+ value={newName.value}
292
+ disabled={disabled || saving.value}
293
+ onValueChange={(v) => (newName.value = v)}
294
+ />
295
+ </div>
296
+
297
+ <div class="flex flex-col gap-1.5">
298
+ <Label htmlFor={`${uid}-path`}>Mount path</Label>
299
+ <Input
300
+ id={`${uid}-path`}
301
+ data-testid="silo-new-path"
302
+ placeholder="/silo"
303
+ value={newPath.value}
304
+ disabled={disabled || saving.value}
305
+ onValueChange={(v) => (newPath.value = v)}
306
+ />
307
+ <p class="text-xs text-text-muted">Where every member mounts it.</p>
308
+ </div>
309
+
310
+ <div class="flex flex-col gap-1.5">
311
+ <span class="text-xs font-medium text-text-muted">Where it lives</span>
312
+ <div class="grid grid-cols-2 gap-2">
313
+ <PathMode
314
+ active={!adopt.value}
315
+ onClick={() => (adopt.value = false)}
316
+ testId="silo-new-managed"
317
+ title="Let Realm manage it"
318
+ detail="A new directory on the worker you picked"
319
+ disabled={disabled || saving.value}
320
+ />
321
+ <PathMode
322
+ active={adopt.value}
323
+ onClick={() => (adopt.value = true)}
324
+ testId="silo-new-adopt"
325
+ title="Use a folder I already have"
326
+ detail="Point it at an existing directory"
327
+ disabled={disabled || saving.value}
328
+ />
329
+ </div>
330
+ </div>
331
+
332
+ {adopt.value ? (
333
+ <div class="flex flex-col gap-1.5">
334
+ <Label htmlFor={`${uid}-host-path`}>Folder path</Label>
335
+ <Input
336
+ id={`${uid}-host-path`}
337
+ data-testid="silo-new-host-path"
338
+ placeholder="/mnt/nas/media"
339
+ value={newHostPath.value}
340
+ disabled={disabled || saving.value}
341
+ onValueChange={(v) => (newHostPath.value = v)}
342
+ />
343
+ <p class="text-xs text-text-muted">
344
+ Realm won't touch this folder. It picks a group for the Silo and gives you one
345
+ command to run on the worker that hands the folder to it.
346
+ </p>
347
+ </div>
348
+ ) : (
349
+ <p class="text-xs text-text-muted">
350
+ Realm creates the directory itself, under{" "}
351
+ <span class="font-mono">/opt/realm/worker/silo/&lt;generated&gt;</span>, and sets
352
+ its group up for you.
353
+ </p>
354
+ )}
355
+
356
+ {createError.value && <p class="text-xs text-danger">{createError.value}</p>}
357
+
358
+ <Button
359
+ size="sm"
360
+ className="self-start"
361
+ disabled={disabled || saving.value}
362
+ data-testid="silo-create-submit"
363
+ onClick={create}
364
+ >
365
+ {saving.value ? "Creating…" : "Create and select"}
366
+ </Button>
367
+ </div>
368
+ )}
369
+ </div>
370
+ )}
371
+ </Frame>
372
+ );
373
+ }
374
+
375
+ function PathMode({
376
+ active,
377
+ onClick,
378
+ testId,
379
+ title,
380
+ detail,
381
+ disabled,
382
+ }: {
383
+ active: boolean;
384
+ onClick: () => void;
385
+ testId: string;
386
+ title: string;
387
+ detail: string;
388
+ disabled: boolean;
389
+ }) {
390
+ return (
391
+ <button
392
+ type="button"
393
+ onClick={onClick}
394
+ disabled={disabled}
395
+ aria-pressed={active}
396
+ data-testid={testId}
397
+ class={[
398
+ "rounded-xl border px-3 py-2 text-left transition-colors",
399
+ disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
400
+ active ? "border-text-body bg-surface-light" : "border-border bg-surface-tinted",
401
+ ].join(" ")}
402
+ >
403
+ <div class="text-xs font-medium text-text-dark">{title}</div>
404
+ <div class="text-[11px] text-text-muted">{detail}</div>
405
+ </button>
406
+ );
407
+ }
408
+
409
+ // An adopted Silo the operator hasn't confirmed. It stays pickable, so the job
410
+ // here is to say what the install can't guarantee - and to carry the command, so
411
+ // creating a Silo mid-install can be finished in place rather than sending the
412
+ // operator to another page with a half-filled form behind them.
413
+ function SetupNotice({
414
+ silo,
415
+ command,
416
+ onAcknowledge,
417
+ disabled,
418
+ }: {
419
+ silo: SiloOption;
420
+ command: string;
421
+ onAcknowledge?: (id: string) => Promise<void>;
422
+ disabled: boolean;
423
+ }) {
424
+ const copied = useSignal(false);
425
+ const saving = useSignal(false);
426
+ const failed = useSignal<string | null>(null);
427
+
428
+ async function copy() {
429
+ await navigator.clipboard.writeText(command);
430
+ copied.value = true;
431
+ setTimeout(() => (copied.value = false), 1500);
432
+ }
433
+
434
+ async function acknowledge() {
435
+ failed.value = null;
436
+ saving.value = true;
437
+ try {
438
+ await onAcknowledge!(silo.id);
439
+ } catch (err) {
440
+ failed.value = (err as Error).message;
441
+ } finally {
442
+ saving.value = false;
443
+ }
444
+ }
445
+
446
+ return (
447
+ <div
448
+ class="flex flex-col gap-2 border-t border-border bg-perm-bg px-3 py-2.5"
449
+ data-testid={`silo-setup-${silo.id}`}
450
+ data-silo-setup-command={command}
451
+ >
452
+ <div class="flex items-start gap-2 text-xs text-perm">
453
+ <WarningIcon className="w-4 h-4 mt-0.5 shrink-0" />
454
+ <span>Setup command not confirmed yet - this app may not be able to read it.</span>
455
+ </div>
456
+ <pre class="overflow-x-auto rounded-lg bg-surface-tinted px-2.5 py-2 font-mono text-[11px] whitespace-pre text-text-body">
457
+ {command}
458
+ </pre>
459
+ <p class="text-[11px] text-text-muted">
460
+ Run it as root on {silo.workerSlug}. It changes the group of the folder and everything
461
+ inside it to {silo.gid}, so anything else reading that folder - a Samba share, a backup
462
+ script - needs adding to that group too. On a large library the recursive walk can run for a
463
+ long time; start it detached.
464
+ </p>
465
+ {failed.value && <p class="text-[11px] text-danger">{failed.value}</p>}
466
+ <div class="flex items-center gap-2">
467
+ <Button
468
+ size="sm"
469
+ variant="ghost"
470
+ disabled={disabled}
471
+ onClick={copy}
472
+ data-testid={`silo-setup-copy-${silo.id}`}
473
+ >
474
+ {copied.value ? <CheckIcon /> : <ClipboardIcon />}
475
+ {copied.value ? "Copied" : "Copy"}
476
+ </Button>
477
+ {onAcknowledge && (
478
+ <Button
479
+ size="sm"
480
+ disabled={disabled || saving.value}
481
+ onClick={acknowledge}
482
+ data-testid={`silo-setup-ack-${silo.id}`}
483
+ >
484
+ {saving.value ? "Saving…" : "I've run this"}
485
+ </Button>
486
+ )}
487
+ </div>
488
+ </div>
489
+ );
490
+ }
491
+
492
+ function Frame({ chips, children }: { chips: JSX.Element; children: ComponentChildren }) {
493
+ return (
494
+ <div class="mt-3 flex flex-col gap-2" data-slot="silo-picker">
495
+ <div class="flex items-baseline justify-between gap-3">
496
+ <span class="text-xs font-medium text-text-muted">Which Silo?</span>
497
+ {chips}
498
+ </div>
499
+ {children}
500
+ </div>
501
+ );
502
+ }
503
+
504
+ function Note({ children }: { children: ComponentChildren }) {
505
+ return (
506
+ <p class="rounded-xl border border-dashed border-border bg-surface-tinted px-3 py-2.5 text-xs text-text-body">
507
+ {children}
508
+ </p>
509
+ );
510
+ }
@@ -0,0 +1,13 @@
1
+ import { cn, type ElementProps } from "../utils.ts";
2
+
3
+ function Skeleton({ className, ...props }: ElementProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("animate-pulse rounded-xl bg-muted", className)}
8
+ {...props}
9
+ />
10
+ );
11
+ }
12
+
13
+ export { Skeleton };
@@ -0,0 +1,33 @@
1
+ import type { JSX } from "preact";
2
+ import { useTheme } from "next-themes";
3
+ import { Toaster as Sonner, toast, type ToasterProps } from "sonner";
4
+
5
+ const Toaster = ({ ...props }: ToasterProps) => {
6
+ const { theme = "system" } = useTheme();
7
+
8
+ return (
9
+ <Sonner
10
+ theme={theme as ToasterProps["theme"]}
11
+ className="toaster group"
12
+ toastOptions={{
13
+ classNames: {
14
+ toast: "rounded-2xl! shadow-lg! border-none! bg-card! text-card-foreground!",
15
+ title: "font-semibold!",
16
+ description: "text-muted-foreground!",
17
+ actionButton: "bg-primary! text-primary-foreground! rounded-xl! font-semibold!",
18
+ cancelButton: "bg-muted! text-muted-foreground! rounded-xl! font-semibold!",
19
+ },
20
+ }}
21
+ style={
22
+ {
23
+ "--normal-bg": "var(--card)",
24
+ "--normal-text": "var(--card-foreground)",
25
+ "--normal-border": "transparent",
26
+ } as JSX.CSSProperties
27
+ }
28
+ {...props}
29
+ />
30
+ );
31
+ };
32
+
33
+ export { Toaster, toast };
@@ -0,0 +1,58 @@
1
+ import type { ComponentProps } from "preact";
2
+ import { Switch as SwitchPrimitive } from "@ark-ui/react/switch";
3
+
4
+ import { cn } from "../utils.ts";
5
+
6
+ /**
7
+ * The switch names itself. Ark's machine points the hidden input's
8
+ * `aria-labelledby` at the label part whether or not it is mounted, so `label`
9
+ * is what gives the control an accessible name - and it is the visible name
10
+ * too, unless `labelClassName` hides it. A caller's own `<label htmlFor>` is
11
+ * fine as a click target; it does not name the control.
12
+ */
13
+ function Switch({
14
+ className,
15
+ id,
16
+ label,
17
+ labelClassName,
18
+ ...props
19
+ }: ComponentProps<typeof SwitchPrimitive.Root> & {
20
+ id?: string;
21
+ label?: string;
22
+ labelClassName?: string;
23
+ }) {
24
+ return (
25
+ <SwitchPrimitive.Root
26
+ ids={id ? { hiddenInput: id } : undefined}
27
+ data-slot="switch"
28
+ className={cn(
29
+ "peer inline-flex shrink-0 cursor-pointer items-center gap-3 outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
30
+ className,
31
+ )}
32
+ {...props}
33
+ >
34
+ {/* Focus lands on the hidden input, so the ring has to key off the
35
+ `data-focus-visible` Zag mirrors onto every part, not `:focus-visible`. */}
36
+ <SwitchPrimitive.Control
37
+ data-slot="switch-control"
38
+ className="inline-flex h-[26px] w-11 shrink-0 items-center rounded-full transition-all duration-250 data-[focus-visible]:ring-3 data-[focus-visible]:ring-ring/50 data-[state=checked]:bg-primary data-[state=checked]:shadow-clay-primary data-[state=unchecked]:bg-border data-[state=unchecked]:shadow-sm"
39
+ >
40
+ <SwitchPrimitive.Thumb
41
+ data-slot="switch-thumb"
42
+ className="pointer-events-none block size-5 rounded-full bg-white shadow-md transition-transform duration-250 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-[3px]"
43
+ />
44
+ </SwitchPrimitive.Control>
45
+ {label !== undefined && (
46
+ <SwitchPrimitive.Label
47
+ data-slot="switch-label"
48
+ className={cn("text-sm leading-none font-medium select-none", labelClassName)}
49
+ >
50
+ {label}
51
+ </SwitchPrimitive.Label>
52
+ )}
53
+ <SwitchPrimitive.HiddenInput />
54
+ </SwitchPrimitive.Root>
55
+ );
56
+ }
57
+
58
+ export { Switch };