From e4feb214b3fbf7ba5a27fec5209562b9358fd987 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 6 Aug 2026 18:49:26 +0200 Subject: [PATCH] feat(cli): reconcile plugin resource env vars into .env on add - On 'appkit add', a plugin's declared resource fields are reconciled into the app's .env (values) and .env.example (names). Never overwrites keys the user already set. - Skips platform-origin fields (injected by Databricks Apps at deploy time); pre-fills static defaults without prompting. - Interactive clack prompts by default; --yes for agents/CI, repeatable --env KEY=VALUE to supply values, --no-resources to opt out. - Pure reconcile core (collectEnvNeeds/reconcileEnv/parseEnv) split from the fs+prompt writer for testability. Supersedes declaredEnvVars. - 20 new tests covering origin filtering, dedup, .env parsing/appending, and the already-set/skip/write paths. Signed-off-by: MarioCadenas --- .../src/cli/commands/registry/add.test.ts | 37 +--- .../shared/src/cli/commands/registry/add.ts | 117 ++++++------ .../commands/registry/env-reconcile.test.ts | 167 ++++++++++++++++++ .../cli/commands/registry/env-reconcile.ts | 146 +++++++++++++++ .../cli/commands/registry/env-writer.test.ts | 111 ++++++++++++ .../src/cli/commands/registry/env-writer.ts | 128 ++++++++++++++ .../src/cli/commands/registry/requirements.ts | 8 + 7 files changed, 628 insertions(+), 86 deletions(-) create mode 100644 packages/shared/src/cli/commands/registry/env-reconcile.test.ts create mode 100644 packages/shared/src/cli/commands/registry/env-reconcile.ts create mode 100644 packages/shared/src/cli/commands/registry/env-writer.test.ts create mode 100644 packages/shared/src/cli/commands/registry/env-writer.ts diff --git a/packages/shared/src/cli/commands/registry/add.test.ts b/packages/shared/src/cli/commands/registry/add.test.ts index 756d39040..d04da681a 100644 --- a/packages/shared/src/cli/commands/registry/add.test.ts +++ b/packages/shared/src/cli/commands/registry/add.test.ts @@ -1,46 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { declaredEnvVars, resolveItems } from "./add"; +import { resolveItems } from "./add"; import type { RegistryItem } from "./client"; function item(name: string, extra: Partial = {}): RegistryItem { return { name, ...extra }; } -describe("declaredEnvVars", () => { - it("collects env vars from required resources", () => { - const manifest = { - resources: { - required: [{ fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } } }], - }, - }; - expect(declaredEnvVars(manifest)).toEqual(["DATABRICKS_WAREHOUSE_ID"]); - }); - - // Bug #2: optional resources were dropped entirely. - it("also collects env vars from optional resources", () => { - const manifest = { - resources: { - required: [{ fields: { id: { env: "REQUIRED_ENV" } } }], - optional: [{ fields: { id: { env: "OPTIONAL_ENV" } } }], - }, - }; - expect(declaredEnvVars(manifest)).toEqual(["REQUIRED_ENV", "OPTIONAL_ENV"]); - }); - - it("skips fields without an env property", () => { - const manifest = { - resources: { - required: [{ fields: { host: { env: "PGHOST" }, note: {} } }], - }, - }; - expect(declaredEnvVars(manifest)).toEqual(["PGHOST"]); - }); - - it("returns empty for a manifest with no resources", () => { - expect(declaredEnvVars({})).toEqual([]); - }); -}); - describe("resolveItems", () => { it("returns requested items in order", async () => { const fetch = vi.fn(async (name: string) => item(name)); diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index e06a9d95e..27f7a7726 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -11,24 +11,18 @@ import { stripNamespace, } from "./client"; import { REGISTRY_REPO, type RegistryToken, resolveToken } from "./constants"; -import { extractRequirements, renderRequirements } from "./requirements"; +import { reportEnvResolutions, syncEnv } from "./env-writer"; +import { + extractRequirements, + type ResourceRequirementRow, + renderRequirements, +} from "./requirements"; import { registerPluginInServer } from "./server-register"; /** Subdirectories that commonly hold the frontend / server in an AppKit app. */ const FRONTEND_SUBDIRS = ["client", "frontend", "web", "app"]; const SERVER_SUBDIRS = ["server", "api", "backend"]; -interface ManifestField { - env?: string; -} -interface ManifestResource { - fields?: Record; -} -interface PluginManifestShape { - name?: string; - resources?: { required?: ManifestResource[]; optional?: ManifestResource[] }; -} - function isDir(p: string): boolean { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } @@ -87,21 +81,6 @@ function resolveUiTarget(base: string, file: RegistryItemFile): string { return path.join(base, target); } -/** Env var names declared by a manifest's resources (required and optional). */ -export function declaredEnvVars(manifest: PluginManifestShape): string[] { - const envs: string[] = []; - const resources = [ - ...(manifest.resources?.required ?? []), - ...(manifest.resources?.optional ?? []), - ]; - for (const res of resources) { - for (const field of Object.values(res.fields ?? {})) { - if (field.env) envs.push(field.env); - } - } - return envs; -} - /** Best-effort: the `toPlugin` export name from the item's index.ts. */ function pluginExportName(item: RegistryItem): string | null { const index = (item.files ?? []).find( @@ -187,7 +166,6 @@ function writeItemFile( interface PluginSummary { importPath: string; exportName: string | null; - envs: string[]; } /** @@ -223,10 +201,19 @@ export async function resolveItems( return ordered; } -async function runAdd( - refs: string[], - opts: { force?: boolean; cwd?: string; register?: boolean }, -): Promise { +interface AddOptions { + force?: boolean; + cwd?: string; + register?: boolean; + /** false = don't reconcile resource env vars into .env. */ + resources?: boolean; + /** true = never prompt; use --env flags or leave unset (agent/CI). */ + yes?: boolean; + /** Pre-supplied env values from repeated --env KEY=VALUE flags. */ + env?: Record; +} + +async function runAdd(refs: string[], opts: AddOptions): Promise { const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); const token = resolveToken(); if (token) { @@ -251,12 +238,12 @@ async function runAdd( const deps = new Set(); let wroteUi = false; const pluginSummaries: PluginSummary[] = []; + const allRequirements: ResourceRequirementRow[] = []; for (const item of items) { for (const dep of item.dependencies ?? []) deps.add(dep); if (isPluginItem(item)) { - let manifest: PluginManifestShape = {}; let pluginRel = path.join("plugins", item.name); for (const file of item.files ?? []) { const target = @@ -269,18 +256,17 @@ async function runAdd( cwd, ); if (path.basename(target) === "manifest.json") { - manifest = JSON.parse(file.content) as PluginManifestShape; pluginRel = path.dirname(target); } } const requirements = extractRequirements(item); if (requirements.length > 0) { console.log(`\n${renderRequirements(item, requirements)}`); + allRequirements.push(...requirements); } pluginSummaries.push({ importPath: `./${pluginRel}`, exportName: pluginExportName(item), - envs: declaredEnvVars(manifest), }); } else { for (const file of item.files ?? []) { @@ -337,20 +323,49 @@ async function runAdd( ), ); } - if (s.envs.length > 0) { - console.log( - ` ${pc.yellow("Required env var(s):")} ${s.envs.join(", ")}`, - ); - } + } + + if (opts.resources !== false && allRequirements.length > 0) { + console.log(pc.dim("\nReconciling resource env vars into .env...")); + const resolutions = await syncEnv(allRequirements, { + cwd, + nonInteractive: Boolean(opts.yes), + values: opts.env, + }); + reportEnvResolutions(resolutions); } } +/** Commander reducer for repeatable `--env KEY=VALUE` flags. */ +function collectEnvFlag( + raw: string, + acc: Record, +): Record { + const eq = raw.indexOf("="); + if (eq === -1) { + console.error(`Ignoring --env "${raw}" (expected KEY=VALUE).`); + return acc; + } + const key = raw.slice(0, eq).trim(); + const value = raw.slice(eq + 1); + if (key) acc[key] = value; + return acc; +} + export const addCommand = new Command("add") .description("Add a UI component or server plugin from the AppKit registry") .argument("", "Registry item name(s), e.g. metric-card or hello") .option("-f, --force", "Overwrite existing files") .option("-C, --cwd ", "Run as if started in ") .option("--no-register", "Don't edit the server entry to register plugins") + .option("--no-resources", "Don't reconcile resource env vars into .env") + .option("-y, --yes", "Don't prompt; use --env values or leave vars unset") + .option( + "--env ", + "Pre-set a resource env var (repeatable)", + collectEnvFlag, + {}, + ) .addHelpText( "after", ` @@ -359,6 +374,11 @@ No components.json is required. Item type is detected automatically: • Server plugins → /plugins//, runs plugin sync, and registers them in your createApp call (use --no-register to skip the server edit) +Server plugins declare Databricks resources. On add, their env vars are +reconciled into .env (and names into .env.example). Interactive by default; +pass --yes for agents/CI (uses --env values, leaves the rest unset) and +--env KEY=VALUE to supply values non-interactively. + The frontend/server roots are detected from common layouts, so you can run this from the repo root. While the registry repo is private, a read token is resolved from \`gh auth token\` or APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. @@ -366,15 +386,12 @@ resolved from \`gh auth token\` or APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOK Examples: $ appkit add metric-card # UI component $ appkit add hello # server plugin - $ appkit add metric-card hello # mix in one call`, + $ appkit add metric-card hello # mix in one call + $ appkit add analytics --yes --env DATABRICKS_WAREHOUSE_ID=abc123`, ) - .action( - ( - items: string[], - opts: { force?: boolean; cwd?: string; register?: boolean }, - ) => - runAdd(items, opts).catch((err) => { - console.error(err); - process.exit(1); - }), + .action((items: string[], opts: AddOptions) => + runAdd(items, opts).catch((err) => { + console.error(err); + process.exit(1); + }), ); diff --git a/packages/shared/src/cli/commands/registry/env-reconcile.test.ts b/packages/shared/src/cli/commands/registry/env-reconcile.test.ts new file mode 100644 index 000000000..b812b8f5e --- /dev/null +++ b/packages/shared/src/cli/commands/registry/env-reconcile.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; +import { + collectEnvNeeds, + type EnvNeed, + parseEnv, + reconcileEnv, + serializeEnvAppend, +} from "./env-reconcile"; +import type { ResourceRequirementRow } from "./requirements"; + +function row( + over: Partial = {}, +): ResourceRequirementRow { + return { + type: "sql_warehouse", + required: true, + fields: [{ key: "id", env: "DATABRICKS_WAREHOUSE_ID", origin: "user" }], + ...over, + }; +} + +describe("collectEnvNeeds", () => { + it("includes user-origin env fields", () => { + const needs = collectEnvNeeds([row()]); + expect(needs.map((n) => n.env)).toEqual(["DATABRICKS_WAREHOUSE_ID"]); + }); + + it("excludes platform-origin fields (deploy-injected)", () => { + const needs = collectEnvNeeds([ + row({ + type: "database", + fields: [ + { key: "host", env: "PGHOST", origin: "platform" }, + { key: "endpoint", env: "LAKEBASE_ENDPOINT", origin: "cli" }, + ], + }), + ]); + expect(needs.map((n) => n.env)).toEqual(["LAKEBASE_ENDPOINT"]); + }); + + it("excludes fields with no env name", () => { + const needs = collectEnvNeeds([ + row({ fields: [{ key: "name", origin: "user" }] }), + ]); + expect(needs).toEqual([]); + }); + + it("orders required needs before optional and de-dupes shared vars", () => { + const needs = collectEnvNeeds([ + row({ + required: false, + type: "volume", + fields: [{ key: "name", env: "VOLUME_NAME", origin: "user" }], + }), + row(), + // duplicate env from another required resource + row({ + type: "other", + fields: [{ key: "id", env: "DATABRICKS_WAREHOUSE_ID", origin: "user" }], + }), + ]); + expect(needs.map((n) => n.env)).toEqual([ + "DATABRICKS_WAREHOUSE_ID", + "VOLUME_NAME", + ]); + }); + + it("carries the static default value", () => { + const needs = collectEnvNeeds([ + row({ + type: "database", + fields: [ + { key: "port", env: "PGPORT", origin: "static", value: "5432" }, + ], + }), + ]); + // static is not platform, so it's included with its default + expect(needs[0]).toMatchObject({ env: "PGPORT", defaultValue: "5432" }); + }); +}); + +describe("parseEnv", () => { + it("parses KEY=VALUE lines, skipping comments and blanks", () => { + const parsed = parseEnv("# comment\nFOO=bar\n\nBAZ = qux \n"); + expect(parsed).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + it("strips surrounding quotes", () => { + expect(parseEnv("A=\"one\"\nB='two'")).toEqual({ A: "one", B: "two" }); + }); + + it("keeps '=' inside values", () => { + expect(parseEnv("URL=postgres://a=b")).toEqual({ URL: "postgres://a=b" }); + }); +}); + +describe("serializeEnvAppend", () => { + it("returns empty for no entries", () => { + expect(serializeEnvAppend([])).toBe(""); + }); + + it("emits KEY=VALUE lines with optional comment", () => { + expect( + serializeEnvAppend([{ env: "FOO", value: "bar", comment: "note" }]), + ).toBe("# note\nFOO=bar\n"); + }); +}); + +describe("reconcileEnv", () => { + const need: EnvNeed = { + env: "DATABRICKS_WAREHOUSE_ID", + resourceType: "sql_warehouse", + required: true, + origin: "user", + }; + + it("reports already-set vars and never overwrites them", async () => { + const provide = vi.fn(); + const res = await reconcileEnv([need], { + existing: { DATABRICKS_WAREHOUSE_ID: "existing" }, + provide, + }); + expect(res).toEqual([ + { env: "DATABRICKS_WAREHOUSE_ID", status: "already-set" }, + ]); + expect(provide).not.toHaveBeenCalled(); + }); + + it("uses static defaults without invoking provide", async () => { + const provide = vi.fn(); + const res = await reconcileEnv( + [{ ...need, defaultValue: "5432", env: "PGPORT" }], + { existing: {}, provide }, + ); + expect(res).toEqual([{ env: "PGPORT", value: "5432", status: "written" }]); + expect(provide).not.toHaveBeenCalled(); + }); + + it("writes a provided value", async () => { + const provide = vi.fn(async () => "wh-123"); + const res = await reconcileEnv([need], { existing: {}, provide }); + expect(res).toEqual([ + { env: "DATABRICKS_WAREHOUSE_ID", value: "wh-123", status: "written" }, + ]); + }); + + it("skips when provide returns undefined", async () => { + const provide = vi.fn(async () => undefined); + const res = await reconcileEnv([need], { existing: {}, provide }); + expect(res).toEqual([ + { env: "DATABRICKS_WAREHOUSE_ID", status: "skipped" }, + ]); + }); + + it("treats an empty existing value as unset", async () => { + const provide = vi.fn(async () => "filled"); + const res = await reconcileEnv([need], { + existing: { DATABRICKS_WAREHOUSE_ID: "" }, + provide, + }); + expect(res[0]).toEqual({ + env: "DATABRICKS_WAREHOUSE_ID", + value: "filled", + status: "written", + }); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/env-reconcile.ts b/packages/shared/src/cli/commands/registry/env-reconcile.ts new file mode 100644 index 000000000..d54c5035d --- /dev/null +++ b/packages/shared/src/cli/commands/registry/env-reconcile.ts @@ -0,0 +1,146 @@ +import type { RequirementField, ResourceRequirementRow } from "./requirements"; + +/** + * A single env var that an installed plugin needs in the local `.env`. + * `platform`-origin fields are excluded upstream — they are injected by + * Databricks Apps at deploy time and never belong in a hand-managed `.env`. + */ +export interface EnvNeed { + env: string; + resourceType: string; + required: boolean; + /** static-origin default value, pre-filled without prompting. */ + defaultValue?: string; + origin?: string; + description?: string; +} + +/** The resolved decision for one env var after reconciliation. */ +export interface EnvResolution { + env: string; + /** The value to write, or undefined when skipped / left unset. */ + value?: string; + status: "written" | "already-set" | "skipped"; +} + +/** + * Flattens requirement rows into the env vars that belong in local `.env`. + * Excludes fields with no `env` name and `platform`-origin fields (deploy-time + * platform injection). Order: required resources first (as given), then optional. + */ +export function collectEnvNeeds(rows: ResourceRequirementRow[]): EnvNeed[] { + const needs: EnvNeed[] = []; + const seen = new Set(); + const ordered = [ + ...rows.filter((r) => r.required), + ...rows.filter((r) => !r.required), + ]; + for (const row of ordered) { + for (const field of row.fields) { + if (!includeInEnv(field)) continue; + const env = field.env as string; + if (seen.has(env)) continue; + seen.add(env); + needs.push({ + env, + resourceType: row.type, + required: row.required, + defaultValue: field.value, + origin: field.origin, + description: field.description, + }); + } + } + return needs; +} + +/** A field belongs in `.env` iff it names an env var and isn't platform-injected. */ +function includeInEnv(field: RequirementField): boolean { + if (!field.env) return false; + if (field.localOnly === false) return false; + return field.origin !== "platform"; +} + +/** Parses a `.env` file body into a KEY -> value map. Minimal KEY=VALUE scan. */ +export function parseEnv(content: string): Record { + const out: Record = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + if (!key) continue; + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +/** + * Serializes new env entries for appending to a `.env` file. Only keys not + * already present are emitted; existing keys are never rewritten (we don't + * clobber user edits). Returns the text to append (empty if nothing new). + */ +export function serializeEnvAppend( + entries: Array<{ env: string; value: string; comment?: string }>, +): string { + if (entries.length === 0) return ""; + const lines: string[] = []; + for (const e of entries) { + if (e.comment) lines.push(`# ${e.comment}`); + lines.push(`${e.env}=${e.value}`); + } + return `${lines.join("\n")}\n`; +} + +/** Provides a value for an env need, or undefined to skip it. */ +export type ValueProvider = (need: EnvNeed) => Promise; + +export interface ReconcileOptions { + /** Existing parsed `.env` values (keys already present are left untouched). */ + existing: Record; + /** Resolves a value for each unset need (prompt in interactive, flag in CI). */ + provide: ValueProvider; +} + +/** + * Reconciles the needed env vars against what's already in `.env`. + * - Already-set keys are reported as "already-set" and never overwritten. + * - static-origin defaults are used without invoking `provide`. + * - Everything else defers to `provide`; a returned undefined means skip. + */ +export async function reconcileEnv( + needs: EnvNeed[], + opts: ReconcileOptions, +): Promise { + const resolutions: EnvResolution[] = []; + for (const need of needs) { + const current = opts.existing[need.env]; + if (current !== undefined && current !== "") { + resolutions.push({ env: need.env, status: "already-set" }); + continue; + } + if (need.defaultValue !== undefined) { + resolutions.push({ + env: need.env, + value: need.defaultValue, + status: "written", + }); + continue; + } + const value = await opts.provide(need); + if (value === undefined || value === "") { + resolutions.push({ env: need.env, status: "skipped" }); + } else { + resolutions.push({ env: need.env, value, status: "written" }); + } + } + return resolutions; +} diff --git a/packages/shared/src/cli/commands/registry/env-writer.test.ts b/packages/shared/src/cli/commands/registry/env-writer.test.ts new file mode 100644 index 000000000..794e7911e --- /dev/null +++ b/packages/shared/src/cli/commands/registry/env-writer.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { syncEnv } from "./env-writer"; +import type { ResourceRequirementRow } from "./requirements"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "env-writer-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } + tempDirs.length = 0; +}); + +const WAREHOUSE_ROW: ResourceRequirementRow = { + type: "sql_warehouse", + required: true, + fields: [{ key: "id", env: "DATABRICKS_WAREHOUSE_ID", origin: "user" }], +}; + +const PLATFORM_ROW: ResourceRequirementRow = { + type: "database", + required: true, + fields: [{ key: "host", env: "PGHOST", origin: "platform" }], +}; + +describe("syncEnv", () => { + it("writes provided values to .env and names to .env.example", async () => { + const cwd = makeTempDir(); + const res = await syncEnv([WAREHOUSE_ROW], { + cwd, + nonInteractive: true, + values: { DATABRICKS_WAREHOUSE_ID: "wh-123" }, + }); + + expect(res).toEqual([ + { env: "DATABRICKS_WAREHOUSE_ID", value: "wh-123", status: "written" }, + ]); + const env = fs.readFileSync(path.join(cwd, ".env"), "utf-8"); + expect(env).toContain("DATABRICKS_WAREHOUSE_ID=wh-123"); + const example = fs.readFileSync(path.join(cwd, ".env.example"), "utf-8"); + expect(example).toContain("DATABRICKS_WAREHOUSE_ID="); + expect(example).not.toContain("wh-123"); + }); + + it("never overwrites an already-set var", async () => { + const cwd = makeTempDir(); + fs.writeFileSync( + path.join(cwd, ".env"), + "DATABRICKS_WAREHOUSE_ID=preexisting\n", + ); + const res = await syncEnv([WAREHOUSE_ROW], { + cwd, + nonInteractive: true, + values: { DATABRICKS_WAREHOUSE_ID: "wh-123" }, + }); + + expect(res[0].status).toBe("already-set"); + const env = fs.readFileSync(path.join(cwd, ".env"), "utf-8"); + expect(env).toContain("DATABRICKS_WAREHOUSE_ID=preexisting"); + expect(env).not.toContain("wh-123"); + }); + + it("excludes platform-injected fields from .env entirely", async () => { + const cwd = makeTempDir(); + const res = await syncEnv([PLATFORM_ROW], { + cwd, + nonInteractive: true, + values: { PGHOST: "should-be-ignored" }, + }); + + expect(res).toEqual([]); + expect(fs.existsSync(path.join(cwd, ".env"))).toBe(false); + }); + + it("in non-interactive mode, leaves vars without a flag unset", async () => { + const cwd = makeTempDir(); + const res = await syncEnv([WAREHOUSE_ROW], { + cwd, + nonInteractive: true, + }); + expect(res[0].status).toBe("skipped"); + // .env not created since nothing was written + expect(fs.existsSync(path.join(cwd, ".env"))).toBe(false); + }); + + it("preserves existing .env content when appending", async () => { + const cwd = makeTempDir(); + fs.writeFileSync(path.join(cwd, ".env"), "EXISTING=1"); + await syncEnv([WAREHOUSE_ROW], { + cwd, + nonInteractive: true, + values: { DATABRICKS_WAREHOUSE_ID: "wh-123" }, + }); + const env = fs.readFileSync(path.join(cwd, ".env"), "utf-8"); + expect(env).toContain("EXISTING=1"); + expect(env).toContain("DATABRICKS_WAREHOUSE_ID=wh-123"); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/env-writer.ts b/packages/shared/src/cli/commands/registry/env-writer.ts new file mode 100644 index 000000000..c9b3b7f01 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/env-writer.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isCancel, text } from "@clack/prompts"; +import pc from "picocolors"; +import { + collectEnvNeeds, + type EnvNeed, + type EnvResolution, + parseEnv, + reconcileEnv, + serializeEnvAppend, + type ValueProvider, +} from "./env-reconcile"; +import type { ResourceRequirementRow } from "./requirements"; + +export interface EnvSyncOptions { + /** Directory holding `.env` / `.env.example` (the app root). */ + cwd: string; + /** true = never prompt (agent/CI). Uses flag values or leaves unset. */ + nonInteractive: boolean; + /** Pre-supplied env values from flags, e.g. { DATABRICKS_WAREHOUSE_ID: "abc" }. */ + values?: Record; +} + +/** Reads a `.env`-style file into a map; empty when the file is absent. */ +function readEnvFile(file: string): Record { + if (!fs.existsSync(file)) return {}; + return parseEnv(fs.readFileSync(file, "utf-8")); +} + +/** Appends text to a file, creating it (with a trailing newline) if needed. */ +function appendToFile(file: string, text: string): void { + if (text === "") return; + if (fs.existsSync(file)) { + const existing = fs.readFileSync(file, "utf-8"); + const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; + fs.writeFileSync(file, existing + sep + text); + } else { + fs.writeFileSync(file, text); + } +} + +/** Builds the value provider: flags first, then clack prompt (unless CI). */ +function makeProvider(opts: EnvSyncOptions): ValueProvider { + return async (need: EnvNeed) => { + const fromFlag = opts.values?.[need.env]; + if (fromFlag !== undefined) return fromFlag; + if (opts.nonInteractive) return undefined; + + const label = need.required + ? `${need.env} (${need.resourceType}, required)` + : `${need.env} (${need.resourceType}, optional)`; + const answer = await text({ + message: label, + placeholder: need.description ?? "leave blank to skip", + }); + if (isCancel(answer)) return undefined; + const value = (answer ?? "").trim(); + return value === "" ? undefined : value; + }; +} + +/** + * Reconciles a plugin's declared resource env vars into the app's local `.env` + * (and mirrors variable names into `.env.example`). Never overwrites keys the + * user already set; skips platform-injected fields. Returns the per-var + * resolutions so callers can report what happened. + */ +export async function syncEnv( + rows: ResourceRequirementRow[], + opts: EnvSyncOptions, +): Promise { + const needs = collectEnvNeeds(rows); + if (needs.length === 0) return []; + + const envPath = path.join(opts.cwd, ".env"); + const examplePath = path.join(opts.cwd, ".env.example"); + const existing = readEnvFile(envPath); + + const resolutions = await reconcileEnv(needs, { + existing, + provide: makeProvider(opts), + }); + + const written = resolutions.filter( + (r): r is EnvResolution & { value: string } => + r.status === "written" && r.value !== undefined, + ); + appendToFile( + envPath, + serializeEnvAppend(written.map((r) => ({ env: r.env, value: r.value }))), + ); + + // .env.example carries the variable names (no secret values), and only for + // vars not already documented there. + const exampleExisting = readEnvFile(examplePath); + const newExampleKeys = needs.filter((n) => !(n.env in exampleExisting)); + appendToFile( + examplePath, + serializeEnvAppend(newExampleKeys.map((n) => ({ env: n.env, value: "" }))), + ); + + return resolutions; +} + +/** Prints a concise summary of what env reconciliation did. */ +export function reportEnvResolutions(resolutions: EnvResolution[]): void { + if (resolutions.length === 0) return; + const written = resolutions.filter((r) => r.status === "written"); + const already = resolutions.filter((r) => r.status === "already-set"); + const skipped = resolutions.filter((r) => r.status === "skipped"); + + if (written.length > 0) { + console.log( + `${pc.green("Wrote to .env:")} ${written.map((r) => r.env).join(", ")}`, + ); + } + if (already.length > 0) { + console.log(pc.dim(`Already set: ${already.map((r) => r.env).join(", ")}`)); + } + if (skipped.length > 0) { + console.log( + `${pc.yellow("Left unset (set before deploy):")} ${skipped + .map((r) => r.env) + .join(", ")}`, + ); + } +} diff --git a/packages/shared/src/cli/commands/registry/requirements.ts b/packages/shared/src/cli/commands/registry/requirements.ts index 4ec9e2978..79470a11f 100644 --- a/packages/shared/src/cli/commands/registry/requirements.ts +++ b/packages/shared/src/cli/commands/registry/requirements.ts @@ -12,6 +12,10 @@ export interface RequirementField { env?: string; origin?: string; description?: string; + /** Default literal value (static origin); pre-filled without prompting. */ + value?: string; + /** Local-dev-only field; platform-injected at deploy time. */ + localOnly?: boolean; } /** A resource requirement flattened for display. */ @@ -28,6 +32,8 @@ interface ManifestFieldShape { env?: string; origin?: string; description?: string; + value?: string; + localOnly?: boolean; } interface ManifestResourceShape { type?: string; @@ -51,6 +57,8 @@ function toFields( env: f.env, origin: f.origin, description: f.description, + value: f.value, + localOnly: f.localOnly, })); }