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
This commit is contained in:
+154
-6
@@ -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('<userIdOrEmail>', '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('<userIdOrEmail>', '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 <id>', 'Plugin ID')
|
||||
.requiredOption('--name <name>', 'Display name for this node')
|
||||
.requiredOption(
|
||||
'--position <pos>',
|
||||
'Mount position: base | panel | view | dashboard | contextMenu',
|
||||
'base'
|
||||
)
|
||||
.option('--storage <json>', '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<string, unknown> = {
|
||||
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 <id>', '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 <json>', '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;
|
||||
}
|
||||
|
||||
@@ -280,6 +280,31 @@ export class Client {
|
||||
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
|
||||
return this.call<unknown>('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
|
||||
}
|
||||
|
||||
// ---- plugin installs ----
|
||||
listInstalls(baseId: string) {
|
||||
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-installs`);
|
||||
}
|
||||
createInstall(baseId: string, body: Record<string, unknown>) {
|
||||
return this.call<unknown>('POST', `/api/base/${baseId}/plugin-installs`, body);
|
||||
}
|
||||
deleteInstall(baseId: string, installId: string) {
|
||||
return this.call<unknown>('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`);
|
||||
}
|
||||
|
||||
// ---- plugin developers ----
|
||||
listDevelopers() {
|
||||
return this.call<unknown[]>('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<unknown>('POST', '/api/plugin-developers', body);
|
||||
}
|
||||
removeDeveloper(userId: string) {
|
||||
return this.call<unknown>('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
||||
|
||||
Reference in New Issue
Block a user