diff --git a/scripts/lakebase/deploy-targets.ts b/scripts/lakebase/deploy-targets.ts new file mode 100644 index 0000000..cb983b4 --- /dev/null +++ b/scripts/lakebase/deploy-targets.ts @@ -0,0 +1,109 @@ +// deploy-targets.yaml parser + writer for the lakebase-apps-deploy domain. +// +// A scaffolded Lakebase-paired project ships with a `deploy-targets.yaml` +// at its root. Each target describes one deployment destination (workspace +// profile, app name, Lakebase project/branch, optional UC + secret config). +// +// The substrate consumes the config in three places: +// 1. `lakebase-deploy` (FEIP-7130 slice 2) — picks the active target and +// drives the build → upload → deploy pipeline. +// 2. `provisionAppEndpoint` (FEIP-7130 slice 3) — uses lakebase_project / +// lakebase_branch to mint the per-branch app URL. +// 3. The lakebase-scm-extension consumes the same parser via the kit's +// package exports, after the slice 6 import flip. +// +// The parser deliberately doesn't depend on a full YAML library: the file +// has a fixed two-level structure (targets → name → key: value), and a +// regex-based parser keeps the kit's dependency surface small. The same +// parser is what the lakebase-scm-extension shipped with originally; this +// module lifts it into the substrate behind a clean, kit-idiomatic surface. + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +export interface DeployTarget { + workspace_profile: string; + workspace_path: string; + app_name: string; + lakebase_project: string; + lakebase_branch: string; + uc_catalog?: string; + uc_schema?: string; + uc_volume?: string; + lakebase_secret_scope?: string; + lakebase_secret_key?: string; + ai_model?: string; +} + +export interface DeployTargetsConfig { + targets: Record; +} + +const TARGETS_FILE = "deploy-targets.yaml"; + +const OPTIONAL_KEYS: Array = [ + "uc_catalog", + "uc_schema", + "uc_volume", + "lakebase_secret_scope", + "lakebase_secret_key", + "ai_model", +]; + +export function readTargets(workspaceRoot: string): DeployTargetsConfig | null { + const targetsFile = join(workspaceRoot, TARGETS_FILE); + if (!existsSync(targetsFile)) return null; + return parseTargetsYaml(readFileSync(targetsFile, "utf-8")); +} + +export function writeTargets(config: DeployTargetsConfig, workspaceRoot: string): void { + const targetsFile = join(workspaceRoot, TARGETS_FILE); + let yaml = "targets:\n"; + for (const [name, target] of Object.entries(config.targets)) { + yaml += ` ${name}:\n`; + yaml += ` workspace_profile: ${target.workspace_profile}\n`; + yaml += ` workspace_path: ${target.workspace_path}\n`; + yaml += ` app_name: ${target.app_name}\n`; + yaml += ` lakebase_project: ${target.lakebase_project}\n`; + yaml += ` lakebase_branch: ${target.lakebase_branch}\n`; + for (const key of OPTIONAL_KEYS) { + const v = target[key]; + if (v) yaml += ` ${key}: ${v}\n`; + } + } + writeFileSync(targetsFile, yaml); +} + +export function parseTargetsYaml(content: string): DeployTargetsConfig { + const targets: Record = {}; + let currentTarget: string | null = null; + + for (const rawLine of content.split("\n")) { + const trimmed = rawLine.trimEnd(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed === "targets:") continue; + + // Target name (2-space indent, ends with colon, no value). + const targetMatch = trimmed.match(/^ {2}(\S+):$/); + if (targetMatch) { + currentTarget = targetMatch[1]; + targets[currentTarget] = {} as DeployTarget; + continue; + } + + // Key-value pair (4-space indent). Tolerates optional quoting on value. + const kvMatch = trimmed.match(/^ {4}(\S+):\s*"?([^"]*)"?\s*$/); + if (kvMatch && currentTarget) { + const key = kvMatch[1]; + (targets[currentTarget] as unknown as Record)[key] = kvMatch[2]; + } + } + + return { targets }; +} + +export function getTargetNames(workspaceRoot: string): string[] { + const config = readTargets(workspaceRoot); + if (!config?.targets) return []; + return Object.keys(config.targets); +} diff --git a/tests/bdd/deploy-targets.test.ts b/tests/bdd/deploy-targets.test.ts new file mode 100644 index 0000000..e70f2bd --- /dev/null +++ b/tests/bdd/deploy-targets.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + parseTargetsYaml, + readTargets, + writeTargets, + getTargetNames, + type DeployTargetsConfig, +} from "../../scripts/lakebase/deploy-targets"; + +let workspace: string; + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "deploy-targets-")); +}); + +afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); +}); + +const MINIMAL_YAML = `targets: + dev: + workspace_profile: DEFAULT + workspace_path: /Users/me/apps/my-app + app_name: my-app-dev + lakebase_project: proj-checkout + lakebase_branch: feature-add-orders +`; + +const FULL_YAML = `targets: + dev: + workspace_profile: DEFAULT + workspace_path: /Users/me/apps/my-app + app_name: my-app-dev + lakebase_project: proj-checkout + lakebase_branch: feature-add-orders + uc_catalog: my_catalog + uc_schema: my_schema + uc_volume: my_volume + lakebase_secret_scope: my-scope + lakebase_secret_key: db-pat + ai_model: claude-opus-4-7 + prod: + workspace_profile: PROD + workspace_path: /Workspaces/prod/my-app + app_name: my-app + lakebase_project: proj-checkout + lakebase_branch: production +`; + +describe("parseTargetsYaml", () => { + it("parses a minimal single target with required fields only", () => { + const config = parseTargetsYaml(MINIMAL_YAML); + expect(Object.keys(config.targets)).toEqual(["dev"]); + expect(config.targets.dev).toEqual({ + workspace_profile: "DEFAULT", + workspace_path: "/Users/me/apps/my-app", + app_name: "my-app-dev", + lakebase_project: "proj-checkout", + lakebase_branch: "feature-add-orders", + }); + }); + + it("parses optional UC + secret + ai_model fields when present", () => { + const config = parseTargetsYaml(FULL_YAML); + expect(config.targets.dev.uc_catalog).toBe("my_catalog"); + expect(config.targets.dev.uc_schema).toBe("my_schema"); + expect(config.targets.dev.uc_volume).toBe("my_volume"); + expect(config.targets.dev.lakebase_secret_scope).toBe("my-scope"); + expect(config.targets.dev.lakebase_secret_key).toBe("db-pat"); + expect(config.targets.dev.ai_model).toBe("claude-opus-4-7"); + }); + + it("parses multiple targets in one file", () => { + const config = parseTargetsYaml(FULL_YAML); + expect(Object.keys(config.targets).sort()).toEqual(["dev", "prod"]); + expect(config.targets.prod.workspace_profile).toBe("PROD"); + expect(config.targets.prod.lakebase_branch).toBe("production"); + // prod is minimal — no optional fields. + expect(config.targets.prod.uc_catalog).toBeUndefined(); + }); + + it("ignores comments and blank lines", () => { + const config = parseTargetsYaml(`# top comment +targets: + # before target + dev: + # inside target + workspace_profile: DEFAULT + workspace_path: /tmp/x + app_name: x + lakebase_project: p + lakebase_branch: b + +# trailing +`); + expect(config.targets.dev.workspace_profile).toBe("DEFAULT"); + }); + + it("tolerates double-quoted values", () => { + const config = parseTargetsYaml(`targets: + dev: + workspace_profile: "DEFAULT" + workspace_path: "/Users/me/apps/x" + app_name: "x" + lakebase_project: "p" + lakebase_branch: "b" +`); + expect(config.targets.dev.workspace_profile).toBe("DEFAULT"); + expect(config.targets.dev.workspace_path).toBe("/Users/me/apps/x"); + }); + + it("returns an empty targets object when input has no targets", () => { + const config = parseTargetsYaml(`targets:\n`); + expect(config.targets).toEqual({}); + }); +}); + +describe("readTargets", () => { + it("returns null when the workspace has no deploy-targets.yaml", () => { + expect(readTargets(workspace)).toBeNull(); + }); + + it("reads + parses an existing deploy-targets.yaml", () => { + writeFileSync(join(workspace, "deploy-targets.yaml"), MINIMAL_YAML); + const config = readTargets(workspace); + expect(config?.targets.dev.app_name).toBe("my-app-dev"); + }); +}); + +describe("writeTargets", () => { + it("writes a config that round-trips through readTargets", () => { + const original: DeployTargetsConfig = parseTargetsYaml(FULL_YAML); + writeTargets(original, workspace); + const round = readTargets(workspace); + expect(round?.targets).toEqual(original.targets); + }); + + it("only emits optional fields when they have a value", () => { + writeTargets(parseTargetsYaml(MINIMAL_YAML), workspace); + const written = readFileSync(join(workspace, "deploy-targets.yaml"), "utf-8"); + expect(written).not.toContain("uc_catalog"); + expect(written).not.toContain("ai_model"); + expect(written).toContain("workspace_profile: DEFAULT"); + }); +}); + +describe("getTargetNames", () => { + it("returns the target names from a real file", () => { + writeFileSync(join(workspace, "deploy-targets.yaml"), FULL_YAML); + expect(getTargetNames(workspace).sort()).toEqual(["dev", "prod"]); + }); + + it("returns an empty array when no file exists", () => { + expect(getTargetNames(workspace)).toEqual([]); + }); +});