feat: add inkreach CLI + inkreach-teable-ops skill
A self-built CLI and companion agent skill for operating a deployed
inkreach-teable instance — configuring tables, fields, records, views, and
the project's OWN automation engine.
Why not the official 'teable' CLI:
inkreach-teable's automation is self-built with a different contract:
- conditions (not filter), fieldIds (not watchFieldIds)
- recordCondition (not recordMatchesConditions)
- webhook route /api/webhook/{baseId}/{wfId} (not the /base//workflow/ form)
- scheduledTime backend honors only timing.type + timing.interval
The official CLI targets the upstream contract and 404s/misconfigures here.
Table/field/record/view APIs ARE upstream-compatible, but automation is not.
inkreach CLI (packages/inkreach-cli/):
- Node.js + commander, zero extra runtime deps
- Uses node:http (NOT fetch — Node 24 fetch drops Authorization header)
- Commands: auth (login/status/token), base/table/field/record/view,
workflow (list/get/create/update/delete/activate/deactivate/test),
trigger set (8 types, builds correct config keys), action add/update/delete
(6 types), secret, runs (list/get/rerun), fire (button-click/webhook), sql
- --json mode for agent parsing; GET->modify->PUT whole-graph for node edits
- Verified end-to-end: create workflow -> set webhook trigger -> add script
action -> activate -> fire webhook -> runs list (succeeded)
inkreach-teable-ops skill (.agents/skills/inkreach-teable-ops/):
- SKILL.md entry: module map, routing rules, 11 key constraints (the real
contract keys), execution rules, error recovery
- guides/: cli-reference, automation-guide, tables-fields, instance-access,
development (build/restart, known gotchas)
- api-reference/: trigger-types (8 triggers, exact config), action-types
(6 actions, sandbox globals), field-types (21 types incl. Button)
- metadata.json + install.sh
This commit is contained in:
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
/**
|
||||||
|
* inkreach — CLI for operating inkreach-teable.
|
||||||
|
*
|
||||||
|
* Entry point. Sets up the global commander program and mounts command groups.
|
||||||
|
*/
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const package_json_1 = require("../package.json");
|
||||||
|
const auth_1 = require("./commands/auth");
|
||||||
|
const workflow_1 = require("./commands/workflow");
|
||||||
|
const resources_1 = require("./commands/resources");
|
||||||
|
const extras_1 = require("./commands/extras");
|
||||||
|
const program = new commander_1.Command();
|
||||||
|
program
|
||||||
|
.name('inkreach')
|
||||||
|
.description('Operate inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 actions).\n' +
|
||||||
|
'Use --json for machine-readable output (agent parsing).')
|
||||||
|
.version(`inkreach ${package_json_1.version}`, '-v, --version');
|
||||||
|
// Global options (highest precedence over config file + env).
|
||||||
|
program
|
||||||
|
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||||
|
.option('--token <token>', 'Personal Access Token')
|
||||||
|
.option('--base-id <id>', 'Default base id')
|
||||||
|
.option('--json', 'Output raw JSON for agent parsing')
|
||||||
|
.option('--debug', 'Print requests/responses to stderr (verbose)');
|
||||||
|
// Command groups
|
||||||
|
program.addCommand((0, auth_1.authCommand)());
|
||||||
|
for (const c of (0, resources_1.baseResourceCommands)())
|
||||||
|
program.addCommand(c);
|
||||||
|
program.addCommand((0, workflow_1.workflowCommand)());
|
||||||
|
program.addCommand((0, workflow_1.triggerCommand)());
|
||||||
|
program.addCommand((0, workflow_1.actionCommand)());
|
||||||
|
program.addCommand((0, extras_1.secretCommand)());
|
||||||
|
program.addCommand((0, extras_1.runsCommand)());
|
||||||
|
program.addCommand((0, extras_1.fireCommand)());
|
||||||
|
program.addCommand((0, extras_1.sqlCommand)());
|
||||||
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Vendored
+135
@@ -0,0 +1,135 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.authCommand = void 0;
|
||||||
|
/**
|
||||||
|
* `inkreach auth` — authentication & config commands.
|
||||||
|
*/
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const ctx_1 = require("../lib/ctx");
|
||||||
|
const config_1 = require("../lib/config");
|
||||||
|
const output_1 = require("../lib/output");
|
||||||
|
void ctx_1.requireBaseId;
|
||||||
|
function authCommand() {
|
||||||
|
const cmd = new commander_1.Command('auth');
|
||||||
|
cmd.description('Manage authentication and CLI config');
|
||||||
|
cmd
|
||||||
|
.command('status')
|
||||||
|
.description('Verify the configured token by calling GET /api/user/me')
|
||||||
|
.action((0, ctx_1.run)(async (ctx) => {
|
||||||
|
const me = (await ctx.client.getMe());
|
||||||
|
const cfg = (0, config_1.readConfigFile)();
|
||||||
|
if (ctx.json) {
|
||||||
|
(0, output_1.printJson)({ config: cfg, user: me });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['config path', (0, config_1.configPath)()],
|
||||||
|
['endpoint', cfg.endpoint || config_1.DEFAULT_ENDPOINT],
|
||||||
|
['token set', cfg.token ? `yes (${cfg.token.slice(0, 14)}…)` : 'no'],
|
||||||
|
['base id', cfg.baseId || '(none)'],
|
||||||
|
['', ''],
|
||||||
|
['user id', me?.id || 'unknown'],
|
||||||
|
['name', me?.name || 'unknown'],
|
||||||
|
['email', me?.email || 'unknown'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('login')
|
||||||
|
.description('Configure endpoint, token, and default base id')
|
||||||
|
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||||
|
.option('--token <pat>', 'Personal Access Token (format: teable_xxx_yyy)')
|
||||||
|
.option('--base-id <id>', 'Default base id for subsequent commands')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
// Merge global flags (--token/--endpoint/--base-id on the program) with
|
||||||
|
// this subcommand's own --endpoint/--token/--base-id options.
|
||||||
|
const g = ctx.program.opts();
|
||||||
|
const patch = {};
|
||||||
|
const endpoint = opts.endpoint || g.endpoint;
|
||||||
|
const tokenVal = opts.token || g.token;
|
||||||
|
const baseId = opts.baseId || g.baseId;
|
||||||
|
if (endpoint)
|
||||||
|
patch.endpoint = endpoint;
|
||||||
|
if (tokenVal)
|
||||||
|
patch.token = tokenVal;
|
||||||
|
if (baseId)
|
||||||
|
patch.baseId = baseId;
|
||||||
|
(0, config_1.writeConfigFile)(patch);
|
||||||
|
const cfg = (0, config_1.readConfigFile)();
|
||||||
|
if (ctx.json) {
|
||||||
|
(0, output_1.printJson)({ ok: true, config: cfg });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
process.stdout.write('✓ Configuration saved:\n');
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['endpoint', cfg.endpoint || config_1.DEFAULT_ENDPOINT],
|
||||||
|
['token', cfg.token ? `set (${cfg.token.slice(0, 14)}…)` : '(not set)'],
|
||||||
|
['base id', cfg.baseId || '(none)'],
|
||||||
|
['path', (0, config_1.configPath)()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const token = cmd.command('token').description('Manage Personal Access Tokens (PAT)');
|
||||||
|
token
|
||||||
|
.command('list')
|
||||||
|
.description('List your access tokens')
|
||||||
|
.action((0, ctx_1.run)(async (ctx) => {
|
||||||
|
const list = (await ctx.client.listAccessTokens());
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'scopes', header: 'SCOPES' },
|
||||||
|
{ key: 'expiredTime', header: 'EXPIRES' },
|
||||||
|
], list.map((t) => ({ ...t, scopes: (t.scopes || []).join(',') })));
|
||||||
|
}));
|
||||||
|
token
|
||||||
|
.command('create')
|
||||||
|
.description('Create a PAT. The full token is returned ONLY ONCE.')
|
||||||
|
.requiredOption('--name <name>', 'Token name')
|
||||||
|
.option('--scopes <csv>', 'Comma-separated scopes', 'automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update')
|
||||||
|
.option('--base-ids <csv>', 'Restrict to base ids (comma-separated). Omit for full access')
|
||||||
|
.option('--full-access', 'Grant full access')
|
||||||
|
.option('--expires <date>', 'Expiry date YYYY-MM-DD (default 2099-12-31)', '2099-12-31')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const scopes = opts.scopes.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const baseIds = opts['base-ids']
|
||||||
|
? opts['base-ids'].split(',').map((s) => s.trim())
|
||||||
|
: undefined;
|
||||||
|
const created = (await ctx.client.createAccessToken({
|
||||||
|
name: opts.name,
|
||||||
|
scopes,
|
||||||
|
baseIds,
|
||||||
|
hasFullAccess: !!opts['full-access'],
|
||||||
|
expiredTime: opts.expires,
|
||||||
|
}));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(created);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', created.id],
|
||||||
|
['token', created.token],
|
||||||
|
['', ''],
|
||||||
|
['note', 'Save this token now — it cannot be retrieved again.'],
|
||||||
|
['use', 'inkreach auth login --token <above>'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
token
|
||||||
|
.command('delete <id>')
|
||||||
|
.description('Revoke an access token')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const id = args[0];
|
||||||
|
await ctx.client.deleteAccessToken(id);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Token ${id} revoked\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.authCommand = authCommand;
|
||||||
Vendored
+288
@@ -0,0 +1,288 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.sqlCommand = exports.fireCommand = exports.runsCommand = exports.secretCommand = void 0;
|
||||||
|
/**
|
||||||
|
* `inkreach secret` — manage workflow automation secrets.
|
||||||
|
* `inkreach runs` — execution history.
|
||||||
|
* `inkreach fire` — manual trigger testing (button-click / webhook).
|
||||||
|
* `inkreach sql` — read-only SQL queries.
|
||||||
|
*/
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const ctx_1 = require("../lib/ctx");
|
||||||
|
const output_1 = require("../lib/output");
|
||||||
|
// ===================== secret =====================
|
||||||
|
function secretCommand() {
|
||||||
|
const cmd = new commander_1.Command('secret');
|
||||||
|
cmd.description('Manage automation script secrets');
|
||||||
|
cmd
|
||||||
|
.command('list <workflowId>')
|
||||||
|
.description('List secrets for a workflow. Values are never returned.')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listSecrets(baseId, workflowId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No secrets found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'description', header: 'DESCRIPTION' },
|
||||||
|
], list.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: (0, output_1.truncate)(s.name, 30),
|
||||||
|
description: (0, output_1.truncate)(s.description, 50),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('set <workflowId>')
|
||||||
|
.description('Create a new secret')
|
||||||
|
.requiredOption('--name <name>', 'Secret name')
|
||||||
|
.requiredOption('--value <value>', 'Secret value')
|
||||||
|
.option('--description <desc>', 'Secret description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const created = await ctx.client.createSecret(baseId, workflowId, {
|
||||||
|
name: opts.name,
|
||||||
|
value: opts.value,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
const s = created;
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(created);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', s.id],
|
||||||
|
['name', s.name],
|
||||||
|
['', ''],
|
||||||
|
['note', 'Secret value is never returned. Update it if needed.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId> <secretId>')
|
||||||
|
.description('Update a secret')
|
||||||
|
.option('--name <name>', 'New name')
|
||||||
|
.option('--value <value>', 'New value')
|
||||||
|
.option('--description <desc>', 'New description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, secretId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
await ctx.client.updateSecret(baseId, workflowId, secretId, {
|
||||||
|
name: opts.name,
|
||||||
|
value: opts.value,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: secretId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Secret ${secretId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId> <secretId>')
|
||||||
|
.description('Delete a secret')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, secretId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
await ctx.client.deleteSecret(baseId, workflowId, secretId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: secretId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Secret ${secretId} deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.secretCommand = secretCommand;
|
||||||
|
// ===================== runs =====================
|
||||||
|
function runsCommand() {
|
||||||
|
const cmd = new commander_1.Command('runs');
|
||||||
|
cmd.description('Execution history for automations');
|
||||||
|
cmd
|
||||||
|
.command('list <workflowId>')
|
||||||
|
.description('List execution history')
|
||||||
|
.option('--status <status>', 'Filter by status: failed, succeeded, or running')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
let list = (await ctx.client.listExecutions(baseId, workflowId));
|
||||||
|
if (opts.status) {
|
||||||
|
list = list.filter((e) => e.status === opts.status);
|
||||||
|
}
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No executions found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'triggerType', header: 'TRIGGER' },
|
||||||
|
{ key: 'startedAt', header: 'STARTED' },
|
||||||
|
{ key: 'finishedAt', header: 'FINISHED' },
|
||||||
|
], list.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
status: e.status,
|
||||||
|
triggerType: e.triggerType || '',
|
||||||
|
startedAt: (0, output_1.truncate)(e.startedAt, 25),
|
||||||
|
finishedAt: (0, output_1.truncate)(e.finishedAt, 25),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <workflowId> <executionId>')
|
||||||
|
.description('Get execution detail with node results')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, executionId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const exec = await ctx.client.getExecution(baseId, workflowId, executionId);
|
||||||
|
const ex = exec;
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(exec);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', ex.id],
|
||||||
|
['status', ex.status],
|
||||||
|
['trigger', ex.triggerType || ''],
|
||||||
|
['started', ex.startedAt || ''],
|
||||||
|
['finished', ex.finishedAt || ''],
|
||||||
|
]);
|
||||||
|
if (ex.nodeResults?.length) {
|
||||||
|
process.stdout.write('\nNODE RESULTS:\n');
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'nodeId', header: 'NODE' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'error', header: 'ERROR' },
|
||||||
|
], ex.nodeResults.map((r) => ({
|
||||||
|
nodeId: r.nodeId,
|
||||||
|
status: r.status,
|
||||||
|
error: (0, output_1.truncate)(r.error, 50),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('rerun <workflowId> <executionId>')
|
||||||
|
.description('Rerun a failed execution')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, executionId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const res = (await ctx.client.rerunExecution(baseId, workflowId, executionId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(res);
|
||||||
|
else {
|
||||||
|
const pairs = [
|
||||||
|
['executionId', executionId],
|
||||||
|
['status', 'rerun requested'],
|
||||||
|
];
|
||||||
|
if (res.id)
|
||||||
|
pairs.push(['newExecutionId', res.id]);
|
||||||
|
(0, output_1.printKv)(pairs);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.runsCommand = runsCommand;
|
||||||
|
// ===================== fire =====================
|
||||||
|
function fireCommand() {
|
||||||
|
const cmd = new commander_1.Command('fire');
|
||||||
|
cmd.description('Manually fire triggers for testing');
|
||||||
|
cmd
|
||||||
|
.command('button-click <tableId> <recordId> <fieldId>')
|
||||||
|
.description('Simulate a button field click')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const [tableId, recordId, fieldId] = args;
|
||||||
|
const res = await ctx.client.fireButtonClick(tableId, recordId, fieldId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(res);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['runId', res.runId],
|
||||||
|
['tableId', res.tableId],
|
||||||
|
['fieldId', res.fieldId],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('webhook <baseId> <workflowId>')
|
||||||
|
.description('Simulate a webhook call')
|
||||||
|
.option('--body <json>', 'JSON body to send')
|
||||||
|
.option('--method <method>', 'HTTP method (informational only)', 'POST')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [baseId, workflowId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const body = opts.body ? JSON.parse(opts.body) : undefined;
|
||||||
|
const res = await ctx.client.fireWebhook(baseId, workflowId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(res);
|
||||||
|
else {
|
||||||
|
process.stdout.write(`✓ Webhook fired for workflow ${workflowId}\n`);
|
||||||
|
if (typeof res === 'object' && res !== null) {
|
||||||
|
(0, output_1.printJson)(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.fireCommand = fireCommand;
|
||||||
|
// ===================== sql =====================
|
||||||
|
function sqlCommand() {
|
||||||
|
const cmd = new commander_1.Command('sql');
|
||||||
|
cmd.description('Execute a read-only SQL query (SELECT only)');
|
||||||
|
cmd.option('--base-id <id>', 'Base id (overrides default)');
|
||||||
|
cmd.argument('<sql>', 'SQL SELECT statement');
|
||||||
|
cmd.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const sql = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const rows = (await ctx.client.sql(baseId, sql));
|
||||||
|
if (ctx.json) {
|
||||||
|
(0, output_1.printJson)(rows);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!rows.length) {
|
||||||
|
process.stdout.write('No rows returned.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = rows[0];
|
||||||
|
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
|
||||||
|
const keys = Object.keys(first);
|
||||||
|
(0, output_1.printTable)(keys.map((k) => ({ key: k, header: k })), rows.map((r) => {
|
||||||
|
const obj = r;
|
||||||
|
const row = {};
|
||||||
|
for (const k of keys) {
|
||||||
|
row[k] = (0, output_1.truncate)(obj[k], 40);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
process.stdout.write(`\n${rows.length} row(s).\n`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.sqlCommand = sqlCommand;
|
||||||
Vendored
+462
@@ -0,0 +1,462 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.baseResourceCommands = void 0;
|
||||||
|
/**
|
||||||
|
* `inkreach base|table|field|record|view` — resource CRUD commands.
|
||||||
|
*
|
||||||
|
* These endpoints are compatible with official teable open-api.
|
||||||
|
*/
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const ctx_1 = require("../lib/ctx");
|
||||||
|
const output_1 = require("../lib/output");
|
||||||
|
function baseResourceCommands() {
|
||||||
|
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||||
|
}
|
||||||
|
exports.baseResourceCommands = baseResourceCommands;
|
||||||
|
// ===================== base =====================
|
||||||
|
function baseCmd() {
|
||||||
|
const cmd = new commander_1.Command('base');
|
||||||
|
cmd.description('Manage bases (database containers)');
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List all accessible bases')
|
||||||
|
.action((0, ctx_1.run)(async (ctx) => {
|
||||||
|
const list = (await ctx.client.listBases());
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No bases found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
], list.map((b) => ({ id: (0, output_1.truncate)(b.id), name: (0, output_1.truncate)(b.name, 30) })));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <baseId>')
|
||||||
|
.description('Get base details')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const baseId = args[0];
|
||||||
|
const base = await ctx.client.getBase(baseId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(base);
|
||||||
|
else {
|
||||||
|
const b = base;
|
||||||
|
(0, output_1.printKv)(Object.entries(b).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
// ===================== table =====================
|
||||||
|
function tableCmd() {
|
||||||
|
const cmd = new commander_1.Command('table');
|
||||||
|
cmd.description('Manage tables within a base');
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List tables in a base')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listTables(baseId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No tables found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'dbTableName', header: 'DB TABLE' },
|
||||||
|
], list.map((t) => ({
|
||||||
|
id: (0, output_1.truncate)(t.id),
|
||||||
|
name: (0, output_1.truncate)(t.name, 30),
|
||||||
|
dbTableName: (0, output_1.truncate)(t.dbTableName ?? ''),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <tableId>')
|
||||||
|
.description('Get table details')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const tableId = args[0];
|
||||||
|
const table = await ctx.client.getTable(tableId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(table);
|
||||||
|
else {
|
||||||
|
const t = table;
|
||||||
|
(0, output_1.printKv)(Object.entries(t).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new table')
|
||||||
|
.requiredOption('--name <name>', 'Table name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.option('--fields <json>', 'Field definitions JSON array, e.g. \'[{"name":"Title","type":"singleLineText"}]\'')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const body = { name: opts.name };
|
||||||
|
if (opts.fields)
|
||||||
|
body.fields = JSON.parse(opts.fields);
|
||||||
|
const table = await ctx.client.createTable(baseId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(table);
|
||||||
|
else {
|
||||||
|
const t = table;
|
||||||
|
(0, output_1.printKv)([['id', t.id], ['name', t.name]]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <tableId>')
|
||||||
|
.description('Update table (rename)')
|
||||||
|
.requiredOption('--name <name>', 'New table name')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const tableId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const table = await ctx.client.updateTable(tableId, { name: opts.name });
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(table);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Table ${tableId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <tableId>')
|
||||||
|
.description('Delete a table')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const tableId = args[0];
|
||||||
|
await ctx.client.deleteTable(tableId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: tableId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Table ${tableId} deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
// ===================== field =====================
|
||||||
|
function fieldCmd() {
|
||||||
|
const cmd = new commander_1.Command('field');
|
||||||
|
cmd.description('Manage fields (columns) in a table');
|
||||||
|
cmd
|
||||||
|
.command('list <tableId>')
|
||||||
|
.description('List fields in a table')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const tableId = args[0];
|
||||||
|
const list = (await ctx.client.listFields(tableId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No fields found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
], list.map((f) => ({
|
||||||
|
id: (0, output_1.truncate)(f.id),
|
||||||
|
name: (0, output_1.truncate)(f.name, 30),
|
||||||
|
type: f.type,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <fieldId>')
|
||||||
|
.description('Get field details')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const fieldId = args[0];
|
||||||
|
const field = await ctx.client.getField(fieldId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(field);
|
||||||
|
else {
|
||||||
|
const f = field;
|
||||||
|
(0, output_1.printKv)(Object.entries(f).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new field')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--name <name>', 'Field name')
|
||||||
|
.requiredOption('--type <type>', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)')
|
||||||
|
.option('--options <json>', 'Field options JSON (type-specific)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const body = { name: opts.name, type: opts.type };
|
||||||
|
if (opts.options)
|
||||||
|
body.options = JSON.parse(opts.options);
|
||||||
|
const field = await ctx.client.createField(opts.tableId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(field);
|
||||||
|
else {
|
||||||
|
const f = field;
|
||||||
|
(0, output_1.printKv)([['id', f.id], ['name', f.name], ['type', f.type]]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <fieldId>')
|
||||||
|
.description('Update a field')
|
||||||
|
.option('--name <name>', 'New field name')
|
||||||
|
.option('--options <json>', 'New field options JSON')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const fieldId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const body = {};
|
||||||
|
if (opts.name)
|
||||||
|
body.name = opts.name;
|
||||||
|
if (opts.options)
|
||||||
|
body.options = JSON.parse(opts.options);
|
||||||
|
const field = await ctx.client.updateField(fieldId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(field);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Field ${fieldId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <fieldId>')
|
||||||
|
.description('Delete a field')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const fieldId = args[0];
|
||||||
|
await ctx.client.deleteField(fieldId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: fieldId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Field ${fieldId} deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
// ===================== record =====================
|
||||||
|
function recordCmd() {
|
||||||
|
const cmd = new commander_1.Command('record');
|
||||||
|
cmd.description('Manage records (rows) in a table');
|
||||||
|
cmd
|
||||||
|
.command('get')
|
||||||
|
.description('Query records with filters, pagination, and search')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--filter <json>', 'Filter conditions JSON')
|
||||||
|
.option('--projection <json>', 'Field projection JSON array')
|
||||||
|
.option('--page-size <n>', 'Records per page')
|
||||||
|
.option('--page <n>', 'Page number (1-based)')
|
||||||
|
.option('--search <kw>', 'Search keyword')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
// validate baseId is present (even though getRecords only needs tableId,
|
||||||
|
// we follow the convention that base-scoped commands require baseId config)
|
||||||
|
(0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const query = {};
|
||||||
|
if (opts.filter)
|
||||||
|
query.filter = JSON.parse(opts.filter);
|
||||||
|
if (opts.projection)
|
||||||
|
query.projection = JSON.parse(opts.projection);
|
||||||
|
if (opts.search)
|
||||||
|
query.search = opts.search;
|
||||||
|
const page = opts.page ? parseInt(opts.page, 10) : undefined;
|
||||||
|
const pageSize = opts.pageSize ? parseInt(opts.pageSize, 10) : undefined;
|
||||||
|
if (pageSize)
|
||||||
|
query.take = pageSize;
|
||||||
|
if (page) {
|
||||||
|
const take = pageSize || 100;
|
||||||
|
query.skip = (page - 1) * take;
|
||||||
|
if (!pageSize)
|
||||||
|
query.take = take;
|
||||||
|
}
|
||||||
|
const result = (await ctx.client.getRecords(opts.tableId, query));
|
||||||
|
if (ctx.json) {
|
||||||
|
(0, output_1.printJson)(result);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!result.records?.length) {
|
||||||
|
process.stdout.write('No records found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstFields = result.records[0].fields || {};
|
||||||
|
const fieldKeys = Object.keys(firstFields);
|
||||||
|
const columns = fieldKeys.map((k) => ({ key: k, header: k }));
|
||||||
|
(0, output_1.printTable)(columns, result.records.map((rec) => {
|
||||||
|
const f = rec.fields || {};
|
||||||
|
const row = { id: (0, output_1.truncate)(rec.id) };
|
||||||
|
for (const k of fieldKeys) {
|
||||||
|
row[k] = (0, output_1.truncate)(f[k], 30);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
if (result.total !== undefined) {
|
||||||
|
process.stdout.write(`\n${result.records.length} of ${result.total} record(s).\n`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
process.stdout.write(`\n${result.records.length} record(s).\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get-one <recordId>')
|
||||||
|
.description('Get a single record by id')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const recordId = args[0];
|
||||||
|
const record = await ctx.client.getRecord(recordId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(record);
|
||||||
|
else {
|
||||||
|
const rec = record;
|
||||||
|
const fields = rec.fields || {};
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', rec.id],
|
||||||
|
...Object.entries(fields).map(([k, v]) => [k, v]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create record(s). Use --fields for a single record, --records for bulk.')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
|
||||||
|
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
let body;
|
||||||
|
if (opts.records) {
|
||||||
|
body = { records: JSON.parse(opts.records) };
|
||||||
|
}
|
||||||
|
else if (opts.fields) {
|
||||||
|
body = { fields: JSON.parse(opts.fields) };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw Object.assign(new Error('Provide --fields or --records'), {
|
||||||
|
code: 'MISSING_FIELDS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await ctx.client.createRecord(opts.tableId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(result);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Record(s) created\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update')
|
||||||
|
.description('Batch update records')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--records <json>', 'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\'')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const body = { records: JSON.parse(opts.records) };
|
||||||
|
const result = await ctx.client.updateRecord(opts.tableId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(result);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Records updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete')
|
||||||
|
.description('Batch delete records by ids')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const ids = opts.recordIds
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const body = { records: ids };
|
||||||
|
const result = await ctx.client.deleteRecord(opts.tableId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(result);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
// ===================== view =====================
|
||||||
|
function viewCmd() {
|
||||||
|
const cmd = new commander_1.Command('view');
|
||||||
|
cmd.description('Manage views in a table');
|
||||||
|
cmd
|
||||||
|
.command('list <tableId>')
|
||||||
|
.description('List views in a table')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const tableId = args[0];
|
||||||
|
const list = (await ctx.client.listViews(tableId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No views found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
], list.map((v) => ({
|
||||||
|
id: (0, output_1.truncate)(v.id),
|
||||||
|
name: (0, output_1.truncate)(v.name, 30),
|
||||||
|
type: v.type,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <viewId>')
|
||||||
|
.description('Get view details')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const viewId = args[0];
|
||||||
|
const view = await ctx.client.getView(viewId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(view);
|
||||||
|
else {
|
||||||
|
const v = view;
|
||||||
|
(0, output_1.printKv)(Object.entries(v).map(([k, val]) => [k, val]));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new view')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--name <name>', 'View name')
|
||||||
|
.option('--type <type>', 'View type (grid, calendar, kanban, gallery, form)', 'grid')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const body = { name: opts.name, type: opts.type };
|
||||||
|
const view = await ctx.client.createView(opts.tableId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(view);
|
||||||
|
else {
|
||||||
|
const v = view;
|
||||||
|
(0, output_1.printKv)([['id', v.id], ['name', v.name], ['type', v.type]]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <viewId>')
|
||||||
|
.description('Update a view (rename)')
|
||||||
|
.option('--name <name>', 'New view name')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const viewId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const body = {};
|
||||||
|
if (opts.name)
|
||||||
|
body.name = opts.name;
|
||||||
|
const view = await ctx.client.updateView(viewId, body);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(view);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ View ${viewId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <viewId>')
|
||||||
|
.description('Delete a view')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
|
const viewId = args[0];
|
||||||
|
await ctx.client.deleteView(viewId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: viewId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ View ${viewId} deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
Vendored
+520
@@ -0,0 +1,520 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.buildActionConfig = exports.actionCommand = exports.buildTriggerConfig = exports.triggerCommand = exports.workflowCommand = void 0;
|
||||||
|
/**
|
||||||
|
* `inkreach workflow` — workflow CRUD + lifecycle.
|
||||||
|
* `inkreach trigger` — set the trigger node's type & config.
|
||||||
|
* `inkreach action` — add/update/delete action nodes.
|
||||||
|
*
|
||||||
|
* All node-mutating commands operate via PUT /node (whole-graph replacement),
|
||||||
|
* so they internally GET the full graph, mutate, then PUT it back.
|
||||||
|
*/
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const ctx_1 = require("../lib/ctx");
|
||||||
|
const output_1 = require("../lib/output");
|
||||||
|
// ===================== workflow =====================
|
||||||
|
function workflowCommand() {
|
||||||
|
const cmd = new commander_1.Command('workflow');
|
||||||
|
cmd.description('Manage automations (workflows) — the self-built automation engine');
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List all workflows in the base')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listWorkflows(baseId));
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No workflows found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'isActive', header: 'ACTIVE' },
|
||||||
|
{ key: 'hasDraft', header: 'DRAFT' },
|
||||||
|
{ key: 'nodes', header: 'NODES' },
|
||||||
|
], list.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: (0, output_1.truncate)(w.name, 30),
|
||||||
|
isActive: w.isActive ? '✓' : '',
|
||||||
|
hasDraft: w.hasDraft ? '*' : '',
|
||||||
|
nodes: (w.nodes || []).length,
|
||||||
|
})));
|
||||||
|
process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('get <workflowId>')
|
||||||
|
.description('Get workflow detail (nodes + edges)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(wf);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', wf.id],
|
||||||
|
['name', wf.name],
|
||||||
|
['description', wf.description || ''],
|
||||||
|
['active', wf.isActive ? 'yes' : 'no'],
|
||||||
|
['hasDraft', wf.hasDraft ? 'yes' : 'no'],
|
||||||
|
]);
|
||||||
|
process.stdout.write('\nNODES:\n');
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
{ key: 'category', header: 'CATEGORY' },
|
||||||
|
{ key: 'config', header: 'CONFIG' },
|
||||||
|
], (wf.nodes || []).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
category: n.category,
|
||||||
|
config: (0, output_1.truncate)(JSON.stringify(n.config), 40),
|
||||||
|
})));
|
||||||
|
if (wf.edges?.length) {
|
||||||
|
process.stdout.write('\nEDGES:\n');
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'sourceNodeId', header: 'SOURCE' },
|
||||||
|
{ key: 'targetNodeId', header: 'TARGET' },
|
||||||
|
], (wf.edges || []).map((e) => ({ ...e })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a workflow (auto-includes a recordCreated trigger node)')
|
||||||
|
.requiredOption('--name <name>', 'Workflow name')
|
||||||
|
.option('--description <desc>', 'Workflow description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.createWorkflow(baseId, {
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(wf);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['id', wf.id],
|
||||||
|
['name', wf.name],
|
||||||
|
['baseId', wf.baseId],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach trigger set ${wf.id} --type <T> ...`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId>')
|
||||||
|
.description('Update workflow name / description')
|
||||||
|
.option('--name <name>', 'New name')
|
||||||
|
.option('--description <desc>', 'New description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.updateWorkflow(baseId, workflowId, {
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(wf);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Workflow ${workflowId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId>')
|
||||||
|
.description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
await ctx.client.deleteWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: workflowId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Workflow ${workflowId} deleted\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('activate <workflowId>')
|
||||||
|
.description('Activate: snapshot + register trigger + create auto access token')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const res = await ctx.client.activateWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, ...res });
|
||||||
|
else
|
||||||
|
(0, output_1.printKv)([['snapshotId', res.snapshotId], ['status', 'activated']]);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('deactivate <workflowId>')
|
||||||
|
.description('Deactivate: unregister trigger')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
await ctx.client.deactivateWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)({ ok: true, id: workflowId });
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Workflow ${workflowId} deactivated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('test <workflowId>')
|
||||||
|
.description('Test-run: execute action nodes with mock input (no trigger, no queue)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const res = await ctx.client.testWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(res);
|
||||||
|
else {
|
||||||
|
process.stdout.write('Test results:\n');
|
||||||
|
(0, output_1.printTable)([
|
||||||
|
{ key: 'nodeId', header: 'NODE' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'error', header: 'ERROR' },
|
||||||
|
], (res.nodeResults || []).map((r) => {
|
||||||
|
const row = r;
|
||||||
|
return {
|
||||||
|
nodeId: row.nodeId,
|
||||||
|
status: row.status,
|
||||||
|
error: (0, output_1.truncate)(row.error, 50),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.workflowCommand = workflowCommand;
|
||||||
|
// ===================== trigger =====================
|
||||||
|
const TRIGGER_TYPES = [
|
||||||
|
'recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated',
|
||||||
|
'formSubmitted', 'scheduledTime', 'buttonClick', 'webhook',
|
||||||
|
];
|
||||||
|
function triggerCommand() {
|
||||||
|
const cmd = new commander_1.Command('trigger');
|
||||||
|
cmd.description('Set the trigger node (type + config). Builds correct config keys for you.');
|
||||||
|
cmd
|
||||||
|
.command('set <workflowId>')
|
||||||
|
.description('Set the trigger type and config (replaces the existing trigger node)')
|
||||||
|
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
|
||||||
|
.option('--table-id <id>', 'Table id (most record/button triggers)')
|
||||||
|
.option('--field-ids <json>', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)')
|
||||||
|
.option('--form-id <id>', 'Form view id (formSubmitted)')
|
||||||
|
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
|
||||||
|
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
|
||||||
|
.option('--batch <json>', 'Batch config JSON {enabled,batchWindow,batchSize}')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
if (!TRIGGER_TYPES.includes(opts.type)) {
|
||||||
|
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), { code: 'INVALID_TYPE' });
|
||||||
|
}
|
||||||
|
const config = buildTriggerConfig(opts.type, {
|
||||||
|
tableId: opts.tableId,
|
||||||
|
fieldIds: opts.fieldIds,
|
||||||
|
formId: opts.formId,
|
||||||
|
conditions: opts.conditions,
|
||||||
|
schedule: opts.schedule,
|
||||||
|
batch: opts.batch,
|
||||||
|
});
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
// Find the trigger node (there is exactly one) and replace its category + config.
|
||||||
|
const triggerIdx = nodes.findIndex((n) => n.type === 'trigger');
|
||||||
|
const newTrigger = {
|
||||||
|
...nodes[triggerIdx >= 0 ? triggerIdx : 0],
|
||||||
|
type: 'trigger',
|
||||||
|
category: opts.type,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
if (triggerIdx >= 0)
|
||||||
|
nodes[triggerIdx] = newTrigger;
|
||||||
|
else
|
||||||
|
nodes.unshift(newTrigger);
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
|
nodes,
|
||||||
|
edges: wf.edges || [],
|
||||||
|
});
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(updated);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['workflowId', workflowId],
|
||||||
|
['triggerType', opts.type],
|
||||||
|
['config', JSON.stringify(config)],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach action add ${workflowId} --type script ...`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.triggerCommand = triggerCommand;
|
||||||
|
/** Build the trigger node config JSON using the SELF-BUILT contract keys. */
|
||||||
|
function buildTriggerConfig(type, opts) {
|
||||||
|
const parse = (s) => (s ? JSON.parse(s) : undefined);
|
||||||
|
const config = {};
|
||||||
|
switch (type) {
|
||||||
|
case 'recordCreated':
|
||||||
|
case 'recordCreatedOrUpdated': {
|
||||||
|
if (opts.tableId)
|
||||||
|
config.tableId = opts.tableId;
|
||||||
|
if (opts.conditions)
|
||||||
|
config.conditions = parse(opts.conditions);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'recordUpdated': {
|
||||||
|
if (opts.tableId)
|
||||||
|
config.tableId = opts.tableId;
|
||||||
|
// NOTE: key is `fieldIds` (NOT watchFieldIds). [] = watch all fields.
|
||||||
|
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'recordCondition': {
|
||||||
|
if (opts.tableId)
|
||||||
|
config.tableId = opts.tableId;
|
||||||
|
// conditions is required for recordCondition
|
||||||
|
config.conditions = opts.conditions ? parse(opts.conditions) : { conjunction: 'and', filterSet: [] };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'formSubmitted': {
|
||||||
|
if (opts.tableId)
|
||||||
|
config.tableId = opts.tableId;
|
||||||
|
if (opts.formId)
|
||||||
|
config.formId = opts.formId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'scheduledTime': {
|
||||||
|
// Backend ONLY honors timing.type + timing.interval (rest is ignored).
|
||||||
|
if (opts.schedule) {
|
||||||
|
const sched = parse(opts.schedule);
|
||||||
|
if (sched.timing)
|
||||||
|
config.timing = sched.timing;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'buttonClick': {
|
||||||
|
if (opts.tableId)
|
||||||
|
config.tableId = opts.tableId;
|
||||||
|
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'webhook': {
|
||||||
|
// authorization is UI-only; backend does NOT validate it.
|
||||||
|
config.authorization = { type: 'none' };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (opts.batch && ['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)) {
|
||||||
|
config.batch = parse(opts.batch);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
exports.buildTriggerConfig = buildTriggerConfig;
|
||||||
|
// ===================== action =====================
|
||||||
|
const ACTION_TYPES = [
|
||||||
|
'script', 'sendEmail', 'httpRequest', 'createRecord', 'updateRecord', 'getRecords',
|
||||||
|
];
|
||||||
|
function actionCommand() {
|
||||||
|
const cmd = new commander_1.Command('action');
|
||||||
|
cmd.description('Add/update/delete action nodes');
|
||||||
|
cmd
|
||||||
|
.command('add <workflowId>')
|
||||||
|
.description('Add an action node after the last existing node')
|
||||||
|
.requiredOption('--type <type>', `Action type: ${ACTION_TYPES.join('|')}`)
|
||||||
|
.option('--config <json>', 'Full action config JSON (overrides the granular flags below)')
|
||||||
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
|
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
|
||||||
|
.option('--to <email>', 'Recipient (sendEmail)')
|
||||||
|
.option('--subject <s>', 'Subject (sendEmail)')
|
||||||
|
.option('--body <html>', 'HTML body (sendEmail)')
|
||||||
|
.option('--url <u>', 'Request URL (httpRequest)')
|
||||||
|
.option('--method <m>', 'HTTP method (httpRequest, default GET)')
|
||||||
|
.option('--headers <json>', 'Request headers JSON (httpRequest)')
|
||||||
|
.option('--req-body <json>', 'Request body JSON (httpRequest)')
|
||||||
|
.option('--target-table <id>', 'Target table id (createRecord/updateRecord/getRecords)')
|
||||||
|
.option('--records <json>', 'Records JSON array (createRecord/updateRecord)')
|
||||||
|
.option('--query <json>', 'Query JSON (getRecords)')
|
||||||
|
.option('--name <n>', 'Optional node display name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0];
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
if (!ACTION_TYPES.includes(opts.type)) {
|
||||||
|
throw Object.assign(new Error(`Invalid action type: ${opts.type}`), { code: 'INVALID_TYPE' });
|
||||||
|
}
|
||||||
|
const config = opts.config ? JSON.parse(opts.config) : buildActionConfig(opts);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
const edges = (wf.edges || []).slice();
|
||||||
|
const newNodeId = `node-${Date.now()}`;
|
||||||
|
const newNode = {
|
||||||
|
id: newNodeId,
|
||||||
|
type: 'action',
|
||||||
|
category: opts.type,
|
||||||
|
config,
|
||||||
|
name: opts.name || null,
|
||||||
|
};
|
||||||
|
// Chain after the last node (trigger → ... → new).
|
||||||
|
const lastNode = nodes[nodes.length - 1];
|
||||||
|
nodes.push(newNode);
|
||||||
|
if (lastNode) {
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${lastNode.id}-${newNodeId}`,
|
||||||
|
sourceNodeId: lastNode.id,
|
||||||
|
targetNodeId: newNodeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(updated);
|
||||||
|
else {
|
||||||
|
(0, output_1.printKv)([
|
||||||
|
['workflowId', workflowId],
|
||||||
|
['nodeId', newNodeId],
|
||||||
|
['type', opts.type],
|
||||||
|
['config', JSON.stringify(config)],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach workflow activate ${workflowId}`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId> <nodeId>')
|
||||||
|
.description('Update an existing action node config')
|
||||||
|
.option('--config <json>', 'Full replacement config JSON')
|
||||||
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
|
.option('--name <n>', 'New node display name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, nodeId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
const idx = nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (idx < 0)
|
||||||
|
throw Object.assign(new Error(`Node ${nodeId} not found`), { code: 'NOT_FOUND' });
|
||||||
|
const node = nodes[idx];
|
||||||
|
let config = node.config || {};
|
||||||
|
if (opts.config)
|
||||||
|
config = JSON.parse(opts.config);
|
||||||
|
else if (opts.code !== undefined || opts['code-file'] !== undefined) {
|
||||||
|
config = { ...config, code: opts['code-file'] ? readFile(opts['code-file']) : opts.code };
|
||||||
|
}
|
||||||
|
nodes[idx] = { ...node, config, name: opts.name ?? node.name };
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
|
nodes,
|
||||||
|
edges: wf.edges || [],
|
||||||
|
});
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(updated);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Node ${nodeId} updated\n`);
|
||||||
|
}));
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId> <nodeId>')
|
||||||
|
.description('Delete an action node and its connected edges')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
|
const [workflowId, nodeId] = args;
|
||||||
|
const opts = o;
|
||||||
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).filter((n) => n.id !== nodeId);
|
||||||
|
const edges = (wf.edges || []).filter((e) => e.sourceNodeId !== nodeId && e.targetNodeId !== nodeId);
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||||
|
if (ctx.json)
|
||||||
|
(0, output_1.printJson)(updated);
|
||||||
|
else
|
||||||
|
process.stdout.write(`✓ Node ${nodeId} deleted\n`);
|
||||||
|
}));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
exports.actionCommand = actionCommand;
|
||||||
|
function readFile(p) {
|
||||||
|
// inline require to keep imports clean
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
return require('node:fs').readFileSync(p, 'utf8');
|
||||||
|
}
|
||||||
|
/** Build action config from granular flags. */
|
||||||
|
function buildActionConfig(opts) {
|
||||||
|
const type = opts.type;
|
||||||
|
const config = {};
|
||||||
|
switch (type) {
|
||||||
|
case 'script': {
|
||||||
|
if (opts['code-file'])
|
||||||
|
config.code = readFile(opts['code-file']);
|
||||||
|
else if (opts.code)
|
||||||
|
config.code = opts.code;
|
||||||
|
if (opts.timeout)
|
||||||
|
config.timeout = Number(opts.timeout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sendEmail': {
|
||||||
|
if (opts.to)
|
||||||
|
config.to = opts.to;
|
||||||
|
if (opts.subject)
|
||||||
|
config.subject = opts.subject;
|
||||||
|
if (opts.body)
|
||||||
|
config.body = opts.body;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'httpRequest': {
|
||||||
|
if (opts.url)
|
||||||
|
config.url = opts.url;
|
||||||
|
if (opts.method)
|
||||||
|
config.method = opts.method;
|
||||||
|
if (opts.headers)
|
||||||
|
config.headers = JSON.parse(opts.headers);
|
||||||
|
if (opts['req-body'])
|
||||||
|
config.body = JSON.parse(opts['req-body']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'createRecord':
|
||||||
|
case 'updateRecord': {
|
||||||
|
if (opts['target-table'])
|
||||||
|
config.tableId = opts['target-table'];
|
||||||
|
if (opts.records)
|
||||||
|
config.records = JSON.parse(opts.records);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'getRecords': {
|
||||||
|
if (opts['target-table'])
|
||||||
|
config.tableId = opts['target-table'];
|
||||||
|
if (opts.query)
|
||||||
|
config.query = JSON.parse(opts.query);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
exports.buildActionConfig = buildActionConfig;
|
||||||
Vendored
+211
@@ -0,0 +1,211 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ApiError = exports.Client = void 0;
|
||||||
|
/**
|
||||||
|
* High-level API client. Each method maps to one REST endpoint of the
|
||||||
|
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
||||||
|
*
|
||||||
|
* Base/table/field/record/view endpoints are compatible with official teable
|
||||||
|
* open-api; workflow/automation endpoints use the project's own contract.
|
||||||
|
*/
|
||||||
|
const request_1 = require("./request");
|
||||||
|
Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return request_1.ApiError; } });
|
||||||
|
class Client {
|
||||||
|
cfg;
|
||||||
|
constructor(cfg) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
}
|
||||||
|
get headers() {
|
||||||
|
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
||||||
|
}
|
||||||
|
async call(method, p, body) {
|
||||||
|
const res = await (0, request_1.request)(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
// ---- auth / user ----
|
||||||
|
getMe() {
|
||||||
|
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
||||||
|
// (/api/auth/user/me is session-only and 403s for PAT.)
|
||||||
|
return this.call('GET', '/api/auth/user');
|
||||||
|
}
|
||||||
|
// ---- access tokens (PAT) ----
|
||||||
|
listAccessTokens() {
|
||||||
|
return this.call('GET', '/api/access-token');
|
||||||
|
}
|
||||||
|
createAccessToken(body) {
|
||||||
|
return this.call('POST', '/api/access-token', body);
|
||||||
|
}
|
||||||
|
deleteAccessToken(id) {
|
||||||
|
return this.call('DELETE', `/api/access-token/${id}`);
|
||||||
|
}
|
||||||
|
// ---- base ----
|
||||||
|
listBases() {
|
||||||
|
// GET /api/base/access/all — lists bases the user can access.
|
||||||
|
return this.call('GET', '/api/base/access/all');
|
||||||
|
}
|
||||||
|
getBase(baseId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}`);
|
||||||
|
}
|
||||||
|
// ---- table ----
|
||||||
|
listTables(baseId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/table`);
|
||||||
|
}
|
||||||
|
getTable(tableId) {
|
||||||
|
return this.call('GET', `/api/table/${tableId}`);
|
||||||
|
}
|
||||||
|
createTable(baseId, body) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/table`, body);
|
||||||
|
}
|
||||||
|
updateTable(tableId, body) {
|
||||||
|
return this.call('PUT', `/api/table/${tableId}`, body);
|
||||||
|
}
|
||||||
|
deleteTable(tableId) {
|
||||||
|
return this.call('DELETE', `/api/table/${tableId}`);
|
||||||
|
}
|
||||||
|
// ---- field ----
|
||||||
|
listFields(tableId) {
|
||||||
|
return this.call('GET', `/api/table/${tableId}/field`);
|
||||||
|
}
|
||||||
|
getField(fieldId) {
|
||||||
|
return this.call('GET', `/api/field/${fieldId}`);
|
||||||
|
}
|
||||||
|
createField(tableId, body) {
|
||||||
|
return this.call('POST', `/api/table/${tableId}/field`, body);
|
||||||
|
}
|
||||||
|
updateField(fieldId, body) {
|
||||||
|
return this.call('PUT', `/api/field/${fieldId}`, body);
|
||||||
|
}
|
||||||
|
deleteField(fieldId) {
|
||||||
|
return this.call('DELETE', `/api/field/${fieldId}`);
|
||||||
|
}
|
||||||
|
// ---- record ----
|
||||||
|
getRecords(tableId, query) {
|
||||||
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call('GET', `/api/table/${tableId}/record${qs}`);
|
||||||
|
}
|
||||||
|
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
||||||
|
flattenQuery(query) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v === undefined || v === null)
|
||||||
|
continue;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
v.forEach((item) => {
|
||||||
|
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (typeof v === 'object') {
|
||||||
|
out[k] = JSON.stringify(v);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
out[k] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
getRecord(recordId, query) {
|
||||||
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call('GET', `/api/record/${recordId}${qs}`);
|
||||||
|
}
|
||||||
|
createRecord(tableId, body) {
|
||||||
|
return this.call('POST', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
updateRecord(tableId, body) {
|
||||||
|
return this.call('PATCH', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
updateRecordOne(recordId, body) {
|
||||||
|
return this.call('PATCH', `/api/record/${recordId}`, body);
|
||||||
|
}
|
||||||
|
deleteRecord(tableId, body) {
|
||||||
|
return this.call('DELETE', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
// ---- view ----
|
||||||
|
listViews(tableId) {
|
||||||
|
return this.call('GET', `/api/table/${tableId}/view`);
|
||||||
|
}
|
||||||
|
getView(viewId) {
|
||||||
|
return this.call('GET', `/api/view/${viewId}`);
|
||||||
|
}
|
||||||
|
createView(tableId, body) {
|
||||||
|
return this.call('POST', `/api/table/${tableId}/view`, body);
|
||||||
|
}
|
||||||
|
updateView(viewId, body) {
|
||||||
|
return this.call('PATCH', `/api/view/${viewId}`, body);
|
||||||
|
}
|
||||||
|
deleteView(viewId) {
|
||||||
|
return this.call('DELETE', `/api/view/${viewId}`);
|
||||||
|
}
|
||||||
|
// ---- workflow (self-built contract) ----
|
||||||
|
listWorkflows(baseId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/workflow`);
|
||||||
|
}
|
||||||
|
getWorkflow(workflowId) {
|
||||||
|
return this.call('GET', `/api/base/any/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
||||||
|
* route that resolves base from the workflow itself, but some auth/permission
|
||||||
|
* paths need the base-scoped route. Prefer this when baseId is known.
|
||||||
|
*/
|
||||||
|
getWorkflowInBase(baseId, workflowId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
createWorkflow(baseId, body) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow`, body);
|
||||||
|
}
|
||||||
|
updateWorkflow(baseId, workflowId, body) {
|
||||||
|
return this.call('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
||||||
|
}
|
||||||
|
deleteWorkflow(baseId, workflowId) {
|
||||||
|
return this.call('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
activateWorkflow(baseId, workflowId) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/activate`);
|
||||||
|
}
|
||||||
|
deactivateWorkflow(baseId, workflowId) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
||||||
|
}
|
||||||
|
testWorkflow(baseId, workflowId) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/test`);
|
||||||
|
}
|
||||||
|
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
||||||
|
replaceNodes(baseId, workflowId, body) {
|
||||||
|
return this.call('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
||||||
|
}
|
||||||
|
// ---- secrets ----
|
||||||
|
listSecrets(baseId, workflowId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
||||||
|
}
|
||||||
|
createSecret(baseId, workflowId, body) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
||||||
|
}
|
||||||
|
updateSecret(baseId, workflowId, secretId, body) {
|
||||||
|
return this.call('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body);
|
||||||
|
}
|
||||||
|
deleteSecret(baseId, workflowId, secretId) {
|
||||||
|
return this.call('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`);
|
||||||
|
}
|
||||||
|
// ---- execution history ----
|
||||||
|
listExecutions(baseId, workflowId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
||||||
|
}
|
||||||
|
getExecution(baseId, workflowId, executionId) {
|
||||||
|
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`);
|
||||||
|
}
|
||||||
|
rerunExecution(baseId, workflowId, executionId) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`);
|
||||||
|
}
|
||||||
|
// ---- trigger (manual fire for testing) ----
|
||||||
|
fireButtonClick(tableId, recordId, fieldId) {
|
||||||
|
return this.call('POST', `/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`);
|
||||||
|
}
|
||||||
|
fireWebhook(baseId, workflowId, body) {
|
||||||
|
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
||||||
|
return this.call('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
||||||
|
}
|
||||||
|
// ---- SQL (read-only) ----
|
||||||
|
sql(baseId, sql) {
|
||||||
|
return this.call('POST', `/api/base/${baseId}/sql`, { sql });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Client = Client;
|
||||||
Vendored
+73
@@ -0,0 +1,73 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.configPath = exports.resolveConfig = exports.writeConfigFile = exports.readConfigFile = exports.DEFAULT_ENDPOINT = void 0;
|
||||||
|
/**
|
||||||
|
* Config storage for inkreach CLI.
|
||||||
|
*
|
||||||
|
* Stores endpoint + token at ~/.inkreach/config.json, with environment variable
|
||||||
|
* overrides INKREACH_ENDPOINT / INKREACH_TOKEN. The --endpoint / --token / --base-id
|
||||||
|
* global flags take the highest precedence (handled by resolveOptions()).
|
||||||
|
*/
|
||||||
|
const fs = __importStar(require("node:fs"));
|
||||||
|
const path = __importStar(require("node:path"));
|
||||||
|
const os = __importStar(require("node:os"));
|
||||||
|
const CONFIG_DIR = path.join(os.homedir(), '.inkreach');
|
||||||
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||||
|
exports.DEFAULT_ENDPOINT = 'http://localhost:3000';
|
||||||
|
function readConfigFile() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.readConfigFile = readConfigFile;
|
||||||
|
function writeConfigFile(cfg) {
|
||||||
|
if (!fs.existsSync(CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
const merged = { ...readConfigFile(), ...cfg };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
exports.writeConfigFile = writeConfigFile;
|
||||||
|
function resolveConfig(overrides) {
|
||||||
|
const file = readConfigFile();
|
||||||
|
return {
|
||||||
|
endpoint: overrides?.endpoint ||
|
||||||
|
process.env.INKREACH_ENDPOINT ||
|
||||||
|
file.endpoint ||
|
||||||
|
exports.DEFAULT_ENDPOINT,
|
||||||
|
token: overrides?.token || process.env.INKREACH_TOKEN || file.token || '',
|
||||||
|
baseId: overrides?.baseId || process.env.INKREACH_BASE_ID || file.baseId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
exports.resolveConfig = resolveConfig;
|
||||||
|
function configPath() {
|
||||||
|
return CONFIG_FILE;
|
||||||
|
}
|
||||||
|
exports.configPath = configPath;
|
||||||
Vendored
+72
@@ -0,0 +1,72 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.requireBaseId = exports.run = exports.makeCtx = void 0;
|
||||||
|
const config_1 = require("./config");
|
||||||
|
const client_1 = require("./client");
|
||||||
|
const request_1 = require("./request");
|
||||||
|
/**
|
||||||
|
* Build a Ctx. `command` is the subcommand being executed (so .opts() returns
|
||||||
|
* its local options); `program` is the top-level program (for global options).
|
||||||
|
*/
|
||||||
|
function makeCtx(program, command) {
|
||||||
|
const g = program.opts();
|
||||||
|
const cfg = (0, config_1.resolveConfig)({
|
||||||
|
endpoint: g.endpoint,
|
||||||
|
token: g.token,
|
||||||
|
baseId: g.baseId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client: new client_1.Client(cfg),
|
||||||
|
json: !!g.json,
|
||||||
|
baseId: cfg.baseId,
|
||||||
|
command,
|
||||||
|
program,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
exports.makeCtx = makeCtx;
|
||||||
|
/**
|
||||||
|
* Wrap an async command action with normalized error handling.
|
||||||
|
*
|
||||||
|
* The wrapped fn receives (ctx, positionalArgs[], options). commander passes
|
||||||
|
* positional args before the trailing Command; we detect the trailing Command
|
||||||
|
* and forward everything else as positional, plus parsed options.
|
||||||
|
*/
|
||||||
|
function run(fn) {
|
||||||
|
return async function (...cmdArgs) {
|
||||||
|
// commander convention: last arg is the Command instance (the subcommand).
|
||||||
|
const command = cmdArgs[cmdArgs.length - 1];
|
||||||
|
const program = command?.parent ? findRoot(command) : command;
|
||||||
|
const positional = cmdArgs.slice(0, -1);
|
||||||
|
const options = (command?.opts() || {});
|
||||||
|
const ctx = makeCtx(program, command);
|
||||||
|
try {
|
||||||
|
await fn(ctx, positional, options);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const { status, code, message } = (0, request_1.describeError)(err);
|
||||||
|
if (ctx.json) {
|
||||||
|
process.stdout.write(JSON.stringify({ error: { status, code, message } }) + '\n');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
exports.run = run;
|
||||||
|
function findRoot(cmd) {
|
||||||
|
let c = cmd;
|
||||||
|
while (c.parent)
|
||||||
|
c = c.parent;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
/** Require a baseId: from flag, else ctx default, else throw with guidance. */
|
||||||
|
function requireBaseId(ctx, flagVal) {
|
||||||
|
const baseId = flagVal || ctx.baseId;
|
||||||
|
if (!baseId) {
|
||||||
|
throw new client_1.ApiError(0, 'MISSING_BASE_ID', 'No base id. Pass --base-id <id>, set a default with `inkreach auth login --base-id <id>`, or export INKREACH_BASE_ID.');
|
||||||
|
}
|
||||||
|
return baseId;
|
||||||
|
}
|
||||||
|
exports.requireBaseId = requireBaseId;
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.truncate = exports.printKv = exports.printTable = exports.printJson = void 0;
|
||||||
|
/** Print as JSON (compact). Safe for agent parsing. */
|
||||||
|
function printJson(value) {
|
||||||
|
process.stdout.write(JSON.stringify(value) + '\n');
|
||||||
|
}
|
||||||
|
exports.printJson = printJson;
|
||||||
|
/** Print a human-readable table. columns: [{key, header, width?}]; rows: object[]. */
|
||||||
|
function printTable(columns, rows) {
|
||||||
|
const headers = columns.map((c) => c.header);
|
||||||
|
const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(r[c.key] ?? '').length)));
|
||||||
|
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||||
|
const fmtRow = (r) => columns.map((c, i) => String(r[c.key] ?? '').padEnd(widths[i])).join(' ');
|
||||||
|
process.stdout.write(headers.map((h, i) => h.padEnd(widths[i])).join(' ') + '\n');
|
||||||
|
process.stdout.write(sep + '\n');
|
||||||
|
rows.forEach((r) => process.stdout.write(fmtRow(r) + '\n'));
|
||||||
|
}
|
||||||
|
exports.printTable = printTable;
|
||||||
|
/** Print key: value pairs vertically. */
|
||||||
|
function printKv(pairs) {
|
||||||
|
const width = Math.max(...pairs.map(([k]) => k.length));
|
||||||
|
pairs.forEach(([k, v]) => {
|
||||||
|
const val = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '');
|
||||||
|
process.stdout.write(`${k.padEnd(width)} ${val}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.printKv = printKv;
|
||||||
|
/** Truncate a long string to width, appending ellipsis. */
|
||||||
|
function truncate(s, width = 40) {
|
||||||
|
const str = String(s ?? '');
|
||||||
|
return str.length > width ? str.slice(0, width - 1) + '…' : str;
|
||||||
|
}
|
||||||
|
exports.truncate = truncate;
|
||||||
Vendored
+124
@@ -0,0 +1,124 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.describeError = exports.request = exports.ApiError = void 0;
|
||||||
|
/**
|
||||||
|
* HTTP request helper using node:http / node:https.
|
||||||
|
*
|
||||||
|
* IMPORTANT: we deliberately avoid global fetch() on Node.js — the Node 24
|
||||||
|
* undici fetch drops the Authorization header in some redirect/proxy scenarios
|
||||||
|
* (confirmed bug in this project). Using the built-in http(s) module keeps the
|
||||||
|
* Authorization: Bearer header intact end-to-end.
|
||||||
|
*/
|
||||||
|
const http = __importStar(require("node:http"));
|
||||||
|
const https = __importStar(require("node:https"));
|
||||||
|
class ApiError extends Error {
|
||||||
|
status;
|
||||||
|
body;
|
||||||
|
constructor(status, message, body) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ApiError = ApiError;
|
||||||
|
async function request(baseUrl, pathName, options = {}) {
|
||||||
|
const url = new URL(pathName.startsWith('http') ? pathName : pathName.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
|
||||||
|
const isHttps = url.protocol === 'https:';
|
||||||
|
const lib = isHttps ? https : http;
|
||||||
|
const method = options.method || 'GET';
|
||||||
|
const headers = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
let bodyData;
|
||||||
|
if (options.body !== undefined && options.body !== null) {
|
||||||
|
bodyData = JSON.stringify(options.body);
|
||||||
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||||
|
headers['Content-Length'] = Buffer.byteLength(bodyData).toString();
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = lib.request({
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
}, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
let data = text;
|
||||||
|
const ct = res.headers['content-type'] || '';
|
||||||
|
if (ct.includes('application/json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = res.statusCode || 200;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
resolve({ status, headers: res.headers, data: data });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const errBody = data;
|
||||||
|
const msg = (errBody && typeof errBody === 'object' && errBody.error?.message) ||
|
||||||
|
(typeof errBody === 'object' && errBody.message) ||
|
||||||
|
`HTTP ${status}`;
|
||||||
|
reject(new ApiError(status, String(msg), errBody));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (options.signal) {
|
||||||
|
options.signal.addEventListener('abort', () => req.destroy(new Error('aborted')));
|
||||||
|
}
|
||||||
|
req.on('error', reject);
|
||||||
|
if (bodyData !== undefined)
|
||||||
|
req.write(bodyData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.request = request;
|
||||||
|
/** Extract `{ code, message }` from an ApiError body for display. */
|
||||||
|
function describeError(err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const b = err.body;
|
||||||
|
return {
|
||||||
|
status: err.status,
|
||||||
|
code: b?.error?.code || `HTTP_${err.status}`,
|
||||||
|
message: b?.error?.message || err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { status: 0, code: 'ERROR', message: err.message };
|
||||||
|
}
|
||||||
|
return { status: 0, code: 'UNKNOWN', message: String(err) };
|
||||||
|
}
|
||||||
|
exports.describeError = describeError;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# 设计:inkreach-cli + inkreach-teable-ops skill
|
||||||
|
|
||||||
|
> 日期:2026-08-07
|
||||||
|
> 分支:develop
|
||||||
|
> 关联调研:自动化 API 契约调研报告(本会话产出)
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
inkreach-teable(本项目)自研了一套自动化(workflow)引擎,与官方 teable 的自动化**完全不兼容**:
|
||||||
|
|
||||||
|
| 维度 | 官方 teable | 本项目(inkreach) |
|
||||||
|
|---|---|---|
|
||||||
|
| 过滤条件键名 | `filter` | `conditions` |
|
||||||
|
| 监听字段键名 | `watchFieldIds` | `fieldIds` |
|
||||||
|
| recordCondition category | `recordMatchesConditions` | `recordCondition` |
|
||||||
|
| Webhook 路由 | `/api/webhook/base/{baseId}/workflow/{wfId}` | `/api/webhook/{baseId}/{wfId}` |
|
||||||
|
| scheduledTime | 完整 timing | 后端只读 `timing.type`+`interval`,其余忽略 |
|
||||||
|
| 节点 config 结构 | 自有契约 | 本项目自研 |
|
||||||
|
|
||||||
|
官方 `teable` CLI(v0.6.22)按官方契约调用,**无法操作本项目自动化**(已实测:列自动化时 404/契约不匹配)。表格/字段/记录/视图的 REST API 则与官方完全兼容。
|
||||||
|
|
||||||
|
**结论**:必须自研一个 `inkreach` CLI 包装本项目真实契约,并配套一个 skill 教 Agent 如何使用。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
1. **`inkreach` CLI**(`packages/inkreach-cli/`):Node.js 命令行工具,封装本项目全部 REST API,重点是自研自动化契约。
|
||||||
|
2. **`inkreach-teable-ops` skill**(`.agents/skills/inkreach-teable-ops/`):教 Agent 何时用哪个 CLI 命令、配置表格与自动化的正确姿势、本项目的部署/开发约定。
|
||||||
|
3. **端到端验证**:在本地实例真实跑通"建表 → 建自动化 → 配 buttonClick 触发器 → 激活 → 查执行历史"全链路。
|
||||||
|
|
||||||
|
## 3. CLI 设计(`packages/inkreach-cli/`)
|
||||||
|
|
||||||
|
### 3.1 技术选型
|
||||||
|
|
||||||
|
- **运行时**:Node.js(无额外运行时依赖,项目已是 Node 栈)。
|
||||||
|
- **框架**:`commander`(成熟、零学习成本、官方 teable CLI 也用 commander)。
|
||||||
|
- **HTTP**:`node:https`/`node:http`(**不用** `fetch`——Node v24 全局 fetch 会吞 Authorization 头,这是本会话已确认的坑)。封装一个 `request()`。
|
||||||
|
- **配置存储**:`~/.inkreach/config.json`(endpoint + token),支持环境变量覆盖 `INKREACH_ENDPOINT` / `INKREACH_TOKEN`。
|
||||||
|
- **输出**:默认人类可读(表格/着色),`--json` 输出原始 JSON 供 Agent 解析。
|
||||||
|
- **零运行时依赖**优先(commander 是唯一依赖),便于 `node bin.js` 直接跑、便于打包。
|
||||||
|
|
||||||
|
### 3.2 命令树
|
||||||
|
|
||||||
|
```
|
||||||
|
inkreach [全局选项] <命令>
|
||||||
|
|
||||||
|
全局选项:
|
||||||
|
--endpoint <url> 覆盖配置的 endpoint(默认 http://localhost:3000)
|
||||||
|
--token <token> 覆盖配置的 PAT
|
||||||
|
--base-id <id> 默认 base(多数命令可省略)
|
||||||
|
--json 输出原始 JSON(供 Agent 解析)
|
||||||
|
--debug 打印请求/响应到 stderr
|
||||||
|
|
||||||
|
认证:
|
||||||
|
auth login 交互式配置 endpoint + 创建/输入 PAT
|
||||||
|
auth status 验证 token 有效性(GET /api/user/me)
|
||||||
|
auth token create 创建 PAT(POST /api/access-token,返回完整 token 仅一次)
|
||||||
|
auth token list 列出 PAT
|
||||||
|
auth token delete <id>
|
||||||
|
|
||||||
|
表格/字段/记录/视图(兼容官方端点):
|
||||||
|
base list / get
|
||||||
|
table list / get / create / update / delete
|
||||||
|
field list / get / create / update / delete
|
||||||
|
record get / create / update / delete
|
||||||
|
view list / get / create / update / delete
|
||||||
|
|
||||||
|
自动化(自研契约,核心):
|
||||||
|
workflow list 列出 base 下所有自动化
|
||||||
|
workflow get <id> 详情(含 nodes + edges)
|
||||||
|
workflow create --name <n> 创建(自动带 recordCreated 触发器)
|
||||||
|
workflow update <id> [--name] [--desc] 改名/描述
|
||||||
|
workflow delete <id> 软删除
|
||||||
|
workflow activate <id> 激活(建快照 + 注册触发器)
|
||||||
|
workflow deactivate <id> 停用
|
||||||
|
workflow test <id> 测试运行(mock 输入跑 action)
|
||||||
|
workflow node set <id> 整体替换 nodes+edges(PUT /node)
|
||||||
|
|
||||||
|
触发器配置(便捷封装,生成正确的 config JSON):
|
||||||
|
trigger set <wfId> --type <T> [选项] 设置触发器类型与配置
|
||||||
|
--type: recordCreated|recordUpdated|recordCondition|recordCreatedOrUpdated|
|
||||||
|
formSubmitted|scheduledTime|buttonClick|webhook
|
||||||
|
--table-id, --field-ids, --form-id, --conditions <json>,
|
||||||
|
--schedule <json>, --batch <json>
|
||||||
|
|
||||||
|
Action 配置:
|
||||||
|
action add <wfId> --type <T> [选项] 添加 action 节点
|
||||||
|
--type: script|sendEmail|httpRequest|createRecord|updateRecord|getRecords
|
||||||
|
各类型选项见 api-reference
|
||||||
|
|
||||||
|
action update <wfId> <nodeId> [选项]
|
||||||
|
action delete <wfId> <nodeId>
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
secret list <wfId>
|
||||||
|
secret set <wfId> --name <n> --value <v>
|
||||||
|
secret update <wfId> <id> [--name] [--value]
|
||||||
|
secret delete <wfId> <id>
|
||||||
|
|
||||||
|
执行历史:
|
||||||
|
runs list <wfId> [--status failed|succeeded|running]
|
||||||
|
runs get <wfId> <executionId>
|
||||||
|
runs rerun <wfId> <executionId>
|
||||||
|
|
||||||
|
触发(手动激发,用于测试):
|
||||||
|
fire button-click <tableId> <recordId> <fieldId> 模拟按钮点击
|
||||||
|
fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook
|
||||||
|
|
||||||
|
SQL(只读):
|
||||||
|
sql <sql> POST /api/base/{baseId}/sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 关键设计决策
|
||||||
|
|
||||||
|
1. **键名严格用代码真实值**:`conditions`(非 filter)、`fieldIds`(非 watchFieldIds)、`recordCondition`(非 recordMatchesConditions)。CLI 内部硬编码正确键名,Agent 无需记忆。
|
||||||
|
2. **`trigger set` / `action add` 是"智能封装"**:Agent 传简单参数,CLI 生成正确的节点 JSON,调 `PUT /node` 整体替换。避免 Agent 手搓节点 JSON 出错。
|
||||||
|
3. **`PUT /node` 是整体替换**:CLI 的 `action add/update/delete` 内部都先 `GET` 完整图、修改、再 `PUT` 全图,保证不丢节点。
|
||||||
|
4. **scheduledTime 如实反映后端行为**:skill 文档明确标注"后端只认 `timing.type`+`interval`",CLI 的 `--schedule` 参数只接受这两个字段,多余的会在文档里说明无效。
|
||||||
|
5. **webhook 路由用真实的**:`fire webhook` 打 `/api/webhook/{baseId}/{wfId}`(非前端展示的 /base//workflow/ 路径)。
|
||||||
|
6. **认证:PAT 格式 `teable_{id}_{加密sign}`**:CLI 不自行加密,通过 `auth token create`(调 POST /api/access-token)拿到完整 token 字符串,存入配置。
|
||||||
|
7. **`--json` 模式**:所有命令支持,输出原始 JSON,Agent 可解析。默认是人类可读表格。
|
||||||
|
8. **错误处理**:非 2xx 响应解析 `{error:{code,message}}`,人类模式打印友好错误,`--json` 模式打印原始错误体。
|
||||||
|
|
||||||
|
## 4. Skill 设计(`.agents/skills/inkreach-teable-ops/`)
|
||||||
|
|
||||||
|
### 4.1 SKILL.md(入口)
|
||||||
|
|
||||||
|
YAML frontmatter(name + 极详细的 description 触发条件)+ 以下章节:
|
||||||
|
|
||||||
|
1. **先决条件与约束**:CLI 安装、endpoint/token、`--base-id` 默认值、`--json` 模式
|
||||||
|
2. **模块地图表**:用户意图 → 模块 → 入口命令 → 该读哪篇 guide
|
||||||
|
3. **路由规则表**:正确做法 vs 错误做法(带原因)
|
||||||
|
4. **快速语法**:最常用命令的示例
|
||||||
|
5. **关键约束**:键名差异、scheduledTime 简化、webhook 路由、PUT 整体替换、快照语义
|
||||||
|
6. **执行规则**:标准顺序(确认上下文 → 读后写 → 执行 → 验证)+ 关键规则(带原因)
|
||||||
|
7. **常见错误与恢复**
|
||||||
|
8. **API 参考索引**
|
||||||
|
|
||||||
|
### 4.2 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/skills/inkreach-teable-ops/
|
||||||
|
├── SKILL.md 入口
|
||||||
|
├── metadata.json 版本元数据
|
||||||
|
├── scripts/
|
||||||
|
│ └── install.sh 安装/升级 inkreach CLI(pnpm build + link)
|
||||||
|
└── guides/ 与 api-reference/ 按需加载
|
||||||
|
├── guides/
|
||||||
|
│ ├── instance-access.md 本实例访问信息(endpoint、服务状态、凭据获取)
|
||||||
|
│ ├── cli-reference.md CLI 全命令参考(含 --json 用法)
|
||||||
|
│ ├── tables-fields.md 建表/字段类型/记录/视图
|
||||||
|
│ ├── automation-guide.md 自动化配置全流程(核心)
|
||||||
|
│ └── development.md 本项目开发(构建/重启/systemd/已知坑)
|
||||||
|
└── api-reference/
|
||||||
|
├── trigger-types.md 8 触发器配置细节(真实键名)
|
||||||
|
├── action-types.md 6 执行器配置细节
|
||||||
|
└── field-types.md 字段类型别名与选项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 与官方 agent-skills 的差异
|
||||||
|
|
||||||
|
- **键名**:全部用本项目真实键名(`conditions`/`fieldIds`/`recordCondition`),不沿用官方的 filter/watchFieldIds。
|
||||||
|
- **scheduledTime**:如实标注后端简化实现,不教 Agent 配后端不认的字段。
|
||||||
|
- **webhook**:用真实路由,明确说明 authorization 后端不校验。
|
||||||
|
- **新增 buttonClick 完整链路**:按钮字段创建 → buttonClick 触发器 → 激活 → `fire button-click` 测试。
|
||||||
|
- **development.md**:官方无此内容,本项目独有(systemd 双进程、构建重启、沙箱无 process 等坑)。
|
||||||
|
|
||||||
|
## 5. 实施顺序(TDD/SDD)
|
||||||
|
|
||||||
|
1. **CLI 骨架**:`packages/inkreach-cli/` 初始化(package.json、bin、commander 框架、`request()` 封装、config 存储、auth login/status)。
|
||||||
|
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
|
||||||
|
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
|
||||||
|
4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。
|
||||||
|
5. **secrets / runs / fire / sql**。
|
||||||
|
6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。
|
||||||
|
7. **端到端验证**:真实实例跑通全链路。
|
||||||
|
8. **注册**:在 AGENTS.md 添加 skill 说明。
|
||||||
|
|
||||||
|
## 6. 验证标准
|
||||||
|
|
||||||
|
- `inkreach auth status` 返回当前用户。
|
||||||
|
- `inkreach workflow list --base-id bseeZR7yiDNeMPCa4CN` 列出自动化。
|
||||||
|
- `inkreach workflow create --name "CLI测试" --base-id bseeZR7yiDNeMPCa4CN` 创建成功。
|
||||||
|
- `inkreach trigger set <wfId> --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'` 正确配置。
|
||||||
|
- `inkreach action add <wfId> --type script --code '...'` 正确添加节点。
|
||||||
|
- `inkreach workflow activate <wfId>` 激活成功(返回 snapshotId)。
|
||||||
|
- `inkreach fire button-click ...` 触发执行。
|
||||||
|
- `inkreach runs list <wfId>` 能看到刚才的执行。
|
||||||
|
- skill 被 Agent 正确加载(出现在 available_skills)。
|
||||||
|
|
||||||
|
## 7. 非目标
|
||||||
|
|
||||||
|
- 不做 App 构建/网页抓取(本项目无此功能)。
|
||||||
|
- 不修复 scheduledTime 后端缺陷(如实记录,CLI 只暴露有效字段)。
|
||||||
|
- 不发布到 npm(本地 `pnpm link` 即可)。
|
||||||
|
- 不保持与官方 teable CLI 的命令兼容(独立命令树)。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@inkreach/teable-cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "CLI for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 actions).",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "commonjs",
|
||||||
|
"bin": {
|
||||||
|
"inkreach": "./dist/bin.js"
|
||||||
|
},
|
||||||
|
"main": "dist/bin.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"dev": "tsc -p tsconfig.build.json --watch",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"clean": "rimraf dist tsconfig.build.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^12.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.18.0",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"typescript": "^5.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* inkreach — CLI for operating inkreach-teable.
|
||||||
|
*
|
||||||
|
* Entry point. Sets up the global commander program and mounts command groups.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { version } from '../package.json';
|
||||||
|
import { authCommand } from './commands/auth';
|
||||||
|
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
|
||||||
|
import { baseResourceCommands } from './commands/resources';
|
||||||
|
import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('inkreach')
|
||||||
|
.description(
|
||||||
|
'Operate inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 actions).\n' +
|
||||||
|
'Use --json for machine-readable output (agent parsing).'
|
||||||
|
)
|
||||||
|
.version(`inkreach ${version}`, '-v, --version');
|
||||||
|
|
||||||
|
// Global options (highest precedence over config file + env).
|
||||||
|
program
|
||||||
|
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||||
|
.option('--token <token>', 'Personal Access Token')
|
||||||
|
.option('--base-id <id>', 'Default base id')
|
||||||
|
.option('--json', 'Output raw JSON for agent parsing')
|
||||||
|
.option('--debug', 'Print requests/responses to stderr (verbose)');
|
||||||
|
|
||||||
|
// Command groups
|
||||||
|
program.addCommand(authCommand());
|
||||||
|
for (const c of baseResourceCommands()) program.addCommand(c);
|
||||||
|
program.addCommand(workflowCommand());
|
||||||
|
program.addCommand(triggerCommand());
|
||||||
|
program.addCommand(actionCommand());
|
||||||
|
program.addCommand(secretCommand());
|
||||||
|
program.addCommand(runsCommand());
|
||||||
|
program.addCommand(fireCommand());
|
||||||
|
program.addCommand(sqlCommand());
|
||||||
|
|
||||||
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
|
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* `inkreach auth` — authentication & config commands.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { run, requireBaseId } from '../lib/ctx';
|
||||||
|
import { writeConfigFile, readConfigFile, configPath, DEFAULT_ENDPOINT } from '../lib/config';
|
||||||
|
import { printJson, printTable, printKv } from '../lib/output';
|
||||||
|
|
||||||
|
void requireBaseId;
|
||||||
|
|
||||||
|
export function authCommand(): Command {
|
||||||
|
const cmd = new Command('auth');
|
||||||
|
cmd.description('Manage authentication and CLI config');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('status')
|
||||||
|
.description('Verify the configured token by calling GET /api/user/me')
|
||||||
|
.action(
|
||||||
|
run(async (ctx) => {
|
||||||
|
const me = (await ctx.client.getMe()) as { id?: string; name?: string; email?: string };
|
||||||
|
const cfg = readConfigFile();
|
||||||
|
if (ctx.json) {
|
||||||
|
printJson({ config: cfg, user: me });
|
||||||
|
} else {
|
||||||
|
printKv([
|
||||||
|
['config path', configPath()],
|
||||||
|
['endpoint', cfg.endpoint || DEFAULT_ENDPOINT],
|
||||||
|
['token set', cfg.token ? `yes (${cfg.token.slice(0, 14)}…)` : 'no'],
|
||||||
|
['base id', cfg.baseId || '(none)'],
|
||||||
|
['', ''],
|
||||||
|
['user id', me?.id || 'unknown'],
|
||||||
|
['name', me?.name || 'unknown'],
|
||||||
|
['email', me?.email || 'unknown'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('login')
|
||||||
|
.description('Configure endpoint, token, and default base id')
|
||||||
|
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||||
|
.option('--token <pat>', 'Personal Access Token (format: teable_xxx_yyy)')
|
||||||
|
.option('--base-id <id>', 'Default base id for subsequent commands')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { endpoint?: string; token?: string; baseId?: string };
|
||||||
|
// Merge global flags (--token/--endpoint/--base-id on the program) with
|
||||||
|
// this subcommand's own --endpoint/--token/--base-id options.
|
||||||
|
const g = ctx.program.opts() as { endpoint?: string; token?: string; baseId?: string };
|
||||||
|
const patch: { endpoint?: string; token?: string; baseId?: string } = {};
|
||||||
|
const endpoint = opts.endpoint || g.endpoint;
|
||||||
|
const tokenVal = opts.token || g.token;
|
||||||
|
const baseId = opts.baseId || g.baseId;
|
||||||
|
if (endpoint) patch.endpoint = endpoint;
|
||||||
|
if (tokenVal) patch.token = tokenVal;
|
||||||
|
if (baseId) patch.baseId = baseId;
|
||||||
|
writeConfigFile(patch);
|
||||||
|
const cfg = readConfigFile();
|
||||||
|
if (ctx.json) {
|
||||||
|
printJson({ ok: true, config: cfg });
|
||||||
|
} else {
|
||||||
|
process.stdout.write('✓ Configuration saved:\n');
|
||||||
|
printKv([
|
||||||
|
['endpoint', cfg.endpoint || DEFAULT_ENDPOINT],
|
||||||
|
['token', cfg.token ? `set (${cfg.token.slice(0, 14)}…)` : '(not set)'],
|
||||||
|
['base id', cfg.baseId || '(none)'],
|
||||||
|
['path', configPath()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = cmd.command('token').description('Manage Personal Access Tokens (PAT)');
|
||||||
|
token
|
||||||
|
.command('list')
|
||||||
|
.description('List your access tokens')
|
||||||
|
.action(
|
||||||
|
run(async (ctx) => {
|
||||||
|
const list = (await ctx.client.listAccessTokens()) as Array<{
|
||||||
|
id: string; name: string; scopes?: string[]; expiredTime?: string;
|
||||||
|
}>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'scopes', header: 'SCOPES' },
|
||||||
|
{ key: 'expiredTime', header: 'EXPIRES' },
|
||||||
|
],
|
||||||
|
list.map((t) => ({ ...t, scopes: (t.scopes || []).join(',') }))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
token
|
||||||
|
.command('create')
|
||||||
|
.description('Create a PAT. The full token is returned ONLY ONCE.')
|
||||||
|
.requiredOption('--name <name>', 'Token name')
|
||||||
|
.option(
|
||||||
|
'--scopes <csv>',
|
||||||
|
'Comma-separated scopes',
|
||||||
|
'automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update'
|
||||||
|
)
|
||||||
|
.option('--base-ids <csv>', 'Restrict to base ids (comma-separated). Omit for full access')
|
||||||
|
.option('--full-access', 'Grant full access')
|
||||||
|
.option('--expires <date>', 'Expiry date YYYY-MM-DD (default 2099-12-31)', '2099-12-31')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { name: string; scopes: string; 'base-ids'?: string; 'full-access'?: boolean; expires: string };
|
||||||
|
const scopes = opts.scopes.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const baseIds = opts['base-ids']
|
||||||
|
? opts['base-ids'].split(',').map((s) => s.trim())
|
||||||
|
: undefined;
|
||||||
|
const created = (await ctx.client.createAccessToken({
|
||||||
|
name: opts.name,
|
||||||
|
scopes,
|
||||||
|
baseIds,
|
||||||
|
hasFullAccess: !!opts['full-access'],
|
||||||
|
expiredTime: opts.expires,
|
||||||
|
})) as { id: string; token: string };
|
||||||
|
if (ctx.json) printJson(created);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['id', created.id],
|
||||||
|
['token', created.token],
|
||||||
|
['', ''],
|
||||||
|
['note', 'Save this token now — it cannot be retrieved again.'],
|
||||||
|
['use', 'inkreach auth login --token <above>'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
token
|
||||||
|
.command('delete <id>')
|
||||||
|
.description('Revoke an access token')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const id = args[0] as string;
|
||||||
|
await ctx.client.deleteAccessToken(id);
|
||||||
|
if (ctx.json) printJson({ ok: true, id });
|
||||||
|
else process.stdout.write(`✓ Token ${id} revoked\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* `inkreach secret` — manage workflow automation secrets.
|
||||||
|
* `inkreach runs` — execution history.
|
||||||
|
* `inkreach fire` — manual trigger testing (button-click / webhook).
|
||||||
|
* `inkreach sql` — read-only SQL queries.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { run, requireBaseId } from '../lib/ctx';
|
||||||
|
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||||
|
|
||||||
|
// ===================== secret =====================
|
||||||
|
|
||||||
|
export function secretCommand(): Command {
|
||||||
|
const cmd = new Command('secret');
|
||||||
|
cmd.description('Manage automation script secrets');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list <workflowId>')
|
||||||
|
.description('List secrets for a workflow. Values are never returned.')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listSecrets(baseId, workflowId)) as Array<{
|
||||||
|
id: string; name: string; description?: string;
|
||||||
|
}>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No secrets found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'description', header: 'DESCRIPTION' },
|
||||||
|
],
|
||||||
|
list.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: truncate(s.name, 30),
|
||||||
|
description: truncate(s.description, 50),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('set <workflowId>')
|
||||||
|
.description('Create a new secret')
|
||||||
|
.requiredOption('--name <name>', 'Secret name')
|
||||||
|
.requiredOption('--value <value>', 'Secret value')
|
||||||
|
.option('--description <desc>', 'Secret description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { name: string; value: string; description?: string; baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const created = await ctx.client.createSecret(baseId, workflowId, {
|
||||||
|
name: opts.name,
|
||||||
|
value: opts.value,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
const s = created as { id: string; name: string };
|
||||||
|
if (ctx.json) printJson(created);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['id', s.id],
|
||||||
|
['name', s.name],
|
||||||
|
['', ''],
|
||||||
|
['note', 'Secret value is never returned. Update it if needed.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId> <secretId>')
|
||||||
|
.description('Update a secret')
|
||||||
|
.option('--name <name>', 'New name')
|
||||||
|
.option('--value <value>', 'New value')
|
||||||
|
.option('--description <desc>', 'New description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, secretId] = args as [string, string];
|
||||||
|
const opts = o as { name?: string; value?: string; description?: string; baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
await ctx.client.updateSecret(baseId, workflowId, secretId, {
|
||||||
|
name: opts.name,
|
||||||
|
value: opts.value,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json) printJson({ ok: true, id: secretId });
|
||||||
|
else process.stdout.write(`✓ Secret ${secretId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId> <secretId>')
|
||||||
|
.description('Delete a secret')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, secretId] = args as [string, string];
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
await ctx.client.deleteSecret(baseId, workflowId, secretId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: secretId });
|
||||||
|
else process.stdout.write(`✓ Secret ${secretId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== runs =====================
|
||||||
|
|
||||||
|
export function runsCommand(): Command {
|
||||||
|
const cmd = new Command('runs');
|
||||||
|
cmd.description('Execution history for automations');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list <workflowId>')
|
||||||
|
.description('List execution history')
|
||||||
|
.option('--status <status>', 'Filter by status: failed, succeeded, or running')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { status?: string; baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
let list = (await ctx.client.listExecutions(baseId, workflowId)) as Array<{
|
||||||
|
id: string; status: string; triggerType?: string; startedAt?: string; finishedAt?: string;
|
||||||
|
}>;
|
||||||
|
if (opts.status) {
|
||||||
|
list = list.filter((e) => e.status === opts.status);
|
||||||
|
}
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No executions found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'triggerType', header: 'TRIGGER' },
|
||||||
|
{ key: 'startedAt', header: 'STARTED' },
|
||||||
|
{ key: 'finishedAt', header: 'FINISHED' },
|
||||||
|
],
|
||||||
|
list.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
status: e.status,
|
||||||
|
triggerType: e.triggerType || '',
|
||||||
|
startedAt: truncate(e.startedAt, 25),
|
||||||
|
finishedAt: truncate(e.finishedAt, 25),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <workflowId> <executionId>')
|
||||||
|
.description('Get execution detail with node results')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, executionId] = args as [string, string];
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const exec = await ctx.client.getExecution(baseId, workflowId, executionId);
|
||||||
|
const ex = exec as {
|
||||||
|
id: string; status: string; triggerType?: string; startedAt?: string; finishedAt?: string;
|
||||||
|
nodeResults?: Array<{ nodeId: string; status: string; error?: string }>;
|
||||||
|
};
|
||||||
|
if (ctx.json) printJson(exec);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['id', ex.id],
|
||||||
|
['status', ex.status],
|
||||||
|
['trigger', ex.triggerType || ''],
|
||||||
|
['started', ex.startedAt || ''],
|
||||||
|
['finished', ex.finishedAt || ''],
|
||||||
|
]);
|
||||||
|
if (ex.nodeResults?.length) {
|
||||||
|
process.stdout.write('\nNODE RESULTS:\n');
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'nodeId', header: 'NODE' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'error', header: 'ERROR' },
|
||||||
|
],
|
||||||
|
ex.nodeResults.map((r) => ({
|
||||||
|
nodeId: r.nodeId,
|
||||||
|
status: r.status,
|
||||||
|
error: truncate(r.error, 50),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('rerun <workflowId> <executionId>')
|
||||||
|
.description('Rerun a failed execution')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, executionId] = args as [string, string];
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const res = (await ctx.client.rerunExecution(baseId, workflowId, executionId)) as {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
if (ctx.json) printJson(res);
|
||||||
|
else {
|
||||||
|
const pairs: Array<[string, unknown]> = [
|
||||||
|
['executionId', executionId],
|
||||||
|
['status', 'rerun requested'],
|
||||||
|
];
|
||||||
|
if (res.id) pairs.push(['newExecutionId', res.id]);
|
||||||
|
printKv(pairs);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== fire =====================
|
||||||
|
|
||||||
|
export function fireCommand(): Command {
|
||||||
|
const cmd = new Command('fire');
|
||||||
|
cmd.description('Manually fire triggers for testing');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('button-click <tableId> <recordId> <fieldId>')
|
||||||
|
.description('Simulate a button field click')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const [tableId, recordId, fieldId] = args as [string, string, string];
|
||||||
|
const res = await ctx.client.fireButtonClick(tableId, recordId, fieldId);
|
||||||
|
if (ctx.json) printJson(res);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['runId', res.runId],
|
||||||
|
['tableId', res.tableId],
|
||||||
|
['fieldId', res.fieldId],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('webhook <baseId> <workflowId>')
|
||||||
|
.description('Simulate a webhook call')
|
||||||
|
.option('--body <json>', 'JSON body to send')
|
||||||
|
.option('--method <method>', 'HTTP method (informational only)', 'POST')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [baseId, workflowId] = args as [string, string];
|
||||||
|
const opts = o as { body?: string; method?: string };
|
||||||
|
const body = opts.body ? (JSON.parse(opts.body) as unknown) : undefined;
|
||||||
|
const res = await ctx.client.fireWebhook(baseId, workflowId, body);
|
||||||
|
if (ctx.json) printJson(res);
|
||||||
|
else {
|
||||||
|
process.stdout.write(`✓ Webhook fired for workflow ${workflowId}\n`);
|
||||||
|
if (typeof res === 'object' && res !== null) {
|
||||||
|
printJson(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== sql =====================
|
||||||
|
|
||||||
|
export function sqlCommand(): Command {
|
||||||
|
const cmd = new Command('sql');
|
||||||
|
cmd.description('Execute a read-only SQL query (SELECT only)');
|
||||||
|
cmd.option('--base-id <id>', 'Base id (overrides default)');
|
||||||
|
cmd.argument('<sql>', 'SQL SELECT statement');
|
||||||
|
|
||||||
|
cmd.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const sql = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const rows = (await ctx.client.sql(baseId, sql)) as unknown[];
|
||||||
|
if (ctx.json) {
|
||||||
|
printJson(rows);
|
||||||
|
} else {
|
||||||
|
if (!rows.length) {
|
||||||
|
process.stdout.write('No rows returned.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = rows[0];
|
||||||
|
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
|
||||||
|
const keys = Object.keys(first as Record<string, unknown>);
|
||||||
|
printTable(
|
||||||
|
keys.map((k) => ({ key: k, header: k })),
|
||||||
|
rows.map((r) => {
|
||||||
|
const obj = r as Record<string, unknown>;
|
||||||
|
const row: Record<string, unknown> = {};
|
||||||
|
for (const k of keys) {
|
||||||
|
row[k] = truncate(obj[k], 40);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
process.stdout.write(`\n${rows.length} row(s).\n`);
|
||||||
|
} else {
|
||||||
|
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* `inkreach base|table|field|record|view` — resource CRUD commands.
|
||||||
|
*
|
||||||
|
* These endpoints are compatible with official teable open-api.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { run, requireBaseId } from '../lib/ctx';
|
||||||
|
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||||
|
|
||||||
|
export function baseResourceCommands(): Command[] {
|
||||||
|
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== base =====================
|
||||||
|
|
||||||
|
function baseCmd(): Command {
|
||||||
|
const cmd = new Command('base');
|
||||||
|
cmd.description('Manage bases (database containers)');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List all accessible bases')
|
||||||
|
.action(
|
||||||
|
run(async (ctx) => {
|
||||||
|
const list = (await ctx.client.listBases()) as Array<Record<string, unknown>>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No bases found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
],
|
||||||
|
list.map((b) => ({ id: truncate(b.id), name: truncate(b.name, 30) }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <baseId>')
|
||||||
|
.description('Get base details')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const baseId = args[0] as string;
|
||||||
|
const base = await ctx.client.getBase(baseId);
|
||||||
|
if (ctx.json) printJson(base);
|
||||||
|
else {
|
||||||
|
const b = base as Record<string, unknown>;
|
||||||
|
printKv(Object.entries(b).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== table =====================
|
||||||
|
|
||||||
|
function tableCmd(): Command {
|
||||||
|
const cmd = new Command('table');
|
||||||
|
cmd.description('Manage tables within a base');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List tables in a base')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listTables(baseId)) as Array<Record<string, unknown>>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No tables found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'dbTableName', header: 'DB TABLE' },
|
||||||
|
],
|
||||||
|
list.map((t) => ({
|
||||||
|
id: truncate(t.id),
|
||||||
|
name: truncate(t.name, 30),
|
||||||
|
dbTableName: truncate(t.dbTableName ?? ''),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <tableId>')
|
||||||
|
.description('Get table details')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const tableId = args[0] as string;
|
||||||
|
const table = await ctx.client.getTable(tableId);
|
||||||
|
if (ctx.json) printJson(table);
|
||||||
|
else {
|
||||||
|
const t = table as Record<string, unknown>;
|
||||||
|
printKv(Object.entries(t).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new table')
|
||||||
|
.requiredOption('--name <name>', 'Table name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.option('--fields <json>', 'Field definitions JSON array, e.g. \'[{"name":"Title","type":"singleLineText"}]\'')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { name: string; baseId?: string; fields?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const body: Record<string, unknown> = { name: opts.name };
|
||||||
|
if (opts.fields) body.fields = JSON.parse(opts.fields);
|
||||||
|
const table = await ctx.client.createTable(baseId, body);
|
||||||
|
if (ctx.json) printJson(table);
|
||||||
|
else {
|
||||||
|
const t = table as Record<string, unknown>;
|
||||||
|
printKv([['id', t.id], ['name', t.name]]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <tableId>')
|
||||||
|
.description('Update table (rename)')
|
||||||
|
.requiredOption('--name <name>', 'New table name')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const tableId = args[0] as string;
|
||||||
|
const opts = o as { name: string };
|
||||||
|
const table = await ctx.client.updateTable(tableId, { name: opts.name });
|
||||||
|
if (ctx.json) printJson(table);
|
||||||
|
else process.stdout.write(`✓ Table ${tableId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <tableId>')
|
||||||
|
.description('Delete a table')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const tableId = args[0] as string;
|
||||||
|
await ctx.client.deleteTable(tableId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: tableId });
|
||||||
|
else process.stdout.write(`✓ Table ${tableId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== field =====================
|
||||||
|
|
||||||
|
function fieldCmd(): Command {
|
||||||
|
const cmd = new Command('field');
|
||||||
|
cmd.description('Manage fields (columns) in a table');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list <tableId>')
|
||||||
|
.description('List fields in a table')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const tableId = args[0] as string;
|
||||||
|
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No fields found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
],
|
||||||
|
list.map((f) => ({
|
||||||
|
id: truncate(f.id),
|
||||||
|
name: truncate(f.name, 30),
|
||||||
|
type: f.type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <fieldId>')
|
||||||
|
.description('Get field details')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const fieldId = args[0] as string;
|
||||||
|
const field = await ctx.client.getField(fieldId);
|
||||||
|
if (ctx.json) printJson(field);
|
||||||
|
else {
|
||||||
|
const f = field as Record<string, unknown>;
|
||||||
|
printKv(Object.entries(f).map(([k, v]) => [k, v]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new field')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--name <name>', 'Field name')
|
||||||
|
.requiredOption('--type <type>', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)')
|
||||||
|
.option('--options <json>', 'Field options JSON (type-specific)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { tableId: string; name: string; type: string; options?: string };
|
||||||
|
const body: Record<string, unknown> = { name: opts.name, type: opts.type };
|
||||||
|
if (opts.options) body.options = JSON.parse(opts.options);
|
||||||
|
const field = await ctx.client.createField(opts.tableId, body);
|
||||||
|
if (ctx.json) printJson(field);
|
||||||
|
else {
|
||||||
|
const f = field as Record<string, unknown>;
|
||||||
|
printKv([['id', f.id], ['name', f.name], ['type', f.type]]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <fieldId>')
|
||||||
|
.description('Update a field')
|
||||||
|
.option('--name <name>', 'New field name')
|
||||||
|
.option('--options <json>', 'New field options JSON')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const fieldId = args[0] as string;
|
||||||
|
const opts = o as { name?: string; options?: string };
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (opts.name) body.name = opts.name;
|
||||||
|
if (opts.options) body.options = JSON.parse(opts.options);
|
||||||
|
const field = await ctx.client.updateField(fieldId, body);
|
||||||
|
if (ctx.json) printJson(field);
|
||||||
|
else process.stdout.write(`✓ Field ${fieldId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <fieldId>')
|
||||||
|
.description('Delete a field')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const fieldId = args[0] as string;
|
||||||
|
await ctx.client.deleteField(fieldId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: fieldId });
|
||||||
|
else process.stdout.write(`✓ Field ${fieldId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== record =====================
|
||||||
|
|
||||||
|
function recordCmd(): Command {
|
||||||
|
const cmd = new Command('record');
|
||||||
|
cmd.description('Manage records (rows) in a table');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get')
|
||||||
|
.description('Query records with filters, pagination, and search')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--filter <json>', 'Filter conditions JSON')
|
||||||
|
.option('--projection <json>', 'Field projection JSON array')
|
||||||
|
.option('--page-size <n>', 'Records per page')
|
||||||
|
.option('--page <n>', 'Page number (1-based)')
|
||||||
|
.option('--search <kw>', 'Search keyword')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as {
|
||||||
|
tableId: string;
|
||||||
|
filter?: string;
|
||||||
|
projection?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
page?: string;
|
||||||
|
search?: string;
|
||||||
|
baseId?: string;
|
||||||
|
};
|
||||||
|
// validate baseId is present (even though getRecords only needs tableId,
|
||||||
|
// we follow the convention that base-scoped commands require baseId config)
|
||||||
|
requireBaseId(ctx, opts.baseId);
|
||||||
|
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
if (opts.filter) query.filter = JSON.parse(opts.filter);
|
||||||
|
if (opts.projection) query.projection = JSON.parse(opts.projection);
|
||||||
|
if (opts.search) query.search = opts.search;
|
||||||
|
|
||||||
|
const page = opts.page ? parseInt(opts.page, 10) : undefined;
|
||||||
|
const pageSize = opts.pageSize ? parseInt(opts.pageSize, 10) : undefined;
|
||||||
|
if (pageSize) query.take = pageSize;
|
||||||
|
if (page) {
|
||||||
|
const take = pageSize || 100;
|
||||||
|
query.skip = (page - 1) * take;
|
||||||
|
if (!pageSize) query.take = take;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await ctx.client.getRecords(opts.tableId, query)) as {
|
||||||
|
records?: Array<{ id: string; fields: Record<string, unknown> }>;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
if (ctx.json) {
|
||||||
|
printJson(result);
|
||||||
|
} else {
|
||||||
|
if (!result.records?.length) {
|
||||||
|
process.stdout.write('No records found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstFields = result.records[0].fields || {};
|
||||||
|
const fieldKeys = Object.keys(firstFields);
|
||||||
|
const columns = fieldKeys.map((k) => ({ key: k, header: k }));
|
||||||
|
printTable(
|
||||||
|
columns,
|
||||||
|
result.records.map((rec) => {
|
||||||
|
const f = rec.fields || {};
|
||||||
|
const row: Record<string, unknown> = { id: truncate(rec.id) };
|
||||||
|
for (const k of fieldKeys) {
|
||||||
|
row[k] = truncate(f[k], 30);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (result.total !== undefined) {
|
||||||
|
process.stdout.write(`\n${result.records.length} of ${result.total} record(s).\n`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`\n${result.records.length} record(s).\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get-one <recordId>')
|
||||||
|
.description('Get a single record by id')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const recordId = args[0] as string;
|
||||||
|
const record = await ctx.client.getRecord(recordId);
|
||||||
|
if (ctx.json) printJson(record);
|
||||||
|
else {
|
||||||
|
const rec = record as { id?: string; fields?: Record<string, unknown> };
|
||||||
|
const fields = rec.fields || {};
|
||||||
|
printKv([
|
||||||
|
['id', rec.id],
|
||||||
|
...Object.entries(fields).map(([k, v]) => [k, v] as [string, unknown]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create record(s). Use --fields for a single record, --records for bulk.')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
|
||||||
|
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { tableId: string; fields?: string; records?: string };
|
||||||
|
let body: unknown;
|
||||||
|
if (opts.records) {
|
||||||
|
body = { records: JSON.parse(opts.records) };
|
||||||
|
} else if (opts.fields) {
|
||||||
|
body = { fields: JSON.parse(opts.fields) };
|
||||||
|
} else {
|
||||||
|
throw Object.assign(new Error('Provide --fields or --records'), {
|
||||||
|
code: 'MISSING_FIELDS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await ctx.client.createRecord(opts.tableId, body);
|
||||||
|
if (ctx.json) printJson(result);
|
||||||
|
else process.stdout.write(`✓ Record(s) created\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update')
|
||||||
|
.description('Batch update records')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--records <json>', 'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\'')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { tableId: string; records: string };
|
||||||
|
const body = { records: JSON.parse(opts.records) };
|
||||||
|
const result = await ctx.client.updateRecord(opts.tableId, body);
|
||||||
|
if (ctx.json) printJson(result);
|
||||||
|
else process.stdout.write(`✓ Records updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete')
|
||||||
|
.description('Batch delete records by ids')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { tableId: string; recordIds: string };
|
||||||
|
const ids = opts.recordIds
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const body = { records: ids };
|
||||||
|
const result = await ctx.client.deleteRecord(opts.tableId, body);
|
||||||
|
if (ctx.json) printJson(result);
|
||||||
|
else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== view =====================
|
||||||
|
|
||||||
|
function viewCmd(): Command {
|
||||||
|
const cmd = new Command('view');
|
||||||
|
cmd.description('Manage views in a table');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list <tableId>')
|
||||||
|
.description('List views in a table')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const tableId = args[0] as string;
|
||||||
|
const list = (await ctx.client.listViews(tableId)) as Array<Record<string, unknown>>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No views found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
],
|
||||||
|
list.map((v) => ({
|
||||||
|
id: truncate(v.id),
|
||||||
|
name: truncate(v.name, 30),
|
||||||
|
type: v.type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <viewId>')
|
||||||
|
.description('Get view details')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const viewId = args[0] as string;
|
||||||
|
const view = await ctx.client.getView(viewId);
|
||||||
|
if (ctx.json) printJson(view);
|
||||||
|
else {
|
||||||
|
const v = view as Record<string, unknown>;
|
||||||
|
printKv(Object.entries(v).map(([k, val]) => [k, val]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new view')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.requiredOption('--name <name>', 'View name')
|
||||||
|
.option('--type <type>', 'View type (grid, calendar, kanban, gallery, form)', 'grid')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { tableId: string; name: string; type: string };
|
||||||
|
const body: Record<string, unknown> = { name: opts.name, type: opts.type };
|
||||||
|
const view = await ctx.client.createView(opts.tableId, body);
|
||||||
|
if (ctx.json) printJson(view);
|
||||||
|
else {
|
||||||
|
const v = view as Record<string, unknown>;
|
||||||
|
printKv([['id', v.id], ['name', v.name], ['type', v.type]]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <viewId>')
|
||||||
|
.description('Update a view (rename)')
|
||||||
|
.option('--name <name>', 'New view name')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const viewId = args[0] as string;
|
||||||
|
const opts = o as { name?: string };
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (opts.name) body.name = opts.name;
|
||||||
|
const view = await ctx.client.updateView(viewId, body);
|
||||||
|
if (ctx.json) printJson(view);
|
||||||
|
else process.stdout.write(`✓ View ${viewId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <viewId>')
|
||||||
|
.description('Delete a view')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const viewId = args[0] as string;
|
||||||
|
await ctx.client.deleteView(viewId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: viewId });
|
||||||
|
else process.stdout.write(`✓ View ${viewId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
/**
|
||||||
|
* `inkreach workflow` — workflow CRUD + lifecycle.
|
||||||
|
* `inkreach trigger` — set the trigger node's type & config.
|
||||||
|
* `inkreach action` — add/update/delete action nodes.
|
||||||
|
*
|
||||||
|
* All node-mutating commands operate via PUT /node (whole-graph replacement),
|
||||||
|
* so they internally GET the full graph, mutate, then PUT it back.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { run, requireBaseId } from '../lib/ctx';
|
||||||
|
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||||
|
import { IWorkflowVo, IWorkflowNodeVo } from '../lib/client';
|
||||||
|
|
||||||
|
// ===================== workflow =====================
|
||||||
|
|
||||||
|
export function workflowCommand(): Command {
|
||||||
|
const cmd = new Command('workflow');
|
||||||
|
cmd.description('Manage automations (workflows) — the self-built automation engine');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List all workflows in the base')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const list = (await ctx.client.listWorkflows(baseId)) as IWorkflowVo[];
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No workflows found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
{ key: 'isActive', header: 'ACTIVE' },
|
||||||
|
{ key: 'hasDraft', header: 'DRAFT' },
|
||||||
|
{ key: 'nodes', header: 'NODES' },
|
||||||
|
],
|
||||||
|
list.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: truncate(w.name, 30),
|
||||||
|
isActive: w.isActive ? '✓' : '',
|
||||||
|
hasDraft: w.hasDraft ? '*' : '',
|
||||||
|
nodes: (w.nodes || []).length,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('get <workflowId>')
|
||||||
|
.description('Get workflow detail (nodes + edges)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
if (ctx.json) printJson(wf);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['id', wf.id],
|
||||||
|
['name', wf.name],
|
||||||
|
['description', wf.description || ''],
|
||||||
|
['active', wf.isActive ? 'yes' : 'no'],
|
||||||
|
['hasDraft', wf.hasDraft ? 'yes' : 'no'],
|
||||||
|
]);
|
||||||
|
process.stdout.write('\nNODES:\n');
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'type', header: 'TYPE' },
|
||||||
|
{ key: 'category', header: 'CATEGORY' },
|
||||||
|
{ key: 'config', header: 'CONFIG' },
|
||||||
|
],
|
||||||
|
(wf.nodes || []).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
category: n.category,
|
||||||
|
config: truncate(JSON.stringify(n.config), 40),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (wf.edges?.length) {
|
||||||
|
process.stdout.write('\nEDGES:\n');
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'sourceNodeId', header: 'SOURCE' },
|
||||||
|
{ key: 'targetNodeId', header: 'TARGET' },
|
||||||
|
],
|
||||||
|
(wf.edges || []).map((e) => ({ ...e }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a workflow (auto-includes a recordCreated trigger node)')
|
||||||
|
.requiredOption('--name <name>', 'Workflow name')
|
||||||
|
.option('--description <desc>', 'Workflow description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as { name: string; description?: string; baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.createWorkflow(baseId, {
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json) printJson(wf);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['id', wf.id],
|
||||||
|
['name', wf.name],
|
||||||
|
['baseId', wf.baseId],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach trigger set ${wf.id} --type <T> ...`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId>')
|
||||||
|
.description('Update workflow name / description')
|
||||||
|
.option('--name <name>', 'New name')
|
||||||
|
.option('--description <desc>', 'New description')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { name?: string; description?: string; baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.updateWorkflow(baseId, workflowId, {
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
if (ctx.json) printJson(wf);
|
||||||
|
else process.stdout.write(`✓ Workflow ${workflowId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId>')
|
||||||
|
.description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
await ctx.client.deleteWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: workflowId });
|
||||||
|
else process.stdout.write(`✓ Workflow ${workflowId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('activate <workflowId>')
|
||||||
|
.description('Activate: snapshot + register trigger + create auto access token')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const res = await ctx.client.activateWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json) printJson({ ok: true, ...res });
|
||||||
|
else printKv([['snapshotId', res.snapshotId], ['status', 'activated']]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('deactivate <workflowId>')
|
||||||
|
.description('Deactivate: unregister trigger')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
await ctx.client.deactivateWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: workflowId });
|
||||||
|
else process.stdout.write(`✓ Workflow ${workflowId} deactivated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('test <workflowId>')
|
||||||
|
.description('Test-run: execute action nodes with mock input (no trigger, no queue)')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const res = await ctx.client.testWorkflow(baseId, workflowId);
|
||||||
|
if (ctx.json) printJson(res);
|
||||||
|
else {
|
||||||
|
process.stdout.write('Test results:\n');
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'nodeId', header: 'NODE' },
|
||||||
|
{ key: 'status', header: 'STATUS' },
|
||||||
|
{ key: 'error', header: 'ERROR' },
|
||||||
|
],
|
||||||
|
(res.nodeResults || []).map((r) => {
|
||||||
|
const row = r as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
nodeId: row.nodeId,
|
||||||
|
status: row.status,
|
||||||
|
error: truncate(row.error, 50),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== trigger =====================
|
||||||
|
|
||||||
|
const TRIGGER_TYPES = [
|
||||||
|
'recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated',
|
||||||
|
'formSubmitted', 'scheduledTime', 'buttonClick', 'webhook',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function triggerCommand(): Command {
|
||||||
|
const cmd = new Command('trigger');
|
||||||
|
cmd.description('Set the trigger node (type + config). Builds correct config keys for you.');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('set <workflowId>')
|
||||||
|
.description('Set the trigger type and config (replaces the existing trigger node)')
|
||||||
|
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
|
||||||
|
.option('--table-id <id>', 'Table id (most record/button triggers)')
|
||||||
|
.option('--field-ids <json>', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)')
|
||||||
|
.option('--form-id <id>', 'Form view id (formSubmitted)')
|
||||||
|
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
|
||||||
|
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
|
||||||
|
.option('--batch <json>', 'Batch config JSON {enabled,batchWindow,batchSize}')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as {
|
||||||
|
type: string; tableId?: string; fieldIds?: string; formId?: string;
|
||||||
|
conditions?: string; schedule?: string; batch?: string; baseId?: string;
|
||||||
|
};
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
if (!TRIGGER_TYPES.includes(opts.type as typeof TRIGGER_TYPES[number])) {
|
||||||
|
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), { code: 'INVALID_TYPE' });
|
||||||
|
}
|
||||||
|
const config = buildTriggerConfig(
|
||||||
|
opts.type,
|
||||||
|
{
|
||||||
|
tableId: opts.tableId,
|
||||||
|
fieldIds: opts.fieldIds,
|
||||||
|
formId: opts.formId,
|
||||||
|
conditions: opts.conditions,
|
||||||
|
schedule: opts.schedule,
|
||||||
|
batch: opts.batch,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
// Find the trigger node (there is exactly one) and replace its category + config.
|
||||||
|
const triggerIdx = nodes.findIndex((n) => n.type === 'trigger');
|
||||||
|
const newTrigger: IWorkflowNodeVo = {
|
||||||
|
...nodes[triggerIdx >= 0 ? triggerIdx : 0],
|
||||||
|
type: 'trigger',
|
||||||
|
category: opts.type,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
if (triggerIdx >= 0) nodes[triggerIdx] = newTrigger;
|
||||||
|
else nodes.unshift(newTrigger);
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
|
nodes,
|
||||||
|
edges: wf.edges || [],
|
||||||
|
});
|
||||||
|
if (ctx.json) printJson(updated);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['workflowId', workflowId],
|
||||||
|
['triggerType', opts.type],
|
||||||
|
['config', JSON.stringify(config)],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach action add ${workflowId} --type script ...`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the trigger node config JSON using the SELF-BUILT contract keys. */
|
||||||
|
export function buildTriggerConfig(
|
||||||
|
type: string,
|
||||||
|
opts: { tableId?: string; fieldIds?: string; formId?: string; conditions?: string; schedule?: string; batch?: string }
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parse = (s?: string): unknown => (s ? JSON.parse(s) : undefined);
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
switch (type) {
|
||||||
|
case 'recordCreated':
|
||||||
|
case 'recordCreatedOrUpdated': {
|
||||||
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
|
if (opts.conditions) config.conditions = parse(opts.conditions);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'recordUpdated': {
|
||||||
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
|
// NOTE: key is `fieldIds` (NOT watchFieldIds). [] = watch all fields.
|
||||||
|
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'recordCondition': {
|
||||||
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
|
// conditions is required for recordCondition
|
||||||
|
config.conditions = opts.conditions ? parse(opts.conditions) : { conjunction: 'and', filterSet: [] };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'formSubmitted': {
|
||||||
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
|
if (opts.formId) config.formId = opts.formId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'scheduledTime': {
|
||||||
|
// Backend ONLY honors timing.type + timing.interval (rest is ignored).
|
||||||
|
if (opts.schedule) {
|
||||||
|
const sched = parse(opts.schedule) as { timing?: Record<string, unknown> };
|
||||||
|
if (sched.timing) config.timing = sched.timing;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'buttonClick': {
|
||||||
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
|
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'webhook': {
|
||||||
|
// authorization is UI-only; backend does NOT validate it.
|
||||||
|
config.authorization = { type: 'none' };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (opts.batch && ['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)) {
|
||||||
|
config.batch = parse(opts.batch);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== action =====================
|
||||||
|
|
||||||
|
const ACTION_TYPES = [
|
||||||
|
'script', 'sendEmail', 'httpRequest', 'createRecord', 'updateRecord', 'getRecords',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function actionCommand(): Command {
|
||||||
|
const cmd = new Command('action');
|
||||||
|
cmd.description('Add/update/delete action nodes');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('add <workflowId>')
|
||||||
|
.description('Add an action node after the last existing node')
|
||||||
|
.requiredOption('--type <type>', `Action type: ${ACTION_TYPES.join('|')}`)
|
||||||
|
.option('--config <json>', 'Full action config JSON (overrides the granular flags below)')
|
||||||
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
|
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
|
||||||
|
.option('--to <email>', 'Recipient (sendEmail)')
|
||||||
|
.option('--subject <s>', 'Subject (sendEmail)')
|
||||||
|
.option('--body <html>', 'HTML body (sendEmail)')
|
||||||
|
.option('--url <u>', 'Request URL (httpRequest)')
|
||||||
|
.option('--method <m>', 'HTTP method (httpRequest, default GET)')
|
||||||
|
.option('--headers <json>', 'Request headers JSON (httpRequest)')
|
||||||
|
.option('--req-body <json>', 'Request body JSON (httpRequest)')
|
||||||
|
.option('--target-table <id>', 'Target table id (createRecord/updateRecord/getRecords)')
|
||||||
|
.option('--records <json>', 'Records JSON array (createRecord/updateRecord)')
|
||||||
|
.option('--query <json>', 'Query JSON (getRecords)')
|
||||||
|
.option('--name <n>', 'Optional node display name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const workflowId = args[0] as string;
|
||||||
|
const opts = o as Record<string, string>;
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
if (!ACTION_TYPES.includes(opts.type as typeof ACTION_TYPES[number])) {
|
||||||
|
throw Object.assign(new Error(`Invalid action type: ${opts.type}`), { code: 'INVALID_TYPE' });
|
||||||
|
}
|
||||||
|
const config = opts.config ? JSON.parse(opts.config) : buildActionConfig(opts);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
const edges = (wf.edges || []).slice();
|
||||||
|
const newNodeId = `node-${Date.now()}`;
|
||||||
|
const newNode: IWorkflowNodeVo = {
|
||||||
|
id: newNodeId,
|
||||||
|
type: 'action',
|
||||||
|
category: opts.type,
|
||||||
|
config,
|
||||||
|
name: opts.name || null,
|
||||||
|
};
|
||||||
|
// Chain after the last node (trigger → ... → new).
|
||||||
|
const lastNode = nodes[nodes.length - 1];
|
||||||
|
nodes.push(newNode);
|
||||||
|
if (lastNode) {
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${lastNode.id}-${newNodeId}`,
|
||||||
|
sourceNodeId: lastNode.id,
|
||||||
|
targetNodeId: newNodeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||||
|
if (ctx.json) printJson(updated);
|
||||||
|
else {
|
||||||
|
printKv([
|
||||||
|
['workflowId', workflowId],
|
||||||
|
['nodeId', newNodeId],
|
||||||
|
['type', opts.type],
|
||||||
|
['config', JSON.stringify(config)],
|
||||||
|
['', ''],
|
||||||
|
['next', `inkreach workflow activate ${workflowId}`],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('update <workflowId> <nodeId>')
|
||||||
|
.description('Update an existing action node config')
|
||||||
|
.option('--config <json>', 'Full replacement config JSON')
|
||||||
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
|
.option('--name <n>', 'New node display name')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, nodeId] = args as [string, string];
|
||||||
|
const opts = o as Record<string, string>;
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).slice();
|
||||||
|
const idx = nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (idx < 0) throw Object.assign(new Error(`Node ${nodeId} not found`), { code: 'NOT_FOUND' });
|
||||||
|
const node = nodes[idx];
|
||||||
|
let config = node.config || {};
|
||||||
|
if (opts.config) config = JSON.parse(opts.config);
|
||||||
|
else if (opts.code !== undefined || opts['code-file'] !== undefined) {
|
||||||
|
config = { ...config, code: opts['code-file'] ? readFile(opts['code-file']) : opts.code };
|
||||||
|
}
|
||||||
|
nodes[idx] = { ...node, config, name: opts.name ?? node.name };
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
|
nodes,
|
||||||
|
edges: wf.edges || [],
|
||||||
|
});
|
||||||
|
if (ctx.json) printJson(updated);
|
||||||
|
else process.stdout.write(`✓ Node ${nodeId} updated\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <workflowId> <nodeId>')
|
||||||
|
.description('Delete an action node and its connected edges')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args, o) => {
|
||||||
|
const [workflowId, nodeId] = args as [string, string];
|
||||||
|
const opts = o as { baseId?: string };
|
||||||
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
|
const nodes = (wf.nodes || []).filter((n) => n.id !== nodeId);
|
||||||
|
const edges = (wf.edges || []).filter((e) => e.sourceNodeId !== nodeId && e.targetNodeId !== nodeId);
|
||||||
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||||
|
if (ctx.json) printJson(updated);
|
||||||
|
else process.stdout.write(`✓ Node ${nodeId} deleted\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p: string): string {
|
||||||
|
// inline require to keep imports clean
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
return require('node:fs').readFileSync(p, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build action config from granular flags. */
|
||||||
|
export function buildActionConfig(opts: Record<string, string>): Record<string, unknown> {
|
||||||
|
const type = opts.type;
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
switch (type) {
|
||||||
|
case 'script': {
|
||||||
|
if (opts['code-file']) config.code = readFile(opts['code-file']);
|
||||||
|
else if (opts.code) config.code = opts.code;
|
||||||
|
if (opts.timeout) config.timeout = Number(opts.timeout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sendEmail': {
|
||||||
|
if (opts.to) config.to = opts.to;
|
||||||
|
if (opts.subject) config.subject = opts.subject;
|
||||||
|
if (opts.body) config.body = opts.body;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'httpRequest': {
|
||||||
|
if (opts.url) config.url = opts.url;
|
||||||
|
if (opts.method) config.method = opts.method;
|
||||||
|
if (opts.headers) config.headers = JSON.parse(opts.headers);
|
||||||
|
if (opts['req-body']) config.body = JSON.parse(opts['req-body']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'createRecord':
|
||||||
|
case 'updateRecord': {
|
||||||
|
if (opts['target-table']) config.tableId = opts['target-table'];
|
||||||
|
if (opts.records) config.records = JSON.parse(opts.records);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'getRecords': {
|
||||||
|
if (opts['target-table']) config.tableId = opts['target-table'];
|
||||||
|
if (opts.query) config.query = JSON.parse(opts.query);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* High-level API client. Each method maps to one REST endpoint of the
|
||||||
|
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
||||||
|
*
|
||||||
|
* Base/table/field/record/view endpoints are compatible with official teable
|
||||||
|
* open-api; workflow/automation endpoints use the project's own contract.
|
||||||
|
*/
|
||||||
|
import { request, ApiError } from './request';
|
||||||
|
import { IConfig } from './config';
|
||||||
|
|
||||||
|
export class Client {
|
||||||
|
constructor(private cfg: IConfig) {}
|
||||||
|
|
||||||
|
private get headers(): Record<string, string> {
|
||||||
|
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- auth / user ----
|
||||||
|
getMe() {
|
||||||
|
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
||||||
|
// (/api/auth/user/me is session-only and 403s for PAT.)
|
||||||
|
return this.call<unknown>('GET', '/api/auth/user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- access tokens (PAT) ----
|
||||||
|
listAccessTokens() {
|
||||||
|
return this.call<unknown[]>('GET', '/api/access-token');
|
||||||
|
}
|
||||||
|
createAccessToken(body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
scopes: string[];
|
||||||
|
baseIds?: string[];
|
||||||
|
spaceIds?: string[];
|
||||||
|
hasFullAccess?: boolean;
|
||||||
|
expiredTime: string;
|
||||||
|
}) {
|
||||||
|
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
|
||||||
|
}
|
||||||
|
deleteAccessToken(id: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- base ----
|
||||||
|
listBases() {
|
||||||
|
// GET /api/base/access/all — lists bases the user can access.
|
||||||
|
return this.call<unknown[]>('GET', '/api/base/access/all');
|
||||||
|
}
|
||||||
|
getBase(baseId: string) {
|
||||||
|
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- table ----
|
||||||
|
listTables(baseId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
||||||
|
}
|
||||||
|
getTable(tableId: string) {
|
||||||
|
return this.call<unknown>('GET', `/api/table/${tableId}`);
|
||||||
|
}
|
||||||
|
createTable(baseId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
||||||
|
}
|
||||||
|
updateTable(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
|
||||||
|
}
|
||||||
|
deleteTable(tableId: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- field ----
|
||||||
|
listFields(tableId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
|
||||||
|
}
|
||||||
|
getField(fieldId: string) {
|
||||||
|
return this.call<unknown>('GET', `/api/field/${fieldId}`);
|
||||||
|
}
|
||||||
|
createField(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
|
||||||
|
}
|
||||||
|
updateField(fieldId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PUT', `/api/field/${fieldId}`, body);
|
||||||
|
}
|
||||||
|
deleteField(fieldId: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- record ----
|
||||||
|
getRecords(tableId: string, query?: Record<string, unknown>) {
|
||||||
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
|
||||||
|
}
|
||||||
|
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
||||||
|
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v === undefined || v === null) continue;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
v.forEach((item) => {
|
||||||
|
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
||||||
|
});
|
||||||
|
} else if (typeof v === 'object') {
|
||||||
|
out[k] = JSON.stringify(v);
|
||||||
|
} else {
|
||||||
|
out[k] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
getRecord(recordId: string, query?: Record<string, unknown>) {
|
||||||
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
|
||||||
|
}
|
||||||
|
createRecord(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
updateRecord(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
updateRecordOne(recordId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
|
||||||
|
}
|
||||||
|
deleteRecord(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('DELETE', `/api/table/${tableId}/record`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- view ----
|
||||||
|
listViews(tableId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
||||||
|
}
|
||||||
|
getView(viewId: string) {
|
||||||
|
return this.call<unknown>('GET', `/api/view/${viewId}`);
|
||||||
|
}
|
||||||
|
createView(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
||||||
|
}
|
||||||
|
updateView(viewId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
|
||||||
|
}
|
||||||
|
deleteView(viewId: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- workflow (self-built contract) ----
|
||||||
|
listWorkflows(baseId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
||||||
|
}
|
||||||
|
getWorkflow(workflowId: string) {
|
||||||
|
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
||||||
|
* route that resolves base from the workflow itself, but some auth/permission
|
||||||
|
* paths need the base-scoped route. Prefer this when baseId is known.
|
||||||
|
*/
|
||||||
|
getWorkflowInBase(baseId: string, workflowId: string) {
|
||||||
|
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
createWorkflow(baseId: string, body: { name: string; description?: string }) {
|
||||||
|
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
|
||||||
|
}
|
||||||
|
updateWorkflow(baseId: string, workflowId: string, body: { name?: string; description?: string }) {
|
||||||
|
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
||||||
|
}
|
||||||
|
deleteWorkflow(baseId: string, workflowId: string) {
|
||||||
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||||
|
}
|
||||||
|
activateWorkflow(baseId: string, workflowId: string) {
|
||||||
|
return this.call<{ snapshotId: string }>('POST', `/api/base/${baseId}/workflow/${workflowId}/activate`);
|
||||||
|
}
|
||||||
|
deactivateWorkflow(baseId: string, workflowId: string) {
|
||||||
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
||||||
|
}
|
||||||
|
testWorkflow(baseId: string, workflowId: string) {
|
||||||
|
return this.call<{ nodeResults: unknown[] }>('POST', `/api/base/${baseId}/workflow/${workflowId}/test`);
|
||||||
|
}
|
||||||
|
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
||||||
|
replaceNodes(baseId: string, workflowId: string, body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }) {
|
||||||
|
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- secrets ----
|
||||||
|
listSecrets(baseId: string, workflowId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
||||||
|
}
|
||||||
|
createSecret(baseId: string, workflowId: string, body: { name: string; description?: string; value: string }) {
|
||||||
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
||||||
|
}
|
||||||
|
updateSecret(baseId: string, workflowId: string, secretId: string, body: { name?: string; description?: string; value?: string }) {
|
||||||
|
return this.call<unknown>('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body);
|
||||||
|
}
|
||||||
|
deleteSecret(baseId: string, workflowId: string, secretId: string) {
|
||||||
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- execution history ----
|
||||||
|
listExecutions(baseId: string, workflowId: string) {
|
||||||
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
||||||
|
}
|
||||||
|
getExecution(baseId: string, workflowId: string, executionId: string) {
|
||||||
|
return this.call<unknown>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`);
|
||||||
|
}
|
||||||
|
rerunExecution(baseId: string, workflowId: string, executionId: string) {
|
||||||
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- trigger (manual fire for testing) ----
|
||||||
|
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
|
||||||
|
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
|
||||||
|
'POST',
|
||||||
|
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
|
||||||
|
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
||||||
|
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SQL (read-only) ----
|
||||||
|
sql(baseId: string, sql: string) {
|
||||||
|
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
||||||
|
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
|
||||||
|
export type WorkflowNodeCategory =
|
||||||
|
| 'recordCreated' | 'recordUpdated' | 'recordCondition' | 'recordCreatedOrUpdated'
|
||||||
|
| 'formSubmitted' | 'scheduledTime' | 'buttonClick' | 'webhook'
|
||||||
|
| 'script' | 'sendEmail' | 'httpRequest'
|
||||||
|
| 'createRecord' | 'updateRecord' | 'getRecords'
|
||||||
|
| 'triggerEnd';
|
||||||
|
|
||||||
|
export interface IWorkflowNodeVo {
|
||||||
|
id: string;
|
||||||
|
workflowId?: string;
|
||||||
|
type: WorkflowNodeType;
|
||||||
|
category: WorkflowNodeCategory | string;
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
name?: string | null;
|
||||||
|
testResult?: Record<string, unknown> | null;
|
||||||
|
inputVariables?: unknown;
|
||||||
|
outputVariables?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkflowEdgeVo {
|
||||||
|
id: string;
|
||||||
|
workflowId?: string;
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkflowVo {
|
||||||
|
id: string;
|
||||||
|
baseId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
hasDraft: boolean;
|
||||||
|
nodes: IWorkflowNodeVo[];
|
||||||
|
edges: IWorkflowEdgeVo[];
|
||||||
|
createdBy?: string;
|
||||||
|
createdTime?: string;
|
||||||
|
lastModifiedBy?: string | null;
|
||||||
|
lastModifiedTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Config storage for inkreach CLI.
|
||||||
|
*
|
||||||
|
* Stores endpoint + token at ~/.inkreach/config.json, with environment variable
|
||||||
|
* overrides INKREACH_ENDPOINT / INKREACH_TOKEN. The --endpoint / --token / --base-id
|
||||||
|
* global flags take the highest precedence (handled by resolveOptions()).
|
||||||
|
*/
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
|
||||||
|
export interface IConfig {
|
||||||
|
endpoint: string;
|
||||||
|
token: string;
|
||||||
|
baseId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_DIR = path.join(os.homedir(), '.inkreach');
|
||||||
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||||
|
|
||||||
|
export const DEFAULT_ENDPOINT = 'http://localhost:3000';
|
||||||
|
|
||||||
|
export function readConfigFile(): Partial<IConfig> {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeConfigFile(cfg: Partial<IConfig>): void {
|
||||||
|
if (!fs.existsSync(CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
const merged = { ...readConfigFile(), ...cfg };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfig(overrides?: Partial<IConfig>): IConfig {
|
||||||
|
const file = readConfigFile();
|
||||||
|
return {
|
||||||
|
endpoint:
|
||||||
|
overrides?.endpoint ||
|
||||||
|
process.env.INKREACH_ENDPOINT ||
|
||||||
|
file.endpoint ||
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
token: overrides?.token || process.env.INKREACH_TOKEN || file.token || '',
|
||||||
|
baseId: overrides?.baseId || process.env.INKREACH_BASE_ID || file.baseId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configPath(): string {
|
||||||
|
return CONFIG_FILE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Shared command context: resolves config from global flags + file + env,
|
||||||
|
* builds a Client, and normalizes error handling.
|
||||||
|
*/
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { resolveConfig } from './config';
|
||||||
|
import { Client, ApiError } from './client';
|
||||||
|
import { describeError } from './request';
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
client: Client;
|
||||||
|
json: boolean;
|
||||||
|
baseId?: string;
|
||||||
|
/** The running subcommand (last in the chain). Use .opts() for its options. */
|
||||||
|
command: Command;
|
||||||
|
program: Command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Ctx. `command` is the subcommand being executed (so .opts() returns
|
||||||
|
* its local options); `program` is the top-level program (for global options).
|
||||||
|
*/
|
||||||
|
export function makeCtx(program: Command, command: Command): Ctx {
|
||||||
|
const g = program.opts() as {
|
||||||
|
endpoint?: string;
|
||||||
|
token?: string;
|
||||||
|
baseId?: string;
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
const cfg = resolveConfig({
|
||||||
|
endpoint: g.endpoint,
|
||||||
|
token: g.token,
|
||||||
|
baseId: g.baseId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client: new Client(cfg),
|
||||||
|
json: !!g.json,
|
||||||
|
baseId: cfg.baseId,
|
||||||
|
command,
|
||||||
|
program,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an async command action with normalized error handling.
|
||||||
|
*
|
||||||
|
* The wrapped fn receives (ctx, positionalArgs[], options). commander passes
|
||||||
|
* positional args before the trailing Command; we detect the trailing Command
|
||||||
|
* and forward everything else as positional, plus parsed options.
|
||||||
|
*/
|
||||||
|
export function run<TArgs extends unknown[] = unknown[]>(
|
||||||
|
fn: (ctx: Ctx, args: TArgs, options: Record<string, unknown>) => Promise<void>
|
||||||
|
): (this: Command, ...cmdArgs: unknown[]) => Promise<void> {
|
||||||
|
return async function (this: Command, ...cmdArgs: unknown[]) {
|
||||||
|
// commander convention: last arg is the Command instance (the subcommand).
|
||||||
|
const command = cmdArgs[cmdArgs.length - 1] as Command;
|
||||||
|
const program = command?.parent ? findRoot(command) : command;
|
||||||
|
const positional = cmdArgs.slice(0, -1) as TArgs;
|
||||||
|
const options = (command?.opts() || {}) as Record<string, unknown>;
|
||||||
|
const ctx = makeCtx(program, command);
|
||||||
|
try {
|
||||||
|
await fn(ctx, positional, options);
|
||||||
|
} catch (err) {
|
||||||
|
const { status, code, message } = describeError(err);
|
||||||
|
if (ctx.json) {
|
||||||
|
process.stdout.write(JSON.stringify({ error: { status, code, message } }) + '\n');
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRoot(cmd: Command): Command {
|
||||||
|
let c = cmd;
|
||||||
|
while (c.parent) c = c.parent;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require a baseId: from flag, else ctx default, else throw with guidance. */
|
||||||
|
export function requireBaseId(ctx: Ctx, flagVal?: string): string {
|
||||||
|
const baseId = flagVal || ctx.baseId;
|
||||||
|
if (!baseId) {
|
||||||
|
throw new ApiError(
|
||||||
|
0,
|
||||||
|
'MISSING_BASE_ID',
|
||||||
|
'No base id. Pass --base-id <id>, set a default with `inkreach auth login --base-id <id>`, or export INKREACH_BASE_ID.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return baseId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Output formatting helpers.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - JSON mode (--json flag): print raw JSON, nothing else.
|
||||||
|
* - Human mode (default): pretty-print tables / key-values.
|
||||||
|
*/
|
||||||
|
export interface OutputOptions {
|
||||||
|
json: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print as JSON (compact). Safe for agent parsing. */
|
||||||
|
export function printJson(value: unknown): void {
|
||||||
|
process.stdout.write(JSON.stringify(value) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print a human-readable table. columns: [{key, header, width?}]; rows: object[]. */
|
||||||
|
export function printTable(
|
||||||
|
columns: Array<{ key: string; header: string }>,
|
||||||
|
rows: Array<Record<string, unknown>>
|
||||||
|
): void {
|
||||||
|
const headers = columns.map((c) => c.header);
|
||||||
|
const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(r[c.key] ?? '').length)));
|
||||||
|
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||||
|
const fmtRow = (r: Record<string, unknown>) =>
|
||||||
|
columns.map((c, i) => String(r[c.key] ?? '').padEnd(widths[i])).join(' ');
|
||||||
|
process.stdout.write(headers.map((h, i) => h.padEnd(widths[i])).join(' ') + '\n');
|
||||||
|
process.stdout.write(sep + '\n');
|
||||||
|
rows.forEach((r) => process.stdout.write(fmtRow(r) + '\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print key: value pairs vertically. */
|
||||||
|
export function printKv(pairs: Array<[string, unknown]>): void {
|
||||||
|
const width = Math.max(...pairs.map(([k]) => k.length));
|
||||||
|
pairs.forEach(([k, v]) => {
|
||||||
|
const val = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '');
|
||||||
|
process.stdout.write(`${k.padEnd(width)} ${val}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate a long string to width, appending ellipsis. */
|
||||||
|
export function truncate(s: unknown, width = 40): string {
|
||||||
|
const str = String(s ?? '');
|
||||||
|
return str.length > width ? str.slice(0, width - 1) + '…' : str;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* HTTP request helper using node:http / node:https.
|
||||||
|
*
|
||||||
|
* IMPORTANT: we deliberately avoid global fetch() on Node.js — the Node 24
|
||||||
|
* undici fetch drops the Authorization header in some redirect/proxy scenarios
|
||||||
|
* (confirmed bug in this project). Using the built-in http(s) module keeps the
|
||||||
|
* Authorization: Bearer header intact end-to-end.
|
||||||
|
*/
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
|
||||||
|
export interface IRequestOptions {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
/** Abort signal for timeouts. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResponse<T = unknown> {
|
||||||
|
status: number;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
constructor(status: number, message: string, body: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T = unknown>(
|
||||||
|
baseUrl: string,
|
||||||
|
pathName: string,
|
||||||
|
options: IRequestOptions = {}
|
||||||
|
): Promise<IResponse<T>> {
|
||||||
|
const url = new URL(
|
||||||
|
pathName.startsWith('http') ? pathName : pathName.replace(/^\//, ''),
|
||||||
|
baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'
|
||||||
|
);
|
||||||
|
|
||||||
|
const isHttps = url.protocol === 'https:';
|
||||||
|
const lib = isHttps ? https : http;
|
||||||
|
const method = options.method || 'GET';
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
let bodyData: string | undefined;
|
||||||
|
if (options.body !== undefined && options.body !== null) {
|
||||||
|
bodyData = JSON.stringify(options.body);
|
||||||
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||||
|
headers['Content-Length'] = Buffer.byteLength(bodyData).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<IResponse<T>>((resolve, reject) => {
|
||||||
|
const req = lib.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
let data: unknown = text;
|
||||||
|
const ct = res.headers['content-type'] || '';
|
||||||
|
if (ct.includes('application/json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = res.statusCode || 200;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
resolve({ status, headers: res.headers, data: data as T });
|
||||||
|
} else {
|
||||||
|
const errBody = data;
|
||||||
|
const msg =
|
||||||
|
(errBody && typeof errBody === 'object' && (errBody as { error?: { message?: string } }).error?.message) ||
|
||||||
|
(typeof errBody === 'object' && (errBody as { message?: string }).message) ||
|
||||||
|
`HTTP ${status}`;
|
||||||
|
reject(new ApiError(status, String(msg), errBody));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (options.signal) {
|
||||||
|
options.signal.addEventListener('abort', () => req.destroy(new Error('aborted')));
|
||||||
|
}
|
||||||
|
req.on('error', reject);
|
||||||
|
if (bodyData !== undefined) req.write(bodyData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `{ code, message }` from an ApiError body for display. */
|
||||||
|
export function describeError(err: unknown): { status: number; code: string; message: string } {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const b = err.body as { error?: { code?: string; message?: string } } | undefined;
|
||||||
|
return {
|
||||||
|
status: err.status,
|
||||||
|
code: b?.error?.code || `HTTP_${err.status}`,
|
||||||
|
message: b?.error?.message || err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { status: 0, code: 'ERROR', message: err.message };
|
||||||
|
}
|
||||||
|
return { status: 0, code: 'UNKNOWN', message: String(err) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user