diff --git a/packages/shared/src/cli/commands/docs.ts b/packages/shared/src/cli/commands/docs.ts index b3a7f357..6b001b72 100644 --- a/packages/shared/src/cli/commands/docs.ts +++ b/packages/shared/src/cli/commands/docs.ts @@ -144,4 +144,14 @@ export const docsCommand = new Command("docs") "Section name (e.g. 'plugins') or path to a doc file (e.g. './docs.md')", ) .option("--full", "Show complete index including all API reference entries") + .addHelpText( + "after", + ` +Examples: + $ appkit docs + $ appkit docs plugins + $ appkit docs "appkit-ui API reference" + $ appkit docs ./docs/plugins/analytics.md + $ appkit docs --full`, + ) .action(runDocs); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 06c8b016..2d893135 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -16,6 +16,9 @@ async function runGenerateTypes( warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; if (!resolvedWarehouseId) { + console.error( + "Skipping type generation: no warehouse ID. Set DATABRICKS_WAREHOUSE_ID or pass as argument.", + ); process.exit(0); } @@ -42,6 +45,8 @@ async function runGenerateTypes( warehouseId: resolvedWarehouseId, noCache: options?.noCache || false, }); + + console.log(`Generated types: ${resolvedOutFile}`); } catch (error) { if ( error instanceof Error && @@ -67,4 +72,13 @@ export const generateTypesCommand = new Command("generate-types") ) .argument("[warehouseId]", "Databricks warehouse ID") .option("--no-cache", "Disable caching for type generation") + .addHelpText( + "after", + ` +Examples: + $ appkit generate-types + $ appkit generate-types . client/src/types.d.ts + $ appkit generate-types . client/src/types.d.ts my-warehouse-id + $ appkit generate-types --no-cache`, + ) .action(runGenerateTypes); diff --git a/packages/shared/src/cli/commands/lint.ts b/packages/shared/src/cli/commands/lint.ts index ad39a961..00eb55ad 100644 --- a/packages/shared/src/cli/commands/lint.ts +++ b/packages/shared/src/cli/commands/lint.ts @@ -143,4 +143,10 @@ function runLint() { export const lintCommand = new Command("lint") .description("Run AST-based linting on TypeScript files") + .addHelpText( + "after", + ` +Examples: + $ appkit lint`, + ) .action(runLint); diff --git a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts index e614cd17..dc1a327e 100644 --- a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts +++ b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts @@ -4,7 +4,12 @@ import process from "node:process"; import { cancel, intro, outro } from "@clack/prompts"; import { Command } from "commander"; import { promptOneResource } from "../create/prompt-resource"; -import { humanizeResourceType } from "../create/resource-defaults"; +import { + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + resourceKeyFromType, +} from "../create/resource-defaults"; import { resolveManifestInDir } from "../manifest-resolve"; import type { PluginManifest, ResourceRequirement } from "../manifest-types"; import { validateManifest } from "../validate/validate-manifest"; @@ -14,17 +19,29 @@ interface ManifestWithExtras extends PluginManifest { [key: string]: unknown; } -async function runPluginAddResource(options: { path?: string }): Promise { - intro("Add resource to plugin manifest"); +interface AddResourceOptions { + path?: string; + type?: string; + required?: boolean; + resourceKey?: string; + description?: string; + permission?: string; + fieldsJson?: string; + dryRun?: boolean; +} - const cwd = process.cwd(); - const pluginDir = path.resolve(cwd, options.path ?? "."); +function loadManifest( + pluginDir: string, +): { manifest: ManifestWithExtras; manifestPath: string } | null { const resolved = resolveManifestInDir(pluginDir, { allowJsManifest: true }); if (!resolved) { console.error( `No manifest found in ${pluginDir}. This command requires manifest.json (manifest.js cannot be edited in place).`, ); + console.error( + " appkit plugin add-resource --path ", + ); process.exit(1); } @@ -37,7 +54,6 @@ async function runPluginAddResource(options: { path?: string }): Promise { const manifestPath = resolved.path; - let manifest: ManifestWithExtras; try { const raw = fs.readFileSync(manifestPath, "utf-8"); const parsed = JSON.parse(raw) as unknown; @@ -48,7 +64,7 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); process.exit(1); } - manifest = parsed as ManifestWithExtras; + return { manifest: parsed as ManifestWithExtras, manifestPath }; } catch (err) { console.error( "Failed to read or parse manifest.json:", @@ -56,6 +72,82 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); process.exit(1); } +} + +function buildEntry( + type: string, + opts: AddResourceOptions, +): { entry: ResourceRequirement; isRequired: boolean } { + const alias = humanizeResourceType(type); + const isRequired = opts.required !== false; + + let fields = getDefaultFieldsForType(type); + if (opts.fieldsJson) { + try { + const parsed = JSON.parse(opts.fieldsJson) as Record< + string, + { env: string; description?: string } + >; + fields = { ...fields, ...parsed }; + } catch { + console.error("Error: --fields-json must be valid JSON."); + console.error( + ' Example: --fields-json \'{"id":{"env":"MY_WAREHOUSE_ID"}}\'', + ); + process.exit(1); + } + } + + const entry: ResourceRequirement = { + type: type as ResourceRequirement["type"], + alias, + resourceKey: opts.resourceKey ?? resourceKeyFromType(type), + description: + opts.description || + `${isRequired ? "Required" : "Optional"} for ${alias} functionality.`, + permission: + opts.permission ?? DEFAULT_PERMISSION_BY_TYPE[type] ?? "CAN_VIEW", + fields, + }; + + return { entry, isRequired }; +} + +function runNonInteractive(opts: AddResourceOptions): void { + const cwd = process.cwd(); + const pluginDir = path.resolve(cwd, opts.path ?? "."); + const loaded = loadManifest(pluginDir); + if (!loaded) return; + const { manifest, manifestPath } = loaded; + + const type = opts.type as string; + const { entry, isRequired } = buildEntry(type, opts); + + if (isRequired) { + manifest.resources.required.push(entry); + } else { + manifest.resources.optional.push(entry); + } + + if (opts.dryRun) { + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log( + `Added ${entry.alias} as ${isRequired ? "required" : "optional"} to ${path.relative(cwd, manifestPath)}`, + ); +} + +async function runInteractive(opts: AddResourceOptions): Promise { + intro("Add resource to plugin manifest"); + + const cwd = process.cwd(); + const pluginDir = path.resolve(cwd, opts.path ?? "."); + const loaded = loadManifest(pluginDir); + if (!loaded) return; + const { manifest, manifestPath } = loaded; const spec = await promptOneResource(); if (!spec) { @@ -65,8 +157,6 @@ async function runPluginAddResource(options: { path?: string }): Promise { const alias = humanizeResourceType(spec.type); const entry: ResourceRequirement = { - // Safe cast: spec.type comes from RESOURCE_TYPE_OPTIONS which reads values - // from the same JSON schema that generates the ResourceType union. type: spec.type as ResourceRequirement["type"], alias, resourceKey: spec.resourceKey, @@ -89,13 +179,44 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); } +async function runPluginAddResource(opts: AddResourceOptions): Promise { + if (opts.type) { + runNonInteractive(opts); + } else { + await runInteractive(opts); + } +} + export const pluginAddResourceCommand = new Command("add-resource") .description( - "Add a resource requirement to an existing plugin manifest (interactive). Overwrites manifest.json in place.", + "Add a resource requirement to an existing plugin manifest. Overwrites manifest.json in place.", ) .option( "-p, --path ", - "Plugin directory containing manifest.json, which will be edited in place (default: .)", + "Plugin directory containing manifest.json (default: .)", + ) + .option( + "-t, --type ", + "Resource type (e.g. sql_warehouse, volume). Enables non-interactive mode.", + ) + .option("--required", "Mark resource as required (default: true)", true) + .option("--no-required", "Mark resource as optional") + .option("--resource-key ", "Resource key (default: derived from type)") + .option("--description ", "Description of the resource requirement") + .option("--permission ", "Permission level (default: from schema)") + .option( + "--fields-json ", + 'JSON object overriding field env vars (e.g. \'{"id":{"env":"MY_WAREHOUSE_ID"}}\')', + ) + .option("--dry-run", "Preview the updated manifest without writing") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin add-resource + $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse + $ appkit plugin add-resource --path plugins/my-plugin --type volume --no-required --dry-run + $ appkit plugin add-resource --type sql_warehouse --fields-json '{"id":{"env":"MY_WAREHOUSE_ID"}}'`, ) .action((opts) => runPluginAddResource(opts).catch((err) => { diff --git a/packages/shared/src/cli/commands/plugin/create/create.test.ts b/packages/shared/src/cli/commands/plugin/create/create.test.ts new file mode 100644 index 00000000..19ec7cd2 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/create.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + buildResourceFromType, + parseResourcesJson, + parseResourcesShorthand, +} from "./create"; + +describe("create non-interactive helpers", () => { + describe("buildResourceFromType", () => { + it("builds a sql_warehouse resource with correct defaults", () => { + const resource = buildResourceFromType("sql_warehouse"); + expect(resource.type).toBe("sql_warehouse"); + expect(resource.required).toBe(true); + expect(resource.resourceKey).toBe("sql-warehouse"); + expect(resource.permission).toBe("CAN_USE"); + expect(resource.fields.id.env).toBe("DATABRICKS_WAREHOUSE_ID"); + }); + + it("builds a volume resource with correct defaults", () => { + const resource = buildResourceFromType("volume"); + expect(resource.type).toBe("volume"); + expect(resource.resourceKey).toBe("volume"); + expect(resource.fields.name.env).toBe("VOLUME_NAME"); + }); + + it("builds an unknown type with a fallback pattern", () => { + const resource = buildResourceFromType("custom_thing"); + expect(resource.type).toBe("custom_thing"); + expect(resource.resourceKey).toBe("custom-thing"); + expect(resource.permission).toBe("CAN_VIEW"); + expect(resource.fields.id.env).toBe("DATABRICKS_CUSTOM_THING_ID"); + }); + }); + + describe("parseResourcesShorthand", () => { + it("parses comma-separated resource types", () => { + const resources = parseResourcesShorthand("sql_warehouse,volume"); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + }); + + it("trims whitespace around types", () => { + const resources = parseResourcesShorthand(" sql_warehouse , volume "); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + }); + + it("filters empty segments", () => { + const resources = parseResourcesShorthand("sql_warehouse,,volume,"); + expect(resources).toHaveLength(2); + }); + + it("returns empty array for empty string", () => { + const resources = parseResourcesShorthand(""); + expect(resources).toHaveLength(0); + }); + }); + + describe("parseResourcesJson", () => { + it("parses minimal JSON with only type", () => { + const resources = parseResourcesJson('[{"type":"sql_warehouse"}]'); + expect(resources).toHaveLength(1); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[0].required).toBe(true); + expect(resources[0].permission).toBe("CAN_USE"); + expect(resources[0].resourceKey).toBe("sql-warehouse"); + }); + + it("allows overriding individual fields", () => { + const json = JSON.stringify([ + { + type: "sql_warehouse", + required: false, + permission: "CAN_MANAGE", + description: "Custom desc", + }, + ]); + const resources = parseResourcesJson(json); + expect(resources[0].required).toBe(false); + expect(resources[0].permission).toBe("CAN_MANAGE"); + expect(resources[0].description).toBe("Custom desc"); + expect(resources[0].resourceKey).toBe("sql-warehouse"); + }); + + it("parses multiple resources", () => { + const json = JSON.stringify([ + { type: "sql_warehouse" }, + { type: "volume", required: false }, + ]); + const resources = parseResourcesJson(json); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + expect(resources[1].required).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 0be53af3..3003b281 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -12,16 +12,205 @@ import { spinner, text, } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { promptOneResource } from "./prompt-resource"; -import { RESOURCE_TYPE_OPTIONS } from "./resource-defaults"; +import { + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + RESOURCE_TYPE_OPTIONS, + resourceKeyFromType, +} from "./resource-defaults"; import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; -import type { CreateAnswers, Placement } from "./types"; +import type { CreateAnswers, Placement, SelectedResource } from "./types"; const NAME_PATTERN = /^[a-z][a-z0-9-]*$/; const DEFAULT_VERSION = "0.1.0"; +const VALID_PLACEMENTS: Placement[] = ["in-repo", "isolated"]; +const REQUIRED_FLAGS = ["placement", "path", "name", "description"] as const; + +interface CreateOptions { + placement?: string; + path?: string; + name?: string; + displayName?: string; + description?: string; + resources?: string; + resourcesJson?: string; + force?: boolean; +} + +function deriveDisplayName(name: string): string { + return name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" "); +} + +function deriveExportName(name: string): string { + return name + .split("-") + .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) + .join(""); +} + +function buildResourceFromType(type: string): SelectedResource { + return { + type, + required: true, + description: `Required for ${humanizeResourceType(type)} functionality.`, + resourceKey: resourceKeyFromType(type), + permission: DEFAULT_PERMISSION_BY_TYPE[type] ?? "CAN_VIEW", + fields: getDefaultFieldsForType(type), + }; +} + +interface JsonResourceEntry { + type: string; + required?: boolean; + description?: string; + resourceKey?: string; + permission?: string; + fields?: Record; +} + +function parseResourcesJson(json: string): SelectedResource[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + console.error("Error: --resources-json must be valid JSON."); + console.error(' Example: --resources-json \'[{"type":"sql_warehouse"}]\''); + process.exit(1); + } + + if (!Array.isArray(parsed)) { + console.error("Error: --resources-json must be a JSON array."); + console.error(' Example: --resources-json \'[{"type":"sql_warehouse"}]\''); + process.exit(1); + } + + return (parsed as JsonResourceEntry[]).map((entry, i) => { + if (!entry.type || typeof entry.type !== "string") { + console.error( + `Error: --resources-json entry ${i} missing required "type" field.`, + ); + process.exit(1); + } + const defaults = buildResourceFromType(entry.type); + return { + type: entry.type, + required: entry.required ?? defaults.required, + description: entry.description ?? defaults.description, + resourceKey: entry.resourceKey ?? defaults.resourceKey, + permission: entry.permission ?? defaults.permission, + fields: entry.fields ?? defaults.fields, + }; + }); +} + +function parseResourcesShorthand(csv: string): SelectedResource[] { + return csv + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map(buildResourceFromType); +} + +function printNextSteps(answers: CreateAnswers, targetDir: string): void { + const relativePath = path.relative(process.cwd(), targetDir); + const importPath = relativePath.startsWith(".") + ? relativePath + : `./${relativePath}`; + const exportName = deriveExportName(answers.name); + + console.log("\nNext steps:\n"); + if (answers.placement === "in-repo") { + console.log(` 1. Import and register in your server:`); + console.log(` import { ${exportName} } from "${importPath}";`); + console.log(` createApp({ plugins: [ ..., ${exportName}() ] });`); + console.log( + ` 2. Run \`npx appkit plugin sync --write\` to update appkit.plugins.json.\n`, + ); + } else { + console.log(` 1. cd into the new package and install dependencies:`); + console.log(` cd ${answers.targetPath} && pnpm install`); + console.log(` 2. Build: pnpm build`); + console.log( + ` 3. In your app: pnpm add ./${answers.targetPath} @databricks/appkit`, + ); + console.log( + ` 4. Import and register: import { ${exportName} } from "";\n`, + ); + } +} -async function runPluginCreate(): Promise { +function runNonInteractive(opts: CreateOptions): void { + const missing = REQUIRED_FLAGS.filter((f) => !opts[f]); + if (missing.length > 0) { + console.error( + `Error: Non-interactive mode requires: ${REQUIRED_FLAGS.map((f) => `--${f}`).join(", ")}`, + ); + console.error(`Missing: ${missing.map((f) => `--${f}`).join(", ")}`); + console.error( + ' appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X"', + ); + process.exit(1); + } + + const placement = opts.placement as Placement; + if (!VALID_PLACEMENTS.includes(placement)) { + console.error( + `Error: --placement must be one of: ${VALID_PLACEMENTS.join(", ")}`, + ); + process.exit(1); + } + + const name = opts.name as string; + if (!NAME_PATTERN.test(name)) { + console.error( + "Error: --name must be lowercase, start with a letter, and use only letters, numbers, and hyphens.", + ); + process.exit(1); + } + + let resources: SelectedResource[] = []; + if (opts.resourcesJson) { + resources = parseResourcesJson(opts.resourcesJson); + } else if (opts.resources) { + resources = parseResourcesShorthand(opts.resources); + } + + const answers: CreateAnswers = { + placement, + targetPath: (opts.path as string).trim(), + name: name.trim(), + displayName: opts.displayName?.trim() || deriveDisplayName(name), + description: (opts.description as string).trim(), + resources, + version: DEFAULT_VERSION, + }; + + const targetDir = resolveTargetDir(process.cwd(), answers); + const dirExists = fs.existsSync(targetDir); + const hasContent = dirExists && fs.readdirSync(targetDir).length > 0; + if (hasContent && !opts.force) { + console.error( + `Error: Directory ${answers.targetPath} already exists and is not empty.`, + ); + console.error(" Use --force to overwrite."); + process.exit(1); + } + + scaffoldPlugin(targetDir, answers, { isolated: placement === "isolated" }); + + console.log( + `Plugin "${answers.name}" created at ${path.relative(process.cwd(), targetDir)}`, + ); + printNextSteps(answers, targetDir); +} + +async function runInteractive(): Promise { intro("Create a new AppKit plugin"); try { @@ -183,42 +372,58 @@ async function runPluginCreate(): Promise { throw err; } - const relativePath = path.relative(process.cwd(), targetDir); - const importPath = relativePath.startsWith(".") - ? relativePath - : `./${relativePath}`; - const exportName = answers.name - .split("-") - .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) - .join(""); - outro("Plugin created successfully."); - - console.log("\nNext steps:\n"); - if (placement === "in-repo") { - console.log(` 1. Import and register in your server:`); - console.log(` import { ${exportName} } from "${importPath}";`); - console.log(` createApp({ plugins: [ ..., ${exportName}() ] });`); - console.log( - ` 2. Run \`npx appkit plugin sync --write\` to update appkit.plugins.json.\n`, - ); - } else { - console.log(` 1. cd into the new package and install dependencies:`); - console.log(` cd ${answers.targetPath} && pnpm install`); - console.log(` 2. Build: pnpm build`); - console.log( - ` 3. In your app: pnpm add ./${answers.targetPath} @databricks/appkit`, - ); - console.log( - ` 4. Import and register: import { ${exportName} } from "";\n`, - ); - } + printNextSteps(answers, targetDir); } catch (err) { console.error(err); process.exit(1); } } +async function runPluginCreate(opts: CreateOptions): Promise { + const hasAnyFlag = REQUIRED_FLAGS.some((f) => opts[f] !== undefined); + if (hasAnyFlag) { + runNonInteractive(opts); + } else { + await runInteractive(); + } +} + export const pluginCreateCommand = new Command("create") - .description("Scaffold a new AppKit plugin (interactive)") - .action(runPluginCreate); + .description("Scaffold a new AppKit plugin") + .option("--placement ", "Where the plugin lives (in-repo, isolated)") + .option("--path ", "Target directory for the plugin") + .option("--name ", "Plugin name (lowercase, hyphens allowed)") + .option("--display-name ", "Human-readable display name") + .option("--description ", "Short description of the plugin") + .addOption( + new Option( + "--resources ", + "Comma-separated resource types (e.g. sql_warehouse,volume)", + ).conflicts("resourcesJson"), + ) + .addOption( + new Option( + "--resources-json ", + 'JSON array of resource specs (e.g. \'[{"type":"sql_warehouse"}]\')', + ).conflicts("resources"), + ) + .option("-f, --force", "Overwrite existing directory without confirmation") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin create + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" --resources sql_warehouse,volume --force + $ appkit plugin create --placement isolated --path appkit-plugin-ml --name ml --description "ML" --resources-json '[{"type":"serving_endpoint"}]'`, + ) + .action((opts) => + runPluginCreate(opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); + +/** Exported for testing. */ +export { buildResourceFromType, parseResourcesJson, parseResourcesShorthand }; diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index 04b8cba9..d2ff00a9 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -20,4 +20,14 @@ export const pluginCommand = new Command("plugin") .addCommand(pluginCreateCommand) .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) - .addCommand(pluginAddResourceCommand); + .addCommand(pluginAddResourceCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit plugin sync --write + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" + $ 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/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index d9bdc206..e9a3b35e 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -205,6 +205,9 @@ async function runPluginList(options: { ); if (!fs.existsSync(manifestPath)) { console.error(`Manifest not found: ${manifestPath}`); + console.error( + " appkit plugin list --manifest or appkit plugin list --dir ", + ); process.exit(1); } try { @@ -238,6 +241,15 @@ export const pluginListCommand = new Command("list") "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) .option("--json", "Output as JSON") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin list + $ appkit plugin list --json + $ appkit plugin list --manifest custom-manifest.json + $ appkit plugin list --dir plugins/`, + ) .action((opts) => runPluginList(opts).catch((err) => { console.error(err); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..4b2c21f0 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -520,7 +520,7 @@ async function scanPluginsDir( function writeManifest( outputPath: string, { plugins }: { plugins: TemplatePluginsManifest["plugins"] }, - options: { write?: boolean; silent?: boolean }, + options: { write?: boolean; silent?: boolean; json?: boolean }, ) { const templateManifest: TemplatePluginsManifest = { $schema: @@ -529,15 +529,19 @@ function writeManifest( plugins, }; + if (options.json) { + console.log(JSON.stringify(templateManifest, null, 2)); + } + if (options.write) { fs.writeFileSync( outputPath, `${JSON.stringify(templateManifest, null, 2)}\n`, ); - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`\n✓ Wrote ${outputPath}`); } - } else if (!options.silent) { + } else if (!options.silent && !options.json) { console.log("\nTo write the manifest, run:"); console.log(" npx appkit plugin sync --write\n"); console.log("Preview:"); @@ -557,6 +561,7 @@ async function runPluginsSync(options: { write?: boolean; output?: string; silent?: boolean; + json?: boolean; requirePlugins?: string; pluginsDir?: string; packageName?: string; @@ -575,7 +580,7 @@ async function runPluginsSync(options: { process.exit(1); } - if (!options.silent) { + if (!options.silent && !options.json) { console.log("Scanning for AppKit plugins...\n"); if (allowJsManifest) { console.warn( @@ -590,7 +595,7 @@ async function runPluginsSync(options: { let pluginUsages = new Set(); if (serverFile) { - if (!options.silent) { + if (!options.silent && !options.json) { const relativePath = path.relative(cwd, serverFile); console.log(`Server entry file: ${relativePath}`); } @@ -602,7 +607,7 @@ async function runPluginsSync(options: { serverImports = parseImports(root); pluginUsages = parsePluginUsages(root); - } else if (!options.silent) { + } else if (!options.silent && !options.json) { console.log( "No server entry file found. Checked:", SERVER_FILE_CANDIDATES.join(", "), @@ -623,7 +628,7 @@ async function runPluginsSync(options: { if (options.pluginsDir) { const resolvedDir = path.resolve(cwd, options.pluginsDir); const pkgName = options.packageName ?? "@databricks/appkit"; - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`Scanning plugins directory: ${options.pluginsDir}`); } Object.assign( @@ -663,7 +668,7 @@ async function runPluginsSync(options: { for (const dir of localDirsToScan) { const resolvedDir = path.resolve(cwd, dir); if (!fs.existsSync(resolvedDir)) continue; - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`Scanning local plugins directory: ${dir}`); } const discovered = await scanPluginsDirRecursive( @@ -689,7 +694,10 @@ async function runPluginsSync(options: { `\nNo manifest (${allowJsManifest ? "manifest.json or manifest.js" : "manifest.json"}) found in: ${options.pluginsDir}`, ); } else { - console.log("\nMake sure you have plugin packages installed."); + console.log( + "\nMake sure you have plugin packages installed, or specify a directory:", + ); + console.log(" appkit plugin sync --plugins-dir "); } process.exit(1); } @@ -744,7 +752,7 @@ async function runPluginsSync(options: { } } - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`\nFound ${pluginCount} plugin(s):`); for (const [name, manifest] of Object.entries(plugins)) { const resourceCount = @@ -802,6 +810,18 @@ export const pluginsSyncCommand = new Command("sync") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) + .option("--json", "Output manifest as JSON to stdout") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin sync + $ appkit plugin sync --write + $ appkit plugin sync --write --require-plugins server,analytics + $ appkit plugin sync --write --plugins-dir src/plugins --package-name @my/pkg + $ appkit plugin sync --json + $ appkit plugin sync --silent`, + ) .action((opts) => runPluginsSync(opts).catch((err) => { console.error(err); diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts index 76ccfbae..59ec7b7a 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -63,13 +63,18 @@ function resolveManifestPaths( return out; } +interface ValidateOptions { + allowJsManifest?: boolean; + json?: boolean; +} + async function runPluginValidate( paths: string[], - options: { allowJsManifest?: boolean }, + options: ValidateOptions, ): Promise { const cwd = process.cwd(); const allowJsManifest = Boolean(options.allowJsManifest); - if (allowJsManifest) { + if (allowJsManifest && !options.json) { console.warn( "Warning: --allow-js-manifest executes manifest.js/manifest.cjs files. Only use with trusted code.", ); @@ -78,18 +83,34 @@ async function runPluginValidate( const manifestPaths = resolveManifestPaths(toValidate, cwd, allowJsManifest); if (manifestPaths.length === 0) { - console.error("No manifest files to validate."); + if (options.json) { + console.log("[]"); + } else { + console.error("No manifest files to validate."); + } process.exit(1); } let hasFailure = false; + const jsonResults: { path: string; valid: boolean; errors?: string[] }[] = []; + for (const { path: manifestPath, type } of manifestPaths) { + const relativePath = path.relative(cwd, manifestPath); let obj: unknown; try { obj = await loadManifestFromFile(manifestPath, type, { allowJsManifest }); } catch (err) { - console.error(`✗ ${manifestPath}`); - console.error(` ${err instanceof Error ? err.message : String(err)}`); + const errMsg = err instanceof Error ? err.message : String(err); + if (options.json) { + jsonResults.push({ + path: relativePath, + valid: false, + errors: [errMsg], + }); + } else { + console.error(`✗ ${manifestPath}`); + console.error(` ${errMsg}`); + } hasFailure = true; continue; } @@ -100,18 +121,36 @@ async function runPluginValidate( ? validateTemplateManifest(obj) : validateManifest(obj); - const relativePath = path.relative(cwd, manifestPath); if (result.valid) { - console.log(`✓ ${relativePath}`); + if (options.json) { + jsonResults.push({ path: relativePath, valid: true }); + } else { + console.log(`✓ ${relativePath}`); + } } else { - console.error(`✗ ${relativePath}`); - if (result.errors?.length) { - console.error(formatValidationErrors(result.errors, obj)); + const errors = result.errors?.length + ? formatValidationErrors(result.errors, obj).split("\n").filter(Boolean) + : []; + if (options.json) { + jsonResults.push({ + path: relativePath, + valid: false, + ...(errors.length > 0 && { errors }), + }); + } else { + console.error(`✗ ${relativePath}`); + if (result.errors?.length) { + console.error(formatValidationErrors(result.errors, obj)); + } } hasFailure = true; } } + if (options.json) { + console.log(JSON.stringify(jsonResults, null, 2)); + } + process.exit(hasFailure ? 1 : 0); } @@ -127,7 +166,18 @@ export const pluginValidateCommand = new Command("validate") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) - .action((paths: string[], opts: { allowJsManifest?: boolean }) => + .option("--json", "Output validation results as JSON") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin validate + $ appkit plugin validate plugins/my-plugin + $ appkit plugin validate plugins/my-plugin plugins/other + $ appkit plugin validate appkit.plugins.json + $ appkit plugin validate --json`, + ) + .action((paths: string[], opts: ValidateOptions) => runPluginValidate(paths, opts).catch((err) => { console.error(err); process.exit(1); diff --git a/packages/shared/src/cli/commands/setup.ts b/packages/shared/src/cli/commands/setup.ts index 3ff38845..55e19c06 100644 --- a/packages/shared/src/cli/commands/setup.ts +++ b/packages/shared/src/cli/commands/setup.ts @@ -126,10 +126,10 @@ function runSetup(options: { write?: boolean }) { if (installed.length === 0) { console.log("No @databricks/appkit packages found in node_modules."); - console.log("\nMake sure you've installed at least one of:"); - PACKAGES.forEach((pkg) => { - console.log(` - ${pkg.name}`); - }); + console.log("\nInstall at least one of:"); + for (const pkg of PACKAGES) { + console.log(` npm install ${pkg.name}`); + } process.exit(1); } @@ -182,4 +182,11 @@ function runSetup(options: { write?: boolean }) { export const setupCommand = new Command("setup") .description("Setup CLAUDE.md with AppKit package references") .option("-w, --write", "Create or update CLAUDE.md file in current directory") + .addHelpText( + "after", + ` +Examples: + $ appkit setup + $ appkit setup --write`, + ) .action(runSetup);