/** * Shared source-config form model for the four workload Source kinds * (image / compose / static / dockerfile). * * Before this module the seed (JSON -> form fields) and serialize * (form fields -> source_config JSON) logic lived inline and DUPLICATED * verbatim in both `routes/apps/new/+page.svelte` and * `routes/apps/[id]/+page.svelte`. A drift between the two silently * changes the `source_config` shape the backend stores, which breaks * deploys. This module is the single source of truth so the create * wizard and the detail-page edit form serialize identically. * * The functions are pure (no Svelte runes, no DOM) so they unit-test in * a plain node environment. Components hold the state objects as `$state` * and call these to seed / serialize. * * Fidelity contract: output key order, defaults, and the preserve/scrub * behaviour below MUST match the legacy inline helpers exactly. Tests in * `sourceForms.test.ts` lock the shapes. */ export type GitProvider = 'gitea' | 'github' | 'gitlab'; /** Image source: deploy a pre-built image from a registry. */ export interface ImageFormState { ref: string; port: number; healthcheck: string; defaultTag: string; registryName: string; cpuLimit: number; memoryLimit: number; maxInstances: number; } /** Compose source: a docker-compose stack. */ export interface ComposeFormState { yaml: string; projectName: string; } /** * Git-discovery fields shared by the static and dockerfile sources — * both clone a repo via the same provider/owner/repo/branch/token path. * Extracted so a single discovery component can bind this slice of * either form. */ export interface GitSourceState { provider: GitProvider; baseURL: string; repoOwner: string; repoName: string; branch: string; accessToken: string; } /** Static source: serve files (optionally Deno) from a repo folder. */ export interface StaticFormState extends GitSourceState { folderPath: string; mode: 'static' | 'deno'; renderMarkdown: boolean; /** Report deploy outcome back to the git provider as a commit status. */ reportCommitStatus: boolean; } /** Dockerfile source: build an image from a Dockerfile in a repo. */ export interface DockerfileFormState extends GitSourceState { contextPath: string; dockerfilePath: string; port: number; /** Report deploy outcome back to the git provider as a commit status. */ reportCommitStatus: boolean; } // ── Defaults ──────────────────────────────────────────────────────── export function emptyImageState(): ImageFormState { return { ref: '', port: 0, healthcheck: '', defaultTag: 'latest', registryName: '', cpuLimit: 0, memoryLimit: 0, maxInstances: 1 }; } export function emptyComposeState(): ComposeFormState { return { yaml: '', projectName: '' }; } function emptyGitSourceState(): GitSourceState { return { provider: 'gitea', baseURL: '', repoOwner: '', repoName: '', branch: 'main', accessToken: '' }; } export function emptyStaticState(): StaticFormState { return { ...emptyGitSourceState(), folderPath: '', mode: 'static', renderMarkdown: false, reportCommitStatus: false }; } export function emptyDockerfileState(): DockerfileFormState { return { ...emptyGitSourceState(), contextPath: '', dockerfilePath: 'Dockerfile', port: 0, reportCommitStatus: false }; } // ── Parse helpers ─────────────────────────────────────────────────── /** Parse to an object for seeding; malformed / non-object JSON -> {}. */ function parseObject(jsonText: string): Record { try { const parsed: unknown = JSON.parse(jsonText); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } } catch { // fall through } return {}; } /** Parse for preserve helpers; malformed JSON -> null (caller guards). */ function tryParse(jsonText: string): Record | null { try { const parsed: unknown = JSON.parse(jsonText); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } } catch { // fall through } return null; } function strOr(value: unknown, fallback: string): string { return typeof value === 'string' ? value : fallback; } /** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */ function strOrTruthy(value: unknown, fallback: string): string { return typeof value === 'string' && value ? value : fallback; } function numOr(value: unknown, fallback: number): number { return typeof value === 'number' ? value : fallback; } function normProvider(value: unknown): GitProvider { return value === 'github' || value === 'gitlab' ? value : 'gitea'; } // ── Seed: source_config JSON -> form state ────────────────────────── export function seedImageState(jsonText: string): ImageFormState { const o = parseObject(jsonText); return { ref: strOr(o.image, ''), port: numOr(o.port, 0), healthcheck: strOr(o.healthcheck, ''), defaultTag: strOr(o.default_tag, 'latest'), registryName: strOr(o.registry_name, ''), cpuLimit: numOr(o.cpu_limit, 0), memoryLimit: numOr(o.memory_limit, 0), maxInstances: numOr(o.max_instances, 1) }; } export function seedComposeState(jsonText: string): ComposeFormState { const o = parseObject(jsonText); return { yaml: strOr(o.compose_yaml, ''), projectName: strOr(o.compose_project_name, '') }; } export function seedStaticState(jsonText: string): StaticFormState { const o = parseObject(jsonText); return { provider: normProvider(o.provider), baseURL: strOr(o.base_url, ''), repoOwner: strOr(o.repo_owner, ''), repoName: strOr(o.repo_name, ''), branch: strOrTruthy(o.branch, 'main'), accessToken: strOr(o.access_token, ''), folderPath: strOr(o.folder_path, ''), mode: o.mode === 'deno' ? 'deno' : 'static', renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false, reportCommitStatus: typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false }; } export function seedDockerfileState(jsonText: string): DockerfileFormState { const o = parseObject(jsonText); return { provider: normProvider(o.provider), baseURL: strOr(o.base_url, ''), repoOwner: strOr(o.repo_owner, ''), repoName: strOr(o.repo_name, ''), branch: strOrTruthy(o.branch, 'main'), accessToken: strOr(o.access_token, ''), contextPath: strOr(o.context_path, ''), dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'), port: numOr(o.port, 0), reportCommitStatus: typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false }; } // ── Serialize: form state -> source_config object ─────────────────── /** * Preserve `env` (object) and `volumes` (array) from an existing config * — they're edited in dedicated detail-page panels, not the source form, * and must survive a form round-trip. */ function preserveEnvVolumes(existingJson: string): { env: Record; volumes: unknown[]; } { const existing = tryParse(existingJson); let env: Record = {}; let volumes: unknown[] = []; if (existing) { if (existing.env && typeof existing.env === 'object') { env = existing.env as Record; } if (Array.isArray(existing.volumes)) { volumes = existing.volumes; } } return { env, volumes }; } export function imageToConfig(s: ImageFormState, existingJson: string): Record { const { env, volumes } = preserveEnvVolumes(existingJson); return { image: s.ref, registry_name: s.registryName, port: s.port, healthcheck: s.healthcheck, env, volumes, cpu_limit: s.cpuLimit, memory_limit: s.memoryLimit, default_tag: s.defaultTag, max_instances: s.maxInstances }; } export function composeToConfig(s: ComposeFormState): Record { return { compose_yaml: s.yaml, compose_project_name: s.projectName }; } export function staticToConfig(s: StaticFormState, existingJson: string): Record { const out: Record = { provider: s.provider, base_url: s.baseURL, repo_owner: s.repoOwner, repo_name: s.repoName, branch: s.branch || 'main', folder_path: s.folderPath, access_token: s.accessToken, mode: s.mode, render_markdown: s.renderMarkdown, // New key appended at the END so existing byte-shape assertions for // the other keys are minimally affected. Storage_* keys (added below // only when present in the existing config) trail this on edit. report_commit_status: s.reportCommitStatus }; // Preserve storage_* keys set via the raw JSON editor (not yet surfaced // as form controls) so a form round-trip doesn't silently drop them. const existing = tryParse(existingJson); if (existing) { if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled; if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb; } return out; } /** * Keys the dockerfile form owns. Everything else in an existing config is * preserved on round-trip EXCEPT the static-only keys (folder_path / mode * / render_markdown / storage_*) which are deliberately scrubbed: after a * static -> dockerfile switch they'd otherwise linger as dead keys and * make the backend log "unknown field" noise on every save. */ const DOCKERFILE_OWNED_KEYS: ReadonlySet = new Set([ 'provider', 'base_url', 'repo_owner', 'repo_name', 'branch', 'access_token', 'context_path', 'dockerfile_path', 'port', 'report_commit_status', 'folder_path', 'mode', 'render_markdown', 'storage_enabled', 'storage_limit_mb' ]); export function dockerfileToConfig( s: DockerfileFormState, existingJson: string ): Record { const preserved: Record = {}; const existing = tryParse(existingJson); if (existing) { for (const [k, v] of Object.entries(existing)) { if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v; } } return { provider: s.provider, base_url: s.baseURL, repo_owner: s.repoOwner, repo_name: s.repoName, branch: s.branch || 'main', access_token: s.accessToken, context_path: s.contextPath, dockerfile_path: s.dockerfilePath || 'Dockerfile', port: s.port || 0, // New owned key appended at the END of the owned block (before any // preserved unknown keys) so existing byte-shape assertions hold. report_commit_status: s.reportCommitStatus, ...preserved }; } /** Pretty-print a config object for the Advanced-JSON editor view. */ export function stringifyConfig(config: Record): string { return JSON.stringify(config, null, 2); } // ── Per-kind validity ─────────────────────────────────────────────── // Encodes the required fields per source kind. These back the wizard's // step gating (replacing the prior opaque ~250-char boolean). Optional // fields (folder_path, context_path, healthcheck, resource limits, ...) // are intentionally not required here. export function isImageValid(s: ImageFormState): boolean { return s.ref.trim() !== ''; } export function isComposeValid(s: ComposeFormState): boolean { return s.yaml.trim() !== ''; } function isGitSourceValid(s: GitSourceState): boolean { return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== ''; } export function isStaticValid(s: StaticFormState): boolean { return isGitSourceValid(s); } export function isDockerfileValid(s: DockerfileFormState): boolean { return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0; }