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
37 changes: 1 addition & 36 deletions packages/shared/src/cli/commands/registry/add.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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));
Expand Down
117 changes: 67 additions & 50 deletions packages/shared/src/cli/commands/registry/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ManifestField>;
}
interface PluginManifestShape {
name?: string;
resources?: { required?: ManifestResource[]; optional?: ManifestResource[] };
}

function isDir(p: string): boolean {
return fs.existsSync(p) && fs.statSync(p).isDirectory();
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -187,7 +166,6 @@ function writeItemFile(
interface PluginSummary {
importPath: string;
exportName: string | null;
envs: string[];
}

/**
Expand Down Expand Up @@ -223,10 +201,19 @@ export async function resolveItems(
return ordered;
}

async function runAdd(
refs: string[],
opts: { force?: boolean; cwd?: string; register?: boolean },
): Promise<void> {
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<string, string>;
}

async function runAdd(refs: string[], opts: AddOptions): Promise<void> {
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
const token = resolveToken();
if (token) {
Expand All @@ -251,12 +238,12 @@ async function runAdd(
const deps = new Set<string>();
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 =
Expand All @@ -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 ?? []) {
Expand Down Expand Up @@ -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<string, string>,
): Record<string, string> {
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("<item...>", "Registry item name(s), e.g. metric-card or hello")
.option("-f, --force", "Overwrite existing files")
.option("-C, --cwd <dir>", "Run as if started in <dir>")
.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 <KEY=VALUE>",
"Pre-set a resource env var (repeatable)",
collectEnvFlag,
{},
)
.addHelpText(
"after",
`
Expand All @@ -359,22 +374,24 @@ No components.json is required. Item type is detected automatically:
• Server plugins → <server>/plugins/<name>/, 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.

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);
}),
);
Loading