From 05f5750c254396ba3cd46d6dc0a23295a244d328 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 11:44:49 +0200 Subject: [PATCH 01/12] feat(cli): add 'appkit add' and 'appkit registry' commands Add a shadcn-namespaced component registry workflow to the AppKit CLI: - 'appkit add ' ensures the @appkit registry namespace in the consumer's components.json, then delegates to 'shadcn add @appkit/'. - 'appkit registry list' enumerates components from the registry index. Registry components import primitives from @databricks/appkit-ui (npm peer), so they stay in sync with the installed AppKit version and design tokens. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 83 +++++++++++++++++++ .../src/cli/commands/registry/constants.ts | 11 +++ .../shared/src/cli/commands/registry/index.ts | 22 +++++ .../shared/src/cli/commands/registry/list.ts | 61 ++++++++++++++ packages/shared/src/cli/index.ts | 4 + 5 files changed, 181 insertions(+) create mode 100644 packages/shared/src/cli/commands/registry/add.ts create mode 100644 packages/shared/src/cli/commands/registry/constants.ts create mode 100644 packages/shared/src/cli/commands/registry/index.ts create mode 100644 packages/shared/src/cli/commands/registry/list.ts diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts new file mode 100644 index 000000000..90a5ec2e7 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { REGISTRY_ITEM_URL_TEMPLATE, REGISTRY_NAMESPACE } from "./constants"; + +interface ComponentsJson { + registries?: Record; + [key: string]: unknown; +} + +/** + * Ensures the consumer's components.json declares the `@appkit` namespace so the + * shadcn CLI can resolve `@appkit/` references. Writes it if missing. + */ +function ensureNamespace(cwd: string): void { + const file = path.join(cwd, "components.json"); + if (!fs.existsSync(file)) { + console.error(`No components.json found in ${cwd}.`); + console.error( + " Run `npx shadcn@latest init` first, or run this from your app root.", + ); + process.exit(1); + } + + let json: ComponentsJson; + try { + json = JSON.parse(fs.readFileSync(file, "utf-8")) as ComponentsJson; + } catch (err) { + console.error( + `components.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + json.registries ??= {}; + if (json.registries[REGISTRY_NAMESPACE] !== REGISTRY_ITEM_URL_TEMPLATE) { + json.registries[REGISTRY_NAMESPACE] = REGISTRY_ITEM_URL_TEMPLATE; + fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`); + console.log( + `Configured ${REGISTRY_NAMESPACE} registry in components.json.`, + ); + } +} + +function runAdd(components: string[], opts: { yes?: boolean }): void { + const cwd = process.cwd(); + ensureNamespace(cwd); + + // Accept bare names (`metric-card`) or already-namespaced refs (`@appkit/x`). + const refs = components.map((c) => + c.includes("/") ? c : `${REGISTRY_NAMESPACE}/${c}`, + ); + + const args = ["shadcn@latest", "add", ...refs]; + if (opts.yes) args.push("--yes"); + + const result = spawnSync("npx", args, { stdio: "inherit", cwd }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + + console.log( + '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so the component is themed.', + ); +} + +export const addCommand = new Command("add") + .description("Add an AppKit registry component to your project") + .argument("", "Component name(s), e.g. metric-card") + .option("-y, --yes", "Skip confirmation prompts") + .addHelpText( + "after", + ` +Examples: + $ appkit add metric-card + $ appkit add metric-card data-table + $ appkit add @appkit/metric-card`, + ) + .action((components: string[], opts: { yes?: boolean }) => + runAdd(components, opts), + ); diff --git a/packages/shared/src/cli/commands/registry/constants.ts b/packages/shared/src/cli/commands/registry/constants.ts new file mode 100644 index 000000000..245af5eae --- /dev/null +++ b/packages/shared/src/cli/commands/registry/constants.ts @@ -0,0 +1,11 @@ +/** shadcn registry namespace consumers reference, e.g. `@appkit/metric-card`. */ +export const REGISTRY_NAMESPACE = "@appkit"; + +// TODO: point at the real hosting domain once the public registry is deployed. +export const REGISTRY_BASE_URL = "https://registry.appkit.databricks.com"; + +/** URL template written into the consumer's components.json `registries` map. */ +export const REGISTRY_ITEM_URL_TEMPLATE = `${REGISTRY_BASE_URL}/r/{name}.json`; + +/** Manifest used by `appkit registry list` to enumerate available components. */ +export const REGISTRY_INDEX_URL = `${REGISTRY_BASE_URL}/registry.json`; diff --git a/packages/shared/src/cli/commands/registry/index.ts b/packages/shared/src/cli/commands/registry/index.ts new file mode 100644 index 000000000..964fe97aa --- /dev/null +++ b/packages/shared/src/cli/commands/registry/index.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { registryListCommand } from "./list"; + +/** + * Parent command for AppKit component registry operations. + * Subcommands: + * - list: Enumerate components available in the registry + * + * Note: `appkit add ` is exposed as a top-level command (see add.ts) + * since it is the primary entry point for consumers. + */ +export const registryCommand = new Command("registry") + .description("AppKit component registry commands") + .addCommand(registryListCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit registry list + $ appkit registry list --json + $ appkit add metric-card`, + ); diff --git a/packages/shared/src/cli/commands/registry/list.ts b/packages/shared/src/cli/commands/registry/list.ts new file mode 100644 index 000000000..967eb32fb --- /dev/null +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -0,0 +1,61 @@ +import process from "node:process"; +import { Command } from "commander"; +import { REGISTRY_INDEX_URL } from "./constants"; + +interface RegistryIndexItem { + name: string; + title?: string; + description?: string; +} + +function printTable(items: RegistryIndexItem[]): void { + if (items.length === 0) { + console.log("No components found in the registry."); + return; + } + const maxName = Math.max(4, ...items.map((i) => i.name.length)); + const header = `${"NAME".padEnd(maxName)} DESCRIPTION`; + console.log(header); + console.log("-".repeat(header.length)); + for (const item of items) { + console.log( + `${item.name.padEnd(maxName)} ${item.description ?? item.title ?? ""}`, + ); + } +} + +async function runList(opts: { json?: boolean }): Promise { + let res: Awaited>; + try { + res = await fetch(REGISTRY_INDEX_URL); + } catch (err) { + console.error(`Failed to reach the registry at ${REGISTRY_INDEX_URL}`); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + if (!res.ok) { + console.error( + `Registry returned HTTP ${res.status} for ${REGISTRY_INDEX_URL}`, + ); + process.exit(1); + } + + const data = (await res.json()) as { items?: RegistryIndexItem[] }; + const items = data.items ?? []; + + if (opts.json) { + console.log(JSON.stringify(items, null, 2)); + } else { + printTable(items); + } +} + +export const registryListCommand = new Command("list") + .description("List components available in the AppKit registry") + .option("--json", "Output as JSON") + .action((opts: { json?: boolean }) => + runList(opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index aa60157c8..590c5ea79 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -9,6 +9,8 @@ import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; import { pluginCommand } from "./commands/plugin/index.js"; +import { addCommand } from "./commands/registry/add.js"; +import { registryCommand } from "./commands/registry/index.js"; import { setupCommand } from "./commands/setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -28,5 +30,7 @@ cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); +cmd.addCommand(registryCommand); +cmd.addCommand(addCommand); await cmd.parseAsync(); From ef52f132b81cbfe130265e6467b4ddda583e6917 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 11:49:43 +0200 Subject: [PATCH 02/12] feat(cli): point registry at public databricks/appkit-registry repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve the registry directly from the public GitHub repo over raw.githubusercontent.com instead of a placeholder host — no separate hosting infra needed. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/constants.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/constants.ts b/packages/shared/src/cli/commands/registry/constants.ts index 245af5eae..e2d19f846 100644 --- a/packages/shared/src/cli/commands/registry/constants.ts +++ b/packages/shared/src/cli/commands/registry/constants.ts @@ -1,11 +1,16 @@ /** shadcn registry namespace consumers reference, e.g. `@appkit/metric-card`. */ export const REGISTRY_NAMESPACE = "@appkit"; -// TODO: point at the real hosting domain once the public registry is deployed. -export const REGISTRY_BASE_URL = "https://registry.appkit.databricks.com"; +/** + * The registry is served directly from the public GitHub repo over + * raw.githubusercontent.com — no separate hosting. Built items live under + * `public/r/` on the default branch; the manifest at the repo root. + */ +export const REGISTRY_RAW_BASE_URL = + "https://raw.githubusercontent.com/databricks/appkit-registry/main"; /** URL template written into the consumer's components.json `registries` map. */ -export const REGISTRY_ITEM_URL_TEMPLATE = `${REGISTRY_BASE_URL}/r/{name}.json`; +export const REGISTRY_ITEM_URL_TEMPLATE = `${REGISTRY_RAW_BASE_URL}/public/r/{name}.json`; /** Manifest used by `appkit registry list` to enumerate available components. */ -export const REGISTRY_INDEX_URL = `${REGISTRY_BASE_URL}/registry.json`; +export const REGISTRY_INDEX_URL = `${REGISTRY_RAW_BASE_URL}/registry.json`; From 9d4a24f8acf62360706a9f8e39a17566d9594f26 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 12:18:05 +0200 Subject: [PATCH 03/12] feat(cli): support private registry via gh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch registry items ourselves (token-aware) and hand a local file to 'shadcn add', rather than relying on a shadcn namespace — so we control the auth headers and the internal/private repo works today. - Token resolved from gh auth token, then APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. - Private fetch uses the GitHub Contents API (Accept: raw); falls back to raw.githubusercontent.com when no token / once the repo is public. - 'appkit registry list' is token-aware too. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 128 +++++++++++++----- .../src/cli/commands/registry/constants.ts | 61 +++++++-- .../shared/src/cli/commands/registry/list.ts | 32 ++++- 3 files changed, 174 insertions(+), 47 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 90a5ec2e7..42a17a926 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -1,62 +1,117 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import process from "node:process"; import { Command } from "commander"; -import { REGISTRY_ITEM_URL_TEMPLATE, REGISTRY_NAMESPACE } from "./constants"; +import { + REGISTRY_ITEM_API_TEMPLATE, + REGISTRY_ITEM_URL_TEMPLATE, + REGISTRY_NAMESPACE, + REGISTRY_REPO, + type RegistryToken, + resolveToken, +} from "./constants"; -interface ComponentsJson { - registries?: Record; - [key: string]: unknown; +function stripNamespace(component: string): string { + const prefix = `${REGISTRY_NAMESPACE}/`; + return component.startsWith(prefix) + ? component.slice(prefix.length) + : component; } /** - * Ensures the consumer's components.json declares the `@appkit` namespace so the - * shadcn CLI can resolve `@appkit/` references. Writes it if missing. + * Fetches a single registry item and writes it to a temp file, returning the + * path. When a token is present the GitHub Contents API is used (works for the + * private/internal repo); otherwise the public raw URL is used. We fetch it + * ourselves — rather than relying on a shadcn registry namespace — so we fully + * control the auth headers, then hand the local file to `shadcn add`. */ -function ensureNamespace(cwd: string): void { - const file = path.join(cwd, "components.json"); - if (!fs.existsSync(file)) { - console.error(`No components.json found in ${cwd}.`); +async function fetchItem( + name: string, + token: RegistryToken | null, +): Promise { + const template = token + ? REGISTRY_ITEM_API_TEMPLATE + : REGISTRY_ITEM_URL_TEMPLATE; + const url = template.replace("{name}", name); + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token.value}`; + headers.Accept = "application/vnd.github.raw"; + } + + let res: Awaited>; + try { + res = await fetch(url, { headers }); + } catch (err) { + console.error(`Failed to fetch "${name}" from ${url}`); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + if (res.status === 404) { + console.error(`Component "${name}" not found in ${REGISTRY_REPO}.`); + if (!token) { + console.error( + " If the registry repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.", + ); + } + process.exit(1); + } + if (res.status === 401 || res.status === 403) { console.error( - " Run `npx shadcn@latest init` first, or run this from your app root.", + `Access denied (HTTP ${res.status}) fetching "${name}" from ${REGISTRY_REPO}.`, ); + console.error(" Check that your token has read access to the repository."); + process.exit(1); + } + if (!res.ok) { + console.error(`Registry returned HTTP ${res.status} for "${name}".`); process.exit(1); } - let json: ComponentsJson; - try { - json = JSON.parse(fs.readFileSync(file, "utf-8")) as ComponentsJson; - } catch (err) { + const body = await res.text(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-registry-")); + const file = path.join(dir, `${name}.json`); + fs.writeFileSync(file, body); + return file; +} + +async function runAdd( + components: string[], + opts: { yes?: boolean }, +): Promise { + const cwd = process.cwd(); + if (!fs.existsSync(path.join(cwd, "components.json"))) { + console.error(`No components.json found in ${cwd}.`); console.error( - `components.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + " Run `npx shadcn@latest init` first, or run this from your app root.", ); process.exit(1); } - json.registries ??= {}; - if (json.registries[REGISTRY_NAMESPACE] !== REGISTRY_ITEM_URL_TEMPLATE) { - json.registries[REGISTRY_NAMESPACE] = REGISTRY_ITEM_URL_TEMPLATE; - fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`); + const token = resolveToken(); + if (token) { console.log( - `Configured ${REGISTRY_NAMESPACE} registry in components.json.`, + `Using ${token.envName} to fetch from ${REGISTRY_REPO} (private).`, ); } -} - -function runAdd(components: string[], opts: { yes?: boolean }): void { - const cwd = process.cwd(); - ensureNamespace(cwd); - // Accept bare names (`metric-card`) or already-namespaced refs (`@appkit/x`). - const refs = components.map((c) => - c.includes("/") ? c : `${REGISTRY_NAMESPACE}/${c}`, - ); + const names = components.map(stripNamespace); + const tmpFiles: string[] = []; + for (const name of names) { + tmpFiles.push(await fetchItem(name, token)); + } - const args = ["shadcn@latest", "add", ...refs]; + const args = ["shadcn@latest", "add", ...tmpFiles]; if (opts.yes) args.push("--yes"); - const result = spawnSync("npx", args, { stdio: "inherit", cwd }); + + for (const file of tmpFiles) { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } + if (result.status !== 0) { process.exit(result.status ?? 1); } @@ -73,11 +128,18 @@ export const addCommand = new Command("add") .addHelpText( "after", ` +While the registry repo is private, a token with read access is used. It is +resolved automatically from \`gh auth token\` (if you're logged in with the +GitHub CLI), or from APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. + Examples: $ appkit add metric-card $ appkit add metric-card data-table $ appkit add @appkit/metric-card`, ) .action((components: string[], opts: { yes?: boolean }) => - runAdd(components, opts), + runAdd(components, opts).catch((err) => { + console.error(err); + process.exit(1); + }), ); diff --git a/packages/shared/src/cli/commands/registry/constants.ts b/packages/shared/src/cli/commands/registry/constants.ts index e2d19f846..41168952f 100644 --- a/packages/shared/src/cli/commands/registry/constants.ts +++ b/packages/shared/src/cli/commands/registry/constants.ts @@ -1,16 +1,59 @@ +import { spawnSync } from "node:child_process"; + /** shadcn registry namespace consumers reference, e.g. `@appkit/metric-card`. */ export const REGISTRY_NAMESPACE = "@appkit"; +/** GitHub repo hosting the registry, and the branch the built items live on. */ +export const REGISTRY_REPO = "databricks/appkit-registry"; +export const REGISTRY_REF = "main"; + +/** + * Public hosting: once the repo is public, items are fetchable directly from + * raw.githubusercontent.com with no auth. + */ +const PUBLIC_RAW_BASE = `https://raw.githubusercontent.com/${REGISTRY_REPO}/${REGISTRY_REF}`; +export const REGISTRY_ITEM_URL_TEMPLATE = `${PUBLIC_RAW_BASE}/public/r/{name}.json`; +export const REGISTRY_INDEX_URL = `${PUBLIC_RAW_BASE}/registry.json`; + /** - * The registry is served directly from the public GitHub repo over - * raw.githubusercontent.com — no separate hosting. Built items live under - * `public/r/` on the default branch; the manifest at the repo root. + * Private/internal hosting: while the repo is internal, files are fetched via + * the GitHub Contents API with a token. `Accept: application/vnd.github.raw` + * makes the API return the file bytes directly (the registry-item JSON). */ -export const REGISTRY_RAW_BASE_URL = - "https://raw.githubusercontent.com/databricks/appkit-registry/main"; +const GH_CONTENTS_API = `https://api.github.com/repos/${REGISTRY_REPO}/contents`; +export const REGISTRY_ITEM_API_TEMPLATE = `${GH_CONTENTS_API}/public/r/{name}.json?ref=${REGISTRY_REF}`; +export const REGISTRY_INDEX_API_URL = `${GH_CONTENTS_API}/registry.json?ref=${REGISTRY_REF}`; + +/** Env vars checked (in order) for a token granting read access to the repo. */ +export const TOKEN_ENV_VARS = [ + "APPKIT_REGISTRY_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", +]; -/** URL template written into the consumer's components.json `registries` map. */ -export const REGISTRY_ITEM_URL_TEMPLATE = `${REGISTRY_RAW_BASE_URL}/public/r/{name}.json`; +export interface RegistryToken { + envName: string; + value: string; +} -/** Manifest used by `appkit registry list` to enumerate available components. */ -export const REGISTRY_INDEX_URL = `${REGISTRY_RAW_BASE_URL}/registry.json`; +/** + * Resolves a token granting read access to the registry repo: first the env + * vars in {@link TOKEN_ENV_VARS}, then the GitHub CLI (`gh auth token`) if the + * user is logged in. Returns null if none are available. + */ +export function resolveToken( + env: NodeJS.ProcessEnv = process.env, +): RegistryToken | null { + for (const envName of TOKEN_ENV_VARS) { + const value = env[envName]; + if (value) return { envName, value }; + } + try { + const res = spawnSync("gh", ["auth", "token"], { encoding: "utf-8" }); + const value = res.status === 0 ? res.stdout.trim() : ""; + if (value) return { envName: "gh auth token", value }; + } catch { + // gh not installed or not on PATH — fall through. + } + return null; +} diff --git a/packages/shared/src/cli/commands/registry/list.ts b/packages/shared/src/cli/commands/registry/list.ts index 967eb32fb..4ddd0a227 100644 --- a/packages/shared/src/cli/commands/registry/list.ts +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -1,6 +1,11 @@ import process from "node:process"; import { Command } from "commander"; -import { REGISTRY_INDEX_URL } from "./constants"; +import { + REGISTRY_INDEX_API_URL, + REGISTRY_INDEX_URL, + REGISTRY_REPO, + resolveToken, +} from "./constants"; interface RegistryIndexItem { name: string; @@ -25,18 +30,35 @@ function printTable(items: RegistryIndexItem[]): void { } async function runList(opts: { json?: boolean }): Promise { + const token = resolveToken(); + const url = token ? REGISTRY_INDEX_API_URL : REGISTRY_INDEX_URL; + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token.value}`; + headers.Accept = "application/vnd.github.raw"; + } + let res: Awaited>; try { - res = await fetch(REGISTRY_INDEX_URL); + res = await fetch(url, { headers }); } catch (err) { - console.error(`Failed to reach the registry at ${REGISTRY_INDEX_URL}`); + console.error(`Failed to reach the registry at ${url}`); console.error(` ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } - if (!res.ok) { + if (res.status === 404 || res.status === 401 || res.status === 403) { console.error( - `Registry returned HTTP ${res.status} for ${REGISTRY_INDEX_URL}`, + `Could not read the registry index from ${REGISTRY_REPO} (HTTP ${res.status}).`, ); + if (!token) { + console.error( + " If the repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.", + ); + } + process.exit(1); + } + if (!res.ok) { + console.error(`Registry returned HTTP ${res.status} for ${url}`); process.exit(1); } From 74610068eaf49434728111cdd311fb7d32ad0f59 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 12:28:11 +0200 Subject: [PATCH 04/12] feat(cli): drop components.json requirement from 'appkit add' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppKit registry items are self-contained (import from @databricks/appkit-ui, no shadcn @/ aliases or registryDependencies), so shadcn's alias resolution isn't needed. Write item files to their target path directly and install npm deps with the detected package manager — no components.json required. - Targets resolved under src/ when present. - --force to overwrite existing files (refuses by default). - Warns on any registryDependency rather than silently dropping it. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 42a17a926..56c2cda21 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -1,6 +1,5 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import process from "node:process"; import { Command } from "commander"; @@ -13,6 +12,21 @@ import { resolveToken, } from "./constants"; +interface RegistryItemFile { + path: string; + content: string; + type: string; + /** Destination path relative to the project root. */ + target?: string; +} + +interface RegistryItem { + name: string; + dependencies?: string[]; + registryDependencies?: string[]; + files?: RegistryItemFile[]; +} + function stripNamespace(component: string): string { const prefix = `${REGISTRY_NAMESPACE}/`; return component.startsWith(prefix) @@ -21,16 +35,14 @@ function stripNamespace(component: string): string { } /** - * Fetches a single registry item and writes it to a temp file, returning the - * path. When a token is present the GitHub Contents API is used (works for the - * private/internal repo); otherwise the public raw URL is used. We fetch it - * ourselves — rather than relying on a shadcn registry namespace — so we fully - * control the auth headers, then hand the local file to `shadcn add`. + * Fetches and parses a single registry item. When a token is present the GitHub + * Contents API is used (works for the private/internal repo); otherwise the + * public raw URL is used. */ async function fetchItem( name: string, token: RegistryToken | null, -): Promise { +): Promise { const template = token ? REGISTRY_ITEM_API_TEMPLATE : REGISTRY_ITEM_URL_TEMPLATE; @@ -71,26 +83,59 @@ async function fetchItem( process.exit(1); } - const body = await res.text(); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-registry-")); - const file = path.join(dir, `${name}.json`); - fs.writeFileSync(file, body); - return file; + return (await res.json()) as RegistryItem; +} + +/** + * Resolves where a registry file should be written. Uses the item's `target`, + * placing it under `src/` when the project has one (matching common app + * layouts). AppKit registry components import primitives from + * `@databricks/appkit-ui` rather than shadcn `@/` aliases, so no components.json + * or alias resolution is needed. + */ +function resolveTarget(cwd: string, file: RegistryItemFile): string { + let target = + file.target ?? path.join("components/appkit", path.basename(file.path)); + if (!target.startsWith("src/") && fs.existsSync(path.join(cwd, "src"))) { + target = path.join("src", target); + } + return path.join(cwd, target); +} + +function detectPackageManager(cwd: string): "pnpm" | "yarn" | "bun" | "npm" { + if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn"; + if (fs.existsSync(path.join(cwd, "bun.lockb"))) return "bun"; + return "npm"; +} + +function installDependencies(deps: string[], cwd: string): void { + if (deps.length === 0) return; + if (!fs.existsSync(path.join(cwd, "package.json"))) { + console.warn( + `No package.json found — install these manually: ${deps.join(" ")}`, + ); + return; + } + const pm = detectPackageManager(cwd); + const subcommand = pm === "npm" ? "install" : "add"; + console.log(`\nInstalling dependencies with ${pm}: ${deps.join(" ")}`); + const result = spawnSync(pm, [subcommand, ...deps], { + stdio: "inherit", + cwd, + }); + if (result.status !== 0) { + console.warn( + `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed: ${deps.join(" ")}`, + ); + } } async function runAdd( components: string[], - opts: { yes?: boolean }, + opts: { force?: boolean }, ): Promise { const cwd = process.cwd(); - if (!fs.existsSync(path.join(cwd, "components.json"))) { - console.error(`No components.json found in ${cwd}.`); - console.error( - " Run `npx shadcn@latest init` first, or run this from your app root.", - ); - process.exit(1); - } - const token = resolveToken(); if (token) { console.log( @@ -99,35 +144,63 @@ async function runAdd( } const names = components.map(stripNamespace); - const tmpFiles: string[] = []; + const items: RegistryItem[] = []; for (const name of names) { - tmpFiles.push(await fetchItem(name, token)); + items.push(await fetchItem(name, token)); } - const args = ["shadcn@latest", "add", ...tmpFiles]; - if (opts.yes) args.push("--yes"); - const result = spawnSync("npx", args, { stdio: "inherit", cwd }); + const deps = new Set(); + const written: string[] = []; - for (const file of tmpFiles) { - fs.rmSync(path.dirname(file), { recursive: true, force: true }); - } + for (const item of items) { + for (const dep of item.dependencies ?? []) deps.add(dep); - if (result.status !== 0) { - process.exit(result.status ?? 1); + // AppKit (Option A) items have no registry dependencies; warn rather than + // silently dropping any a future item might declare. + for (const rd of item.registryDependencies ?? []) { + console.warn( + ` Note: "${item.name}" declares registryDependency "${rd}" — add it separately if it isn't already present.`, + ); + } + + for (const file of item.files ?? []) { + const dest = resolveTarget(cwd, file); + const existed = fs.existsSync(dest); + if (existed && !opts.force) { + console.error( + `Refusing to overwrite ${path.relative(cwd, dest)} — pass --force to replace it.`, + ); + process.exit(1); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, file.content); + written.push(path.relative(cwd, dest)); + console.log( + `${existed ? "Updated" : "Created"} ${path.relative(cwd, dest)}`, + ); + } } - console.log( - '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so the component is themed.', - ); + installDependencies([...deps], cwd); + + if (written.length > 0) { + console.log( + '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so the component is themed.', + ); + } } export const addCommand = new Command("add") .description("Add an AppKit registry component to your project") .argument("", "Component name(s), e.g. metric-card") - .option("-y, --yes", "Skip confirmation prompts") + .option("-f, --force", "Overwrite existing files") .addHelpText( "after", ` +No components.json is required. Files are written to each item's target path +(under src/ when present) and npm dependencies are installed with your project's +package manager. + While the registry repo is private, a token with read access is used. It is resolved automatically from \`gh auth token\` (if you're logged in with the GitHub CLI), or from APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. @@ -137,7 +210,7 @@ Examples: $ appkit add metric-card data-table $ appkit add @appkit/metric-card`, ) - .action((components: string[], opts: { yes?: boolean }) => + .action((components: string[], opts: { force?: boolean }) => runAdd(components, opts).catch((err) => { console.error(err); process.exit(1); From 8f619816ea5d755f8a9437a7c46ed0b1a6c29de5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 12:37:13 +0200 Subject: [PATCH 05/12] fix(cli): detect frontend root for monorepo app layouts 'appkit add' is typically run from the repo root, where the frontend lives in a client/ subdir. Detect the frontend root (dir with components.json or src/, including common client/frontend/web/app subdirs) and write components under /src/. Install deps into the nearest package.json (single root package.json in the AppKit app layout). Add --cwd to override. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 82 +++++++++++++++---- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 56c2cda21..f9890fb4d 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -86,20 +86,59 @@ async function fetchItem( return (await res.json()) as RegistryItem; } +/** Subdirectories that commonly hold the frontend in an AppKit app layout. */ +const FRONTEND_SUBDIRS = ["client", "frontend", "web", "app"]; + +/** + * Locates the frontend root to write components into. AppKit apps put the + * client in a `client/` subdir (with its own components.json + src/), and the + * CLI is typically run from the repo root. We prefer the dir containing + * components.json, then a dir with a src/, checking the cwd and common + * subdirs before falling back to cwd. + */ +function findFrontendRoot(cwd: string): string { + if (fs.existsSync(path.join(cwd, "components.json"))) return cwd; + for (const sub of FRONTEND_SUBDIRS) { + if (fs.existsSync(path.join(cwd, sub, "components.json"))) { + return path.join(cwd, sub); + } + } + if (fs.existsSync(path.join(cwd, "src"))) return cwd; + for (const sub of FRONTEND_SUBDIRS) { + if (fs.existsSync(path.join(cwd, sub, "src"))) { + return path.join(cwd, sub); + } + } + return cwd; +} + +/** + * Finds the dir to install npm deps into: the nearest package.json walking up + * from the frontend root to the cwd (a monorepo-style app has a single root + * package.json while the client lives in client/). + */ +function findInstallDir(base: string, cwd: string): string { + let dir = base; + for (;;) { + if (fs.existsSync(path.join(dir, "package.json"))) return dir; + if (dir === cwd) break; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return cwd; +} + /** - * Resolves where a registry file should be written. Uses the item's `target`, - * placing it under `src/` when the project has one (matching common app - * layouts). AppKit registry components import primitives from - * `@databricks/appkit-ui` rather than shadcn `@/` aliases, so no components.json - * or alias resolution is needed. + * Resolves where a registry file is written, relative to the frontend root. + * Uses the item's `target`, placing it under `src/` when that root has one. */ -function resolveTarget(cwd: string, file: RegistryItemFile): string { - let target = - file.target ?? path.join("components/appkit", path.basename(file.path)); - if (!target.startsWith("src/") && fs.existsSync(path.join(cwd, "src"))) { +function resolveTarget(base: string, file: RegistryItemFile): string { + let target = file.target ?? path.join("components", path.basename(file.path)); + if (!target.startsWith("src/") && fs.existsSync(path.join(base, "src"))) { target = path.join("src", target); } - return path.join(cwd, target); + return path.join(base, target); } function detectPackageManager(cwd: string): "pnpm" | "yarn" | "bun" | "npm" { @@ -133,9 +172,14 @@ function installDependencies(deps: string[], cwd: string): void { async function runAdd( components: string[], - opts: { force?: boolean }, + opts: { force?: boolean; cwd?: string }, ): Promise { - const cwd = process.cwd(); + const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); + const base = findFrontendRoot(cwd); + if (base !== cwd) { + console.log(`Detected frontend root: ${path.relative(cwd, base)}/`); + } + const token = resolveToken(); if (token) { console.log( @@ -164,7 +208,7 @@ async function runAdd( } for (const file of item.files ?? []) { - const dest = resolveTarget(cwd, file); + const dest = resolveTarget(base, file); const existed = fs.existsSync(dest); if (existed && !opts.force) { console.error( @@ -181,7 +225,7 @@ async function runAdd( } } - installDependencies([...deps], cwd); + installDependencies([...deps], findInstallDir(base, cwd)); if (written.length > 0) { console.log( @@ -194,12 +238,14 @@ export const addCommand = new Command("add") .description("Add an AppKit registry component to your project") .argument("", "Component name(s), e.g. metric-card") .option("-f, --force", "Overwrite existing files") + .option("-C, --cwd ", "Run as if started in ") .addHelpText( "after", ` -No components.json is required. Files are written to each item's target path -(under src/ when present) and npm dependencies are installed with your project's -package manager. +No components.json is required. The frontend root is detected automatically +(a client/ subdir or a dir with components.json / src/), so you can run this +from the repo root. Files land under /src/; npm dependencies +install into the nearest package.json. While the registry repo is private, a token with read access is used. It is resolved automatically from \`gh auth token\` (if you're logged in with the @@ -210,7 +256,7 @@ Examples: $ appkit add metric-card data-table $ appkit add @appkit/metric-card`, ) - .action((components: string[], opts: { force?: boolean }) => + .action((components: string[], opts: { force?: boolean; cwd?: string }) => runAdd(components, opts).catch((err) => { console.error(err); process.exit(1); From a9083b4c1d29adc903727bb0fbcd236ebcf35cfc Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 12:47:11 +0200 Subject: [PATCH 06/12] feat(cli): add 'appkit plugin add' for registry-distributed plugins Server plugins can now be distributed through the same registry as UI components. 'appkit plugin add ' fetches a plugin item, writes it to plugins// (verbatim targets, not under client/src), installs npm deps, runs 'plugin sync' to register it, and prints the createApp snippet plus any required env vars. - Extract shared fetch into registry/client.ts (reused by add + plugin add). - Detect plugin items by the presence of manifest.json. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/plugin/add/add.ts | 225 ++++++++++++++++++ .../shared/src/cli/commands/plugin/index.ts | 3 + .../shared/src/cli/commands/registry/add.ts | 89 +------ .../src/cli/commands/registry/client.ts | 84 +++++++ 4 files changed, 319 insertions(+), 82 deletions(-) create mode 100644 packages/shared/src/cli/commands/plugin/add/add.ts create mode 100644 packages/shared/src/cli/commands/registry/client.ts diff --git a/packages/shared/src/cli/commands/plugin/add/add.ts b/packages/shared/src/cli/commands/plugin/add/add.ts new file mode 100644 index 000000000..9e1ef1e20 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/add/add.ts @@ -0,0 +1,225 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { + fetchRegistryItem, + type RegistryItem, + type RegistryItemFile, + stripNamespace, +} from "../../registry/client"; +import { REGISTRY_REPO, resolveToken } from "../../registry/constants"; + +interface ManifestField { + env?: string; + description?: string; +} +interface ManifestResource { + alias?: string; + fields?: Record; +} +interface PluginManifestShape { + name?: string; + resources?: { required?: ManifestResource[]; optional?: ManifestResource[] }; +} + +/** A registry item is a plugin if it ships a manifest.json. */ +function isPluginItem(item: RegistryItem): boolean { + return (item.files ?? []).some( + (f) => path.basename(f.target ?? f.path) === "manifest.json", + ); +} + +function manifestFile(item: RegistryItem): RegistryItemFile | undefined { + return (item.files ?? []).find( + (f) => path.basename(f.target ?? f.path) === "manifest.json", + ); +} + +/** The directory a plugin's files are rooted at, e.g. `plugins/`. */ +function pluginDir(item: RegistryItem): string { + const mf = manifestFile(item); + const rel = mf?.target ?? mf?.path ?? `plugins/${item.name}/manifest.json`; + return path.dirname(rel); +} + +/** Required env vars declared by the manifest's required resources. */ +function requiredEnvVars(manifest: PluginManifestShape): string[] { + const envs: string[] = []; + for (const res of manifest.resources?.required ?? []) { + 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( + (f) => path.basename(f.target ?? f.path) === "index.ts", + ); + if (!index) return null; + // Scaffolded index.ts: `export { ClassPlugin, exportName } from "./name";` + const match = index.content.match(/export\s*\{([^}]*)\}/); + if (!match) return null; + const names = match[1].split(",").map((s) => s.trim()); + // Prefer the camelCase toPlugin instance (not the PascalCase class). + return names.find((n) => /^[a-z]/.test(n)) ?? names[0] ?? null; +} + +function runSync(repoRoot: string): void { + const selfBin = process.argv[1]; + const result = spawnSync( + process.execPath, + [selfBin, "plugin", "sync", "--write"], + { stdio: "inherit", cwd: repoRoot }, + ); + if (result.status !== 0) { + console.warn( + " Plugin sync did not complete cleanly — run `appkit plugin sync --write` manually.", + ); + } +} + +async function runPluginAdd( + plugins: string[], + opts: { force?: boolean; cwd?: string }, +): Promise { + const repoRoot = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); + const token = resolveToken(); + if (token) { + console.log( + `Using ${token.envName} to fetch from ${REGISTRY_REPO} (private).`, + ); + } + + const names = plugins.map(stripNamespace); + const items: RegistryItem[] = []; + for (const name of names) { + items.push(await fetchRegistryItem(name, token)); + } + + const deps = new Set(); + const summaries: Array<{ + dir: string; + exportName: string | null; + envs: string[]; + }> = []; + + for (const item of items) { + if (!isPluginItem(item)) { + console.error( + `"${item.name}" is not a plugin (no manifest.json). Use \`appkit add ${item.name}\` for UI components.`, + ); + process.exit(1); + } + + for (const dep of item.dependencies ?? []) deps.add(dep); + + let manifest: PluginManifestShape = {}; + for (const file of item.files ?? []) { + // Plugin files carry explicit targets (e.g. plugins//index.ts), + // written verbatim relative to the repo root — never under client/src. + const target = + file.target ?? + path.join("plugins", item.name, path.basename(file.path)); + const dest = path.join(repoRoot, target); + const existed = fs.existsSync(dest); + if (existed && !opts.force) { + console.error( + `Refusing to overwrite ${path.relative(repoRoot, dest)} — pass --force to replace it.`, + ); + process.exit(1); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, file.content); + console.log( + `${existed ? "Updated" : "Created"} ${path.relative(repoRoot, dest)}`, + ); + if (path.basename(target) === "manifest.json") { + manifest = JSON.parse(file.content) as PluginManifestShape; + } + } + + summaries.push({ + dir: pluginDir(item), + exportName: pluginExportName(item), + envs: requiredEnvVars(manifest), + }); + } + + if (deps.size > 0) { + installDependencies([...deps], repoRoot); + } + + console.log("\nRegistering plugins (appkit plugin sync)..."); + runSync(repoRoot); + + // Print the remaining manual wiring. + console.log("\nNext steps:"); + for (const s of summaries) { + const imp = s.exportName ?? ""; + console.log(`\n • ${s.dir}`); + console.log( + ` Register it in your server's createApp call:\n` + + ` import { ${imp} } from "./${s.dir}";\n` + + ` const app = await createApp({ plugins: [${imp}, /* ... */] });`, + ); + if (s.envs.length > 0) { + console.log(` Set required env var(s): ${s.envs.join(", ")}`); + } + } +} + +function detectPackageManager(cwd: string): "pnpm" | "yarn" | "bun" | "npm" { + if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn"; + if (fs.existsSync(path.join(cwd, "bun.lockb"))) return "bun"; + return "npm"; +} + +function installDependencies(deps: string[], cwd: string): void { + if (!fs.existsSync(path.join(cwd, "package.json"))) { + console.warn( + `No package.json found — install these manually: ${deps.join(" ")}`, + ); + return; + } + const pm = detectPackageManager(cwd); + const subcommand = pm === "npm" ? "install" : "add"; + console.log(`\nInstalling dependencies with ${pm}: ${deps.join(" ")}`); + const result = spawnSync(pm, [subcommand, ...deps], { + stdio: "inherit", + cwd, + }); + if (result.status !== 0) { + console.warn( + `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed.`, + ); + } +} + +export const pluginAddCommand = new Command("add") + .description("Add a plugin from the AppKit registry") + .argument("", "Plugin name(s) from the registry") + .option("-f, --force", "Overwrite existing files") + .option("-C, --cwd ", "Run as if started in ") + .addHelpText( + "after", + ` +Fetches a plugin from the registry, writes it under plugins//, installs +npm dependencies, runs \`plugin sync\`, then prints the server-registration +snippet and any required env vars. + +Examples: + $ appkit plugin add hello + $ appkit plugin add @appkit/hello`, + ) + .action((plugins: string[], opts: { force?: boolean; cwd?: string }) => + runPluginAdd(plugins, opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index 7b5a96acc..6ec88327a 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import { pluginAddCommand } from "./add/add"; import { pluginAddResourceCommand } from "./add-resource/add-resource"; import { pluginCreateCommand } from "./create/create"; import { pluginListCommand } from "./list/list"; @@ -20,6 +21,7 @@ export const pluginCommand = new Command("plugin") .description("Plugin management commands") .addCommand(pluginsSyncCommand) .addCommand(pluginCreateCommand) + .addCommand(pluginAddCommand) .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) .addCommand(pluginAddResourceCommand) @@ -30,6 +32,7 @@ export const pluginCommand = new Command("plugin") Examples: $ appkit plugin sync --write $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" + $ appkit plugin add hello $ appkit plugin validate . $ appkit plugin list --json $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index f9890fb4d..a3621264c 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -4,87 +4,12 @@ import path from "node:path"; import process from "node:process"; import { Command } from "commander"; import { - REGISTRY_ITEM_API_TEMPLATE, - REGISTRY_ITEM_URL_TEMPLATE, - REGISTRY_NAMESPACE, - REGISTRY_REPO, - type RegistryToken, - resolveToken, -} from "./constants"; - -interface RegistryItemFile { - path: string; - content: string; - type: string; - /** Destination path relative to the project root. */ - target?: string; -} - -interface RegistryItem { - name: string; - dependencies?: string[]; - registryDependencies?: string[]; - files?: RegistryItemFile[]; -} - -function stripNamespace(component: string): string { - const prefix = `${REGISTRY_NAMESPACE}/`; - return component.startsWith(prefix) - ? component.slice(prefix.length) - : component; -} - -/** - * Fetches and parses a single registry item. When a token is present the GitHub - * Contents API is used (works for the private/internal repo); otherwise the - * public raw URL is used. - */ -async function fetchItem( - name: string, - token: RegistryToken | null, -): Promise { - const template = token - ? REGISTRY_ITEM_API_TEMPLATE - : REGISTRY_ITEM_URL_TEMPLATE; - const url = template.replace("{name}", name); - const headers: Record = {}; - if (token) { - headers.Authorization = `Bearer ${token.value}`; - headers.Accept = "application/vnd.github.raw"; - } - - let res: Awaited>; - try { - res = await fetch(url, { headers }); - } catch (err) { - console.error(`Failed to fetch "${name}" from ${url}`); - console.error(` ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - if (res.status === 404) { - console.error(`Component "${name}" not found in ${REGISTRY_REPO}.`); - if (!token) { - console.error( - " If the registry repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.", - ); - } - process.exit(1); - } - if (res.status === 401 || res.status === 403) { - console.error( - `Access denied (HTTP ${res.status}) fetching "${name}" from ${REGISTRY_REPO}.`, - ); - console.error(" Check that your token has read access to the repository."); - process.exit(1); - } - if (!res.ok) { - console.error(`Registry returned HTTP ${res.status} for "${name}".`); - process.exit(1); - } - - return (await res.json()) as RegistryItem; -} + fetchRegistryItem, + type RegistryItem, + type RegistryItemFile, + stripNamespace, +} from "./client"; +import { REGISTRY_REPO, resolveToken } from "./constants"; /** Subdirectories that commonly hold the frontend in an AppKit app layout. */ const FRONTEND_SUBDIRS = ["client", "frontend", "web", "app"]; @@ -190,7 +115,7 @@ async function runAdd( const names = components.map(stripNamespace); const items: RegistryItem[] = []; for (const name of names) { - items.push(await fetchItem(name, token)); + items.push(await fetchRegistryItem(name, token)); } const deps = new Set(); diff --git a/packages/shared/src/cli/commands/registry/client.ts b/packages/shared/src/cli/commands/registry/client.ts new file mode 100644 index 000000000..1eb1001a9 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/client.ts @@ -0,0 +1,84 @@ +import process from "node:process"; +import { + REGISTRY_ITEM_API_TEMPLATE, + REGISTRY_ITEM_URL_TEMPLATE, + REGISTRY_NAMESPACE, + REGISTRY_REPO, + type RegistryToken, +} from "./constants"; + +export interface RegistryItemFile { + path: string; + content: string; + type: string; + /** Destination path relative to the project root. */ + target?: string; +} + +export interface RegistryItem { + name: string; + type?: string; + dependencies?: string[]; + registryDependencies?: string[]; + files?: RegistryItemFile[]; +} + +/** Removes a leading `@appkit/` namespace from a component reference. */ +export function stripNamespace(component: string): string { + const prefix = `${REGISTRY_NAMESPACE}/`; + return component.startsWith(prefix) + ? component.slice(prefix.length) + : component; +} + +/** + * Fetches and parses a single registry item. When a token is present the GitHub + * Contents API is used (works for the private/internal repo); otherwise the + * public raw URL is used. Exits the process with a helpful message on failure. + */ +export async function fetchRegistryItem( + name: string, + token: RegistryToken | null, +): Promise { + const template = token + ? REGISTRY_ITEM_API_TEMPLATE + : REGISTRY_ITEM_URL_TEMPLATE; + const url = template.replace("{name}", name); + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token.value}`; + headers.Accept = "application/vnd.github.raw"; + } + + let res: Awaited>; + try { + res = await fetch(url, { headers }); + } catch (err) { + console.error(`Failed to fetch "${name}" from ${url}`); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + if (res.status === 404) { + console.error(`"${name}" not found in ${REGISTRY_REPO}.`); + if (!token) { + console.error( + " If the registry repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.", + ); + } + process.exit(1); + } + if (res.status === 401 || res.status === 403) { + console.error( + `Access denied (HTTP ${res.status}) fetching "${name}" from ${REGISTRY_REPO}.`, + ); + console.error(" Check that your token has read access to the repository."); + process.exit(1); + } + if (!res.ok) { + console.error(`Registry returned HTTP ${res.status} for "${name}".`); + process.exit(1); + } + + return (await res.json()) as RegistryItem; +} From 9b47c0edb562169ff4b1099c2b749a76b998036c Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 16:44:50 +0200 Subject: [PATCH 07/12] refactor(cli): unify plugin install into 'appkit add'; plugins under server/plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the separate 'appkit plugin add' command. 'appkit add' now detects the item kind (plugin = has manifest.json) and routes: • UI components → /src/components/appkit/ • server plugins → /plugins// (server/ subdir detected) then installs deps, runs plugin sync for plugins, and prints next steps. A single call can mix components and plugins. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/plugin/add/add.ts | 225 --------------- .../shared/src/cli/commands/plugin/index.ts | 3 - .../shared/src/cli/commands/registry/add.ts | 262 +++++++++++++----- 3 files changed, 190 insertions(+), 300 deletions(-) delete mode 100644 packages/shared/src/cli/commands/plugin/add/add.ts diff --git a/packages/shared/src/cli/commands/plugin/add/add.ts b/packages/shared/src/cli/commands/plugin/add/add.ts deleted file mode 100644 index 9e1ef1e20..000000000 --- a/packages/shared/src/cli/commands/plugin/add/add.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import { Command } from "commander"; -import { - fetchRegistryItem, - type RegistryItem, - type RegistryItemFile, - stripNamespace, -} from "../../registry/client"; -import { REGISTRY_REPO, resolveToken } from "../../registry/constants"; - -interface ManifestField { - env?: string; - description?: string; -} -interface ManifestResource { - alias?: string; - fields?: Record; -} -interface PluginManifestShape { - name?: string; - resources?: { required?: ManifestResource[]; optional?: ManifestResource[] }; -} - -/** A registry item is a plugin if it ships a manifest.json. */ -function isPluginItem(item: RegistryItem): boolean { - return (item.files ?? []).some( - (f) => path.basename(f.target ?? f.path) === "manifest.json", - ); -} - -function manifestFile(item: RegistryItem): RegistryItemFile | undefined { - return (item.files ?? []).find( - (f) => path.basename(f.target ?? f.path) === "manifest.json", - ); -} - -/** The directory a plugin's files are rooted at, e.g. `plugins/`. */ -function pluginDir(item: RegistryItem): string { - const mf = manifestFile(item); - const rel = mf?.target ?? mf?.path ?? `plugins/${item.name}/manifest.json`; - return path.dirname(rel); -} - -/** Required env vars declared by the manifest's required resources. */ -function requiredEnvVars(manifest: PluginManifestShape): string[] { - const envs: string[] = []; - for (const res of manifest.resources?.required ?? []) { - 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( - (f) => path.basename(f.target ?? f.path) === "index.ts", - ); - if (!index) return null; - // Scaffolded index.ts: `export { ClassPlugin, exportName } from "./name";` - const match = index.content.match(/export\s*\{([^}]*)\}/); - if (!match) return null; - const names = match[1].split(",").map((s) => s.trim()); - // Prefer the camelCase toPlugin instance (not the PascalCase class). - return names.find((n) => /^[a-z]/.test(n)) ?? names[0] ?? null; -} - -function runSync(repoRoot: string): void { - const selfBin = process.argv[1]; - const result = spawnSync( - process.execPath, - [selfBin, "plugin", "sync", "--write"], - { stdio: "inherit", cwd: repoRoot }, - ); - if (result.status !== 0) { - console.warn( - " Plugin sync did not complete cleanly — run `appkit plugin sync --write` manually.", - ); - } -} - -async function runPluginAdd( - plugins: string[], - opts: { force?: boolean; cwd?: string }, -): Promise { - const repoRoot = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); - const token = resolveToken(); - if (token) { - console.log( - `Using ${token.envName} to fetch from ${REGISTRY_REPO} (private).`, - ); - } - - const names = plugins.map(stripNamespace); - const items: RegistryItem[] = []; - for (const name of names) { - items.push(await fetchRegistryItem(name, token)); - } - - const deps = new Set(); - const summaries: Array<{ - dir: string; - exportName: string | null; - envs: string[]; - }> = []; - - for (const item of items) { - if (!isPluginItem(item)) { - console.error( - `"${item.name}" is not a plugin (no manifest.json). Use \`appkit add ${item.name}\` for UI components.`, - ); - process.exit(1); - } - - for (const dep of item.dependencies ?? []) deps.add(dep); - - let manifest: PluginManifestShape = {}; - for (const file of item.files ?? []) { - // Plugin files carry explicit targets (e.g. plugins//index.ts), - // written verbatim relative to the repo root — never under client/src. - const target = - file.target ?? - path.join("plugins", item.name, path.basename(file.path)); - const dest = path.join(repoRoot, target); - const existed = fs.existsSync(dest); - if (existed && !opts.force) { - console.error( - `Refusing to overwrite ${path.relative(repoRoot, dest)} — pass --force to replace it.`, - ); - process.exit(1); - } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.writeFileSync(dest, file.content); - console.log( - `${existed ? "Updated" : "Created"} ${path.relative(repoRoot, dest)}`, - ); - if (path.basename(target) === "manifest.json") { - manifest = JSON.parse(file.content) as PluginManifestShape; - } - } - - summaries.push({ - dir: pluginDir(item), - exportName: pluginExportName(item), - envs: requiredEnvVars(manifest), - }); - } - - if (deps.size > 0) { - installDependencies([...deps], repoRoot); - } - - console.log("\nRegistering plugins (appkit plugin sync)..."); - runSync(repoRoot); - - // Print the remaining manual wiring. - console.log("\nNext steps:"); - for (const s of summaries) { - const imp = s.exportName ?? ""; - console.log(`\n • ${s.dir}`); - console.log( - ` Register it in your server's createApp call:\n` + - ` import { ${imp} } from "./${s.dir}";\n` + - ` const app = await createApp({ plugins: [${imp}, /* ... */] });`, - ); - if (s.envs.length > 0) { - console.log(` Set required env var(s): ${s.envs.join(", ")}`); - } - } -} - -function detectPackageManager(cwd: string): "pnpm" | "yarn" | "bun" | "npm" { - if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"; - if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn"; - if (fs.existsSync(path.join(cwd, "bun.lockb"))) return "bun"; - return "npm"; -} - -function installDependencies(deps: string[], cwd: string): void { - if (!fs.existsSync(path.join(cwd, "package.json"))) { - console.warn( - `No package.json found — install these manually: ${deps.join(" ")}`, - ); - return; - } - const pm = detectPackageManager(cwd); - const subcommand = pm === "npm" ? "install" : "add"; - console.log(`\nInstalling dependencies with ${pm}: ${deps.join(" ")}`); - const result = spawnSync(pm, [subcommand, ...deps], { - stdio: "inherit", - cwd, - }); - if (result.status !== 0) { - console.warn( - `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed.`, - ); - } -} - -export const pluginAddCommand = new Command("add") - .description("Add a plugin from the AppKit registry") - .argument("", "Plugin name(s) from the registry") - .option("-f, --force", "Overwrite existing files") - .option("-C, --cwd ", "Run as if started in ") - .addHelpText( - "after", - ` -Fetches a plugin from the registry, writes it under plugins//, installs -npm dependencies, runs \`plugin sync\`, then prints the server-registration -snippet and any required env vars. - -Examples: - $ appkit plugin add hello - $ appkit plugin add @appkit/hello`, - ) - .action((plugins: string[], opts: { force?: boolean; cwd?: string }) => - runPluginAdd(plugins, opts).catch((err) => { - console.error(err); - process.exit(1); - }), - ); diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index 6ec88327a..7b5a96acc 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -1,5 +1,4 @@ import { Command } from "commander"; -import { pluginAddCommand } from "./add/add"; import { pluginAddResourceCommand } from "./add-resource/add-resource"; import { pluginCreateCommand } from "./create/create"; import { pluginListCommand } from "./list/list"; @@ -21,7 +20,6 @@ export const pluginCommand = new Command("plugin") .description("Plugin management commands") .addCommand(pluginsSyncCommand) .addCommand(pluginCreateCommand) - .addCommand(pluginAddCommand) .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) .addCommand(pluginAddResourceCommand) @@ -32,7 +30,6 @@ export const pluginCommand = new Command("plugin") Examples: $ appkit plugin sync --write $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" - $ appkit plugin add hello $ appkit plugin validate . $ appkit plugin list --json $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index a3621264c..cae014d71 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -11,15 +11,36 @@ import { } from "./client"; import { REGISTRY_REPO, resolveToken } from "./constants"; -/** Subdirectories that commonly hold the frontend in an AppKit app layout. */ +/** 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; +} +interface PluginManifestShape { + name?: string; + resources?: { required?: ManifestResource[] }; +} + +function isDir(p: string): boolean { + return fs.existsSync(p) && fs.statSync(p).isDirectory(); +} + +/** A registry item is a server plugin if it ships a manifest.json. */ +function isPluginItem(item: RegistryItem): boolean { + return (item.files ?? []).some( + (f) => path.basename(f.target ?? f.path) === "manifest.json", + ); +} /** - * Locates the frontend root to write components into. AppKit apps put the - * client in a `client/` subdir (with its own components.json + src/), and the - * CLI is typically run from the repo root. We prefer the dir containing - * components.json, then a dir with a src/, checking the cwd and common - * subdirs before falling back to cwd. + * Locates the frontend root for UI components. AppKit apps put the client in a + * client/ subdir (with its own components.json + src/); the CLI is typically + * run from the repo root. Prefer the dir with components.json, then a src/. */ function findFrontendRoot(cwd: string): string { if (fs.existsSync(path.join(cwd, "components.json"))) return cwd; @@ -28,44 +49,63 @@ function findFrontendRoot(cwd: string): string { return path.join(cwd, sub); } } - if (fs.existsSync(path.join(cwd, "src"))) return cwd; + if (isDir(path.join(cwd, "src"))) return cwd; for (const sub of FRONTEND_SUBDIRS) { - if (fs.existsSync(path.join(cwd, sub, "src"))) { - return path.join(cwd, sub); - } + if (isDir(path.join(cwd, sub, "src"))) return path.join(cwd, sub); } return cwd; } -/** - * Finds the dir to install npm deps into: the nearest package.json walking up - * from the frontend root to the cwd (a monorepo-style app has a single root - * package.json while the client lives in client/). - */ -function findInstallDir(base: string, cwd: string): string { - let dir = base; +/** Locates the server root for plugins (the server/ subdir, else cwd). */ +function findServerRoot(cwd: string): string { + for (const sub of SERVER_SUBDIRS) { + if (isDir(path.join(cwd, sub))) return path.join(cwd, sub); + } + return cwd; +} + +/** Nearest dir with a package.json, walking up from start (for dep install). */ +function findNearestPackageJson(start: string): string { + let dir = start; for (;;) { if (fs.existsSync(path.join(dir, "package.json"))) return dir; - if (dir === cwd) break; const parent = path.dirname(dir); - if (parent === dir) break; + if (parent === dir) return start; dir = parent; } - return cwd; } -/** - * Resolves where a registry file is written, relative to the frontend root. - * Uses the item's `target`, placing it under `src/` when that root has one. - */ -function resolveTarget(base: string, file: RegistryItemFile): string { +/** UI file destination: target under the frontend root, placed in src/ if present. */ +function resolveUiTarget(base: string, file: RegistryItemFile): string { let target = file.target ?? path.join("components", path.basename(file.path)); - if (!target.startsWith("src/") && fs.existsSync(path.join(base, "src"))) { + if (!target.startsWith("src/") && isDir(path.join(base, "src"))) { target = path.join("src", target); } return path.join(base, target); } +function requiredEnvVars(manifest: PluginManifestShape): string[] { + const envs: string[] = []; + for (const res of manifest.resources?.required ?? []) { + 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( + (f) => path.basename(f.target ?? f.path) === "index.ts", + ); + const match = index?.content.match(/export\s*\{([^}]*)\}/); + if (!match) return null; + const names = match[1].split(",").map((s) => s.trim()); + // Prefer the camelCase toPlugin instance over the PascalCase class. + return names.find((n) => /^[a-z]/.test(n)) ?? names[0] ?? null; +} + function detectPackageManager(cwd: string): "pnpm" | "yarn" | "bun" | "npm" { if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"; if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn"; @@ -95,16 +135,49 @@ function installDependencies(deps: string[], cwd: string): void { } } +/** Runs `appkit plugin sync --write` via this same CLI binary. */ +function runPluginSync(cwd: string): void { + const result = spawnSync( + process.execPath, + [process.argv[1], "plugin", "sync", "--write"], + { stdio: "inherit", cwd }, + ); + if (result.status !== 0) { + console.warn( + " Plugin sync did not complete cleanly — run `appkit plugin sync --write` manually.", + ); + } +} + +function writeItemFile( + dest: string, + content: string, + force: boolean, + cwd: string, +): void { + const existed = fs.existsSync(dest); + if (existed && !force) { + console.error( + `Refusing to overwrite ${path.relative(cwd, dest)} — pass --force to replace it.`, + ); + process.exit(1); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, content); + console.log(`${existed ? "Updated" : "Created"} ${path.relative(cwd, dest)}`); +} + +interface PluginSummary { + importPath: string; + exportName: string | null; + envs: string[]; +} + async function runAdd( - components: string[], + refs: string[], opts: { force?: boolean; cwd?: string }, ): Promise { const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); - const base = findFrontendRoot(cwd); - if (base !== cwd) { - console.log(`Detected frontend root: ${path.relative(cwd, base)}/`); - } - const token = resolveToken(); if (token) { console.log( @@ -112,77 +185,122 @@ async function runAdd( ); } - const names = components.map(stripNamespace); + const names = refs.map(stripNamespace); const items: RegistryItem[] = []; for (const name of names) { items.push(await fetchRegistryItem(name, token)); } + const hasUi = items.some((i) => !isPluginItem(i)); + const hasPlugin = items.some(isPluginItem); + const frontendRoot = hasUi ? findFrontendRoot(cwd) : cwd; + const serverRoot = hasPlugin ? findServerRoot(cwd) : cwd; + if (hasUi && frontendRoot !== cwd) { + console.log(`UI components → ${path.relative(cwd, frontendRoot)}/`); + } + if (hasPlugin && serverRoot !== cwd) { + console.log(`Plugins → ${path.relative(cwd, serverRoot)}/`); + } + const deps = new Set(); - const written: string[] = []; + let wroteUi = false; + const pluginSummaries: PluginSummary[] = []; for (const item of items) { for (const dep of item.dependencies ?? []) deps.add(dep); - // AppKit (Option A) items have no registry dependencies; warn rather than - // silently dropping any a future item might declare. - for (const rd of item.registryDependencies ?? []) { - console.warn( - ` Note: "${item.name}" declares registryDependency "${rd}" — add it separately if it isn't already present.`, - ); - } - - for (const file of item.files ?? []) { - const dest = resolveTarget(base, file); - const existed = fs.existsSync(dest); - if (existed && !opts.force) { - console.error( - `Refusing to overwrite ${path.relative(cwd, dest)} — pass --force to replace it.`, + if (isPluginItem(item)) { + let manifest: PluginManifestShape = {}; + let pluginRel = path.join("plugins", item.name); + for (const file of item.files ?? []) { + const target = + file.target ?? + path.join("plugins", item.name, path.basename(file.path)); + writeItemFile( + path.join(serverRoot, target), + file.content, + Boolean(opts.force), + cwd, ); - process.exit(1); + if (path.basename(target) === "manifest.json") { + manifest = JSON.parse(file.content) as PluginManifestShape; + pluginRel = path.dirname(target); + } + } + pluginSummaries.push({ + importPath: `./${pluginRel}`, + exportName: pluginExportName(item), + envs: requiredEnvVars(manifest), + }); + } else { + for (const file of item.files ?? []) { + // UI (Option A) items have no registry deps; warn on any a future item adds. + for (const rd of item.registryDependencies ?? []) { + console.warn( + ` Note: "${item.name}" declares registryDependency "${rd}" — add it separately if needed.`, + ); + } + writeItemFile( + resolveUiTarget(frontendRoot, file), + file.content, + Boolean(opts.force), + cwd, + ); + wroteUi = true; } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.writeFileSync(dest, file.content); - written.push(path.relative(cwd, dest)); - console.log( - `${existed ? "Updated" : "Created"} ${path.relative(cwd, dest)}`, - ); } } - installDependencies([...deps], findInstallDir(base, cwd)); + installDependencies([...deps], findNearestPackageJson(cwd)); - if (written.length > 0) { + if (hasPlugin) { + console.log("\nRegistering plugins (appkit plugin sync)..."); + runPluginSync(cwd); + } + + if (wroteUi) { console.log( - '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so the component is themed.', + '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so components are themed.', ); } + if (pluginSummaries.length > 0) { + console.log("\nNext steps — register in your server's createApp call:"); + for (const s of pluginSummaries) { + const imp = s.exportName ?? ""; + console.log( + `\n import { ${imp} } from "${s.importPath}";\n` + + ` const app = await createApp({ plugins: [${imp}, /* ... */] });`, + ); + if (s.envs.length > 0) { + console.log(` Required env var(s): ${s.envs.join(", ")}`); + } + } + } } export const addCommand = new Command("add") - .description("Add an AppKit registry component to your project") - .argument("", "Component name(s), e.g. metric-card") + .description("Add a UI component or server plugin from the AppKit registry") + .argument("", "Registry item name(s), e.g. metric-card or hello") .option("-f, --force", "Overwrite existing files") .option("-C, --cwd ", "Run as if started in ") .addHelpText( "after", ` -No components.json is required. The frontend root is detected automatically -(a client/ subdir or a dir with components.json / src/), so you can run this -from the repo root. Files land under /src/; npm dependencies -install into the nearest package.json. +No components.json is required. Item type is detected automatically: + • UI components → /src/components/appkit/ (client/ detected) + • Server plugins → /plugins// + plugin sync + register snippet -While the registry repo is private, a token with read access is used. It is -resolved automatically from \`gh auth token\` (if you're logged in with the -GitHub CLI), or from APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. +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 - $ appkit add metric-card data-table - $ appkit add @appkit/metric-card`, + $ appkit add metric-card # UI component + $ appkit add hello # server plugin + $ appkit add metric-card hello # mix in one call`, ) - .action((components: string[], opts: { force?: boolean; cwd?: string }) => - runAdd(components, opts).catch((err) => { + .action((items: string[], opts: { force?: boolean; cwd?: string }) => + runAdd(items, opts).catch((err) => { console.error(err); process.exit(1); }), From 0294f31301a3f7155cbe96d933b8124b7ade3673 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 16:55:46 +0200 Subject: [PATCH 08/12] feat(cli): auto-register added plugins in the server createApp call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After placing a plugin and running sync, 'appkit add' edits the server entry (server/index.ts etc.) to add the import and insert the plugin into the createApp({ plugins: [...] }) array, reusing the ast-grep machinery from 'plugin sync'. Best-effort and safe: • idempotent — skips if the plugin is already registered • only edits the standard plugins:[...] array literal; otherwise falls back to printing the manual snippet • --no-register opts out of the server edit Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 50 +++++--- .../cli/commands/registry/server-register.ts | 117 ++++++++++++++++++ 2 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/cli/commands/registry/server-register.ts diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index cae014d71..9c56aba92 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -10,6 +10,7 @@ import { stripNamespace, } from "./client"; import { REGISTRY_REPO, resolveToken } from "./constants"; +import { registerPluginInServer } from "./server-register"; /** Subdirectories that commonly hold the frontend / server in an AppKit app. */ const FRONTEND_SUBDIRS = ["client", "frontend", "web", "app"]; @@ -175,7 +176,7 @@ interface PluginSummary { async function runAdd( refs: string[], - opts: { force?: boolean; cwd?: string }, + opts: { force?: boolean; cwd?: string; register?: boolean }, ): Promise { const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd(); const token = resolveToken(); @@ -263,17 +264,32 @@ async function runAdd( '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so components are themed.', ); } - if (pluginSummaries.length > 0) { - console.log("\nNext steps — register in your server's createApp call:"); - for (const s of pluginSummaries) { + for (const s of pluginSummaries) { + // Try to wire the plugin into the server's createApp call automatically; + // fall back to printing the snippet when the shape isn't the standard one. + let wired = false; + if (opts.register !== false && s.exportName) { + const result = registerPluginInServer(cwd, s.importPath, s.exportName); + if (result.status === "wired") { + console.log(`\nRegistered ${s.exportName} in ${result.file}`); + wired = true; + } else if (result.status === "already") { + console.log( + `\n${s.exportName} is already registered in ${result.file}`, + ); + wired = true; + } + } + if (!wired) { const imp = s.exportName ?? ""; console.log( - `\n import { ${imp} } from "${s.importPath}";\n` + + "\nAdd this to your server's createApp call:\n" + + ` import { ${imp} } from "${s.importPath}";\n` + ` const app = await createApp({ plugins: [${imp}, /* ... */] });`, ); - if (s.envs.length > 0) { - console.log(` Required env var(s): ${s.envs.join(", ")}`); - } + } + if (s.envs.length > 0) { + console.log(` Required env var(s): ${s.envs.join(", ")}`); } } } @@ -283,12 +299,14 @@ export const addCommand = new Command("add") .argument("", "Registry item name(s), e.g. metric-card or hello") .option("-f, --force", "Overwrite existing files") .option("-C, --cwd ", "Run as if started in ") + .option("--no-register", "Don't edit the server entry to register plugins") .addHelpText( "after", ` No components.json is required. Item type is detected automatically: • UI components → /src/components/appkit/ (client/ detected) - • Server plugins → /plugins// + plugin sync + register snippet + • Server plugins → /plugins//, runs plugin sync, and registers + them in your createApp call (use --no-register to skip the server edit) 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 @@ -299,9 +317,13 @@ Examples: $ appkit add hello # server plugin $ appkit add metric-card hello # mix in one call`, ) - .action((items: string[], opts: { force?: boolean; cwd?: string }) => - runAdd(items, opts).catch((err) => { - console.error(err); - process.exit(1); - }), + .action( + ( + items: string[], + opts: { force?: boolean; cwd?: string; register?: boolean }, + ) => + runAdd(items, opts).catch((err) => { + console.error(err); + process.exit(1); + }), ); diff --git a/packages/shared/src/cli/commands/registry/server-register.ts b/packages/shared/src/cli/commands/registry/server-register.ts new file mode 100644 index 000000000..348f7c4ae --- /dev/null +++ b/packages/shared/src/cli/commands/registry/server-register.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Lang, parse, type SgNode } from "@ast-grep/napi"; + +/** Server entry candidates, relative to the repo/server root, in priority order. */ +const SERVER_FILE_CANDIDATES = [ + "server/server.ts", + "server/index.ts", + "server.ts", + "index.ts", + "src/server.ts", + "src/index.ts", +]; + +export interface RegisterResult { + /** wired = edited; already = plugin was present; skipped = couldn't safely edit. */ + status: "wired" | "already" | "skipped"; + file?: string; + reason?: string; +} + +function findServerFile(repoRoot: string): string | null { + for (const candidate of SERVER_FILE_CANDIDATES) { + const p = path.join(repoRoot, candidate); + if (fs.existsSync(p)) return p; + } + return null; +} + +/** The `plugins: [...]` array node inside a createApp call, if present. */ +function findPluginsArray(root: SgNode): SgNode | null { + for (const pair of root.findAll({ rule: { kind: "pair" } })) { + const key = pair.find({ rule: { kind: "property_identifier" } }); + if (key?.text() !== "plugins") continue; + const arr = pair.find({ rule: { kind: "array" } }); + if (arr) return arr; + } + return null; +} + +function arrayElementNames(arr: SgNode): Set { + const names = new Set(); + for (const child of arr.children()) { + if (child.kind() === "identifier") { + names.add(child.text()); + } else if (child.kind() === "call_expression") { + const callee = child.children()[0]; + if (callee?.kind() === "identifier") names.add(callee.text()); + } + } + return names; +} + +/** + * Best-effort: register a plugin in the server entry's `createApp({ plugins })` + * call by inserting the import and adding it to the array. Only edits the + * standard shape (a `plugins: [...]` array literal); returns `skipped` otherwise + * so the caller can fall back to printing manual instructions. Idempotent. + */ +export function registerPluginInServer( + repoRoot: string, + importPath: string, + exportName: string, +): RegisterResult { + const serverFile = findServerFile(repoRoot); + if (!serverFile) { + return { status: "skipped", reason: "no server entry file found" }; + } + + const content = fs.readFileSync(serverFile, "utf-8"); + const lang = serverFile.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript; + const root = parse(lang, content).root(); + + const arr = findPluginsArray(root); + if (!arr) { + return { + status: "skipped", + reason: "no createApp({ plugins: [...] }) array found", + }; + } + + const file = path.relative(repoRoot, serverFile); + if (arrayElementNames(arr).has(exportName)) { + return { status: "already", file }; + } + + const edits = []; + + // Insert the plugin right after the array's opening bracket. + const arrText = arr.text(); + const inner = arrText.slice(1, -1).trim(); + const newArr = + inner.length === 0 + ? `[${exportName}]` + : `[${exportName}, ${arrText.slice(1)}`; + edits.push(arr.replace(newArr)); + + // Add the import unless one from the same path already exists. + const importStmts = root.findAll({ rule: { kind: "import_statement" } }); + const hasImport = importStmts.some((s) => { + const src = s.find({ rule: { kind: "string" } }); + return src?.text().replace(/^['"]|['"]$/g, "") === importPath; + }); + const importLine = `import { ${exportName} } from "${importPath}";`; + if (!hasImport && importStmts.length > 0) { + const last = importStmts[importStmts.length - 1]; + edits.push(last.replace(`${last.text()}\n${importLine}`)); + } + + let output = root.commitEdits(edits); + if (!hasImport && importStmts.length === 0) { + output = `${importLine}\n${output}`; + } + fs.writeFileSync(serverFile, output); + + return { status: "wired", file }; +} From c607d2ab4175d178ac58ed9d744d1e86a0441301 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 17:08:33 +0200 Subject: [PATCH 09/12] fix(cli): register plugin as factory call with correct indentation toPlugin exports are factories, so register as `hello()` (matching server(), analytics()), not a bare identifier. Insert before the first array element with its indentation so multi-line plugins arrays keep their formatting instead of jamming onto the opening-bracket line. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/add.ts | 2 +- .../cli/commands/registry/server-register.ts | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 9c56aba92..fb2551f97 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -285,7 +285,7 @@ async function runAdd( console.log( "\nAdd this to your server's createApp call:\n" + ` import { ${imp} } from "${s.importPath}";\n` + - ` const app = await createApp({ plugins: [${imp}, /* ... */] });`, + ` const app = await createApp({ plugins: [${imp}(), /* ... */] });`, ); } if (s.envs.length > 0) { diff --git a/packages/shared/src/cli/commands/registry/server-register.ts b/packages/shared/src/cli/commands/registry/server-register.ts index 348f7c4ae..ef1977765 100644 --- a/packages/shared/src/cli/commands/registry/server-register.ts +++ b/packages/shared/src/cli/commands/registry/server-register.ts @@ -86,14 +86,25 @@ export function registerPluginInServer( const edits = []; - // Insert the plugin right after the array's opening bracket. - const arrText = arr.text(); - const inner = arrText.slice(1, -1).trim(); - const newArr = - inner.length === 0 - ? `[${exportName}]` - : `[${exportName}, ${arrText.slice(1)}`; - edits.push(arr.replace(newArr)); + // toPlugin exports are factories, registered as a call: `hello()`. + const newElem = `${exportName}()`; + + // Insert before the first element, matching its indentation so the array + // formatting is preserved (or inline for a single-line array). + const elementKinds = ["identifier", "call_expression", "spread_element"]; + const firstEl = arr + .children() + .find((c) => elementKinds.includes(c.kind() as string)); + if (!firstEl) { + edits.push(arr.replace(`[${newElem}]`)); + } else { + const startIdx = firstEl.range().start.index; + const lineStart = content.lastIndexOf("\n", startIdx - 1); + const indent = content.slice(lineStart + 1, startIdx); + const multiline = lineStart !== -1 && /^[ \t]*$/.test(indent); + const sep = multiline ? `,\n${indent}` : ", "; + edits.push(firstEl.replace(`${newElem}${sep}${firstEl.text()}`)); + } // Add the import unless one from the same path already exists. const importStmts = root.findAll({ rule: { kind: "import_statement" } }); From cb064bfdb901b152cdc6bb2c874a29c964ee917a Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 18:10:51 +0200 Subject: [PATCH 10/12] feat(cli): verified-item marker + colorized registry output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'appkit registry list' shows a VERIFIED ✓ column (from item meta.verified) with a --verified filter; JSON output gains a top-level verified field. - Colorize list + add output with picocolors (green created/registered/✓, yellow updated/warnings, red errors, dim hints). Padding is applied before coloring so columns stay aligned; picocolors no-ops on non-TTY/NO_COLOR. Signed-off-by: MarioCadenas --- packages/shared/package.json | 3 +- .../shared/src/cli/commands/registry/add.ts | 48 +++++++++++------ .../shared/src/cli/commands/registry/list.ts | 54 ++++++++++++++----- pnpm-lock.yaml | 3 ++ 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 00aafed93..26b147db7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -37,9 +37,10 @@ }, "dependencies": { "@ast-grep/napi": "0.37.0", - "@standard-schema/spec": "1.1.0", "@clack/prompts": "1.0.1", + "@standard-schema/spec": "1.1.0", "commander": "12.1.0", + "picocolors": "1.1.1", "zod": "4.3.6" } } diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index fb2551f97..8361891a8 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { Command } from "commander"; +import pc from "picocolors"; import { fetchRegistryItem, type RegistryItem, @@ -118,7 +119,9 @@ function installDependencies(deps: string[], cwd: string): void { if (deps.length === 0) return; if (!fs.existsSync(path.join(cwd, "package.json"))) { console.warn( - `No package.json found — install these manually: ${deps.join(" ")}`, + pc.yellow( + `No package.json found — install these manually: ${deps.join(" ")}`, + ), ); return; } @@ -131,7 +134,9 @@ function installDependencies(deps: string[], cwd: string): void { }); if (result.status !== 0) { console.warn( - `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed: ${deps.join(" ")}`, + pc.yellow( + `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed: ${deps.join(" ")}`, + ), ); } } @@ -145,7 +150,9 @@ function runPluginSync(cwd: string): void { ); if (result.status !== 0) { console.warn( - " Plugin sync did not complete cleanly — run `appkit plugin sync --write` manually.", + pc.yellow( + " Plugin sync did not complete cleanly — run `appkit plugin sync --write` manually.", + ), ); } } @@ -159,13 +166,16 @@ function writeItemFile( const existed = fs.existsSync(dest); if (existed && !force) { console.error( - `Refusing to overwrite ${path.relative(cwd, dest)} — pass --force to replace it.`, + pc.red( + `Refusing to overwrite ${path.relative(cwd, dest)} — pass --force to replace it.`, + ), ); process.exit(1); } fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, content); - console.log(`${existed ? "Updated" : "Created"} ${path.relative(cwd, dest)}`); + const label = existed ? pc.yellow("Updated") : pc.green("Created"); + console.log(`${label} ${path.relative(cwd, dest)}`); } interface PluginSummary { @@ -197,10 +207,10 @@ async function runAdd( const frontendRoot = hasUi ? findFrontendRoot(cwd) : cwd; const serverRoot = hasPlugin ? findServerRoot(cwd) : cwd; if (hasUi && frontendRoot !== cwd) { - console.log(`UI components → ${path.relative(cwd, frontendRoot)}/`); + console.log(pc.dim(`UI components → ${path.relative(cwd, frontendRoot)}/`)); } if (hasPlugin && serverRoot !== cwd) { - console.log(`Plugins → ${path.relative(cwd, serverRoot)}/`); + console.log(pc.dim(`Plugins → ${path.relative(cwd, serverRoot)}/`)); } const deps = new Set(); @@ -255,13 +265,15 @@ async function runAdd( installDependencies([...deps], findNearestPackageJson(cwd)); if (hasPlugin) { - console.log("\nRegistering plugins (appkit plugin sync)..."); + console.log(pc.dim("\nRegistering plugins (appkit plugin sync)...")); runPluginSync(cwd); } if (wroteUi) { console.log( - '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so components are themed.', + pc.dim( + '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so components are themed.', + ), ); } for (const s of pluginSummaries) { @@ -271,11 +283,13 @@ async function runAdd( if (opts.register !== false && s.exportName) { const result = registerPluginInServer(cwd, s.importPath, s.exportName); if (result.status === "wired") { - console.log(`\nRegistered ${s.exportName} in ${result.file}`); + console.log( + `\n${pc.green("Registered")} ${s.exportName} in ${result.file}`, + ); wired = true; } else if (result.status === "already") { console.log( - `\n${s.exportName} is already registered in ${result.file}`, + pc.dim(`\n${s.exportName} is already registered in ${result.file}`), ); wired = true; } @@ -283,13 +297,17 @@ async function runAdd( if (!wired) { const imp = s.exportName ?? ""; console.log( - "\nAdd this to your server's createApp call:\n" + - ` import { ${imp} } from "${s.importPath}";\n` + - ` const app = await createApp({ plugins: [${imp}(), /* ... */] });`, + `\n${pc.bold("Add this to your server's createApp call:")}\n` + + pc.dim( + ` import { ${imp} } from "${s.importPath}";\n` + + ` const app = await createApp({ plugins: [${imp}(), /* ... */] });`, + ), ); } if (s.envs.length > 0) { - console.log(` Required env var(s): ${s.envs.join(", ")}`); + console.log( + ` ${pc.yellow("Required env var(s):")} ${s.envs.join(", ")}`, + ); } } } diff --git a/packages/shared/src/cli/commands/registry/list.ts b/packages/shared/src/cli/commands/registry/list.ts index 4ddd0a227..0157e2d6b 100644 --- a/packages/shared/src/cli/commands/registry/list.ts +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -1,5 +1,6 @@ import process from "node:process"; import { Command } from "commander"; +import pc from "picocolors"; import { REGISTRY_INDEX_API_URL, REGISTRY_INDEX_URL, @@ -11,25 +12,39 @@ interface RegistryIndexItem { name: string; title?: string; description?: string; + meta?: { verified?: boolean }; +} + +function isVerified(item: RegistryIndexItem): boolean { + return item.meta?.verified === true; } function printTable(items: RegistryIndexItem[]): void { if (items.length === 0) { - console.log("No components found in the registry."); + console.log(pc.dim("No items found in the registry.")); return; } const maxName = Math.max(4, ...items.map((i) => i.name.length)); - const header = `${"NAME".padEnd(maxName)} DESCRIPTION`; - console.log(header); - console.log("-".repeat(header.length)); + const verifiedCol = "VERIFIED"; + // Pad plain text before coloring so ANSI codes don't break alignment. + const header = `${"NAME".padEnd(maxName)} ${verifiedCol} DESCRIPTION`; + console.log(pc.bold(header)); + console.log(pc.dim("─".repeat(header.length))); for (const item of items) { - console.log( - `${item.name.padEnd(maxName)} ${item.description ?? item.title ?? ""}`, - ); + const verified = isVerified(item); + const name = pc.cyan(item.name.padEnd(maxName)); + const mark = verified + ? pc.green("✓".padEnd(verifiedCol.length)) + : " ".repeat(verifiedCol.length); + const desc = item.description ?? item.title ?? ""; + console.log(`${name} ${mark} ${verified ? desc : pc.dim(desc)}`); } } -async function runList(opts: { json?: boolean }): Promise { +async function runList(opts: { + json?: boolean; + verified?: boolean; +}): Promise { const token = resolveToken(); const url = token ? REGISTRY_INDEX_API_URL : REGISTRY_INDEX_URL; const headers: Record = {}; @@ -48,7 +63,9 @@ async function runList(opts: { json?: boolean }): Promise { } if (res.status === 404 || res.status === 401 || res.status === 403) { console.error( - `Could not read the registry index from ${REGISTRY_REPO} (HTTP ${res.status}).`, + pc.red( + `Could not read the registry index from ${REGISTRY_REPO} (HTTP ${res.status}).`, + ), ); if (!token) { console.error( @@ -63,19 +80,30 @@ async function runList(opts: { json?: boolean }): Promise { } const data = (await res.json()) as { items?: RegistryIndexItem[] }; - const items = data.items ?? []; + let items = data.items ?? []; + if (opts.verified) { + items = items.filter(isVerified); + } if (opts.json) { - console.log(JSON.stringify(items, null, 2)); + // Surface `verified` as a top-level field for easy scripting. + console.log( + JSON.stringify( + items.map((i) => ({ ...i, verified: isVerified(i) })), + null, + 2, + ), + ); } else { printTable(items); } } export const registryListCommand = new Command("list") - .description("List components available in the AppKit registry") + .description("List items available in the AppKit registry") .option("--json", "Output as JSON") - .action((opts: { json?: boolean }) => + .option("--verified", "Show only verified items") + .action((opts: { json?: boolean; verified?: boolean }) => runList(opts).catch((err) => { console.error(err); process.exit(1); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d08e5808..6fc267498 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -557,6 +557,9 @@ importers: commander: specifier: 12.1.0 version: 12.1.0 + picocolors: + specifier: 1.1.1 + version: 1.1.1 zod: specifier: 4.3.6 version: 4.3.6 From 23beb55cd6147f58319586ba922035cd93e29348 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 18:28:03 +0200 Subject: [PATCH 11/12] feat(cli): show item TYPE (component/plugin/hook/...) in registry list Derive a friendly kind per item: a manifest.json marks a plugin; otherwise map the shadcn registry:* type (component/hook/lib/theme/...). Adds a colorized TYPE column to the table and a 'kind' field to --json output. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/list.ts | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/list.ts b/packages/shared/src/cli/commands/registry/list.ts index 0157e2d6b..f887734e3 100644 --- a/packages/shared/src/cli/commands/registry/list.ts +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -10,34 +10,77 @@ import { interface RegistryIndexItem { name: string; + type?: string; title?: string; description?: string; meta?: { verified?: boolean }; + files?: Array<{ path?: string; target?: string }>; } function isVerified(item: RegistryIndexItem): boolean { return item.meta?.verified === true; } +/** A friendly kind for the TYPE column: plugin, component, hook, theme, … */ +function itemKind(item: RegistryIndexItem): string { + const hasManifest = (item.files ?? []).some( + (f) => + f.target?.endsWith("manifest.json") || f.path?.endsWith("manifest.json"), + ); + if (hasManifest) return "plugin"; + switch (item.type) { + case "registry:component": + case "registry:block": + return "component"; + case "registry:hook": + return "hook"; + case "registry:lib": + return "lib"; + case "registry:theme": + return "theme"; + case "registry:ui": + return "ui"; + case "registry:page": + return "page"; + default: + return item.type?.replace(/^registry:/, "") || "item"; + } +} + +const KIND_COLOR: Record string> = { + plugin: pc.magenta, + component: pc.blue, + hook: pc.cyan, + theme: pc.yellow, + lib: pc.green, +}; + function printTable(items: RegistryIndexItem[]): void { if (items.length === 0) { console.log(pc.dim("No items found in the registry.")); return; } + const kinds = items.map(itemKind); const maxName = Math.max(4, ...items.map((i) => i.name.length)); + const maxKind = Math.max(4, ...kinds.map((k) => k.length)); const verifiedCol = "VERIFIED"; // Pad plain text before coloring so ANSI codes don't break alignment. - const header = `${"NAME".padEnd(maxName)} ${verifiedCol} DESCRIPTION`; + const header = `${"NAME".padEnd(maxName)} ${"TYPE".padEnd(maxKind)} ${verifiedCol} DESCRIPTION`; console.log(pc.bold(header)); console.log(pc.dim("─".repeat(header.length))); - for (const item of items) { + for (const [i, item] of items.entries()) { const verified = isVerified(item); + const kind = kinds[i]; + const colorKind = KIND_COLOR[kind] ?? pc.white; const name = pc.cyan(item.name.padEnd(maxName)); + const kindCell = colorKind(kind.padEnd(maxKind)); const mark = verified ? pc.green("✓".padEnd(verifiedCol.length)) : " ".repeat(verifiedCol.length); const desc = item.description ?? item.title ?? ""; - console.log(`${name} ${mark} ${verified ? desc : pc.dim(desc)}`); + console.log( + `${name} ${kindCell} ${mark} ${verified ? desc : pc.dim(desc)}`, + ); } } @@ -86,10 +129,14 @@ async function runList(opts: { } if (opts.json) { - // Surface `verified` as a top-level field for easy scripting. + // Surface `kind` + `verified` as top-level fields for easy scripting. console.log( JSON.stringify( - items.map((i) => ({ ...i, verified: isVerified(i) })), + items.map((i) => ({ + ...i, + kind: itemKind(i), + verified: isVerified(i), + })), null, 2, ), From 8713a9de9fde646f0d2681765d15af81d63a6ef0 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 25 Jun 2026 18:53:51 +0200 Subject: [PATCH 12/12] feat(cli): add 'appkit registry search' over name/desc/type/keywords Adds a search subcommand so agents (and people) can match intent to items: matches all query terms against name, title, description, derived kind, and item categories. Shares the token-aware index fetch + table/JSON rendering with 'registry list'. --verified and --json supported. Signed-off-by: MarioCadenas --- .../shared/src/cli/commands/registry/index.ts | 10 ++- .../shared/src/cli/commands/registry/list.ts | 84 ++++++++++++++++--- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/index.ts b/packages/shared/src/cli/commands/registry/index.ts index 964fe97aa..8e4289758 100644 --- a/packages/shared/src/cli/commands/registry/index.ts +++ b/packages/shared/src/cli/commands/registry/index.ts @@ -1,22 +1,24 @@ import { Command } from "commander"; -import { registryListCommand } from "./list"; +import { registryListCommand, registrySearchCommand } from "./list"; /** * Parent command for AppKit component registry operations. * Subcommands: - * - list: Enumerate components available in the registry + * - list: Enumerate items available in the registry + * - search: Find items by name, description, type, or keyword * - * Note: `appkit add ` is exposed as a top-level command (see add.ts) + * Note: `appkit add ` is exposed as a top-level command (see add.ts) * since it is the primary entry point for consumers. */ export const registryCommand = new Command("registry") .description("AppKit component registry commands") .addCommand(registryListCommand) + .addCommand(registrySearchCommand) .addHelpText( "after", ` Examples: $ appkit registry list - $ appkit registry list --json + $ appkit registry search kpi dashboard $ appkit add metric-card`, ); diff --git a/packages/shared/src/cli/commands/registry/list.ts b/packages/shared/src/cli/commands/registry/list.ts index f887734e3..4bf72cac3 100644 --- a/packages/shared/src/cli/commands/registry/list.ts +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -13,6 +13,7 @@ interface RegistryIndexItem { type?: string; title?: string; description?: string; + categories?: string[]; meta?: { verified?: boolean }; files?: Array<{ path?: string; target?: string }>; } @@ -21,6 +22,29 @@ function isVerified(item: RegistryIndexItem): boolean { return item.meta?.verified === true; } +/** Free-text haystack for search matching. */ +function searchHaystack(item: RegistryIndexItem): string { + return [ + item.name, + item.title ?? "", + item.description ?? "", + itemKind(item), + ...(item.categories ?? []), + ] + .join(" ") + .toLowerCase(); +} + +/** True if every whitespace-separated term in `query` appears in the item. */ +function matchesQuery(item: RegistryIndexItem, query: string): boolean { + const haystack = searchHaystack(item); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((term) => haystack.includes(term)); +} + /** A friendly kind for the TYPE column: plugin, component, hook, theme, … */ function itemKind(item: RegistryIndexItem): string { const hasManifest = (item.files ?? []).some( @@ -84,10 +108,8 @@ function printTable(items: RegistryIndexItem[]): void { } } -async function runList(opts: { - json?: boolean; - verified?: boolean; -}): Promise { +/** Fetches the registry index (token-aware), or exits with a helpful message. */ +async function fetchIndex(): Promise { const token = resolveToken(); const url = token ? REGISTRY_INDEX_API_URL : REGISTRY_INDEX_URL; const headers: Record = {}; @@ -100,7 +122,7 @@ async function runList(opts: { try { res = await fetch(url, { headers }); } catch (err) { - console.error(`Failed to reach the registry at ${url}`); + console.error(pc.red(`Failed to reach the registry at ${url}`)); console.error(` ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } @@ -118,16 +140,15 @@ async function runList(opts: { process.exit(1); } if (!res.ok) { - console.error(`Registry returned HTTP ${res.status} for ${url}`); + console.error(pc.red(`Registry returned HTTP ${res.status} for ${url}`)); process.exit(1); } const data = (await res.json()) as { items?: RegistryIndexItem[] }; - let items = data.items ?? []; - if (opts.verified) { - items = items.filter(isVerified); - } + return data.items ?? []; +} +function output(items: RegistryIndexItem[], opts: { json?: boolean }): void { if (opts.json) { // Surface `kind` + `verified` as top-level fields for easy scripting. console.log( @@ -146,6 +167,29 @@ async function runList(opts: { } } +async function runList(opts: { + json?: boolean; + verified?: boolean; +}): Promise { + let items = await fetchIndex(); + if (opts.verified) items = items.filter(isVerified); + output(items, opts); +} + +async function runSearch( + query: string, + opts: { json?: boolean; verified?: boolean }, +): Promise { + let items = await fetchIndex(); + items = items.filter((i) => matchesQuery(i, query)); + if (opts.verified) items = items.filter(isVerified); + if (items.length === 0 && !opts.json) { + console.log(pc.dim(`No items match "${query}".`)); + return; + } + output(items, opts); +} + export const registryListCommand = new Command("list") .description("List items available in the AppKit registry") .option("--json", "Output as JSON") @@ -156,3 +200,23 @@ export const registryListCommand = new Command("list") process.exit(1); }), ); + +export const registrySearchCommand = new Command("search") + .description("Search registry items by name, description, type, or keyword") + .argument("", "Search terms (all must match)") + .option("--json", "Output as JSON") + .option("--verified", "Show only verified items") + .addHelpText( + "after", + ` +Examples: + $ appkit registry search chart + $ appkit registry search kpi dashboard + $ appkit registry search plugin --json`, + ) + .action((query: string[], opts: { json?: boolean; verified?: boolean }) => + runSearch(query.join(" "), opts).catch((err) => { + console.error(err); + process.exit(1); + }), + );