"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildActionConfig = exports.actionCommand = exports.buildTriggerConfig = exports.triggerCommand = exports.workflowCommand = void 0; /** * `inkreach-teable workflow` — workflow CRUD + lifecycle. * `inkreach-teable trigger` — set the trigger node's type & config. * `inkreach-teable 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-teable 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, legacy single). Prefer --form-ids.') .option('--form-ids ', 'Comma-separated form view ids (formSubmitted, multi-select)') .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, formIds: opts.formIds, 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-teable 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; // Prefer --form-ids (csv) which populates formIds:string[]. Fall back to // legacy --form-id (single string) for backward compatibility. if (opts.formIds) { config.formIds = opts.formIds .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0); } else 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', 'sendNotification', ]; 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('--dependencies ', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script)') .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('--to-user ', 'Recipient user id (sendNotification)') .option('--title ', 'Notification title (sendNotification)') .option('--message ', 'Notification message (sendNotification)') .option('--severity ', 'Notification severity info|warning|critical (sendNotification)') .option('--path ', 'Click-through path, e.g. /base/xx/table/yy (sendNotification)') .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-teable 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('--timeout ', 'Script timeout ms (script)') .option('--dependencies ', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script; replaces existing deps)') .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 { // Granular update: merge only the flags the user actually passed. config = { ...config }; // commander stores --kebab-case flags camelCased (--code-file -> // opts.codeFile); accept both forms like buildActionConfig's pick(). const codeFile = opts['code-file'] ?? opts.codeFile; if (opts.code !== undefined || codeFile !== undefined) { config.code = codeFile ? readFile(codeFile) : opts.code; } if (opts.timeout !== undefined) config.timeout = Number(opts.timeout); if (opts.dependencies !== undefined) { config.dependencies = parseDependencies(opts.dependencies); } } 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'); } /** * Parse `--dependencies` into the ScriptDependency[] shape the backend expects. * Accepts: * - full JSON: '[{"name":"axios","version":"1.6.0"}]' * - name list: 'axios,lodash' (version defaults to 'latest') * - name@ver: 'axios@1.6.0,lodash@4.17.21' * - empty '[]' or '' to clear deps */ function parseDependencies(raw) { const trimmed = raw.trim(); if (!trimmed || trimmed === '[]') return []; // JSON form if (trimmed[0] === '[') { const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) throw new Error('--dependencies JSON must be an array'); return parsed.map((d) => { if (typeof d === 'string') return { name: d }; if (d && typeof d === 'object' && typeof d.name === 'string') { return { name: d.name, ...(d.version ? { version: String(d.version) } : {}) }; } throw new Error('--dependencies entries must be strings or {name,version?} objects'); }); } // Comma list form: axios@1.6.0,lodash return trimmed .split(',') .map((s) => s.trim()) .filter(Boolean) .map((spec) => { const at = spec.lastIndexOf('@'); if (at > 0) return { name: spec.slice(0, at), version: spec.slice(at + 1) }; return { name: spec }; }); } /** Build action config from granular flags. */ function buildActionConfig(opts) { const type = opts.type; // Helper: commander stores --kebab-case flags as camelCase in opts (e.g. // --target-table -> opts.targetTable). Accept both forms for safety. const pick = (k) => (opts[k] ?? opts[toCamel(k)]); const config = {}; switch (type) { case 'script': { const codeFile = pick('code-file'); if (codeFile) config.code = readFile(codeFile); else if (opts.code) config.code = opts.code; if (opts.timeout) config.timeout = Number(opts.timeout); if (opts.dependencies) config.dependencies = parseDependencies(opts.dependencies); 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); const reqBody = pick('req-body'); if (reqBody) config.body = JSON.parse(reqBody); break; } case 'createRecord': case 'updateRecord': { const tt = pick('target-table'); if (tt) config.tableId = tt; if (opts.records) config.records = JSON.parse(opts.records); break; } case 'getRecords': { const tt = pick('target-table'); if (tt) config.tableId = tt; if (opts.query) config.query = JSON.parse(opts.query); break; } case 'sendNotification': { if (opts.toUser) config.toUserId = opts.toUser; if (opts.title) config.title = opts.title; if (opts.message) config.message = opts.message; if (opts.severity) config.severity = opts.severity; if (opts.path) config.path = opts.path; break; } default: break; } return config; } exports.buildActionConfig = buildActionConfig; function toCamel(s) { return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); }