@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.
- package/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- package/src/yaml.ts +15 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
import { useEffect } from "preact/hooks";
|
|
2
|
+
import { useSignal, useComputed } from "@preact/signals";
|
|
3
|
+
import type { SerializablePermission } from "../../sandbox.ts";
|
|
4
|
+
import { Button } from "../components/button.tsx";
|
|
5
|
+
import { ImagePosture } from "../components/image-posture.tsx";
|
|
6
|
+
import { PermissionRow } from "../components/permission-row.tsx";
|
|
7
|
+
import { PermissionSection, SectionLabel } from "../components/permission-section.tsx";
|
|
8
|
+
import { CubeIcon } from "../components/icons.tsx";
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
} from "../components/dialog.tsx";
|
|
17
|
+
import { Checkbox } from "../components/checkbox.tsx";
|
|
18
|
+
import {
|
|
19
|
+
PERMISSION_META,
|
|
20
|
+
partitionHostKinds,
|
|
21
|
+
type HostPermissionKind,
|
|
22
|
+
type WorkerCapability,
|
|
23
|
+
type PermissionKind,
|
|
24
|
+
} from "../../home.ts";
|
|
25
|
+
import {
|
|
26
|
+
TabsForm,
|
|
27
|
+
type ConfigFieldTree,
|
|
28
|
+
type ConfigField,
|
|
29
|
+
type ConfigContainerNode,
|
|
30
|
+
} from "./tabs-form.tsx";
|
|
31
|
+
import { DeployTargetsPicker, type DeployTargets } from "./deploy-targets-picker.tsx";
|
|
32
|
+
import { descriptorsToGrants } from "../../grants-builder.ts";
|
|
33
|
+
import { permsInInactiveSections } from "./perms-inactive.ts";
|
|
34
|
+
import { findMissingRequired } from "./required-fields.ts";
|
|
35
|
+
import { InstallError, type InstallErrorShape } from "./install-error.tsx";
|
|
36
|
+
import {
|
|
37
|
+
InstallAdapterError,
|
|
38
|
+
type InstallAdapter,
|
|
39
|
+
type PreviewResult,
|
|
40
|
+
type SiloOption,
|
|
41
|
+
} from "./adapter.ts";
|
|
42
|
+
|
|
43
|
+
interface Worker {
|
|
44
|
+
id: string;
|
|
45
|
+
slug: string;
|
|
46
|
+
name: string | null;
|
|
47
|
+
capabilities: WorkerCapability[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Rough "how old" string for the image posture card.
|
|
51
|
+
function formatAge(iso: string | null): string {
|
|
52
|
+
if (!iso) return "unknown";
|
|
53
|
+
const then = new Date(iso).getTime();
|
|
54
|
+
if (Number.isNaN(then)) return "unknown";
|
|
55
|
+
const days = Math.floor((Date.now() - then) / 86_400_000);
|
|
56
|
+
if (days < 7) return days <= 1 ? "1 day" : `${days} days`;
|
|
57
|
+
const weeks = Math.floor(days / 7);
|
|
58
|
+
if (weeks < 9) return weeks === 1 ? "1 week" : `${weeks} weeks`;
|
|
59
|
+
const months = Math.floor(days / 30);
|
|
60
|
+
if (months < 24) return months === 1 ? "1 month" : `${months} months`;
|
|
61
|
+
const years = Math.floor(days / 365);
|
|
62
|
+
return years === 1 ? "1 year" : `${years} years`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Props {
|
|
66
|
+
workers: Worker[];
|
|
67
|
+
plumbing: InstallAdapter;
|
|
68
|
+
/**
|
|
69
|
+
* Pre-fill the source on mount and run `preview` immediately, jumping
|
|
70
|
+
* straight to the summary step. The textarea isn't rendered when this
|
|
71
|
+
* is set — the panel assumes the host has authoritative access to the
|
|
72
|
+
* realm.ts on disk. Used by `realm-sdk dev`'s /install route.
|
|
73
|
+
*/
|
|
74
|
+
initialSource?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Upstream registry id (e.g. "realm/counter-fresh") forwarded to
|
|
77
|
+
* `plumbing.install` so the host can correlate the new app row with
|
|
78
|
+
* its store listing. Set by the host when `initialSource` came from a
|
|
79
|
+
* known listing.
|
|
80
|
+
*/
|
|
81
|
+
sourceId?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Skip the DeployTargetsPicker on the configure step. `deployTargets`
|
|
84
|
+
* stays at its `true` default so the install payload remains valid.
|
|
85
|
+
* Used in dev where there's exactly one local target.
|
|
86
|
+
*/
|
|
87
|
+
hideDeployTargets?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Render a secondary "Install as template" button next to the primary
|
|
90
|
+
* install button. A template install posts `role: "template"`, landing a
|
|
91
|
+
* parked row that never deploys — a `builder`-scoped app (e.g.
|
|
92
|
+
* Forge) stamps previews from it. Off by default, so the store-install and
|
|
93
|
+
* `realm-sdk dev` flows keep a single install button. Home's "from source"
|
|
94
|
+
* install page sets it.
|
|
95
|
+
*/
|
|
96
|
+
showInstallAsTemplate?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The worker a single app starts pinned to: the first that
|
|
100
|
+
// advertises every required host kind. `null` when none qualifies — the
|
|
101
|
+
// picker then renders expanded and unpicked, because *why* nothing was
|
|
102
|
+
// chosen (each worker's missing kinds) is what the operator needs to read.
|
|
103
|
+
function autoPickWorker(workers: Worker[], requiredKinds: HostPermissionKind[]): string | null {
|
|
104
|
+
const capable = workers.find((w) => {
|
|
105
|
+
const caps = new Set(w.capabilities);
|
|
106
|
+
return requiredKinds.every((k) => caps.has(k));
|
|
107
|
+
});
|
|
108
|
+
return capable?.id ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function defaultConfigValues(schema: ConfigFieldTree): Record<string, unknown> {
|
|
112
|
+
const out: Record<string, unknown> = {};
|
|
113
|
+
for (const [name, field] of Object.entries(schema)) {
|
|
114
|
+
if (field.type === "permission") continue;
|
|
115
|
+
if (field.type === "tab" || field.type === "section") {
|
|
116
|
+
// Optional sections are `undefined` until the user toggles the
|
|
117
|
+
// feature on - that's the only way the sandbox learns the feature
|
|
118
|
+
// is active.
|
|
119
|
+
if (field.optional) continue;
|
|
120
|
+
out[name] = defaultConfigValues(field.fields);
|
|
121
|
+
} else if ("default" in field && field.default !== undefined) {
|
|
122
|
+
out[name] = field.default;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setPath(
|
|
129
|
+
obj: Record<string, unknown>,
|
|
130
|
+
path: string,
|
|
131
|
+
value: unknown,
|
|
132
|
+
): Record<string, unknown> {
|
|
133
|
+
const segments = path.split(".");
|
|
134
|
+
const clone: Record<string, unknown> = { ...obj };
|
|
135
|
+
let cursor = clone;
|
|
136
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
137
|
+
const seg = segments[i]!;
|
|
138
|
+
const next = cursor[seg];
|
|
139
|
+
const nextObj =
|
|
140
|
+
next && typeof next === "object" && !Array.isArray(next)
|
|
141
|
+
? { ...(next as Record<string, unknown>) }
|
|
142
|
+
: {};
|
|
143
|
+
cursor[seg] = nextObj;
|
|
144
|
+
cursor = nextObj;
|
|
145
|
+
}
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
delete cursor[segments[segments.length - 1]!];
|
|
148
|
+
} else {
|
|
149
|
+
cursor[segments[segments.length - 1]!] = value;
|
|
150
|
+
}
|
|
151
|
+
return clone;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// "Don't show this again" for the install-as-template explainer: one key
|
|
155
|
+
// holding a level. 0 = never dismissed, 1 = base explainer, 2 = base + the
|
|
156
|
+
// "no persistent volumes" note; a higher level subsumes the lower, so a
|
|
157
|
+
// level-1 dismissal still surfaces the volumes note on the first
|
|
158
|
+
// volume-declaring template. Guarded so SSR / disabled storage always show.
|
|
159
|
+
const TEMPLATE_NOTICE_KEY = "realm.templateInstallNoticeDismissed";
|
|
160
|
+
function dismissedLevel(): number {
|
|
161
|
+
try {
|
|
162
|
+
if (typeof localStorage === "undefined") return 0;
|
|
163
|
+
return Number(localStorage.getItem(TEMPLATE_NOTICE_KEY)) || 0;
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function setDismissedLevel(level: number): void {
|
|
169
|
+
try {
|
|
170
|
+
// Never downgrade — a higher dismissal already covers the lower notice.
|
|
171
|
+
if (typeof localStorage !== "undefined") {
|
|
172
|
+
localStorage.setItem(TEMPLATE_NOTICE_KEY, String(Math.max(level, dismissedLevel())));
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Private mode / storage disabled — the notice just keeps showing.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// The dismissal level a template needs: a volume-declaring template shows the
|
|
179
|
+
// extra note (2); a volumeless one only the base explainer (1).
|
|
180
|
+
function templateNoticeLevel(hasVolumes: boolean): number {
|
|
181
|
+
return hasVolumes ? 2 : 1;
|
|
182
|
+
}
|
|
183
|
+
function shouldShowTemplateDialog(hasVolumes: boolean): boolean {
|
|
184
|
+
return dismissedLevel() < templateNoticeLevel(hasVolumes);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default function InstallAppPanel({
|
|
188
|
+
workers,
|
|
189
|
+
plumbing,
|
|
190
|
+
initialSource,
|
|
191
|
+
sourceId,
|
|
192
|
+
hideDeployTargets,
|
|
193
|
+
showInstallAsTemplate,
|
|
194
|
+
}: Props) {
|
|
195
|
+
const step = useSignal<"discover" | "summary" | "configure">("discover");
|
|
196
|
+
const source = useSignal(initialSource ?? "");
|
|
197
|
+
const preview = useSignal<PreviewResult | null>(null);
|
|
198
|
+
const config = useSignal<Record<string, unknown>>({});
|
|
199
|
+
const enabled = useSignal<Set<string>>(new Set());
|
|
200
|
+
const grantValues = useSignal<Record<string, unknown>>({});
|
|
201
|
+
// Per-path: a `home.publicDomain` leaf whose add field still holds a
|
|
202
|
+
// hostname nobody turned into a row. Keyed rather than a flag because an
|
|
203
|
+
// app may declare two leaves.
|
|
204
|
+
const pendingDomains = useSignal<Record<string, boolean>>({});
|
|
205
|
+
const deployTargets = useSignal<DeployTargets | null>(null);
|
|
206
|
+
// Only loaded for apps that declare host.silo; every other install never
|
|
207
|
+
// asks the host for its Silos.
|
|
208
|
+
const silos = useSignal<SiloOption[]>([]);
|
|
209
|
+
const parsing = useSignal(false);
|
|
210
|
+
const installing = useSignal(false);
|
|
211
|
+
const error = useSignal<InstallErrorShape | null>(null);
|
|
212
|
+
// "Install as template" opens an explainer dialog (unless the operator
|
|
213
|
+
// dismissed it); the checkbox persists that dismissal to localStorage.
|
|
214
|
+
const templateDialogOpen = useSignal(false);
|
|
215
|
+
const dontShowAgain = useSignal(false);
|
|
216
|
+
// The host declares whether it does Silos by implementing the methods. A host
|
|
217
|
+
// without them gets no picker and no gate: `realm-sdk dev` has no Home to hold
|
|
218
|
+
// a Silo record, and its compiler already fails a silo app with an actionable
|
|
219
|
+
// message that a disabled button would replace with a dead end.
|
|
220
|
+
const hostHasSilos = typeof plumbing.listSilos === "function";
|
|
221
|
+
|
|
222
|
+
async function onParse() {
|
|
223
|
+
error.value = null;
|
|
224
|
+
if (!source.value.trim()) {
|
|
225
|
+
error.value = { kind: "message", message: "Paste a realm.ts source first." };
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
parsing.value = true;
|
|
229
|
+
try {
|
|
230
|
+
const result = await plumbing.preview(source.value);
|
|
231
|
+
preview.value = result;
|
|
232
|
+
// Pre-fill from an existing install if the host surfaces one (`realm-sdk
|
|
233
|
+
// dev` does, via the on-disk realm-sdk.dev.ts), so a second visit to
|
|
234
|
+
// /install on the same app is an edit flow. Otherwise schema defaults.
|
|
235
|
+
config.value = result.currentConfig ?? defaultConfigValues(result.configFields);
|
|
236
|
+
// Default the deploy picker per app kind: "single" pre-selects the first
|
|
237
|
+
// worker that satisfies the app's required host kinds (null if none can);
|
|
238
|
+
// "system" has no choice to make and stays null, which is what the install
|
|
239
|
+
// payload omits. In dev (`hideDeployTargets`) there is no picker and the
|
|
240
|
+
// dev install handler ignores `deployed` either way.
|
|
241
|
+
deployTargets.value =
|
|
242
|
+
hideDeployTargets || result.kind === "system"
|
|
243
|
+
? null
|
|
244
|
+
: autoPickWorker(
|
|
245
|
+
workers,
|
|
246
|
+
partitionHostKinds(result.permissionsFlat, result.configFields).required,
|
|
247
|
+
);
|
|
248
|
+
const initial = new Set<string>();
|
|
249
|
+
const initialGrantValues: Record<string, unknown> = {};
|
|
250
|
+
if (result.currentGrants) {
|
|
251
|
+
for (const [path, grant] of Object.entries(result.currentGrants)) {
|
|
252
|
+
initial.add(path);
|
|
253
|
+
// PeerGrant has no `data` field (peers are presence-only at
|
|
254
|
+
// grant time); skip those. For host grants, `data === true`
|
|
255
|
+
// is the presence-only flag (host.gpu/egress/management)
|
|
256
|
+
// and doesn't need a per-path override either.
|
|
257
|
+
if ("data" in grant && grant.data !== true && grant.data !== undefined) {
|
|
258
|
+
initialGrantValues[path] = grant.data;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
// No existing fixture — start with every required permission
|
|
263
|
+
// toggled on; optional ones default off, admin can opt in from
|
|
264
|
+
// the configure step.
|
|
265
|
+
for (const [path, perm] of Object.entries(result.permissionsFlat)) {
|
|
266
|
+
if (!(perm.meta as { isOptional?: boolean }).isOptional) initial.add(path);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
enabled.value = initial;
|
|
270
|
+
grantValues.value = initialGrantValues;
|
|
271
|
+
// Loaded before the step advances, so the configure step never renders a
|
|
272
|
+
// picker whose list is still in flight - an empty picker and a loading
|
|
273
|
+
// one look identical, and the difference is "create a duplicate Silo".
|
|
274
|
+
const listSilos = plumbing.listSilos?.bind(plumbing);
|
|
275
|
+
if (listSilos && Object.values(result.permissionsFlat).some((p) => p.kind === "host.silo")) {
|
|
276
|
+
try {
|
|
277
|
+
silos.value = await listSilos();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
error.value = {
|
|
280
|
+
kind: "network",
|
|
281
|
+
message: `Couldn't load Silos: ${(err as Error).message}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
step.value = "summary";
|
|
286
|
+
} catch (err) {
|
|
287
|
+
error.value =
|
|
288
|
+
err instanceof InstallAdapterError
|
|
289
|
+
? err.shape
|
|
290
|
+
: { kind: "network", message: (err as Error).message };
|
|
291
|
+
} finally {
|
|
292
|
+
parsing.value = false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function onInstall(asTemplate: boolean) {
|
|
297
|
+
if (!preview.value) return;
|
|
298
|
+
error.value = null;
|
|
299
|
+
installing.value = true;
|
|
300
|
+
let installedId: string | null = null;
|
|
301
|
+
try {
|
|
302
|
+
// Drop any grants the admin left toggled on inside an
|
|
303
|
+
// optional section that's currently off - the feature isn't
|
|
304
|
+
// consumed so the grant shouldn't be stored.
|
|
305
|
+
const inactive = permsInInactiveSections(
|
|
306
|
+
preview.value.configFields,
|
|
307
|
+
config.value,
|
|
308
|
+
preview.value.permissionsFlat,
|
|
309
|
+
);
|
|
310
|
+
const effectiveEnabled = new Set<string>();
|
|
311
|
+
for (const p of enabled.value) {
|
|
312
|
+
if (!inactive.has(p)) effectiveEnabled.add(p);
|
|
313
|
+
}
|
|
314
|
+
const grants = descriptorsToGrants(preview.value.permissionsFlat, {
|
|
315
|
+
enabled: effectiveEnabled,
|
|
316
|
+
grantValues: grantValues.value,
|
|
317
|
+
});
|
|
318
|
+
// A single app has to name its worker; `missingDeployTarget` already
|
|
319
|
+
// disables the button, so this is the type narrowing. A system app
|
|
320
|
+
// sends nothing — the host derives its placement from the source.
|
|
321
|
+
const deployed = deployTargets.value;
|
|
322
|
+
if (missingDeployTarget.value) return;
|
|
323
|
+
const result = await plumbing.install({
|
|
324
|
+
source: source.value,
|
|
325
|
+
...(deployed !== null ? { deployed } : {}),
|
|
326
|
+
config: config.value,
|
|
327
|
+
grants,
|
|
328
|
+
...(sourceId ? { sourceId } : {}),
|
|
329
|
+
...(asTemplate ? { role: "template" as const } : {}),
|
|
330
|
+
});
|
|
331
|
+
installedId = result.id;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
error.value =
|
|
334
|
+
err instanceof InstallAdapterError
|
|
335
|
+
? err.shape
|
|
336
|
+
: { kind: "network", message: (err as Error).message };
|
|
337
|
+
return;
|
|
338
|
+
} finally {
|
|
339
|
+
installing.value = false;
|
|
340
|
+
}
|
|
341
|
+
// Post-install side-effect runs only after `install` resolved.
|
|
342
|
+
// An exception thrown here must NOT masquerade as install
|
|
343
|
+
// failure — the DB row is real. installedId is set iff install
|
|
344
|
+
// resolved (the early `missingDeployTarget` / catch paths returned).
|
|
345
|
+
if (installedId !== null) plumbing.onInstalled(installedId);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Auto-preview when host pre-fills the source. The host owns the
|
|
349
|
+
// file so there's no paste step; if preview fails we show the error
|
|
350
|
+
// in place of (the now-hidden) textarea.
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (initialSource !== undefined) {
|
|
353
|
+
void onParse();
|
|
354
|
+
}
|
|
355
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
const togglePermission = (path: string, v: boolean) => {
|
|
359
|
+
const next = new Set(enabled.value);
|
|
360
|
+
if (v) next.add(path);
|
|
361
|
+
else next.delete(path);
|
|
362
|
+
enabled.value = next;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// The worker scoping the Silo choice. A system app has no worker (null),
|
|
366
|
+
// and never could: a Silo is a directory on one box, so `host.silo` is
|
|
367
|
+
// single-only (`appKinds`, schema/host.ts) and the declaration is refused
|
|
368
|
+
// before an install ever gets here.
|
|
369
|
+
const siloWorkerId = useComputed(() =>
|
|
370
|
+
typeof deployTargets.value === "string" ? deployTargets.value : null,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
async function onCreateSilo(input: { name: string; containerPath?: string; hostPath?: string }) {
|
|
374
|
+
const workerId = siloWorkerId.value;
|
|
375
|
+
if (!workerId) throw new Error("Pick a worker first.");
|
|
376
|
+
if (!plumbing.createSilo) throw new Error("This host can't create Silos.");
|
|
377
|
+
const created = await plumbing.createSilo({ ...input, workerId });
|
|
378
|
+
silos.value = [...silos.value, created];
|
|
379
|
+
return created;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function onAcknowledgeSilo(id: string) {
|
|
383
|
+
await plumbing.acknowledgeSilo!(id);
|
|
384
|
+
// Clearing the command locally is the same derivation the host does, and it
|
|
385
|
+
// beats re-listing: a refetch mid-install could hand back a list the
|
|
386
|
+
// operator's own create isn't in yet.
|
|
387
|
+
silos.value = silos.value.map((s) => (s.id === id ? { ...s, setupCommand: null } : s));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function onDeployTargetsChange(v: DeployTargets | null) {
|
|
391
|
+
deployTargets.value = v;
|
|
392
|
+
// A Silo picked for the old worker can't resolve on the new one, and the
|
|
393
|
+
// picker filters it out of view. Drop it rather than install a grant
|
|
394
|
+
// naming a box the app no longer runs on.
|
|
395
|
+
const workerId = typeof v === "string" ? v : null;
|
|
396
|
+
const next = { ...grantValues.value };
|
|
397
|
+
let dropped = false;
|
|
398
|
+
for (const [path, id] of Object.entries(next)) {
|
|
399
|
+
if (preview.value?.permissionsFlat[path]?.kind !== "host.silo") continue;
|
|
400
|
+
if (silos.value.some((s) => s.id === id && s.workerId === workerId)) continue;
|
|
401
|
+
delete next[path];
|
|
402
|
+
dropped = true;
|
|
403
|
+
}
|
|
404
|
+
if (dropped) grantValues.value = next;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Required perms whose optional-section ancestors are all active: toggling a
|
|
408
|
+
// section off drops its perms from what the install still needs.
|
|
409
|
+
const requiredNow = useComputed(() => {
|
|
410
|
+
if (!preview.value) return [] as string[];
|
|
411
|
+
const inactive = permsInInactiveSections(
|
|
412
|
+
preview.value.configFields,
|
|
413
|
+
config.value,
|
|
414
|
+
preview.value.permissionsFlat,
|
|
415
|
+
);
|
|
416
|
+
return Object.entries(preview.value.permissionsFlat)
|
|
417
|
+
.filter(([p, m]) => !(m.meta as { isOptional?: boolean }).isOptional && !inactive.has(p))
|
|
418
|
+
.map(([p]) => p);
|
|
419
|
+
});
|
|
420
|
+
const missingPerms = useComputed(() => requiredNow.value.filter((p) => !enabled.value.has(p)));
|
|
421
|
+
const missingConfig = useComputed(() => {
|
|
422
|
+
if (!preview.value) return [] as string[];
|
|
423
|
+
return findMissingRequired(preview.value.configFields, config.value);
|
|
424
|
+
});
|
|
425
|
+
// `host.silo` has no descriptor to fall back on, so an unpicked Silo emits
|
|
426
|
+
// `data: undefined` and sanitizeGrants 400s. Catch it as a disabled button
|
|
427
|
+
// rather than a server error after the operator hit Install.
|
|
428
|
+
const missingSilo = useComputed(() => {
|
|
429
|
+
if (!preview.value || !hostHasSilos) return [] as string[];
|
|
430
|
+
const inactive = permsInInactiveSections(
|
|
431
|
+
preview.value.configFields,
|
|
432
|
+
config.value,
|
|
433
|
+
preview.value.permissionsFlat,
|
|
434
|
+
);
|
|
435
|
+
return Object.entries(preview.value.permissionsFlat)
|
|
436
|
+
.filter(([p, m]) => m.kind === "host.silo" && enabled.value.has(p) && !inactive.has(p))
|
|
437
|
+
.filter(([p]) => typeof grantValues.value[p] !== "string")
|
|
438
|
+
.map(([p]) => p);
|
|
439
|
+
});
|
|
440
|
+
// A domain typed but never added would be dropped at submit with nothing to
|
|
441
|
+
// show for it — blank is a valid grant, so no other gate catches it.
|
|
442
|
+
const pendingDomain = useComputed(() => Object.values(pendingDomains.value).some(Boolean));
|
|
443
|
+
// Single apps must be pinned to exactly one worker. In dev mode
|
|
444
|
+
// (`hideDeployTargets`) there's no picker and the host's single local
|
|
445
|
+
// target stands in, so skip the gate. System apps have no target to
|
|
446
|
+
// pick and stay null.
|
|
447
|
+
const missingDeployTarget = useComputed(() => {
|
|
448
|
+
if (!preview.value || hideDeployTargets) return false;
|
|
449
|
+
if (preview.value.kind !== "single") return false;
|
|
450
|
+
return typeof deployTargets.value !== "string";
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Top-level tabs (key → display title). Empty for flat apps.
|
|
454
|
+
const tabs = useComputed(() => {
|
|
455
|
+
if (!preview.value) return [] as { key: string; title: string }[];
|
|
456
|
+
return Object.entries(preview.value.configFields)
|
|
457
|
+
.filter(([, f]) => f.type === "tab")
|
|
458
|
+
.map(([key, f]) => ({ key, title: (f as ConfigContainerNode).title ?? key }));
|
|
459
|
+
});
|
|
460
|
+
// Bucket every still-unmet required item (config field or permission) by
|
|
461
|
+
// the tab it lives under, so each tab can show a count badge and the
|
|
462
|
+
// summary can say "Need N settings under <tab>". Items not under any tab
|
|
463
|
+
// (flat apps, or top-level fields alongside tabs) fall into `noTab`.
|
|
464
|
+
const needs = useComputed(() => {
|
|
465
|
+
const byTab: Record<string, number> = {};
|
|
466
|
+
let noTab = 0;
|
|
467
|
+
const tabKeys = new Set(tabs.value.map((t) => t.key));
|
|
468
|
+
for (const path of [...missingPerms.value, ...missingConfig.value]) {
|
|
469
|
+
const top = path.split(".")[0]!;
|
|
470
|
+
if (tabKeys.has(top)) byTab[top] = (byTab[top] ?? 0) + 1;
|
|
471
|
+
else noTab += 1;
|
|
472
|
+
}
|
|
473
|
+
return { byTab, noTab };
|
|
474
|
+
});
|
|
475
|
+
// Human-readable "still needed" lines for the configure step.
|
|
476
|
+
const needsLines = useComputed(() => {
|
|
477
|
+
const lines: string[] = [];
|
|
478
|
+
const plural = (n: number) => (n === 1 ? "setting" : "settings");
|
|
479
|
+
for (const t of tabs.value) {
|
|
480
|
+
const n = needs.value.byTab[t.key] ?? 0;
|
|
481
|
+
if (n > 0) lines.push(`${n} ${plural(n)} under ${t.title}`);
|
|
482
|
+
}
|
|
483
|
+
if (needs.value.noTab > 0) lines.push(`${needs.value.noTab} ${plural(needs.value.noTab)}`);
|
|
484
|
+
if (missingDeployTarget.value) lines.push("to choose a worker to deploy to");
|
|
485
|
+
if (missingSilo.value.length > 0) {
|
|
486
|
+
// A system app is a dead end, not a missing step: the app asks to run
|
|
487
|
+
// everywhere and a Silo is a directory on one box. Say that instead of
|
|
488
|
+
// pointing at a picker the operator will never be offered.
|
|
489
|
+
lines.push(
|
|
490
|
+
preview.value?.kind === "system"
|
|
491
|
+
? "a Silo, which this app can't have — it deploys to every worker, and a Silo lives on one"
|
|
492
|
+
: "to pick a Silo",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (pendingDomain.value) lines.push("to add the domain you typed, or clear the field");
|
|
496
|
+
return lines;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Host kinds — required vs optional — alongside which of those any worker can
|
|
500
|
+
// satisfy. Optional rows backed by an unsupported kind gray out; unsupported
|
|
501
|
+
// required kinds warn that the app installs dormant until a capable worker
|
|
502
|
+
// joins.
|
|
503
|
+
const partitioned = useComputed(() =>
|
|
504
|
+
preview.value
|
|
505
|
+
? partitionHostKinds(preview.value.permissionsFlat, preview.value.configFields)
|
|
506
|
+
: { required: [], optional: [] },
|
|
507
|
+
);
|
|
508
|
+
const supportedKinds = useComputed(() => {
|
|
509
|
+
const set = new Set<WorkerCapability>();
|
|
510
|
+
for (const w of workers) {
|
|
511
|
+
for (const k of w.capabilities) set.add(k);
|
|
512
|
+
}
|
|
513
|
+
return set;
|
|
514
|
+
});
|
|
515
|
+
const unsupportedRequiredKinds = useComputed(() =>
|
|
516
|
+
partitioned.value.required.filter((k) => !supportedKinds.value.has(k)),
|
|
517
|
+
);
|
|
518
|
+
const unsupportedOptionalKinds = useComputed(
|
|
519
|
+
() =>
|
|
520
|
+
new Set(
|
|
521
|
+
partitioned.value.optional.filter((k) => !supportedKinds.value.has(k)),
|
|
522
|
+
) as Set<HostPermissionKind>,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Rendered at the top of BOTH the Review and configure steps: the worker
|
|
526
|
+
// scopes capability-gated permissions and the Silo choice, so it stays
|
|
527
|
+
// visible and changeable while those are filled in. Only one step is mounted
|
|
528
|
+
// at a time, so the two slots never collide.
|
|
529
|
+
const deployPicker =
|
|
530
|
+
hideDeployTargets || !preview.value ? null : (
|
|
531
|
+
<section class="flex flex-col gap-2">
|
|
532
|
+
<DeployTargetsPicker
|
|
533
|
+
workers={workers}
|
|
534
|
+
value={deployTargets.value}
|
|
535
|
+
kind={preview.value.kind}
|
|
536
|
+
onChange={onDeployTargetsChange}
|
|
537
|
+
disabled={installing.value}
|
|
538
|
+
requiredKinds={partitioned.value.required}
|
|
539
|
+
collapsible
|
|
540
|
+
/>
|
|
541
|
+
</section>
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<div class="flex flex-col gap-6 p-6 max-w-3xl">
|
|
546
|
+
{step.value === "discover" && initialSource !== undefined && (
|
|
547
|
+
<section class="flex flex-col gap-2">
|
|
548
|
+
{parsing.value && <div class="text-xs text-muted-foreground">Loading realm.ts…</div>}
|
|
549
|
+
{error.value && <InstallError error={error.value} />}
|
|
550
|
+
</section>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{step.value === "discover" && initialSource === undefined && (
|
|
554
|
+
<section class="flex flex-col gap-2">
|
|
555
|
+
<textarea
|
|
556
|
+
class="font-mono text-xs rounded-2xl border border-border bg-card p-3 min-h-48 w-full outline-none focus:ring-2 focus:ring-ring/50"
|
|
557
|
+
placeholder='import { createApp, r } from "@yourrealm/sdk"; …'
|
|
558
|
+
value={source.value}
|
|
559
|
+
onInput={(e) => (source.value = (e.target as HTMLTextAreaElement).value)}
|
|
560
|
+
disabled={parsing.value || installing.value}
|
|
561
|
+
data-testid="install-source"
|
|
562
|
+
/>
|
|
563
|
+
<div class="flex items-center gap-2">
|
|
564
|
+
<Button
|
|
565
|
+
onClick={onParse}
|
|
566
|
+
disabled={parsing.value || installing.value || !source.value.trim()}
|
|
567
|
+
data-testid="parse-source"
|
|
568
|
+
>
|
|
569
|
+
{parsing.value ? "Parsing…" : "Parse"}
|
|
570
|
+
</Button>
|
|
571
|
+
<a href="/" class="text-xs text-muted-foreground hover:underline">
|
|
572
|
+
Cancel
|
|
573
|
+
</a>
|
|
574
|
+
</div>
|
|
575
|
+
{error.value && <InstallError error={error.value} />}
|
|
576
|
+
</section>
|
|
577
|
+
)}
|
|
578
|
+
|
|
579
|
+
{step.value === "summary" && preview.value && (
|
|
580
|
+
<>
|
|
581
|
+
{deployPicker}
|
|
582
|
+
<SummaryStep
|
|
583
|
+
preview={preview.value}
|
|
584
|
+
grantValues={grantValues.value}
|
|
585
|
+
onGrantValueChange={(path, value) =>
|
|
586
|
+
(grantValues.value = { ...grantValues.value, [path]: value })
|
|
587
|
+
}
|
|
588
|
+
onConfirm={() => (step.value = "configure")}
|
|
589
|
+
onBack={() => (step.value = "discover")}
|
|
590
|
+
formatAge={formatAge}
|
|
591
|
+
/>
|
|
592
|
+
</>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{step.value === "configure" && preview.value && (
|
|
596
|
+
<>
|
|
597
|
+
{deployPicker}
|
|
598
|
+
|
|
599
|
+
{Object.keys(preview.value.configFields).length +
|
|
600
|
+
Object.keys(preview.value.permissionsFlat).length ===
|
|
601
|
+
0 &&
|
|
602
|
+
hideDeployTargets && (
|
|
603
|
+
<div class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3">
|
|
604
|
+
Nothing to configure — this app declares no config fields or permissions. Click
|
|
605
|
+
Install app to continue.
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
608
|
+
{Object.keys(preview.value.configFields).length +
|
|
609
|
+
Object.keys(preview.value.permissionsFlat).length >
|
|
610
|
+
0 && (
|
|
611
|
+
<section class="flex flex-col gap-3">
|
|
612
|
+
<TabsForm
|
|
613
|
+
configFields={preview.value.configFields}
|
|
614
|
+
permissionsFlat={preview.value.permissionsFlat}
|
|
615
|
+
configValues={config.value}
|
|
616
|
+
enabledPermissions={enabled.value}
|
|
617
|
+
grantValues={grantValues.value}
|
|
618
|
+
silos={hostHasSilos ? silos.value : undefined}
|
|
619
|
+
siloWorkerId={siloWorkerId.value}
|
|
620
|
+
siloKind={preview.value.kind}
|
|
621
|
+
onCreateSilo={plumbing.createSilo ? onCreateSilo : undefined}
|
|
622
|
+
onAcknowledgeSilo={plumbing.acknowledgeSilo ? onAcknowledgeSilo : undefined}
|
|
623
|
+
disabled={installing.value}
|
|
624
|
+
needsByTab={needs.value.byTab}
|
|
625
|
+
onConfigChange={(path, v) => (config.value = setPath(config.value, path, v))}
|
|
626
|
+
onPermissionToggle={togglePermission}
|
|
627
|
+
onGrantValueChange={(path, value) =>
|
|
628
|
+
(grantValues.value = { ...grantValues.value, [path]: value })
|
|
629
|
+
}
|
|
630
|
+
onDomainPendingChange={(path, pending) =>
|
|
631
|
+
(pendingDomains.value = { ...pendingDomains.value, [path]: pending })
|
|
632
|
+
}
|
|
633
|
+
/>
|
|
634
|
+
</section>
|
|
635
|
+
)}
|
|
636
|
+
|
|
637
|
+
{error.value && <InstallError error={error.value} />}
|
|
638
|
+
|
|
639
|
+
{unsupportedRequiredKinds.value.length > 0 && (
|
|
640
|
+
<div
|
|
641
|
+
class="rounded-2xl border border-border bg-muted/40 text-muted-foreground text-xs px-4 py-3"
|
|
642
|
+
data-testid="unsupported-required-kinds"
|
|
643
|
+
>
|
|
644
|
+
No worker currently satisfies:{" "}
|
|
645
|
+
<span class="font-mono font-semibold">
|
|
646
|
+
{unsupportedRequiredKinds.value.join(", ")}
|
|
647
|
+
</span>
|
|
648
|
+
. The app will install dormant and start running when a capable worker joins.
|
|
649
|
+
</div>
|
|
650
|
+
)}
|
|
651
|
+
|
|
652
|
+
{unsupportedOptionalKinds.value.size > 0 && (
|
|
653
|
+
<div class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3">
|
|
654
|
+
Not supported by any worker:{" "}
|
|
655
|
+
<span class="font-mono font-semibold">
|
|
656
|
+
{[...unsupportedOptionalKinds.value].join(", ")}
|
|
657
|
+
</span>
|
|
658
|
+
</div>
|
|
659
|
+
)}
|
|
660
|
+
|
|
661
|
+
{needsLines.value.length > 0 && (
|
|
662
|
+
<div
|
|
663
|
+
class="rounded-2xl border border-amber-300/50 bg-amber-50 text-amber-900 text-xs px-4 py-3 flex flex-col gap-1"
|
|
664
|
+
data-testid="missing-required"
|
|
665
|
+
>
|
|
666
|
+
<span class="font-medium">Almost there — still need:</span>
|
|
667
|
+
<ul class="list-disc pl-4 flex flex-col gap-0.5">
|
|
668
|
+
{needsLines.value.map((line) => (
|
|
669
|
+
<li key={line}>{line}</li>
|
|
670
|
+
))}
|
|
671
|
+
</ul>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
|
|
675
|
+
<div class="flex items-center gap-2">
|
|
676
|
+
<Button variant="outline" onClick={() => (step.value = "summary")}>
|
|
677
|
+
Back
|
|
678
|
+
</Button>
|
|
679
|
+
<Button
|
|
680
|
+
onClick={() => onInstall(false)}
|
|
681
|
+
disabled={
|
|
682
|
+
installing.value ||
|
|
683
|
+
missingPerms.value.length > 0 ||
|
|
684
|
+
missingConfig.value.length > 0 ||
|
|
685
|
+
missingDeployTarget.value ||
|
|
686
|
+
missingSilo.value.length > 0 ||
|
|
687
|
+
pendingDomain.value
|
|
688
|
+
}
|
|
689
|
+
data-testid="install-app"
|
|
690
|
+
>
|
|
691
|
+
{installing.value ? "Installing…" : "Install app"}
|
|
692
|
+
</Button>
|
|
693
|
+
{showInstallAsTemplate && (
|
|
694
|
+
<Button
|
|
695
|
+
variant="outline"
|
|
696
|
+
onClick={() => {
|
|
697
|
+
// Explain what a template does (and that previews drop their
|
|
698
|
+
// volumes) before stamping it — unless the operator already
|
|
699
|
+
// dismissed this template's notice level.
|
|
700
|
+
const hasVols = preview.value?.hasPersistentVolumes ?? false;
|
|
701
|
+
if (!shouldShowTemplateDialog(hasVols)) {
|
|
702
|
+
onInstall(true);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// A prior (lower-level) dismissal pre-fills "don't show again".
|
|
706
|
+
dontShowAgain.value = dismissedLevel() > 0;
|
|
707
|
+
templateDialogOpen.value = true;
|
|
708
|
+
}}
|
|
709
|
+
disabled={
|
|
710
|
+
installing.value ||
|
|
711
|
+
missingPerms.value.length > 0 ||
|
|
712
|
+
missingConfig.value.length > 0 ||
|
|
713
|
+
missingDeployTarget.value ||
|
|
714
|
+
missingSilo.value.length > 0 ||
|
|
715
|
+
pendingDomain.value
|
|
716
|
+
}
|
|
717
|
+
data-testid="install-as-template"
|
|
718
|
+
>
|
|
719
|
+
Install as template
|
|
720
|
+
</Button>
|
|
721
|
+
)}
|
|
722
|
+
<a href="/" class="text-xs text-muted-foreground hover:underline">
|
|
723
|
+
Cancel
|
|
724
|
+
</a>
|
|
725
|
+
</div>
|
|
726
|
+
</>
|
|
727
|
+
)}
|
|
728
|
+
|
|
729
|
+
<Dialog
|
|
730
|
+
open={templateDialogOpen.value}
|
|
731
|
+
onOpenChange={(d: { open: boolean }) => (templateDialogOpen.value = d.open)}
|
|
732
|
+
>
|
|
733
|
+
<DialogContent data-testid="install-template-dialog">
|
|
734
|
+
<DialogHeader>
|
|
735
|
+
<DialogTitle>Install as template</DialogTitle>
|
|
736
|
+
<DialogDescription>
|
|
737
|
+
A template is scaffolding — it doesn't run on its own. A builder app (like Forge)
|
|
738
|
+
creates an app from it for each pull request, and those previews are torn down
|
|
739
|
+
automatically when the request closes.
|
|
740
|
+
</DialogDescription>
|
|
741
|
+
</DialogHeader>
|
|
742
|
+
{preview.value?.hasPersistentVolumes && (
|
|
743
|
+
<div
|
|
744
|
+
class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3"
|
|
745
|
+
data-testid="template-volumes-note"
|
|
746
|
+
>
|
|
747
|
+
No action needed — apps created from this template won't have persistent volumes and
|
|
748
|
+
will be garbage collected.
|
|
749
|
+
</div>
|
|
750
|
+
)}
|
|
751
|
+
<label class="flex items-center gap-2 text-sm">
|
|
752
|
+
<Checkbox
|
|
753
|
+
checked={dontShowAgain.value}
|
|
754
|
+
onCheckedChange={(d: { checked: boolean }) =>
|
|
755
|
+
(dontShowAgain.value = d.checked === true)
|
|
756
|
+
}
|
|
757
|
+
/>
|
|
758
|
+
Don't show this again
|
|
759
|
+
</label>
|
|
760
|
+
<DialogFooter>
|
|
761
|
+
<Button variant="outline" onClick={() => (templateDialogOpen.value = false)}>
|
|
762
|
+
Cancel
|
|
763
|
+
</Button>
|
|
764
|
+
<Button
|
|
765
|
+
onClick={() => {
|
|
766
|
+
// Record dismissal at THIS template's level (volumeless → 1,
|
|
767
|
+
// volumes → 2); setDismissedLevel never downgrades a prior one.
|
|
768
|
+
if (dontShowAgain.value) {
|
|
769
|
+
setDismissedLevel(
|
|
770
|
+
templateNoticeLevel(preview.value?.hasPersistentVolumes ?? false),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
templateDialogOpen.value = false;
|
|
774
|
+
onInstall(true);
|
|
775
|
+
}}
|
|
776
|
+
data-testid="confirm-install-template"
|
|
777
|
+
>
|
|
778
|
+
Install as template
|
|
779
|
+
</Button>
|
|
780
|
+
</DialogFooter>
|
|
781
|
+
</DialogContent>
|
|
782
|
+
</Dialog>
|
|
783
|
+
</div>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---- Summary step -----------------------------------------------------------
|
|
788
|
+
interface SummaryProps {
|
|
789
|
+
preview: PreviewResult;
|
|
790
|
+
grantValues: Record<string, unknown>;
|
|
791
|
+
onGrantValueChange: (path: string, value: unknown) => void;
|
|
792
|
+
onConfirm: () => void;
|
|
793
|
+
onBack: () => void;
|
|
794
|
+
formatAge: (iso: string | null) => string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function SummaryStep({
|
|
798
|
+
preview,
|
|
799
|
+
grantValues,
|
|
800
|
+
onGrantValueChange,
|
|
801
|
+
onConfirm,
|
|
802
|
+
onBack,
|
|
803
|
+
formatAge,
|
|
804
|
+
}: SummaryProps) {
|
|
805
|
+
// A permission is hidden here if ANY ancestor tab/section along its dotted
|
|
806
|
+
// path is optional - walking only the first segment misses perms inside an
|
|
807
|
+
// optional section that's itself inside a tab (e.g. `general.s3.bucket`).
|
|
808
|
+
// Those show up in the configure step once the feature toggle is on.
|
|
809
|
+
function isTopLevelPermission(path: string): boolean {
|
|
810
|
+
const segments = path.split(".");
|
|
811
|
+
// Skip the last segment (the permission name itself). Optionality
|
|
812
|
+
// on the leaf comes from meta.__optional, not from configFields.
|
|
813
|
+
let tree: Record<string, ConfigField> | undefined = preview.configFields;
|
|
814
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
815
|
+
if (!tree) return true;
|
|
816
|
+
const node: ConfigField | undefined = tree[segments[i]!];
|
|
817
|
+
if (!node) return true;
|
|
818
|
+
if ((node.type === "section" || node.type === "tab") && node.optional) return false;
|
|
819
|
+
if (node.type === "tab" || node.type === "section") {
|
|
820
|
+
tree = node.fields;
|
|
821
|
+
} else {
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
const shown = Object.entries(preview.permissionsFlat).filter(([p]) => isTopLevelPermission(p));
|
|
828
|
+
const required = shown.filter(([, m]) => !(m.meta as { isOptional?: boolean }).isOptional);
|
|
829
|
+
const dangerousRequired = required.filter(
|
|
830
|
+
([, m]) => PERMISSION_META[m.kind as PermissionKind]?.danger,
|
|
831
|
+
);
|
|
832
|
+
const normalRequired = required.filter(
|
|
833
|
+
([, m]) => !PERMISSION_META[m.kind as PermissionKind]?.danger,
|
|
834
|
+
);
|
|
835
|
+
const renderRequiredRow = ([path, perm]: [string, SerializablePermission]) => {
|
|
836
|
+
const kindKey = perm.kind;
|
|
837
|
+
if (!(kindKey in PERMISSION_META)) return null;
|
|
838
|
+
// grantValues is sparse (only entries the admin actively edited);
|
|
839
|
+
// fall back to the descriptor's perm.data for the displayed value.
|
|
840
|
+
const portArg =
|
|
841
|
+
kindKey === "host.port"
|
|
842
|
+
? ((grantValues[path] ?? perm.data) as string | number | undefined)
|
|
843
|
+
: undefined;
|
|
844
|
+
const urlArg =
|
|
845
|
+
kindKey === "remote.fetchUrl" || kindKey === "remote.artifact"
|
|
846
|
+
? (perm.data as { url: string }).url
|
|
847
|
+
: undefined;
|
|
848
|
+
const titleLabel = portArg != null ? String(portArg) : undefined;
|
|
849
|
+
const reason = (perm.meta as { reason?: string }).reason ?? "";
|
|
850
|
+
return (
|
|
851
|
+
<PermissionRow
|
|
852
|
+
key={path}
|
|
853
|
+
kind={kindKey as PermissionKind}
|
|
854
|
+
reason={reason}
|
|
855
|
+
required
|
|
856
|
+
checked
|
|
857
|
+
onCheckedChange={() => {}}
|
|
858
|
+
label={titleLabel}
|
|
859
|
+
port={portArg}
|
|
860
|
+
onPortChange={kindKey === "host.port" ? (v) => onGrantValueChange(path, v) : undefined}
|
|
861
|
+
url={urlArg}
|
|
862
|
+
/>
|
|
863
|
+
);
|
|
864
|
+
};
|
|
865
|
+
return (
|
|
866
|
+
<>
|
|
867
|
+
<section class="flex flex-col gap-3">
|
|
868
|
+
<h2 class="text-sm font-bold text-foreground">Review</h2>
|
|
869
|
+
{dangerousRequired.length > 0 && (
|
|
870
|
+
<PermissionSection danger label="Dangerous required permissions">
|
|
871
|
+
{dangerousRequired.map(renderRequiredRow)}
|
|
872
|
+
</PermissionSection>
|
|
873
|
+
)}
|
|
874
|
+
{normalRequired.length > 0 && (
|
|
875
|
+
<PermissionSection label="Required permissions">
|
|
876
|
+
{normalRequired.map(renderRequiredRow)}
|
|
877
|
+
</PermissionSection>
|
|
878
|
+
)}
|
|
879
|
+
{preview.images && preview.images.length > 0 && (
|
|
880
|
+
<div class="flex flex-col gap-3 bg-trust-bg rounded-3xl shadow-clay-blue p-4">
|
|
881
|
+
<SectionLabel
|
|
882
|
+
icon={<CubeIcon className="w-4 h-4 text-trust" />}
|
|
883
|
+
className="text-trust pl-0.5"
|
|
884
|
+
>
|
|
885
|
+
Images
|
|
886
|
+
</SectionLabel>
|
|
887
|
+
{preview.images.map((img) => (
|
|
888
|
+
<ImagePosture
|
|
889
|
+
key={img.ref}
|
|
890
|
+
image={img.ref}
|
|
891
|
+
digest={img.digest ?? "not yet pinned"}
|
|
892
|
+
runsAs={img.runsAs ?? "unknown (will resolve on install)"}
|
|
893
|
+
runsAsRoot={img.runsAsRoot}
|
|
894
|
+
exposes={img.exposes}
|
|
895
|
+
volumes={img.volumes}
|
|
896
|
+
age={formatAge(img.imageCreatedAt)}
|
|
897
|
+
/>
|
|
898
|
+
))}
|
|
899
|
+
</div>
|
|
900
|
+
)}
|
|
901
|
+
</section>
|
|
902
|
+
<div class="flex items-center gap-2">
|
|
903
|
+
<Button variant="outline" onClick={onBack}>
|
|
904
|
+
Back
|
|
905
|
+
</Button>
|
|
906
|
+
<Button onClick={onConfirm} data-testid="summary-continue">
|
|
907
|
+
Continue
|
|
908
|
+
</Button>
|
|
909
|
+
</div>
|
|
910
|
+
</>
|
|
911
|
+
);
|
|
912
|
+
}
|