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 1/3] 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) ---- From 49836d26556b38ebdd7847c2c09ddc119bdcf09d Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 13 Aug 2026 15:29:41 +0000 Subject: [PATCH 2/3] docs: add plugin CLI commands to skill reference --- .../inkreach-teable/guides/cli-reference.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.agents/skills/inkreach-teable/guides/cli-reference.md b/.agents/skills/inkreach-teable/guides/cli-reference.md index e751829..ec10bdf 100644 --- a/.agents/skills/inkreach-teable/guides/cli-reference.md +++ b/.agents/skills/inkreach-teable/guides/cli-reference.md @@ -165,6 +165,35 @@ Read-only. Must use `dbTableName`/`dbFieldName` (from `table get`/`field get`), inkreach-teable sql 'SELECT "name" FROM "bseXXX"."tbl_abc" LIMIT 10' ``` +## plugin + +Manage plugins and developer whitelist. + +```bash +# List plugins registered in the base +inkreach-teable plugin list + +# Register a plugin (set runtimeType, positions, logo) +inkreach-teable plugin register --name --positions '["base","panel"]' + +# Developer whitelist (admin only) +inkreach-teable plugin dev list +inkreach-teable plugin dev allow # add (userId or email) +inkreach-teable plugin dev revoke # remove + +# Install / uninstall (creates a base-level mount node) +inkreach-teable plugin install --plugin --name "My Plugin" --position base +inkreach-teable plugin installs # list installs in current base +inkreach-teable plugin uninstall --install + +# Enable/disable plugins +inkreach-teable plugin config enable --plugin +inkreach-teable plugin config disable --plugin +``` + +Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes: +`/api/plugin-developers` (GET/POST/DELETE). + ## Output Modes - **Human (default)**: tables / key-values, colored where supported. From bc8c76851dc87d59f5d2f9a1e078fb235c1689d5 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 13 Aug 2026 15:39:56 +0000 Subject: [PATCH 3/3] docs(skill): add plugin module to SKILL.md + routing rules + description - Module map: new Plugins row (list/install/uninstall, dev allow/revoke) - Routing rules: 3 new plugin intents (custom UI, allow devs, external API) - Description: mention dynamic plugins + dev whitelist + CSP --- .agents/skills/inkreach-teable/SKILL.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.agents/skills/inkreach-teable/SKILL.md b/.agents/skills/inkreach-teable/SKILL.md index a1af258..72c9e1d 100644 --- a/.agents/skills/inkreach-teable/SKILL.md +++ b/.agents/skills/inkreach-teable/SKILL.md @@ -5,15 +5,17 @@ description: >- automation engine). Configure tables, fields, records, views, and automations (8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime, buttonClick, webhook; 6 actions: script, sendEmail, httpRequest, createRecord, - updateRecord, getRecords), plus secrets and execution history. Trigger when the - user wants to manage tables/fields/records/views, build or configure - automations/workflows, set up button-field triggers, schedule jobs, create - webhooks, inspect run history, or troubleshoot a deployed inkreach-teable - instance - even without saying "inkreach-teable" but referencing Teable-style IDs - (bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure - tables/automation on the local/deployed instance. Also covers dev/deploy tasks - (build, restart systemd services). Uses the inkreach-teable CLI, which targets - the project's OWN automation contract (NOT the official teable CLI). + updateRecord, getRecords), plus secrets, execution history, and dynamic plugins + (install/uninstall, base-level mount nodes, developer whitelist, CSP apiOrigins). + Trigger when the user wants to manage tables/fields/records/views, build or + configure automations/workflows, set up button-field triggers, schedule jobs, + create webhooks, inspect run history, install or configure plugins, allow + developers to upload plugins, or troubleshoot a deployed inkreach-teable + instance - even without saying "inkreach-teable" but referencing Teable-style + IDs (bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure + tables/automation/plugins on the local/deployed instance. Also covers dev/deploy + tasks (build, restart systemd services). Uses the inkreach-teable CLI, which + targets the project's OWN automation contract (NOT the official teable CLI). --- # inkreach-teable operations @@ -50,6 +52,7 @@ Respond in the user's language. Keep answers concise and action-oriented. | Execution History | Inspect run results / rerun failures | `runs list/get/rerun` | [cli-reference.md § runs](guides/cli-reference.md#runs) | | Manual Trigger | Test by firing button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md#testing--triggering) | | SQL | Read-only analytics, JOINs, aggregation | `sql ` | [cli-reference.md § sql](guides/cli-reference.md#sql) | +| Plugins | Install/manage dynamic plugins, developer whitelist, CSP | `plugin list/install/uninstall`, `plugin dev allow/revoke` | [cli-reference.md § plugin](guides/cli-reference.md#plugin) | | Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) | ### 2.2 Routing Rules @@ -67,6 +70,9 @@ Respond in the user's language. Keep answers concise and action-oriented. | Schedule recurring job | Trigger `scheduledTime` (only type+interval work) | Passing triggerTime/weekdays (backend ignores) | | Per-row AI / transformation | Action `script` reading `triggerInput` | Manual row-by-row via record API | | Send notification | Action `sendEmail` or `httpRequest` | — | +| Add a custom UI / view / panel | Plugins: `plugin install --position base` | Manually editing DB rows | +| Allow a user to upload plugins | Plugins: `plugin dev allow ` (admin only) | — | +| Plugin needs external API access | Plugins: declare `apiOrigins` in `plugin.json` manifest | Editing CSP in next.config.js (global) | | Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs returned) | | Analytics / aggregation / JOIN | `sql` with GROUP BY | Fetch all records + compute in code | | Store an API key for a script | `secret set` → read in script via `process.env.NAME` | Hardcoding in script code |