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 new file mode 100644 index 000000000..8361891a8 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -0,0 +1,347 @@ +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 pc from "picocolors"; +import { + fetchRegistryItem, + type RegistryItem, + type RegistryItemFile, + 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"]; +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 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; + for (const sub of FRONTEND_SUBDIRS) { + if (fs.existsSync(path.join(cwd, sub, "components.json"))) { + return path.join(cwd, sub); + } + } + if (isDir(path.join(cwd, "src"))) return cwd; + for (const sub of FRONTEND_SUBDIRS) { + if (isDir(path.join(cwd, sub, "src"))) return path.join(cwd, sub); + } + return cwd; +} + +/** 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; + const parent = path.dirname(dir); + if (parent === dir) return start; + dir = parent; + } +} + +/** 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/") && 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"; + 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( + pc.yellow( + `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( + pc.yellow( + `Dependency install exited with code ${result.status ?? "unknown"} — install manually if needed: ${deps.join(" ")}`, + ), + ); + } +} + +/** 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( + pc.yellow( + " 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( + 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); + const label = existed ? pc.yellow("Updated") : pc.green("Created"); + console.log(`${label} ${path.relative(cwd, dest)}`); +} + +interface PluginSummary { + importPath: string; + exportName: string | null; + envs: string[]; +} + +async function runAdd( + refs: string[], + opts: { force?: boolean; cwd?: string; register?: boolean }, +): Promise { + const cwd = 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 = 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(pc.dim(`UI components → ${path.relative(cwd, frontendRoot)}/`)); + } + if (hasPlugin && serverRoot !== cwd) { + console.log(pc.dim(`Plugins → ${path.relative(cwd, serverRoot)}/`)); + } + + const deps = new Set(); + let wroteUi = false; + const pluginSummaries: PluginSummary[] = []; + + for (const item of items) { + for (const dep of item.dependencies ?? []) deps.add(dep); + + if (isPluginItem(item)) { + let manifest: PluginManifestShape = {}; + let pluginRel = path.join("plugins", item.name); + for (const file of item.files ?? []) { + const target = + file.target ?? + path.join("plugins", item.name, path.basename(file.path)); + writeItemFile( + path.join(serverRoot, target), + file.content, + Boolean(opts.force), + cwd, + ); + 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; + } + } + } + + installDependencies([...deps], findNearestPackageJson(cwd)); + + if (hasPlugin) { + console.log(pc.dim("\nRegistering plugins (appkit plugin sync)...")); + runPluginSync(cwd); + } + + if (wroteUi) { + console.log( + pc.dim( + '\nReminder: import "@databricks/appkit-ui/styles.css" once at your app root so components are themed.', + ), + ); + } + 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( + `\n${pc.green("Registered")} ${s.exportName} in ${result.file}`, + ); + wired = true; + } else if (result.status === "already") { + console.log( + pc.dim(`\n${s.exportName} is already registered in ${result.file}`), + ); + wired = true; + } + } + if (!wired) { + const imp = s.exportName ?? ""; + console.log( + `\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( + ` ${pc.yellow("Required env var(s):")} ${s.envs.join(", ")}`, + ); + } + } +} + +export const addCommand = new Command("add") + .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 ") + .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//, 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 +resolved from \`gh auth token\` or APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN. + +Examples: + $ appkit add metric-card # UI component + $ appkit add hello # server plugin + $ appkit add metric-card hello # mix in one call`, + ) + .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/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; +} 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..41168952f --- /dev/null +++ b/packages/shared/src/cli/commands/registry/constants.ts @@ -0,0 +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`; + +/** + * 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). + */ +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", +]; + +export interface RegistryToken { + envName: string; + value: string; +} + +/** + * 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/index.ts b/packages/shared/src/cli/commands/registry/index.ts new file mode 100644 index 000000000..8e4289758 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/index.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { registryListCommand, registrySearchCommand } from "./list"; + +/** + * Parent command for AppKit component registry operations. + * Subcommands: + * - 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) + * 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 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 new file mode 100644 index 000000000..4bf72cac3 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/list.ts @@ -0,0 +1,222 @@ +import process from "node:process"; +import { Command } from "commander"; +import pc from "picocolors"; +import { + REGISTRY_INDEX_API_URL, + REGISTRY_INDEX_URL, + REGISTRY_REPO, + resolveToken, +} from "./constants"; + +interface RegistryIndexItem { + name: string; + type?: string; + title?: string; + description?: string; + categories?: string[]; + meta?: { verified?: boolean }; + files?: Array<{ path?: string; target?: string }>; +} + +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( + (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)} ${"TYPE".padEnd(maxKind)} ${verifiedCol} DESCRIPTION`; + console.log(pc.bold(header)); + console.log(pc.dim("─".repeat(header.length))); + 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} ${kindCell} ${mark} ${verified ? desc : pc.dim(desc)}`, + ); + } +} + +/** 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 = {}; + 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(pc.red(`Failed to reach the registry at ${url}`)); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + if (res.status === 404 || res.status === 401 || res.status === 403) { + console.error( + pc.red( + `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(pc.red(`Registry returned HTTP ${res.status} for ${url}`)); + process.exit(1); + } + + const data = (await res.json()) as { items?: RegistryIndexItem[] }; + 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( + JSON.stringify( + items.map((i) => ({ + ...i, + kind: itemKind(i), + verified: isVerified(i), + })), + null, + 2, + ), + ); + } else { + printTable(items); + } +} + +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") + .option("--verified", "Show only verified items") + .action((opts: { json?: boolean; verified?: boolean }) => + runList(opts).catch((err) => { + console.error(err); + 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); + }), + ); 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..ef1977765 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/server-register.ts @@ -0,0 +1,128 @@ +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 = []; + + // 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" } }); + 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 }; +} 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(); 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