From a1ceb19c49b7f407b2e816dc46c715fe124d0b38 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 29 May 2026 14:43:19 +0200 Subject: [PATCH] feat(deploy-to-hub): publish as relocated project bundle + resource drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deployAll now builds a self-contained project bundle (buildProjectBundle), pushing resource types, empty resource stubs at relocated f// paths, and the rewritten items — so a fork's references resolve inside the project. - Resource-dependency detection is unified on the same bundle: the UI list (dependencyTypes) is derived from the bundle preview, guaranteeing what's shown matches what's pushed. Removes the duplicate in-component detection (extractResRefs/refsForItem/resolveResourceSet/typeForResource). - Input-type deps (schema format: resource-) are synced as types and conventional f// stubs alongside hardcoded ones. - Replaces the hardcoded-path warning/fix/block machinery with a read-only "Resource dependencies" drawer: per-type usages tagged input vs hardcoded path, with an info popover explaining the portability tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspaceSettings/DeployToHub.svelte | 548 +++++++++++------- 1 file changed, 339 insertions(+), 209 deletions(-) diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 5ff06587e4..5a243800a6 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -12,7 +12,6 @@ import WorkspaceDeployLayout from '$lib/components/WorkspaceDeployLayout.svelte' import SchemaForm from '$lib/components/SchemaForm.svelte' import Tooltip from '$lib/components/Tooltip.svelte' - import Popover from '$lib/components/Popover.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { sendUserToast } from '$lib/toast' @@ -27,6 +26,18 @@ } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte' + import { + buildProjectBundle, + extractScriptRefs, + extractFlowRefs, + extractAppRefs, + type BundleDeps, + type BundledItem, + type FetchedItem, + type ItemKind, + type ItemRef, + type ProjectBundle + } from './projectBundle' import { Check, Cloud, @@ -34,12 +45,14 @@ ExternalLink, GitCompare, Globe, + Info, Loader2, Play, RotateCcw, TriangleAlert, X } from 'lucide-svelte' + import Popover from '$lib/components/Popover.svelte' import type { Kind } from '$lib/utils_deployable' type Phase = 'predeploy' | 'draft' | 'under_review' | 'live' @@ -146,6 +159,7 @@ let publishTarget = $state() let publishing = $state(false) + let resourceDrawer = $state() let bundleDrawer = $state() let bundleName = $state('') let bundleSummary = $state('') @@ -177,8 +191,6 @@ async function loadWorkspace(workspace: string) { loading = true - resRefCache = new Map() - resTypeCache = new Map() try { const [apps, rawApps, flows, scripts, settings] = await Promise.all([ listAllPages((p) => AppService.listApps({ workspace, ...p })), @@ -308,64 +320,106 @@ let hubItemIds = $state>({}) - async function pushItem(workspace: string, slug: string, it: DeployItem): Promise { - if (it.kind === 'script') { - const s = await ScriptService.getScriptByPath({ workspace, path: it.path }) - const body = { - summary: s.summary || it.path, - app: slug, - description: s.description ?? '', - kind: typeof s.kind === 'string' ? s.kind.toLowerCase() : 'script', - content: s.content, - language: s.language, - schema: s.schema ?? undefined, - lockfile: s.lock ?? undefined, - workspace_slug: slug + // Fetchers + resolver that turn workspace API responses into the shapes the + // project-bundle closure needs. + function buildBundleDeps(workspace: string): BundleDeps { + return { + fetchItem: async (ref: ItemRef): Promise => { + try { + if (ref.kind === 'script') { + const s = await ScriptService.getScriptByPath({ workspace, path: ref.path }) + return { + kind: 'script', + path: ref.path, + summary: s.summary, + description: s.description ?? undefined, + content: s.content, + language: s.language, + schema: s.schema, + lock: s.lock ?? undefined, + scriptKind: typeof s.kind === 'string' ? s.kind.toLowerCase() : 'script' + } + } else if (ref.kind === 'flow') { + const f = await FlowService.getFlowByPath({ workspace, path: ref.path }) + return { + kind: 'flow', + path: ref.path, + summary: f.summary, + description: f.description ?? undefined, + value: f.value, + schema: f.schema + } + } else if (ref.kind === 'app') { + const a = await AppService.getAppByPath({ workspace, path: ref.path }) + return { kind: 'app', path: ref.path, summary: a.summary, value: a.value } + } else if (ref.kind === 'raw_app') { + const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${ref.path}`, { + credentials: 'include' + }) + if (!r.ok) return undefined + return { kind: 'raw_app', path: ref.path, content: await r.text() } + } + } catch (e: any) { + return undefined + } + return undefined + }, + resolveResourceType: async (path: string): Promise => { + try { + const r = await ResourceService.getResource({ workspace, path }) + return r.resource_type ?? undefined + } catch (e: any) { + return undefined + } } - const resp = await postHub(workspace, '/hub/scripts', body) - const id = resp?.id - if (typeof id === 'number') hubItemIds = { ...hubItemIds, [it.key]: id } + } + } + + // Push one already-rewritten bundle item to the Hub. Paths inside content/value + // have already been relocated under the project folder by buildProjectBundle. + async function pushBundledItem(workspace: string, slug: string, it: BundledItem): Promise { + const key = `${it.kind}:${it.path}` + if (it.kind === 'script') { + const resp = await postHub(workspace, '/hub/scripts', { + summary: it.summary || it.newPath, + app: slug, + description: it.description ?? '', + kind: it.scriptKind ?? 'script', + content: it.content, + language: it.language, + schema: it.schema ?? undefined, + lockfile: it.lock ?? undefined, + workspace_slug: slug + }) + if (typeof resp?.id === 'number') hubItemIds = { ...hubItemIds, [key]: resp.id } } else if (it.kind === 'flow') { - const f = await FlowService.getFlowByPath({ workspace, path: it.path }) - const body = { + const resp = await postHub(workspace, '/hub/flows', { flow: { - summary: f.summary || it.path, - description: f.description ?? undefined, - value: f.value, - schema: f.schema ?? undefined + summary: it.summary || it.newPath, + description: it.description ?? undefined, + value: it.value, + schema: it.schema ?? undefined }, apps: [], workspace_slug: slug - } - const resp = await postHub(workspace, '/hub/flows', body) - const id = resp?.id - if (typeof id === 'number') hubItemIds = { ...hubItemIds, [it.key]: id } - } else if (it.kind === 'app') { - const a = await AppService.getAppByPath({ workspace, path: it.path }) - const body = { - app: a.value, - apps: [], - summary: a.summary || it.path, - description: undefined, - workspace_slug: slug - } - await postHub(workspace, '/hub/apps', body) - } else if (it.kind === 'raw_app') { - const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${it.path}`, { - credentials: 'include' }) - if (!r.ok) throw new Error(`fetch raw_app ${it.path}: ${await r.text()}`) - const raw = await r.text() - const body = { - raw, + if (typeof resp?.id === 'number') hubItemIds = { ...hubItemIds, [key]: resp.id } + } else if (it.kind === 'app') { + await postHub(workspace, '/hub/apps', { + app: it.value, apps: [], - summary: it.summary || it.path, + summary: it.summary || it.newPath, description: undefined, workspace_slug: slug - } - await postHub(workspace, '/hub/raw_apps', body) - } else { - throw new Error(`unsupported kind ${it.kind} in v1`) + }) + } else if (it.kind === 'raw_app') { + await postHub(workspace, '/hub/raw_apps', { + raw: it.content ?? '', + apps: [], + summary: it.summary || it.newPath, + description: undefined, + workspace_slug: slug + }) } } @@ -389,116 +443,99 @@ } } - // Resource paths referenced via the canonical $res: form inside a piece of - // script content or a stringified flow/app value. - function extractResRefs(text: string): string[] { - const out: string[] = [] - // Both canonical resource-reference forms the platform emits. - const re = /(?:\$res:|res:\/\/)([\w\-./]+)/g - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) out.push(m[1]) - return out - } - const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache']) - // itemKey -> the $res: paths it references. Cached so toggling selection - // doesn't refetch unchanged items' content. - let resRefCache = new Map() - // resource path -> its type. Only successful resolutions are cached; an - // unresolved path stays uncached so a later-created resource is picked up. - let resTypeCache = new Map() - async function refsForItem(workspace: string, it: DeployItem): Promise { - const cached = resRefCache.get(it.key) - if (cached) return cached - let refs: string[] = [] - try { - if (it.kind === 'script') { - const s = await ScriptService.getScriptByPath({ workspace, path: it.path }) - refs = extractResRefs(s.content ?? '') - } else if (it.kind === 'flow') { - const f = await FlowService.getFlowByPath({ workspace, path: it.path }) - refs = extractResRefs(JSON.stringify(f.value ?? {})) - } else if (it.kind === 'app') { - const a = await AppService.getAppByPath({ workspace, path: it.path }) - refs = extractResRefs(JSON.stringify(a.value ?? {})) - } else if (it.kind === 'raw_app') { - const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${it.path}`, { - credentials: 'include' - }) - if (r.ok) refs = extractResRefs(await r.text()) + // Walk a JSON schema's properties for `format: resource-` and return the + // distinct names. This is how a flow/script declares it *takes* a + // resource as an input rather than hardcoding one. + function typesFromSchema(schema: any): string[] { + const out = new Set() + const props = schema?.properties + if (props && typeof props === 'object') { + for (const key of Object.keys(props)) { + const fmt = props[key]?.format + if (typeof fmt === 'string' && fmt.startsWith('resource-')) { + out.add(fmt.slice('resource-'.length)) + } } - } catch (e: any) { - // best-effort: a missing/unreadable item contributes no refs } - resRefCache.set(it.key, refs) - return refs + return [...out] } - async function typeForResource(workspace: string, path: string): Promise { - const cached = resTypeCache.get(path) - if (cached) return cached - let rt = '' - try { - const r = await ResourceService.getResource({ workspace, path }) - rt = r.resource_type ?? '' - } catch (e: any) { - // referenced path with no resource in the source workspace - } - if (rt) resTypeCache.set(path, rt) - return rt - } - - interface DetectedResource { - path: string - resource_type: string - usedBy: { key: string; label: string; kind: Kind }[] - } - - // Resources a fork needs: every $res: reference found in the selected - // scripts/flows/apps, resolved to {path, type} and tagged with which items - // reference it. Values are never read. A referenced path with no resource in - // the source workspace (or a hidden internal type) is skipped. - async function resolveResourceSet(workspace: string): Promise { - const byPath = new Map() - const items = selectedItems - const refsPerItem = await Promise.all(items.map((it) => refsForItem(workspace, it))) - items.forEach((it, i) => { - const origin = { key: it.key, label: it.summary?.trim() || it.path, kind: it.kind } - for (const p of refsPerItem[i]) { - const arr = byPath.get(p) ?? [] - if (!arr.some((o) => o.key === origin.key)) arr.push(origin) - byPath.set(p, arr) - } - }) - const paths = [...byPath.keys()] - const types = await Promise.all(paths.map((p) => typeForResource(workspace, p))) - const resolved: DetectedResource[] = [] - paths.forEach((path, i) => { - const rt = types[i] - if (rt && !HIDDEN_RESOURCE_TYPES.has(rt)) - resolved.push({ path, resource_type: rt, usedBy: byPath.get(path)! }) - }) - return resolved.sort((a, b) => a.path.localeCompare(b.path)) - } - - // Read-only preview of the resource stubs that will be synced, kept in sync - // with the current selection. - let detectedResources = $state([]) + // Live preview of the project bundle for the current selection. Built with the + // exact same closure logic used at deploy time, so the dependency list shown + // here is guaranteed to match what gets pushed. + let bundlePreview = $state(undefined) let detectingResources = $state(false) + + // Dependency list shown to the user, by resource type. `hasHardcoded` marks a + // type that is pinned by a `$res:` path somewhere (relocated as a stub); + // otherwise it is taken as an input. Derived purely from the bundle preview. + type DependencyUsage = + | { role: 'input'; label: string; kind: ItemKind } + | { role: 'hardcoded'; label: string; kind: ItemKind; path: string } + interface DependencyType { + resource_type: string + hasHardcoded: boolean + usages: DependencyUsage[] + } + let dependencyTypes = $derived.by(() => { + const b = bundlePreview + if (!b) return [] as DependencyType[] + // Bundle item content is already relocated, so its $res: refs use newPath. + // Key the stub lookup by newPath; surface the originalPath to the user. + const stubByNewPath = new Map(b.resourceStubs.map((s) => [s.newPath, s])) + const byType = new Map() + const ensure = (rt: string) => { + let e = byType.get(rt) + if (!e) { + e = { resource_type: rt, hasHardcoded: false, usages: [] } + byType.set(rt, e) + } + return e + } + for (const it of b.items) { + const label = (it.summary?.trim() || it.path) ?? it.path + // Hardcoded $res: refs in this item's code/graph. + const refs = + it.kind === 'flow' + ? extractFlowRefs(it.value).filter((r) => r.kind === 'resource') + : it.kind === 'app' + ? extractAppRefs(it.value) + : extractScriptRefs(it.content ?? '') + for (const r of refs) { + const stub = stubByNewPath.get(r.path) + if (!stub || HIDDEN_RESOURCE_TYPES.has(stub.resource_type)) continue + const e = ensure(stub.resource_type) + e.hasHardcoded = true + e.usages.push({ role: 'hardcoded', label, kind: it.kind, path: stub.originalPath }) + } + // Resources taken as inputs (schema format: resource-). + for (const t of typesFromSchema(it.schema)) { + if (HIDDEN_RESOURCE_TYPES.has(t)) continue + ensure(t).usages.push({ role: 'input', label, kind: it.kind }) + } + } + return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type)) + }) + $effect(() => { const workspace = $workspaceStore // re-run when the selection changes selectedItemKeys if (!workspace || phase !== 'predeploy') { - detectedResources = [] + bundlePreview = undefined return } let cancelled = false detectingResources = true - resolveResourceSet(workspace) - .then((r) => { - if (!cancelled) detectedResources = r + const slug = sanitizeSlug(hubSlug) + const seed: ItemRef[] = selectedItems + .filter((i) => i.kind !== 'resource') + .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })) + buildProjectBundle(seed, slug, buildBundleDeps(workspace)) + .then((b) => { + if (!cancelled) bundlePreview = b }) .finally(() => { if (!cancelled) detectingResources = false @@ -508,14 +545,16 @@ } }) + // Push resource types for the bundle's stubs. Builtins (git_repository, …) + // aren't in the workspace resource_type table — we push them with an empty + // schema so the Hub still tracks the dependency. async function pushResourceTypes( workspace: string, slug: string, - resolved: { path: string; resource_type: string }[] + types: string[] ): Promise { - const uniques = Array.from(new Set(resolved.map((r) => r.resource_type))) const results = await Promise.all( - uniques.map(async (name) => { + types.map(async (name) => { let schema: unknown = undefined let description: string | undefined = undefined try { @@ -523,8 +562,7 @@ schema = rt.schema ?? undefined description = rt.description ?? undefined } catch (e: any) { - // Builtin types (e.g. git_repository) aren't in the workspace resource_type - // table; push with an empty schema so the Hub still tracks the dependency. + // builtin type — push with empty schema } try { await postHub(workspace, '/hub/resource_types', { @@ -540,26 +578,7 @@ } }) ) - return results.reduce((a, b) => a + b, 0) - } - - // Sync the empty resource stubs (path + type, never values) so a fork can - // recreate those paths and prompt the user to fill credentials. Must run - // after pushResourceTypes — the Hub rejects a resource whose type it doesn't - // yet know. - async function pushResources( - workspace: string, - slug: string, - resolved: { path: string; resource_type: string }[] - ): Promise { - if (resolved.length === 0) return 0 - try { - await postHub(workspace, '/hub/resources', { resources: resolved, workspace_slug: slug }) - return 0 - } catch (e: any) { - sendUserToast(`Resource sync failed: ${e?.message ?? e}`, true) - return 1 - } + return results.reduce((a: number, b) => a + b, 0) } async function deployAll() { @@ -569,30 +588,74 @@ deploying = true let failures = 0 try { - const resolvedResources = await resolveResourceSet(workspace) - const depFailures = - (await pushResourceTypes(workspace, slug, resolvedResources)) + - (await pushResources(workspace, slug, resolvedResources)) + // 1. Build the self-contained project bundle: closure of the selection, + // every path relocated under f// and all references rewritten. + const seed: ItemRef[] = selectedItems + .filter((i) => i.kind !== 'resource') + .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })) + const bundle = await buildProjectBundle(seed, slug, buildBundleDeps(workspace)) + + if (bundle.unresolved.length > 0) { + sendUserToast( + `Skipped ${bundle.unresolved.length} unresolved reference(s): ${bundle.unresolved.join(', ')}`, + true + ) + } + + // 2. Resource types then empty resource stubs (at their relocated paths). + // Types come from two places: stubs (hardcoded $res: paths we relocated) + // AND schema inputs (format: resource-) — a flow/script that takes a + // resource as a parameter still declares a dependency the Hub must know, + // even though there's no path to stub. + const inputTypes = bundle.items + .flatMap((i) => typesFromSchema(i.schema)) + .filter((t) => !HIDDEN_RESOURCE_TYPES.has(t)) + const types = [ + ...new Set([...bundle.resourceStubs.map((s) => s.resource_type), ...inputTypes]) + ] + const depFailures = await pushResourceTypes(workspace, slug, types) + + // Empty resource stubs at relocated paths. Hardcoded refs keep their + // relocated path; input-type deps (no path) get a conventional + // f// stub so they show up on the project and a fork can fill + // them. Dedup by path. + const stubsByPath = new Map() + for (const s of bundle.resourceStubs) + stubsByPath.set(s.newPath, { path: s.newPath, resource_type: s.resource_type }) + for (const t of inputTypes) { + const path = `f/${slug}/${t}` + if (!stubsByPath.has(path)) stubsByPath.set(path, { path, resource_type: t }) + } + const stubs = [...stubsByPath.values()] + if (stubs.length > 0) { + try { + await postHub(workspace, '/hub/resources', { resources: stubs, workspace_slug: slug }) + } catch (e: any) { + sendUserToast(`Resource sync failed: ${e?.message ?? e}`, true) + failures++ + } + } failures += depFailures - // Don't publish items whose resource dependencies failed to sync — a fork - // would get scripts/flows with $res: references that resolve to no stub. - if (depFailures > 0) { + if (failures > 0) { sendUserToast( `Resource dependency sync failed — items not published to avoid broken references.`, true ) return } - for (const it of selectedItems) { - deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'loading' } } + + // 3. Push every bundled item (already rewritten). + for (const it of bundle.items) { + const key = `${it.kind}:${it.path}` + deploymentStatus = { ...deploymentStatus, [key]: { status: 'loading' } } try { - await pushItem(workspace, slug, it) - deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'deployed' } } + await pushBundledItem(workspace, slug, it) + deploymentStatus = { ...deploymentStatus, [key]: { status: 'deployed' } } } catch (e: any) { failures++ deploymentStatus = { ...deploymentStatus, - [it.key]: { status: 'failed', error: e?.message ?? String(e) } + [key]: { status: 'failed', error: e?.message ?? String(e) } } } } @@ -1014,42 +1077,38 @@ {#if detectingResources} {:else} - ({detectedResources.length}) + ({dependencyTypes.length}) {/if} - Resources referenced by the selected items. Synced to the Hub as empty stubs - (path + type only, never values) so a fork recreates them and prompts you to fill - credentials. + Resource types the selected items depend on (whether passed as inputs or referenced + by a hardcoded path). Synced to the Hub so a fork knows what credentials it needs to + fill. - {#if detectedResources.length === 0} + {#if dependencyTypes.length === 0} No resource references detected in the current selection. {:else} -
- {#each detectedResources as r (r.path)} - - - {r.resource_type} - ({r.usedBy.length}) - - {#snippet text()} -
-
- {r.resource_type} - {r.path} -
-
used by
- {#each r.usedBy as u (u.key)} - {u.kind}: {u.label} - {/each} -
- {/snippet} -
+
+ {#each dependencyTypes as r (r.resource_type)} + + {r.resource_type} + {/each} +
{/if}
@@ -1417,6 +1476,77 @@
+ + resourceDrawer?.closeDrawer()}> +
+

+ Resource types the selected items depend on. Each is synced to the Hub so a fork knows what + credentials it needs to fill. Input means the item takes + the resource as a parameter; hardcoded path means the item + pins a specific resource path in its code. +

+ {#if dependencyTypes.length === 0} + No resource references in the current selection. + {:else} + {#each dependencyTypes as r (r.resource_type)} +
+
+ + {r.resource_type} + + + {r.usages.length} usage{r.usages.length > 1 ? 's' : ''} + +
+
+ {#each r.usages as u (u.role + ':' + u.label + ':' + (u.role === 'hardcoded' ? u.path : ''))} +
+
+ {u.kind} + {u.label} + + {u.role === 'hardcoded' ? 'hardcoded path' : 'input'} + {#if u.role === 'hardcoded'} + + + {#snippet text()} +
+ + This {u.kind} references the resource by a hardcoded path + $res:{u.path}. + + + For portability, prefer taking the resource as an input — a fork + won't have this exact path. It's relocated into the project on + publish, but converting it to an input keeps the item reusable. + +
+ {/snippet} +
+ {/if} +
+
+
+ {/each} +
+
+ {/each} + {/if} +
+
+
+ bundleDrawer?.closeDrawer()}>