Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions scripts/lakebase/deploy-targets.ts
Original file line number Diff line number Diff line change
@@ -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<string, DeployTarget>;
}

const TARGETS_FILE = "deploy-targets.yaml";

const OPTIONAL_KEYS: Array<keyof DeployTarget> = [
"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<string, DeployTarget> = {};
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<string, string>)[key] = kvMatch[2];
}
}

return { targets };
}

export function getTargetNames(workspaceRoot: string): string[] {
const config = readTargets(workspaceRoot);
if (!config?.targets) return [];
return Object.keys(config.targets);
}
159 changes: 159 additions & 0 deletions tests/bdd/deploy-targets.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});