Files
inkreach-teable-cli/dist/commands/workflow.js
T
yeuimu 3a03e0753c fix(cli): read commander's camelCase keys for kebab-case flags
commander stores --code-file as opts.codeFile (kebab keys are never set),
so 'action update --code-file' silently no-opped (workflow.ts read
opts['code-file']) and 'auth token create --base-ids/--full-access'
never applied (auth.ts read the kebab keys). The 'action add' path was
unaffected because buildActionConfig's pick() checks both forms.

- workflow.ts action update: opts['code-file'] ?? opts.codeFile
- auth.ts token create: baseIds/fullAccess dual-form read
- verified live: action update --code-file now persists the new script
  (was silently keeping old code); commander dual-key behavior confirmed
  by direct parse (kebab keys absent, camelCase present, and this
  commander version drops the --base-ids VALUE entirely — the dual read
  covers both)
2026-08-16 17:47:17 +00:00

618 lines
25 KiB
JavaScript

"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 <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, _args, o) => {
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const list = (await ctx.client.listWorkflows(baseId));
if (ctx.json)
(0, output_1.printJson)(list);
else {
if (!list.length) {
process.stdout.write('No workflows found.\n');
return;
}
(0, output_1.printTable)([
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'isActive', header: 'ACTIVE' },
{ key: 'hasDraft', header: 'DRAFT' },
{ key: 'nodes', header: 'NODES' },
], list.map((w) => ({
id: w.id,
name: (0, output_1.truncate)(w.name, 30),
isActive: w.isActive ? '✓' : '',
hasDraft: w.hasDraft ? '*' : '',
nodes: (w.nodes || []).length,
})));
process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`);
}
}));
cmd
.command('get <workflowId>')
.description('Get workflow detail (nodes + edges)')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
if (ctx.json)
(0, output_1.printJson)(wf);
else {
(0, output_1.printKv)([
['id', wf.id],
['name', wf.name],
['description', wf.description || ''],
['active', wf.isActive ? 'yes' : 'no'],
['hasDraft', wf.hasDraft ? 'yes' : 'no'],
]);
process.stdout.write('\nNODES:\n');
(0, output_1.printTable)([
{ key: 'id', header: 'ID' },
{ key: 'type', header: 'TYPE' },
{ key: 'category', header: 'CATEGORY' },
{ key: 'config', header: 'CONFIG' },
], (wf.nodes || []).map((n) => ({
id: n.id,
type: n.type,
category: n.category,
config: (0, output_1.truncate)(JSON.stringify(n.config), 40),
})));
if (wf.edges?.length) {
process.stdout.write('\nEDGES:\n');
(0, output_1.printTable)([
{ key: 'id', header: 'ID' },
{ key: 'sourceNodeId', header: 'SOURCE' },
{ key: 'targetNodeId', header: 'TARGET' },
], (wf.edges || []).map((e) => ({ ...e })));
}
}
}));
cmd
.command('create')
.description('Create a workflow (auto-includes a recordCreated trigger node)')
.requiredOption('--name <name>', 'Workflow name')
.option('--description <desc>', 'Workflow description')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, _args, o) => {
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const wf = await ctx.client.createWorkflow(baseId, {
name: opts.name,
description: opts.description,
});
if (ctx.json)
(0, output_1.printJson)(wf);
else {
(0, output_1.printKv)([
['id', wf.id],
['name', wf.name],
['baseId', wf.baseId],
['', ''],
['next', `inkreach-teable trigger set ${wf.id} --type <T> ...`],
]);
}
}));
cmd
.command('update <workflowId>')
.description('Update workflow name / description')
.option('--name <name>', 'New name')
.option('--description <desc>', 'New description')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const wf = await ctx.client.updateWorkflow(baseId, workflowId, {
name: opts.name,
description: opts.description,
});
if (ctx.json)
(0, output_1.printJson)(wf);
else
process.stdout.write(`✓ Workflow ${workflowId} updated\n`);
}));
cmd
.command('delete <workflowId>')
.description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
await ctx.client.deleteWorkflow(baseId, workflowId);
if (ctx.json)
(0, output_1.printJson)({ ok: true, id: workflowId });
else
process.stdout.write(`✓ Workflow ${workflowId} deleted\n`);
}));
cmd
.command('activate <workflowId>')
.description('Activate: snapshot + register trigger + create auto access token')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const res = await ctx.client.activateWorkflow(baseId, workflowId);
if (ctx.json)
(0, output_1.printJson)({ ok: true, ...res });
else
(0, output_1.printKv)([
['snapshotId', res.snapshotId],
['status', 'activated'],
]);
}));
cmd
.command('deactivate <workflowId>')
.description('Deactivate: unregister trigger')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
await ctx.client.deactivateWorkflow(baseId, workflowId);
if (ctx.json)
(0, output_1.printJson)({ ok: true, id: workflowId });
else
process.stdout.write(`✓ Workflow ${workflowId} deactivated\n`);
}));
cmd
.command('test <workflowId>')
.description('Test-run: execute action nodes with mock input (no trigger, no queue)')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const res = await ctx.client.testWorkflow(baseId, workflowId);
if (ctx.json)
(0, output_1.printJson)(res);
else {
process.stdout.write('Test results:\n');
(0, output_1.printTable)([
{ key: 'nodeId', header: 'NODE' },
{ key: 'status', header: 'STATUS' },
{ key: 'error', header: 'ERROR' },
], (res.nodeResults || []).map((r) => {
const row = r;
return {
nodeId: row.nodeId,
status: row.status,
error: (0, output_1.truncate)(row.error, 50),
};
}));
}
}));
return cmd;
}
exports.workflowCommand = workflowCommand;
// ===================== trigger =====================
const TRIGGER_TYPES = [
'recordCreated',
'recordUpdated',
'recordCondition',
'recordCreatedOrUpdated',
'formSubmitted',
'scheduledTime',
'buttonClick',
'webhook',
];
function triggerCommand() {
const cmd = new commander_1.Command('trigger');
cmd.description('Set the trigger node (type + config). Builds correct config keys for you.');
cmd
.command('set <workflowId>')
.description('Set the trigger type and config (replaces the existing trigger node)')
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
.option('--table-id <id>', 'Table id (most record/button triggers)')
.option('--field-ids <json>', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)')
.option('--form-id <id>', 'Form view id (formSubmitted, legacy single). Prefer --form-ids.')
.option('--form-ids <csv>', 'Comma-separated form view ids (formSubmitted, multi-select)')
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
.option('--batch <json>', 'Batch config JSON {enabled,batchWindow,batchSize}')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
if (!TRIGGER_TYPES.includes(opts.type)) {
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), {
code: 'INVALID_TYPE',
});
}
const config = buildTriggerConfig(opts.type, {
tableId: opts.tableId,
fieldIds: opts.fieldIds,
formId: opts.formId,
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',
];
function actionCommand() {
const cmd = new commander_1.Command('action');
cmd.description('Add/update/delete action nodes');
cmd
.command('add <workflowId>')
.description('Add an action node after the last existing node')
.requiredOption('--type <type>', `Action type: ${ACTION_TYPES.join('|')}`)
.option('--config <json>', 'Full action config JSON (overrides the granular flags below)')
.option('--code <js>', 'Script JS code (script)')
.option('--code-file <path>', 'Read script code from a file (script)')
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
.option('--dependencies <json>', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script)')
.option('--to <email>', 'Recipient (sendEmail)')
.option('--subject <s>', 'Subject (sendEmail)')
.option('--body <html>', 'HTML body (sendEmail)')
.option('--url <u>', 'Request URL (httpRequest)')
.option('--method <m>', 'HTTP method (httpRequest, default GET)')
.option('--headers <json>', 'Request headers JSON (httpRequest)')
.option('--req-body <json>', 'Request body JSON (httpRequest)')
.option('--target-table <id>', 'Target table id (createRecord/updateRecord/getRecords)')
.option('--records <json>', 'Records JSON array (createRecord/updateRecord)')
.option('--query <json>', 'Query JSON (getRecords)')
.option('--name <n>', 'Optional node display name')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const workflowId = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
if (!ACTION_TYPES.includes(opts.type)) {
throw Object.assign(new Error(`Invalid action type: ${opts.type}`), {
code: 'INVALID_TYPE',
});
}
const config = opts.config ? JSON.parse(opts.config) : buildActionConfig(opts);
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
const nodes = (wf.nodes || []).slice();
const edges = (wf.edges || []).slice();
const newNodeId = `node-${Date.now()}`;
const newNode = {
id: newNodeId,
type: 'action',
category: opts.type,
config,
name: opts.name || null,
};
// Chain after the last node (trigger → ... → new).
const lastNode = nodes[nodes.length - 1];
nodes.push(newNode);
if (lastNode) {
edges.push({
id: `edge-${lastNode.id}-${newNodeId}`,
sourceNodeId: lastNode.id,
targetNodeId: newNodeId,
});
}
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
if (ctx.json)
(0, output_1.printJson)(updated);
else {
(0, output_1.printKv)([
['workflowId', workflowId],
['nodeId', newNodeId],
['type', opts.type],
['config', JSON.stringify(config)],
['', ''],
['next', `inkreach-teable workflow activate ${workflowId}`],
]);
}
}));
cmd
.command('update <workflowId> <nodeId>')
.description('Update an existing action node config')
.option('--config <json>', 'Full replacement config JSON')
.option('--code <js>', 'Script JS code (script)')
.option('--code-file <path>', 'Read script code from a file (script)')
.option('--timeout <ms>', 'Script timeout ms (script)')
.option('--dependencies <json>', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script; replaces existing deps)')
.option('--name <n>', 'New node display name')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const [workflowId, nodeId] = args;
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
const nodes = (wf.nodes || []).slice();
const idx = nodes.findIndex((n) => n.id === nodeId);
if (idx < 0)
throw Object.assign(new Error(`Node ${nodeId} not found`), { code: 'NOT_FOUND' });
const node = nodes[idx];
let config = node.config || {};
if (opts.config)
config = JSON.parse(opts.config);
else {
// 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 <workflowId> <nodeId>')
.description('Delete an action node and its connected edges')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const [workflowId, nodeId] = args;
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
const nodes = (wf.nodes || []).filter((n) => n.id !== nodeId);
const edges = (wf.edges || []).filter((e) => e.sourceNodeId !== nodeId && e.targetNodeId !== nodeId);
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
if (ctx.json)
(0, output_1.printJson)(updated);
else
process.stdout.write(`✓ Node ${nodeId} deleted\n`);
}));
return cmd;
}
exports.actionCommand = actionCommand;
function readFile(p) {
// inline require to keep imports clean
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('node:fs').readFileSync(p, 'utf8');
}
/**
* 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;
}
default:
break;
}
return config;
}
exports.buildActionConfig = buildActionConfig;
function toCamel(s) {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}