commit 5fa843df05cad725fe76de8208bc5d7266742486 Author: yeuimu <2197651308@qq.com> Date: Wed Aug 5 02:04:39 2026 +0800 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 diff --git a/dist/bin.js b/dist/bin.js new file mode 100644 index 0000000..7af0905 --- /dev/null +++ b/dist/bin.js @@ -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 ', 'Backend URL (default http://localhost:3000)') + .option('--token ', 'Personal Access Token') + .option('--base-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); +}); diff --git a/dist/commands/auth.js b/dist/commands/auth.js new file mode 100644 index 0000000..7f7daaa --- /dev/null +++ b/dist/commands/auth.js @@ -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 ', 'Backend URL (default http://localhost:3000)') + .option('--token ', 'Personal Access Token (format: teable_xxx_yyy)') + .option('--base-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 ', 'Token name') + .option('--scopes ', 'Comma-separated scopes', 'automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update') + .option('--base-ids ', 'Restrict to base ids (comma-separated). Omit for full access') + .option('--full-access', 'Grant full access') + .option('--expires ', '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 '], + ]); + } + })); + token + .command('delete ') + .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; diff --git a/dist/commands/extras.js b/dist/commands/extras.js new file mode 100644 index 0000000..328d892 --- /dev/null +++ b/dist/commands/extras.js @@ -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 ') + .description('List secrets for a workflow. Values are never returned.') + .option('--base-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 ') + .description('Create a new secret') + .requiredOption('--name ', 'Secret name') + .requiredOption('--value ', 'Secret value') + .option('--description ', 'Secret description') + .option('--base-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 ') + .description('Update a secret') + .option('--name ', 'New name') + .option('--value ', 'New value') + .option('--description ', 'New description') + .option('--base-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 ') + .description('Delete a secret') + .option('--base-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 ') + .description('List execution history') + .option('--status ', 'Filter by status: failed, succeeded, or running') + .option('--base-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 ') + .description('Get execution detail with node results') + .option('--base-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 ') + .description('Rerun a failed execution') + .option('--base-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 ') + .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 ') + .description('Simulate a webhook call') + .option('--body ', 'JSON body to send') + .option('--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 ', 'Base id (overrides default)'); + cmd.argument('', '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; diff --git a/dist/commands/resources.js b/dist/commands/resources.js new file mode 100644 index 0000000..64a1bd1 --- /dev/null +++ b/dist/commands/resources.js @@ -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 ') + .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 ', '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 ') + .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 ', 'Table name') + .option('--base-id ', 'Base id (overrides default)') + .option('--fields ', '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 ') + .description('Update table (rename)') + .requiredOption('--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 ') + .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 ') + .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 ') + .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 ', 'Table id') + .requiredOption('--name ', 'Field name') + .requiredOption('--type ', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)') + .option('--options ', '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 ') + .description('Update a field') + .option('--name ', 'New field name') + .option('--options ', '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 ') + .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 ', 'Table id') + .option('--filter ', 'Filter conditions JSON') + .option('--projection ', 'Field projection JSON array') + .option('--page-size ', 'Records per page') + .option('--page ', 'Page number (1-based)') + .option('--search ', 'Search keyword') + .option('--base-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 ') + .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 ', 'Table id') + .option('--fields ', 'Fields object JSON, e.g. \'{"Title":"hello"}\'') + .option('--records ', '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 ', 'Table id') + .requiredOption('--records ', '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 ', 'Table id') + .requiredOption('--record-ids ', '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 ') + .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 ') + .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 ', 'Table id') + .requiredOption('--name ', 'View name') + .option('--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 ') + .description('Update a view (rename)') + .option('--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 ') + .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; +} diff --git a/dist/commands/workflow.js b/dist/commands/workflow.js new file mode 100644 index 0000000..55cbef2 --- /dev/null +++ b/dist/commands/workflow.js @@ -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 ', '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 ' for details.\n`); + } + })); + cmd + .command('get ') + .description('Get workflow detail (nodes + edges)') + .option('--base-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 ', 'Workflow name') + .option('--description ', 'Workflow description') + .option('--base-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 ...`], + ]); + } + })); + cmd + .command('update ') + .description('Update workflow name / description') + .option('--name ', 'New name') + .option('--description ', 'New description') + .option('--base-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 ') + .description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)') + .option('--base-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 ') + .description('Activate: snapshot + register trigger + create auto access token') + .option('--base-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 ') + .description('Deactivate: unregister trigger') + .option('--base-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 ') + .description('Test-run: execute action nodes with mock input (no trigger, no queue)') + .option('--base-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 ') + .description('Set the trigger type and config (replaces the existing trigger node)') + .requiredOption('--type ', `Trigger type: ${TRIGGER_TYPES.join('|')}`) + .option('--table-id ', 'Table id (most record/button triggers)') + .option('--field-ids ', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)') + .option('--form-id ', 'Form view id (formSubmitted)') + .option('--conditions ', 'Filter conditions JSON (recordCondition/recordCreated)') + .option('--schedule ', 'Schedule JSON {timing:{type,interval}} (scheduledTime)') + .option('--batch ', 'Batch config JSON {enabled,batchWindow,batchSize}') + .option('--base-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 ') + .description('Add an action node after the last existing node') + .requiredOption('--type ', `Action type: ${ACTION_TYPES.join('|')}`) + .option('--config ', 'Full action config JSON (overrides the granular flags below)') + .option('--code ', 'Script JS code (script)') + .option('--code-file ', 'Read script code from a file (script)') + .option('--timeout ', 'Script timeout ms (script, default 60000)') + .option('--to ', 'Recipient (sendEmail)') + .option('--subject ', 'Subject (sendEmail)') + .option('--body ', 'HTML body (sendEmail)') + .option('--url ', 'Request URL (httpRequest)') + .option('--method ', 'HTTP method (httpRequest, default GET)') + .option('--headers ', 'Request headers JSON (httpRequest)') + .option('--req-body ', 'Request body JSON (httpRequest)') + .option('--target-table ', 'Target table id (createRecord/updateRecord/getRecords)') + .option('--records ', 'Records JSON array (createRecord/updateRecord)') + .option('--query ', 'Query JSON (getRecords)') + .option('--name ', 'Optional node display name') + .option('--base-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 ') + .description('Update an existing action node config') + .option('--config ', 'Full replacement config JSON') + .option('--code ', 'Script JS code (script)') + .option('--code-file ', 'Read script code from a file (script)') + .option('--name ', 'New node display name') + .option('--base-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 ') + .description('Delete an action node and its connected edges') + .option('--base-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; diff --git a/dist/lib/client.js b/dist/lib/client.js new file mode 100644 index 0000000..81e748f --- /dev/null +++ b/dist/lib/client.js @@ -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; diff --git a/dist/lib/config.js b/dist/lib/config.js new file mode 100644 index 0000000..42eb4b1 --- /dev/null +++ b/dist/lib/config.js @@ -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; diff --git a/dist/lib/ctx.js b/dist/lib/ctx.js new file mode 100644 index 0000000..d42616a --- /dev/null +++ b/dist/lib/ctx.js @@ -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 , set a default with `inkreach auth login --base-id `, or export INKREACH_BASE_ID.'); + } + return baseId; +} +exports.requireBaseId = requireBaseId; diff --git a/dist/lib/output.js b/dist/lib/output.js new file mode 100644 index 0000000..c114faf --- /dev/null +++ b/dist/lib/output.js @@ -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; diff --git a/dist/lib/request.js b/dist/lib/request.js new file mode 100644 index 0000000..79e3d14 --- /dev/null +++ b/dist/lib/request.js @@ -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; diff --git a/docs/cli-skill-design.md b/docs/cli-skill-design.md new file mode 100644 index 0000000..27f6efa --- /dev/null +++ b/docs/cli-skill-design.md @@ -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 覆盖配置的 endpoint(默认 http://localhost:3000) + --token 覆盖配置的 PAT + --base-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 + +表格/字段/记录/视图(兼容官方端点): + 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 详情(含 nodes + edges) + workflow create --name 创建(自动带 recordCreated 触发器) + workflow update [--name] [--desc] 改名/描述 + workflow delete 软删除 + workflow activate 激活(建快照 + 注册触发器) + workflow deactivate 停用 + workflow test 测试运行(mock 输入跑 action) + workflow node set 整体替换 nodes+edges(PUT /node) + + 触发器配置(便捷封装,生成正确的 config JSON): + trigger set --type [选项] 设置触发器类型与配置 + --type: recordCreated|recordUpdated|recordCondition|recordCreatedOrUpdated| + formSubmitted|scheduledTime|buttonClick|webhook + --table-id, --field-ids, --form-id, --conditions , + --schedule , --batch + + Action 配置: + action add --type [选项] 添加 action 节点 + --type: script|sendEmail|httpRequest|createRecord|updateRecord|getRecords + 各类型选项见 api-reference + + action update [选项] + action delete + + Secrets: + secret list + secret set --name --value + secret update [--name] [--value] + secret delete + + 执行历史: + runs list [--status failed|succeeded|running] + runs get + runs rerun + +触发(手动激发,用于测试): + fire button-click 模拟按钮点击 + fire webhook [--body ] 模拟 webhook + +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 --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'` 正确配置。 +- `inkreach action add --type script --code '...'` 正确添加节点。 +- `inkreach workflow activate ` 激活成功(返回 snapshotId)。 +- `inkreach fire button-click ...` 触发执行。 +- `inkreach runs list ` 能看到刚才的执行。 +- skill 被 Agent 正确加载(出现在 available_skills)。 + +## 7. 非目标 + +- 不做 App 构建/网页抓取(本项目无此功能)。 +- 不修复 scheduledTime 后端缺陷(如实记录,CLI 只暴露有效字段)。 +- 不发布到 npm(本地 `pnpm link` 即可)。 +- 不保持与官方 teable CLI 的命令兼容(独立命令树)。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d695e7a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..e97fa96 --- /dev/null +++ b/src/bin.ts @@ -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 ', 'Backend URL (default http://localhost:3000)') + .option('--token ', 'Personal Access Token') + .option('--base-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); +}); diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..b4eb336 --- /dev/null +++ b/src/commands/auth.ts @@ -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 ', 'Backend URL (default http://localhost:3000)') + .option('--token ', 'Personal Access Token (format: teable_xxx_yyy)') + .option('--base-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 ', 'Token name') + .option( + '--scopes ', + 'Comma-separated scopes', + 'automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update' + ) + .option('--base-ids ', 'Restrict to base ids (comma-separated). Omit for full access') + .option('--full-access', 'Grant full access') + .option('--expires ', '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 '], + ]); + } + }) + ); + token + .command('delete ') + .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; +} diff --git a/src/commands/extras.ts b/src/commands/extras.ts new file mode 100644 index 0000000..926fe61 --- /dev/null +++ b/src/commands/extras.ts @@ -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 ') + .description('List secrets for a workflow. Values are never returned.') + .option('--base-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 ') + .description('Create a new secret') + .requiredOption('--name ', 'Secret name') + .requiredOption('--value ', 'Secret value') + .option('--description ', 'Secret description') + .option('--base-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 ') + .description('Update a secret') + .option('--name ', 'New name') + .option('--value ', 'New value') + .option('--description ', 'New description') + .option('--base-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 ') + .description('Delete a secret') + .option('--base-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 ') + .description('List execution history') + .option('--status ', 'Filter by status: failed, succeeded, or running') + .option('--base-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 ') + .description('Get execution detail with node results') + .option('--base-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 ') + .description('Rerun a failed execution') + .option('--base-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 ') + .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 ') + .description('Simulate a webhook call') + .option('--body ', 'JSON body to send') + .option('--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 ', 'Base id (overrides default)'); + cmd.argument('', '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); + printTable( + keys.map((k) => ({ key: k, header: k })), + rows.map((r) => { + const obj = r as Record; + const row: Record = {}; + 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; +} diff --git a/src/commands/resources.ts b/src/commands/resources.ts new file mode 100644 index 0000000..c84b80f --- /dev/null +++ b/src/commands/resources.ts @@ -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>; + 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 ') + .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; + 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 ', '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>; + 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 ') + .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; + printKv(Object.entries(t).map(([k, v]) => [k, v])); + } + }) + ); + + cmd + .command('create') + .description('Create a new table') + .requiredOption('--name ', 'Table name') + .option('--base-id ', 'Base id (overrides default)') + .option('--fields ', '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 = { 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; + printKv([['id', t.id], ['name', t.name]]); + } + }) + ); + + cmd + .command('update ') + .description('Update table (rename)') + .requiredOption('--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 ') + .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 ') + .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>; + 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 ') + .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; + printKv(Object.entries(f).map(([k, v]) => [k, v])); + } + }) + ); + + cmd + .command('create') + .description('Create a new field') + .requiredOption('--table-id ', 'Table id') + .requiredOption('--name ', 'Field name') + .requiredOption('--type ', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)') + .option('--options ', '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 = { 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; + printKv([['id', f.id], ['name', f.name], ['type', f.type]]); + } + }) + ); + + cmd + .command('update ') + .description('Update a field') + .option('--name ', 'New field name') + .option('--options ', '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 = {}; + 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 ') + .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 ', 'Table id') + .option('--filter ', 'Filter conditions JSON') + .option('--projection ', 'Field projection JSON array') + .option('--page-size ', 'Records per page') + .option('--page ', 'Page number (1-based)') + .option('--search ', 'Search keyword') + .option('--base-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 = {}; + 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 }>; + 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 = { 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 ') + .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 }; + 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 ', 'Table id') + .option('--fields ', 'Fields object JSON, e.g. \'{"Title":"hello"}\'') + .option('--records ', '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 ', 'Table id') + .requiredOption('--records ', '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 ', 'Table id') + .requiredOption('--record-ids ', '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 ') + .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>; + 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 ') + .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; + printKv(Object.entries(v).map(([k, val]) => [k, val])); + } + }) + ); + + cmd + .command('create') + .description('Create a new view') + .requiredOption('--table-id ', 'Table id') + .requiredOption('--name ', 'View name') + .option('--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 = { 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; + printKv([['id', v.id], ['name', v.name], ['type', v.type]]); + } + }) + ); + + cmd + .command('update ') + .description('Update a view (rename)') + .option('--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 = {}; + 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 ') + .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; +} diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts new file mode 100644 index 0000000..0f33792 --- /dev/null +++ b/src/commands/workflow.ts @@ -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 ', '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 ' for details.\n`); + } + }) + ); + + cmd + .command('get ') + .description('Get workflow detail (nodes + edges)') + .option('--base-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 ', 'Workflow name') + .option('--description ', 'Workflow description') + .option('--base-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 ...`], + ]); + } + }) + ); + + cmd + .command('update ') + .description('Update workflow name / description') + .option('--name ', 'New name') + .option('--description ', 'New description') + .option('--base-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 ') + .description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)') + .option('--base-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 ') + .description('Activate: snapshot + register trigger + create auto access token') + .option('--base-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 ') + .description('Deactivate: unregister trigger') + .option('--base-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 ') + .description('Test-run: execute action nodes with mock input (no trigger, no queue)') + .option('--base-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; + 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 ') + .description('Set the trigger type and config (replaces the existing trigger node)') + .requiredOption('--type ', `Trigger type: ${TRIGGER_TYPES.join('|')}`) + .option('--table-id ', 'Table id (most record/button triggers)') + .option('--field-ids ', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)') + .option('--form-id ', 'Form view id (formSubmitted)') + .option('--conditions ', 'Filter conditions JSON (recordCondition/recordCreated)') + .option('--schedule ', 'Schedule JSON {timing:{type,interval}} (scheduledTime)') + .option('--batch ', 'Batch config JSON {enabled,batchWindow,batchSize}') + .option('--base-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 { + const parse = (s?: string): unknown => (s ? JSON.parse(s) : undefined); + const config: Record = {}; + 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 }; + 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 ') + .description('Add an action node after the last existing node') + .requiredOption('--type ', `Action type: ${ACTION_TYPES.join('|')}`) + .option('--config ', 'Full action config JSON (overrides the granular flags below)') + .option('--code ', 'Script JS code (script)') + .option('--code-file ', 'Read script code from a file (script)') + .option('--timeout ', 'Script timeout ms (script, default 60000)') + .option('--to ', 'Recipient (sendEmail)') + .option('--subject ', 'Subject (sendEmail)') + .option('--body ', 'HTML body (sendEmail)') + .option('--url ', 'Request URL (httpRequest)') + .option('--method ', 'HTTP method (httpRequest, default GET)') + .option('--headers ', 'Request headers JSON (httpRequest)') + .option('--req-body ', 'Request body JSON (httpRequest)') + .option('--target-table ', 'Target table id (createRecord/updateRecord/getRecords)') + .option('--records ', 'Records JSON array (createRecord/updateRecord)') + .option('--query ', 'Query JSON (getRecords)') + .option('--name ', 'Optional node display name') + .option('--base-id ', 'Base id (overrides default)') + .action( + run(async (ctx, args, o) => { + const workflowId = args[0] as string; + const opts = o as Record; + 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 ') + .description('Update an existing action node config') + .option('--config ', 'Full replacement config JSON') + .option('--code ', 'Script JS code (script)') + .option('--code-file ', 'Read script code from a file (script)') + .option('--name ', 'New node display name') + .option('--base-id ', 'Base id (overrides default)') + .action( + run(async (ctx, args, o) => { + const [workflowId, nodeId] = args as [string, string]; + const opts = o as Record; + 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 ') + .description('Delete an action node and its connected edges') + .option('--base-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): Record { + const type = opts.type; + const config: Record = {}; + 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; +} diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..89734a9 --- /dev/null +++ b/src/lib/client.ts @@ -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 { + return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {}; + } + + private async call(method: string, p: string, body?: unknown): Promise { + const res = await 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: { + 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('GET', '/api/base/access/all'); + } + getBase(baseId: string) { + return this.call('GET', `/api/base/${baseId}`); + } + + // ---- table ---- + listTables(baseId: string) { + return this.call('GET', `/api/base/${baseId}/table`); + } + getTable(tableId: string) { + return this.call('GET', `/api/table/${tableId}`); + } + createTable(baseId: string, body: unknown) { + return this.call('POST', `/api/base/${baseId}/table`, body); + } + updateTable(tableId: string, body: unknown) { + return this.call('PUT', `/api/table/${tableId}`, body); + } + deleteTable(tableId: string) { + return this.call<{ id: string }>('DELETE', `/api/table/${tableId}`); + } + + // ---- field ---- + listFields(tableId: string) { + return this.call('GET', `/api/table/${tableId}/field`); + } + getField(fieldId: string) { + return this.call('GET', `/api/field/${fieldId}`); + } + createField(tableId: string, body: unknown) { + return this.call('POST', `/api/table/${tableId}/field`, body); + } + updateField(fieldId: string, body: unknown) { + return this.call('PUT', `/api/field/${fieldId}`, body); + } + deleteField(fieldId: string) { + return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`); + } + + // ---- record ---- + getRecords(tableId: string, query?: Record) { + 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[]=...). */ + private flattenQuery(query: Record): Record { + const out: Record = {}; + 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) { + const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : ''; + return this.call('GET', `/api/record/${recordId}${qs}`); + } + createRecord(tableId: string, body: unknown) { + return this.call('POST', `/api/table/${tableId}/record`, body); + } + updateRecord(tableId: string, body: unknown) { + return this.call('PATCH', `/api/table/${tableId}/record`, body); + } + updateRecordOne(recordId: string, body: unknown) { + return this.call('PATCH', `/api/record/${recordId}`, body); + } + deleteRecord(tableId: string, body: unknown) { + return this.call('DELETE', `/api/table/${tableId}/record`, body); + } + + // ---- view ---- + listViews(tableId: string) { + return this.call('GET', `/api/table/${tableId}/view`); + } + getView(viewId: string) { + return this.call('GET', `/api/view/${viewId}`); + } + createView(tableId: string, body: unknown) { + return this.call('POST', `/api/table/${tableId}/view`, body); + } + updateView(viewId: string, body: unknown) { + return this.call('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('GET', `/api/base/${baseId}/workflow`); + } + getWorkflow(workflowId: string) { + 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: string, workflowId: string) { + return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}`); + } + createWorkflow(baseId: string, body: { name: string; description?: string }) { + return this.call('POST', `/api/base/${baseId}/workflow`, body); + } + updateWorkflow(baseId: string, workflowId: string, body: { name?: string; description?: string }) { + return this.call('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body); + } + deleteWorkflow(baseId: string, workflowId: string) { + return this.call('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('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('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body); + } + + // ---- secrets ---- + listSecrets(baseId: string, workflowId: string) { + return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`); + } + createSecret(baseId: string, workflowId: string, body: { name: string; description?: string; value: string }) { + return this.call('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('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body); + } + deleteSecret(baseId: string, workflowId: string, secretId: string) { + return this.call('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`); + } + + // ---- execution history ---- + listExecutions(baseId: string, workflowId: string) { + return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`); + } + getExecution(baseId: string, workflowId: string, executionId: string) { + return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`); + } + rerunExecution(baseId: string, workflowId: string, executionId: string) { + return this.call('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('POST', `/api/webhook/${baseId}/${workflowId}`, body); + } + + // ---- SQL (read-only) ---- + sql(baseId: string, sql: string) { + return this.call('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 | null; + name?: string | null; + testResult?: Record | 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 }; diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..cd6174a --- /dev/null +++ b/src/lib/config.ts @@ -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 { + try { + const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); + return JSON.parse(raw); + } catch { + return {}; + } +} + +export function writeConfigFile(cfg: Partial): 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 { + 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; +} diff --git a/src/lib/ctx.ts b/src/lib/ctx.ts new file mode 100644 index 0000000..14a45e6 --- /dev/null +++ b/src/lib/ctx.ts @@ -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( + fn: (ctx: Ctx, args: TArgs, options: Record) => Promise +): (this: Command, ...cmdArgs: unknown[]) => Promise { + 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; + 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 , set a default with `inkreach auth login --base-id `, or export INKREACH_BASE_ID.' + ); + } + return baseId; +} diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..49f320d --- /dev/null +++ b/src/lib/output.ts @@ -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> +): 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) => + 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; +} diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..40492fd --- /dev/null +++ b/src/lib/request.ts @@ -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; + body?: unknown; + /** Abort signal for timeouts. */ + signal?: AbortSignal; +} + +export interface IResponse { + 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( + baseUrl: string, + pathName: string, + options: IRequestOptions = {} +): Promise> { + 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 = { + 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>((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) }; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2cb2285 --- /dev/null +++ b/tsconfig.build.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5c29504 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true + } +}