From 3eb66513eae8e0dc4a805aff57f39df637fc593b Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 13 Aug 2026 15:07:32 +0000 Subject: [PATCH] feat(plugin): developer whitelist CLI + idempotent backend - CLI: plugin dev list/allow/revoke (userId or email) - Backend: PluginDeveloperService.addDeveloper is now idempotent (returns existing record instead of unique-constraint 500) - Backend: removeDeveloper silently no-ops on P2025 (not found) - Controller: route api/plugin-developers (avoids :pluginId collision), @DisabledPermission so admin check is the sole gate --- src/commands/plugin.ts | 160 +++++++++++++++++++++++++++++++++++++++-- src/lib/client.ts | 25 +++++++ 2 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/commands/plugin.ts b/src/commands/plugin.ts index 4eab763..af26f8b 100644 --- a/src/commands/plugin.ts +++ b/src/commands/plugin.ts @@ -8,9 +8,132 @@ import { ApiError } from '../lib/request'; void requireBaseId; +// ---- developer whitelist subcommands (Feature 4) ---- +function addDevCommands(cmd: Command) { + const dev = cmd.command('dev').description('Manage plugin developer whitelist (admin)'); + + dev + .command('list') + .description('List all whitelisted developers') + .action( + run(async (ctx) => { + const list = (await ctx.client.listDevelopers()) as Array<{ + userId: string; + email: string; + createdBy: string; + }>; + if (ctx.json) printJson(list); + else if (!list.length) process.stdout.write('(no developers)\n'); + else + printTable( + [ + { key: 'userId', header: 'USER_ID' }, + { key: 'email', header: 'EMAIL' }, + { key: 'createdBy', header: 'ADDED_BY' }, + ], + list + ); + }) + ); + + dev + .command('allow') + .description('Add a user to the developer whitelist (userId or email)') + .argument('', 'User ID or email') + .action( + run(async (ctx, args) => { + const userIdOrEmail = args[0] as string; + await ctx.client.addDeveloper(userIdOrEmail); + process.stdout.write(`✓ Developer added: ${userIdOrEmail}\n`); + }) + ); + + dev + .command('revoke') + .description('Remove a user from the developer whitelist (userId or email)') + .argument('', 'User ID or email') + .action( + run(async (ctx, args) => { + const userIdOrEmail = args[0] as string; + await ctx.client.removeDeveloper(userIdOrEmail); + process.stdout.write(`✓ Developer revoked: ${userIdOrEmail}\n`); + }) + ); +} + +// ---- install / uninstall subcommands (Feature 1) ---- +function addInstallCommands(cmd: Command) { + cmd + .command('install') + .description('Install a plugin into the current base (creates a node)') + .requiredOption('--plugin ', 'Plugin ID') + .requiredOption('--name ', 'Display name for this node') + .requiredOption( + '--position ', + 'Mount position: base | panel | view | dashboard | contextMenu', + 'base' + ) + .option('--storage ', 'Initial storage JSON passed to the plugin') + .action( + run(async (ctx, _args, o) => { + const opts = o as { plugin: string; name: string; position: string; storage?: string }; + const body: Record = { + pluginId: opts.plugin, + name: opts.name, + position: opts.position, + }; + if (opts.storage) body.storage = opts.storage; + const result = (await ctx.client.createInstall(ctx.baseId!, body)) as { id: string }; + if (ctx.json) printJson(result); + else + process.stdout.write( + `✓ Plugin installed: ${result.id} (${opts.name} → ${opts.position})\n` + ); + }) + ); + + cmd + .command('uninstall') + .description('Remove a plugin install by its install ID') + .requiredOption('--install ', 'Install ID') + .action( + run(async (ctx, _args, o) => { + const opts = o as { install: string }; + await ctx.client.deleteInstall(ctx.baseId!, opts.install); + process.stdout.write(`✓ Plugin install removed: ${opts.install}\n`); + }) + ); + + cmd + .command('installs') + .description('List plugin installs for the current base') + .action( + run(async (ctx) => { + const list = (await ctx.client.listInstalls(ctx.baseId!)) as Array<{ + id: string; + pluginId: string; + name: string; + position: string; + }>; + if (ctx.json) printJson(list); + else if (!list.length) process.stdout.write('(no installs)\n'); + else + printTable( + [ + { key: 'id', header: 'INSTALL_ID' }, + { key: 'pluginId', header: 'PLUGIN' }, + { key: 'name', header: 'NAME' }, + { key: 'position', header: 'POSITION' }, + ], + list + ); + }) + ); +} + export function pluginCommand(): Command { const cmd = new Command('plugin'); - cmd.description('Manage plugins (list, register, config)'); + cmd.description('Manage plugins (list, register, config, create, install, dev)'); cmd .command('list') @@ -18,7 +141,12 @@ export function pluginCommand(): Command { .action( run(async (ctx) => { try { - const list = (await ctx.client.getPlugins()) as Array<{ id: string; name: string; status: string; runtimeType?: string }>; + const list = (await ctx.client.getPlugins()) as Array<{ + id: string; + name: string; + status: string; + runtimeType?: string; + }>; if (ctx.json) { printJson(list); } else { @@ -29,7 +157,12 @@ export function pluginCommand(): Command { { key: 'status', header: 'STATUS' }, { key: 'runtimeType', header: 'RUNTIME' }, ], - list.map((p) => ({ id: p.id, name: p.name, status: p.status, runtimeType: p.runtimeType ?? 'url' })) + list.map((p) => ({ + id: p.id, + name: p.name, + status: p.status, + runtimeType: p.runtimeType ?? 'url', + })) ); } } catch (e) { @@ -52,7 +185,13 @@ export function pluginCommand(): Command { .option('--positions ', 'Positions array, e.g. \'["panel"]\'', '["panel"]') .action( run(async (ctx, _args, o) => { - const opts = o as { url: string; name: string; description?: string; logo?: string; positions: string }; + const opts = o as { + url: string; + name: string; + description?: string; + logo?: string; + positions: string; + }; const positions = JSON.parse(opts.positions); const created = await ctx.client.registerPlugin({ name: opts.name, @@ -112,7 +251,10 @@ export function pluginCommand(): Command { tableIds: opts.table ? [opts.table] : [], }); if (ctx.json) printJson(result); - else process.stdout.write(`✓ Plugin ${opts.plugin} enabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n`); + else + process.stdout.write( + `✓ Plugin ${opts.plugin} enabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n` + ); }) ); @@ -130,9 +272,15 @@ export function pluginCommand(): Command { writeEnabled: false, tableIds: opts.table ? [opts.table] : [], }); - process.stdout.write(`✓ Plugin ${opts.plugin} disabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n`); + process.stdout.write( + `✓ Plugin ${opts.plugin} disabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n` + ); }) ); + // Wire feature subcommands + addDevCommands(cmd); + addInstallCommands(cmd); + return cmd; } diff --git a/src/lib/client.ts b/src/lib/client.ts index bb3ec2b..3b59b5c 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -280,6 +280,31 @@ export class Client { upsertPluginConfig(baseId: string, pluginId: string, body: Record) { return this.call('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body); } + + // ---- plugin installs ---- + listInstalls(baseId: string) { + return this.call('GET', `/api/base/${baseId}/plugin-installs`); + } + createInstall(baseId: string, body: Record) { + return this.call('POST', `/api/base/${baseId}/plugin-installs`, body); + } + deleteInstall(baseId: string, installId: string) { + return this.call('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`); + } + + // ---- plugin developers ---- + listDevelopers() { + return this.call('GET', '/api/plugin-developers'); + } + addDeveloper(userIdOrEmail: string) { + // Backend reads @Body('userId') or @Body('email'). Send whichever looks right. + const isEmail = userIdOrEmail.includes('@'); + const body = isEmail ? { email: userIdOrEmail } : { userId: userIdOrEmail }; + return this.call('POST', '/api/plugin-developers', body); + } + removeDeveloper(userId: string) { + return this.call('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`); + } } // ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----