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
1 change: 1 addition & 0 deletions packages/shared/src/cli/commands/registry/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ async function runAdd(refs: string[], opts: AddOptions): Promise<void> {
cwd,
nonInteractive: Boolean(opts.yes),
values: opts.env,
profile: opts.profile,
});
reportEnvResolutions(resolutions);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
import { buildConfigPlan } from "./config-plan";
import type { ResourceRequirementRow } from "./requirements";

/** A DABs `${var.<name>}` reference, built to avoid a JS-template literal. */
/** A DABs `${var.<name>}` 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 + "}";
}

Expand Down
60 changes: 48 additions & 12 deletions packages/shared/src/cli/commands/registry/env-writer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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). */
Expand All @@ -20,8 +21,13 @@ export interface EnvSyncOptions {
nonInteractive: boolean;
/** Pre-supplied env values from flags, e.g. { DATABRICKS_WAREHOUSE_ID: "abc" }. */
values?: Record<string, string>;
/** 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<string, string> {
if (!fs.existsSync(file)) return {};
Expand All @@ -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<string | undefined> {
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);
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
153 changes: 153 additions & 0 deletions packages/shared/src/cli/commands/registry/workspace-picker.ts
Original file line number Diff line number Diff line change
@@ -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<string, WorkspaceLister> = {
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<string, unknown>;
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);
}