Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/shared/src/cli/commands/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
14 changes: 14 additions & 0 deletions packages/shared/src/cli/commands/generate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -42,6 +45,8 @@ async function runGenerateTypes(
warehouseId: resolvedWarehouseId,
noCache: options?.noCache || false,
});

console.log(`Generated types: ${resolvedOutFile}`);
} catch (error) {
if (
error instanceof Error &&
Expand All @@ -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);
6 changes: 6 additions & 0 deletions packages/shared/src/cli/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
143 changes: 132 additions & 11 deletions packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,17 +19,29 @@ interface ManifestWithExtras extends PluginManifest {
[key: string]: unknown;
}

async function runPluginAddResource(options: { path?: string }): Promise<void> {
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 <dir-with-manifest.json>",
);
process.exit(1);
}

Expand All @@ -37,7 +54,6 @@ async function runPluginAddResource(options: { path?: string }): Promise<void> {

const manifestPath = resolved.path;

let manifest: ManifestWithExtras;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
Expand All @@ -48,14 +64,90 @@ async function runPluginAddResource(options: { path?: string }): Promise<void> {
);
process.exit(1);
}
manifest = parsed as ManifestWithExtras;
return { manifest: parsed as ManifestWithExtras, manifestPath };
} catch (err) {
console.error(
"Failed to read or parse manifest.json:",
err instanceof Error ? err.message : err,
);
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<void> {
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) {
Expand All @@ -65,8 +157,6 @@ async function runPluginAddResource(options: { path?: string }): Promise<void> {

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,
Expand All @@ -89,13 +179,44 @@ async function runPluginAddResource(options: { path?: string }): Promise<void> {
);
}

async function runPluginAddResource(opts: AddResourceOptions): Promise<void> {
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 <dir>",
"Plugin directory containing manifest.json, which will be edited in place (default: .)",
"Plugin directory containing manifest.json (default: .)",
)
.option(
"-t, --type <resource_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 <key>", "Resource key (default: derived from type)")
.option("--description <text>", "Description of the resource requirement")
.option("--permission <perm>", "Permission level (default: from schema)")
.option(
"--fields-json <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) => {
Expand Down
99 changes: 99 additions & 0 deletions packages/shared/src/cli/commands/plugin/create/create.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading