diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 5f6c20af0..e27937389 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -339,6 +339,7 @@ async function runAdd(refs: string[], opts: AddOptions): Promise { cwd, nonInteractive: Boolean(opts.yes), values: opts.env, + profile: opts.profile, }); reportEnvResolutions(resolutions); diff --git a/packages/shared/src/cli/commands/registry/config-plan.test.ts b/packages/shared/src/cli/commands/registry/config-plan.test.ts index 39cccfe1d..e3fff564a 100644 --- a/packages/shared/src/cli/commands/registry/config-plan.test.ts +++ b/packages/shared/src/cli/commands/registry/config-plan.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { buildConfigPlan } from "./config-plan"; import type { ResourceRequirementRow } from "./requirements"; -/** A DABs `${var.}` reference, built to avoid a JS-template literal. */ +/** A DABs `${var.}` reference (literal bundle syntax, not JS interp). */ function varRef(name: string): string { + // biome-ignore lint/style/useTemplate: template literal would trip noTemplateCurlyInString on literal DABs ${var.…} syntax return "${var." + name + "}"; } diff --git a/packages/shared/src/cli/commands/registry/env-writer.ts b/packages/shared/src/cli/commands/registry/env-writer.ts index c9b3b7f01..c1e9912fd 100644 --- a/packages/shared/src/cli/commands/registry/env-writer.ts +++ b/packages/shared/src/cli/commands/registry/env-writer.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { isCancel, text } from "@clack/prompts"; +import { isCancel, select, text } from "@clack/prompts"; import pc from "picocolors"; import { collectEnvNeeds, @@ -12,6 +12,7 @@ import { type ValueProvider, } from "./env-reconcile"; import type { ResourceRequirementRow } from "./requirements"; +import { isFlatListable, listWorkspaceResources } from "./workspace-picker"; export interface EnvSyncOptions { /** Directory holding `.env` / `.env.example` (the app root). */ @@ -20,8 +21,13 @@ export interface EnvSyncOptions { nonInteractive: boolean; /** Pre-supplied env values from flags, e.g. { DATABRICKS_WAREHOUSE_ID: "abc" }. */ values?: Record; + /** Databricks profile for the workspace picker (else the CLI default). */ + profile?: string; } +/** Sentinel select value meaning "let me type the id myself". */ +const MANUAL = "__manual__"; + /** Reads a `.env`-style file into a map; empty when the file is absent. */ function readEnvFile(file: string): Record { if (!fs.existsSync(file)) return {}; @@ -40,23 +46,53 @@ function appendToFile(file: string, text: string): void { } } -/** Builds the value provider: flags first, then clack prompt (unless CI). */ +/** Free-text prompt for one env need; undefined to skip. */ +async function promptText(need: EnvNeed): Promise { + const tag = need.required ? "required" : "optional"; + const answer = await text({ + message: `${need.env} (${need.resourceType}, ${tag})`, + placeholder: need.description ?? "leave blank to skip", + }); + if (isCancel(answer)) return undefined; + const value = (answer ?? "").trim(); + return value === "" ? undefined : value; +} + +/** + * Builds the value provider. Precedence: --env flag, then (interactive only) + * a workspace picker for flat-listable resource types, else a free-text + * prompt. The picker degrades to free-text whenever the workspace can't be + * listed (no profile, offline, auth error, empty) so it never hard-fails. + */ 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; + if (isFlatListable(need.resourceType)) { + const choices = listWorkspaceResources(need.resourceType, opts.profile); + if (choices.length > 0) { + const picked = await select({ + message: `${need.env} — pick a ${need.resourceType}`, + options: [ + ...choices.map((c) => ({ value: c.value, label: c.label })), + { value: MANUAL, label: "Enter manually / skip" }, + ], + }); + if (isCancel(picked)) return undefined; + if (picked !== MANUAL) return String(picked); + // fall through to free-text + } else { + console.log( + pc.dim( + ` No ${need.resourceType} found in the workspace — enter an id manually.`, + ), + ); + } + } + + return promptText(need); }; } diff --git a/packages/shared/src/cli/commands/registry/workspace-picker.test.ts b/packages/shared/src/cli/commands/registry/workspace-picker.test.ts new file mode 100644 index 000000000..1cc434885 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/workspace-picker.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type CliRunner, + isFlatListable, + listWorkspaceResources, + toChoices, +} from "./workspace-picker"; + +describe("isFlatListable", () => { + it("recognizes flat types and rejects parent-context/unknown ones", () => { + expect(isFlatListable("sql_warehouse")).toBe(true); + expect(isFlatListable("genie_space")).toBe(true); + // parent-context types are handled elsewhere + expect(isFlatListable("volume")).toBe(false); + expect(isFlatListable("secret")).toBe(false); + expect(isFlatListable("nonsense")).toBe(false); + }); +}); + +describe("toChoices", () => { + it("reads id and label from a bare array", () => { + const choices = toChoices( + [{ id: "w1", name: "Warehouse One" }], + "id", + "name", + ); + expect(choices).toEqual([{ value: "w1", label: "Warehouse One (w1)" }]); + }); + + it("unwraps a single wrapper key holding the array", () => { + const choices = toChoices({ warehouses: [{ id: "w2" }] }, "id", "name"); + expect(choices).toEqual([{ value: "w2", label: "w2" }]); + }); + + it("skips items missing the id field", () => { + const choices = toChoices([{ name: "no id" }, { id: "ok" }], "id", "name"); + expect(choices).toEqual([{ value: "ok", label: "ok" }]); + }); + + it("coerces non-string ids (e.g. numeric job_id)", () => { + const choices = toChoices([{ job_id: 42, name: "ETL" }], "job_id", "name"); + expect(choices).toEqual([{ value: "42", label: "ETL (42)" }]); + }); +}); + +describe("listWorkspaceResources", () => { + const runner = (stdout: string, status = 0): CliRunner => + vi.fn(() => ({ status, stdout })); + + it("returns choices from a successful list", () => { + const res = listWorkspaceResources( + "sql_warehouse", + undefined, + runner(JSON.stringify([{ id: "w1", name: "One" }])), + ); + expect(res).toEqual([{ value: "w1", label: "One (w1)" }]); + }); + + it("passes -p profile through to the CLI", () => { + const run = vi.fn(() => ({ status: 0, stdout: "[]" })); + listWorkspaceResources("sql_warehouse", "dogfood", run); + expect(run).toHaveBeenCalledWith([ + "warehouses", + "list", + "-o", + "json", + "-p", + "dogfood", + ]); + }); + + it("returns [] for an unknown type", () => { + expect(listWorkspaceResources("nonsense", undefined, runner("[]"))).toEqual( + [], + ); + }); + + it("returns [] on non-zero CLI exit (offline/auth error)", () => { + expect( + listWorkspaceResources("sql_warehouse", undefined, runner("", 1)), + ).toEqual([]); + }); + + it("returns [] on non-JSON output", () => { + expect( + listWorkspaceResources("sql_warehouse", undefined, runner("not json")), + ).toEqual([]); + }); + + it("returns [] when the runner throws (CLI missing)", () => { + const throwing: CliRunner = () => { + throw new Error("ENOENT"); + }; + expect( + listWorkspaceResources("sql_warehouse", undefined, throwing), + ).toEqual([]); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/workspace-picker.ts b/packages/shared/src/cli/commands/registry/workspace-picker.ts new file mode 100644 index 000000000..6bdeb6f5a --- /dev/null +++ b/packages/shared/src/cli/commands/registry/workspace-picker.ts @@ -0,0 +1,153 @@ +import { spawnSync } from "node:child_process"; + +/** + * Lists a user's real Databricks workspace resources so `appkit add` can offer + * a picker instead of blind free-text entry. Shells out to the `databricks` + * CLI (packages/shared has no SDK access — this mirrors the existing spawnSync + * pattern in constants.ts/add.ts). Falls back gracefully: any failure returns + * an empty list and the caller drops to free-text entry. + */ + +/** A workspace resource choice surfaced in the picker. */ +export interface WorkspaceChoice { + /** Value written to the env var (the resource id/name). */ + value: string; + /** Human label shown in the picker (name, falling back to value). */ + label: string; +} + +/** + * How to list a resource type via the CLI. `command` is the argv after + * `databricks`; `idField`/`labelField` name the JSON properties to read. + * Only flat (no parent-context) types live here — parent-context types + * (volume, uc_function, secret, vector_search_index) are handled separately. + */ +interface WorkspaceLister { + command: string[]; + idField: string; + labelField?: string; +} + +/** Flat, top-level listable resource types (verified against CLI v1.10+). */ +export const WORKSPACE_LISTERS: Record = { + sql_warehouse: { + command: ["warehouses", "list"], + idField: "id", + labelField: "name", + }, + job: { command: ["jobs", "list"], idField: "job_id", labelField: "name" }, + serving_endpoint: { + command: ["serving-endpoints", "list"], + idField: "name", + labelField: "name", + }, + uc_connection: { + command: ["connections", "list"], + idField: "name", + labelField: "full_name", + }, + database: { + command: ["database", "list-database-instances"], + idField: "name", + labelField: "name", + }, + genie_space: { + command: ["genie", "list-spaces"], + idField: "space_id", + labelField: "title", + }, + experiment: { + command: ["experiments", "list-experiments"], + idField: "experiment_id", + labelField: "name", + }, + app: { command: ["apps", "list"], idField: "name", labelField: "name" }, +}; + +/** True when a resource type can be listed with a flat (no-parent) command. */ +export function isFlatListable(resourceType: string): boolean { + return resourceType in WORKSPACE_LISTERS; +} + +/** Runs a databricks CLI subcommand returning JSON; injectable for tests. */ +export type CliRunner = (args: string[]) => { + status: number | null; + stdout: string; +}; + +const defaultRunner: CliRunner = (args) => { + const res = spawnSync("databricks", args, { encoding: "utf-8" }); + return { status: res.status, stdout: res.stdout ?? "" }; +}; + +/** + * Extracts `{value,label}` choices from a parsed CLI list response. The CLI + * returns either a bare array or an object wrapping one; we scan for the first + * array of objects. Items missing the id field are skipped. + */ +export function toChoices( + parsed: unknown, + idField: string, + labelField?: string, +): WorkspaceChoice[] { + const arr = firstArray(parsed); + const choices: WorkspaceChoice[] = []; + for (const item of arr) { + if (typeof item !== "object" || item === null) continue; + const record = item as Record; + const id = record[idField]; + if (id === undefined || id === null) continue; + const value = String(id); + const rawLabel = labelField ? record[labelField] : undefined; + const label = + typeof rawLabel === "string" && rawLabel.length > 0 + ? `${rawLabel} (${value})` + : value; + choices.push({ value, label }); + } + return choices; +} + +/** Finds the first array in a CLI response (bare array or single wrapper key). */ +function firstArray(parsed: unknown): unknown[] { + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === "object") { + for (const v of Object.values(parsed)) { + if (Array.isArray(v)) return v; + } + } + return []; +} + +/** + * Lists workspace resources of a flat-listable type. Returns [] on any failure + * (unknown type, CLI missing/errored, non-JSON output) so the caller can fall + * back to free-text entry. `profile` is passed through as `-p` when set. + */ +export function listWorkspaceResources( + resourceType: string, + profile?: string, + runner: CliRunner = defaultRunner, +): WorkspaceChoice[] { + const lister = WORKSPACE_LISTERS[resourceType]; + if (!lister) return []; + + const args = [...lister.command, "-o", "json"]; + if (profile) args.push("-p", profile); + + let result: { status: number | null; stdout: string }; + try { + result = runner(args); + } catch { + return []; + } + if (result.status !== 0 || !result.stdout.trim()) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return []; + } + return toChoices(parsed, lister.idField, lister.labelField); +}