From a3f8e0b037e4750f6147ecb091c5d46404a0ac1b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 6 Aug 2026 19:06:29 +0200 Subject: [PATCH] feat(cli): drill-down workspace picker for parent-context resource types Adds the chained picker for the 4 resource types that need parent context before their instances can be listed: - volume, uc_function: catalog -> schema -> resource - secret: scope -> key - vector_search_index: endpoint -> index - PARENT_CONTEXT_CHAINS declares each type's drill-down as ordered steps; each step builds its list command from prior picks. Positional-arg CLI form is used (databricks schemas list , volumes list , etc.). - The env provider walks the chain interactively via clack selects, falling back to free-text on cancel, empty level, or 'Enter manually' at any step. - Extracted runList so flat and drill-down share JSON parsing + failure handling. 14 new tests for depth, positional args, and each chain. Signed-off-by: MarioCadenas --- .../src/cli/commands/registry/env-writer.ts | 85 ++++++-- .../registry/workspace-picker.test.ts | 104 ++++++++++ .../cli/commands/registry/workspace-picker.ts | 195 +++++++++++++++++- 3 files changed, 359 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/env-writer.ts b/packages/shared/src/cli/commands/registry/env-writer.ts index c1e9912fd..e582c8083 100644 --- a/packages/shared/src/cli/commands/registry/env-writer.ts +++ b/packages/shared/src/cli/commands/registry/env-writer.ts @@ -12,7 +12,13 @@ import { type ValueProvider, } from "./env-reconcile"; import type { ResourceRequirementRow } from "./requirements"; -import { isFlatListable, listWorkspaceResources } from "./workspace-picker"; +import { + isFlatListable, + isParentContext, + listParentContextStep, + listWorkspaceResources, + parentContextDepth, +} from "./workspace-picker"; export interface EnvSyncOptions { /** Directory holding `.env` / `.env.example` (the app root). */ @@ -58,11 +64,61 @@ async function promptText(need: EnvNeed): Promise { return value === "" ? undefined : value; } +/** Presents one workspace list as a select; MANUAL/cancel handled by caller. */ +async function selectFrom( + message: string, + choices: { value: string; label: string }[], +): Promise { + const picked = await select({ + message, + options: [ + ...choices.map((c) => ({ value: c.value, label: c.label })), + { value: MANUAL, label: "Enter manually / skip" }, + ], + }); + if (isCancel(picked)) return null; + return String(picked) as string | typeof MANUAL; +} + +/** + * Drill-down picker for parent-context types (volume→catalog/schema, + * secret→scope, vector_search_index→endpoint). Walks each step, listing the + * next level from the prior pick. Returns the final resource id, or undefined + * to fall back to free-text (on cancel, empty level, or MANUAL at any step). + */ +async function pickParentContext( + need: EnvNeed, + profile: string | undefined, +): Promise { + const depth = parentContextDepth(need.resourceType); + const picks: string[] = []; + for (let i = 0; i < depth; i++) { + const step = listParentContextStep(need.resourceType, i, picks, profile); + if (!step || step.choices.length === 0) { + console.log( + pc.dim( + ` No ${step?.key ?? need.resourceType} found — enter the id manually.`, + ), + ); + return undefined; + } + const picked = await selectFrom( + `${need.env} — pick a ${step.key}`, + step.choices, + ); + if (picked === null || picked === MANUAL) return undefined; + picks.push(picked); + } + // Last pick is the resource id itself. + return picks[picks.length - 1]; +} + /** - * 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. + * Builds the value provider. Precedence: --env flag, then (interactive only) a + * workspace picker — flat select for flat-listable types, drill-down for + * parent-context 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) => { @@ -73,15 +129,12 @@ function makeProvider(opts: EnvSyncOptions): ValueProvider { 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); + const picked = await selectFrom( + `${need.env} — pick a ${need.resourceType}`, + choices, + ); + if (picked === null) return undefined; + if (picked !== MANUAL) return picked; // fall through to free-text } else { console.log( @@ -90,6 +143,10 @@ function makeProvider(opts: EnvSyncOptions): ValueProvider { ), ); } + } else if (isParentContext(need.resourceType)) { + const picked = await pickParentContext(need, opts.profile); + if (picked !== undefined) return picked; + // fall through to free-text } 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 index 1cc434885..f543681ab 100644 --- a/packages/shared/src/cli/commands/registry/workspace-picker.test.ts +++ b/packages/shared/src/cli/commands/registry/workspace-picker.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { type CliRunner, isFlatListable, + isParentContext, + listParentContextStep, listWorkspaceResources, + parentContextDepth, toChoices, } from "./workspace-picker"; @@ -96,3 +99,104 @@ describe("listWorkspaceResources", () => { ).toEqual([]); }); }); + +describe("isParentContext / parentContextDepth", () => { + it("identifies the four parent-context types and their depth", () => { + expect(isParentContext("volume")).toBe(true); + expect(isParentContext("uc_function")).toBe(true); + expect(isParentContext("secret")).toBe(true); + expect(isParentContext("vector_search_index")).toBe(true); + // flat types are not parent-context + expect(isParentContext("sql_warehouse")).toBe(false); + + expect(parentContextDepth("volume")).toBe(3); // catalog → schema → volume + expect(parentContextDepth("secret")).toBe(2); // scope → key + expect(parentContextDepth("vector_search_index")).toBe(2); + expect(parentContextDepth("sql_warehouse")).toBe(0); + }); +}); + +describe("listParentContextStep", () => { + it("lists catalogs at step 0 for volume", () => { + const run = vi.fn(() => ({ + status: 0, + stdout: JSON.stringify([{ name: "main" }]), + })); + const step = listParentContextStep("volume", 0, [], "dogfood", run); + expect(step?.key).toBe("catalog"); + expect(step?.choices).toEqual([{ value: "main", label: "main (main)" }]); + expect(run).toHaveBeenCalledWith([ + "catalogs", + "list", + "-o", + "json", + "-p", + "dogfood", + ]); + }); + + it("passes the picked catalog+schema as positional args at step 2", () => { + const run = vi.fn(() => ({ + status: 0, + stdout: JSON.stringify([ + { full_name: "main.sales.events", name: "events" }, + ]), + })); + const step = listParentContextStep( + "volume", + 2, + ["main", "sales"], + undefined, + run, + ); + expect(step?.key).toBe("volume"); + // positional args, not flags + expect(run).toHaveBeenCalledWith([ + "volumes", + "list", + "main", + "sales", + "-o", + "json", + ]); + expect(step?.choices).toEqual([ + { value: "main.sales.events", label: "events (main.sales.events)" }, + ]); + }); + + it("drills scope → key for secret", () => { + const run = vi.fn(() => ({ + status: 0, + stdout: JSON.stringify([{ key: "api-token" }]), + })); + const step = listParentContextStep( + "secret", + 1, + ["my-scope"], + undefined, + run, + ); + expect(step?.key).toBe("key"); + expect(run).toHaveBeenCalledWith([ + "secrets", + "list-secrets", + "my-scope", + "-o", + "json", + ]); + expect(step?.choices).toEqual([ + { value: "api-token", label: "api-token (api-token)" }, + ]); + }); + + it("returns null past the end of the chain", () => { + const run = vi.fn(() => ({ status: 0, stdout: "[]" })); + expect(listParentContextStep("secret", 5, [], undefined, run)).toBeNull(); + }); + + it("returns empty choices (not null) when a level lists nothing", () => { + const run = vi.fn(() => ({ status: 0, stdout: "[]" })); + const step = listParentContextStep("volume", 0, [], undefined, run); + expect(step?.choices).toEqual([]); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/workspace-picker.ts b/packages/shared/src/cli/commands/registry/workspace-picker.ts index 6bdeb6f5a..d155ce145 100644 --- a/packages/shared/src/cli/commands/registry/workspace-picker.ts +++ b/packages/shared/src/cli/commands/registry/workspace-picker.ts @@ -120,19 +120,18 @@ function firstArray(parsed: unknown): unknown[] { } /** - * 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. + * Runs a `databricks … list -o json` command and returns parsed choices, or + * [] on any failure (CLI missing/errored, empty, non-JSON). `command` is the + * argv after `databricks`; `-o json` and `-p ` are appended. */ -export function listWorkspaceResources( - resourceType: string, - profile?: string, +export function runList( + command: string[], + idField: string, + labelField: string | undefined, + profile: string | undefined, runner: CliRunner = defaultRunner, ): WorkspaceChoice[] { - const lister = WORKSPACE_LISTERS[resourceType]; - if (!lister) return []; - - const args = [...lister.command, "-o", "json"]; + const args = [...command, "-o", "json"]; if (profile) args.push("-p", profile); let result: { status: number | null; stdout: string }; @@ -149,5 +148,179 @@ export function listWorkspaceResources( } catch { return []; } - return toChoices(parsed, lister.idField, lister.labelField); + return toChoices(parsed, idField, labelField); +} + +/** + * Lists workspace resources of a flat-listable type. Returns [] on any failure + * so the caller can fall back to free-text entry. + */ +export function listWorkspaceResources( + resourceType: string, + profile?: string, + runner: CliRunner = defaultRunner, +): WorkspaceChoice[] { + const lister = WORKSPACE_LISTERS[resourceType]; + if (!lister) return []; + return runList( + lister.command, + lister.idField, + lister.labelField, + profile, + runner, + ); +} + +/** + * A drill-down step for a parent-context resource type. `list(parents)` builds + * the CLI argv given the values picked in prior steps (e.g. [catalog] → schema + * list command). `key` labels the step for prompts. + */ +export interface ParentContextStep { + key: string; + list: (parents: string[]) => { command: string[] } & { + idField: string; + labelField?: string; + }; +} + +/** + * Drill-down chains for parent-context resource types. Each ends by listing + * the resource itself; earlier steps list the parents to pick first. + * Positional-arg CLI gotcha: `databricks schemas list ` etc. take the + * parent as a positional, not a flag. + */ +export const PARENT_CONTEXT_CHAINS: Record = { + volume: [ + { + key: "catalog", + list: () => ({ + command: ["catalogs", "list"], + idField: "name", + labelField: "name", + }), + }, + { + key: "schema", + list: ([catalog]) => ({ + command: ["schemas", "list", catalog], + idField: "name", + labelField: "name", + }), + }, + { + key: "volume", + list: ([catalog, schema]) => ({ + command: ["volumes", "list", catalog, schema], + idField: "full_name", + labelField: "name", + }), + }, + ], + uc_function: [ + { + key: "catalog", + list: () => ({ + command: ["catalogs", "list"], + idField: "name", + labelField: "name", + }), + }, + { + key: "schema", + list: ([catalog]) => ({ + command: ["schemas", "list", catalog], + idField: "name", + labelField: "name", + }), + }, + { + key: "function", + list: ([catalog, schema]) => ({ + command: ["functions", "list", catalog, schema], + idField: "full_name", + labelField: "name", + }), + }, + ], + secret: [ + { + key: "scope", + list: () => ({ + command: ["secrets", "list-scopes"], + idField: "name", + labelField: "name", + }), + }, + { + key: "key", + list: ([scope]) => ({ + command: ["secrets", "list-secrets", scope], + idField: "key", + labelField: "key", + }), + }, + ], + vector_search_index: [ + { + key: "endpoint", + list: () => ({ + command: ["vector-search-endpoints", "list-endpoints"], + idField: "name", + labelField: "name", + }), + }, + { + key: "index", + list: ([endpoint]) => ({ + command: ["vector-search-indexes", "list-indexes", endpoint], + idField: "name", + labelField: "name", + }), + }, + ], +}; + +/** True when a resource type needs a parent-context drill-down to list. */ +export function isParentContext(resourceType: string): boolean { + return resourceType in PARENT_CONTEXT_CHAINS; +} + +/** One resolved step of a drill-down: the choices to present at this level. */ +export interface DrillStep { + key: string; + choices: WorkspaceChoice[]; +} + +/** + * Lists the choices for a single drill-down step given the values picked so + * far. Returns [] on failure. The caller drives the interaction (present + * `choices`, collect a pick, call again with it appended to `parents`). + */ +export function listParentContextStep( + resourceType: string, + stepIndex: number, + parents: string[], + profile?: string, + runner: CliRunner = defaultRunner, +): DrillStep | null { + const chain = PARENT_CONTEXT_CHAINS[resourceType]; + if (!chain || stepIndex >= chain.length) return null; + const step = chain[stepIndex]; + const spec = step.list(parents); + return { + key: step.key, + choices: runList( + spec.command, + spec.idField, + spec.labelField, + profile, + runner, + ), + }; +} + +/** Number of drill-down steps for a parent-context type (0 if not one). */ +export function parentContextDepth(resourceType: string): number { + return PARENT_CONTEXT_CHAINS[resourceType]?.length ?? 0; }