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
85 changes: 71 additions & 14 deletions packages/shared/src/cli/commands/registry/env-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -58,11 +64,61 @@ async function promptText(need: EnvNeed): Promise<string | undefined> {
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<string | typeof MANUAL | null> {
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<string | undefined> {
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) => {
Expand All @@ -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(
Expand All @@ -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);
Expand Down
104 changes: 104 additions & 0 deletions packages/shared/src/cli/commands/registry/workspace-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import {
type CliRunner,
isFlatListable,
isParentContext,
listParentContextStep,
listWorkspaceResources,
parentContextDepth,
toChoices,
} from "./workspace-picker";

Expand Down Expand Up @@ -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([]);
});
});
Loading