merge: plugin CLI commands + base mount + CSP apiOrigins + developer whitelist
This commit is contained in:
@@ -5,15 +5,17 @@ description: >-
|
|||||||
automation engine). Configure tables, fields, records, views, and automations
|
automation engine). Configure tables, fields, records, views, and automations
|
||||||
(8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime,
|
(8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime,
|
||||||
buttonClick, webhook; 6 actions: script, sendEmail, httpRequest, createRecord,
|
buttonClick, webhook; 6 actions: script, sendEmail, httpRequest, createRecord,
|
||||||
updateRecord, getRecords), plus secrets and execution history. Trigger when the
|
updateRecord, getRecords), plus secrets, execution history, and dynamic plugins
|
||||||
user wants to manage tables/fields/records/views, build or configure
|
(install/uninstall, base-level mount nodes, developer whitelist, CSP apiOrigins).
|
||||||
automations/workflows, set up button-field triggers, schedule jobs, create
|
Trigger when the user wants to manage tables/fields/records/views, build or
|
||||||
webhooks, inspect run history, or troubleshoot a deployed inkreach-teable
|
configure automations/workflows, set up button-field triggers, schedule jobs,
|
||||||
instance - even without saying "inkreach-teable" but referencing Teable-style IDs
|
create webhooks, inspect run history, install or configure plugins, allow
|
||||||
(bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure
|
developers to upload plugins, or troubleshoot a deployed inkreach-teable
|
||||||
tables/automation on the local/deployed instance. Also covers dev/deploy tasks
|
instance - even without saying "inkreach-teable" but referencing Teable-style
|
||||||
(build, restart systemd services). Uses the inkreach-teable CLI, which targets
|
IDs (bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure
|
||||||
the project's OWN automation contract (NOT the official teable CLI).
|
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
|
# 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) |
|
| 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) |
|
| 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 <sql>` | [cli-reference.md § sql](guides/cli-reference.md#sql) |
|
| SQL | Read-only analytics, JOINs, aggregation | `sql <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) |
|
| Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) |
|
||||||
|
|
||||||
### 2.2 Routing Rules
|
### 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) |
|
| 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 |
|
| Per-row AI / transformation | Action `script` reading `triggerInput` | Manual row-by-row via record API |
|
||||||
| Send notification | Action `sendEmail` or `httpRequest` | — |
|
| 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 <userIdOrEmail>` (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) |
|
| 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 |
|
| 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 |
|
| Store an API key for a script | `secret set` → read in script via `process.env.NAME` | Hardcoding in script code |
|
||||||
|
|||||||
@@ -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'
|
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 <name> --positions '["base","panel"]'
|
||||||
|
|
||||||
|
# Developer whitelist (admin only)
|
||||||
|
inkreach-teable plugin dev list
|
||||||
|
inkreach-teable plugin dev allow <userIdOrEmail> # add (userId or email)
|
||||||
|
inkreach-teable plugin dev revoke <userIdOrEmail> # remove
|
||||||
|
|
||||||
|
# Install / uninstall (creates a base-level mount node)
|
||||||
|
inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position base
|
||||||
|
inkreach-teable plugin installs # list installs in current base
|
||||||
|
inkreach-teable plugin uninstall --install <installId>
|
||||||
|
|
||||||
|
# Enable/disable plugins
|
||||||
|
inkreach-teable plugin config enable --plugin <pluginId>
|
||||||
|
inkreach-teable plugin config disable --plugin <pluginId>
|
||||||
|
```
|
||||||
|
|
||||||
|
Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes:
|
||||||
|
`/api/plugin-developers` (GET/POST/DELETE).
|
||||||
|
|
||||||
## Output Modes
|
## Output Modes
|
||||||
|
|
||||||
- **Human (default)**: tables / key-values, colored where supported.
|
- **Human (default)**: tables / key-values, colored where supported.
|
||||||
|
|||||||
+154
-6
@@ -8,9 +8,132 @@ import { ApiError } from '../lib/request';
|
|||||||
|
|
||||||
void requireBaseId;
|
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 {
|
export function pluginCommand(): Command {
|
||||||
const cmd = new Command('plugin');
|
const cmd = new Command('plugin');
|
||||||
cmd.description('Manage plugins (list, register, config)');
|
cmd.description('Manage plugins (list, register, config, create, install, dev)');
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('list')
|
.command('list')
|
||||||
@@ -18,7 +141,12 @@ export function pluginCommand(): Command {
|
|||||||
.action(
|
.action(
|
||||||
run(async (ctx) => {
|
run(async (ctx) => {
|
||||||
try {
|
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) {
|
if (ctx.json) {
|
||||||
printJson(list);
|
printJson(list);
|
||||||
} else {
|
} else {
|
||||||
@@ -29,7 +157,12 @@ export function pluginCommand(): Command {
|
|||||||
{ key: 'status', header: 'STATUS' },
|
{ key: 'status', header: 'STATUS' },
|
||||||
{ key: 'runtimeType', header: 'RUNTIME' },
|
{ 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) {
|
} catch (e) {
|
||||||
@@ -52,7 +185,13 @@ export function pluginCommand(): Command {
|
|||||||
.option('--positions <json>', 'Positions array, e.g. \'["panel"]\'', '["panel"]')
|
.option('--positions <json>', 'Positions array, e.g. \'["panel"]\'', '["panel"]')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, _args, o) => {
|
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 positions = JSON.parse(opts.positions);
|
||||||
const created = await ctx.client.registerPlugin({
|
const created = await ctx.client.registerPlugin({
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
@@ -112,7 +251,10 @@ export function pluginCommand(): Command {
|
|||||||
tableIds: opts.table ? [opts.table] : [],
|
tableIds: opts.table ? [opts.table] : [],
|
||||||
});
|
});
|
||||||
if (ctx.json) printJson(result);
|
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,
|
writeEnabled: false,
|
||||||
tableIds: opts.table ? [opts.table] : [],
|
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;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,31 @@ export class Client {
|
|||||||
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
|
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
|
||||||
return this.call<unknown>('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
|
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) ----
|
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
||||||
|
|||||||
Reference in New Issue
Block a user