From cf25c8ff933644784334f14b2b00745b00673dc4 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Tue, 11 Aug 2026 05:51:27 +0000 Subject: [PATCH] feat(cli): support --dependencies for script actions - action add/update: new --dependencies option - parseDependencies accepts JSON, name@version shorthand, or bare names - update is incremental; --dependencies replaces the whole array - docs: deployment/systemd.md + skill SKILL/action-types/development document the npm-deps feature and the service-PATH requirement (spawn npm ENOENT without nvm bin dir on PATH) --- .agents/skills/inkreach-teable/SKILL.md | 2 +- .../api-reference/action-types.md | 17 +- .../inkreach-teable/guides/development.md | 15 ++ dist/commands/workflow.js | 56 ++++++- src/commands/workflow.ts | 158 ++++++++++++++---- 5 files changed, 213 insertions(+), 35 deletions(-) diff --git a/.agents/skills/inkreach-teable/SKILL.md b/.agents/skills/inkreach-teable/SKILL.md index 07a6ba3..a1af258 100644 --- a/.agents/skills/inkreach-teable/SKILL.md +++ b/.agents/skills/inkreach-teable/SKILL.md @@ -109,7 +109,7 @@ These differ from official teable and from the (stale) `docs/automation/triggers 8. **`workflow delete` is a SOFT delete** (cascades to base_node, auto-token, trigger unregister). Test workflows should be `deactivate`d, not deleted. 9. **Secrets values are write-only** — `secret list` never returns the value. Scripts read them via `process.env.`. 10. **PAT format is `teable_{id}_{encryptedSign}`** — create via `auth token create`, the full string is returned **once**. -11. **Script sandbox has NO `fs`/`eval`/`Function`**, but has `triggerInput`, `console`, `fetch`, `process.env` (= secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Use `triggerInput._accessToken` + `triggerInput._apiBaseUrl` to call back into the instance API. +11. **Script sandbox has NO `fs`/`eval`/`Function`**, but has `triggerInput`, `console`, `fetch`, `process.env` (= secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Use `triggerInput._accessToken` + `triggerInput._apiBaseUrl` to call back into the instance API. **npm deps supported**: declare via `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or shorthand `axios@1.6.0,dayjs`), then `require('axios')` works in-sandbox. **Requires** `npm` on the backend service PATH (see [action-types.md](api-reference/action-types.md) §script + [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting) — missing it → `spawn npm ENOENT`. ## 4. Execution Rules diff --git a/.agents/skills/inkreach-teable/api-reference/action-types.md b/.agents/skills/inkreach-teable/api-reference/action-types.md index 9f31ffe..cf75b73 100644 --- a/.agents/skills/inkreach-teable/api-reference/action-types.md +++ b/.agents/skills/inkreach-teable/api-reference/action-types.md @@ -50,6 +50,21 @@ inkreach-teable action add --type script \ inkreach-teable action add --type script --code-file ./my-script.js --timeout 120000 ``` +**With npm dependencies** (installed into a sandbox cache, then `require()`-able): +```bash +# JSON form (recommended) +inkreach-teable action add --type script \ + --code-file ./sync.js \ + --dependencies '[{"name":"axios","version":"1.6.0"},{"name":"dayjs","version":"1.11.10"}]' +# shorthand: name@version,name (version omitted → 'latest') +inkreach-teable action add --type script --code-file ./sync.js --dependencies 'axios@1.6.0,dayjs' +# replace / clear deps on an existing node +inkreach-teable action update --dependencies '[{"name":"axios"}]' +inkreach-teable action update --dependencies '[]' # clear +``` + +> ⚠️ **Deployment requirement**: the backend installs deps via `spawn('npm')`, so `npm` must be on the **service** PATH. systemd user units default to a PATH that omits the nvm bin dir → scripts with deps fail with `spawn npm ENOENT`. Fix: add `Environment=PATH=:/usr/local/sbin:...` to `teable-backend.service` (see [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting). + **Config:** ```json { @@ -59,7 +74,7 @@ inkreach-teable action add --type script --code-file ./my-script.js --tim } ``` - `timeout`: default 60000 (120000 in batch mode). -- `dependencies`: optional npm packages; `require('axios')` works in-sandbox. +- `dependencies`: optional npm packages; `require('axios')` works in-sandbox. Install is cached by `name@version` hash; first run downloads, later runs reuse. CLI accepts JSON `[{"name","version?"}]` or shorthand `name@version,name`. `update` replaces the whole array (not append). **Output:** ```json diff --git a/.agents/skills/inkreach-teable/guides/development.md b/.agents/skills/inkreach-teable/guides/development.md index cbb98d4..5695753 100644 --- a/.agents/skills/inkreach-teable/guides/development.md +++ b/.agents/skills/inkreach-teable/guides/development.md @@ -59,6 +59,21 @@ via `process.env.NAME` (this is allowed — only `process.env` is exposed, not `triggerInput._apiBaseUrl` for back-channel API calls. No `fs`, `eval`, `Function`, or `child_process`. +### Script sandbox npm deps need npm on the service PATH +Script actions CAN use npm packages — declare them via the CLI +(`action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` +or shorthand `axios@1.6.0,dayjs`) and `require('axios')` works in-sandbox. +The backend installs deps into a cache dir (`/tmp/teable-automation-deps/`) +via `spawn('npm', ['install', ...])`, keyed by `name@version`. + +**Gotcha:** systemd user services default to `PATH=/usr/local/bin:/usr/bin:...` +which OMITS the nvm bin dir where `npm` lives → scripts with deps fail with +`Dependency install failed: spawn npm ENOENT`. Fix: add +`Environment=PATH=/root/.nvm/versions/node/vX/bin:/usr/local/sbin:...` to +`teable-backend.service`, `daemon-reload`, restart. Verify: +`inkreach-teable workflow test ` on a script with `require('dayjs')` → +`succeeded`. + ### scheduledTime backend is a simplified implementation The backend `scheduled-time.trigger.ts` only honors `timing.type` + `timing.interval`. It ignores `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz`. diff --git a/dist/commands/workflow.js b/dist/commands/workflow.js index 2581999..dd71a0d 100644 --- a/dist/commands/workflow.js +++ b/dist/commands/workflow.js @@ -349,6 +349,7 @@ function actionCommand() { .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)') @@ -410,6 +411,8 @@ function actionCommand() { .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) => { @@ -425,8 +428,17 @@ function actionCommand() { 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 }; + else { + // Granular update: merge only the flags the user actually passed. + config = { ...config }; + if (opts.code !== undefined || opts['code-file'] !== undefined) { + config.code = opts['code-file'] ? readFile(opts['code-file']) : 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, { @@ -463,6 +475,44 @@ function readFile(p) { // 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; @@ -479,6 +529,8 @@ function buildActionConfig(opts) { config.code = opts.code; if (opts.timeout) config.timeout = Number(opts.timeout); + if (opts.dependencies) + config.dependencies = parseDependencies(opts.dependencies); break; } case 'sendEmail': { diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 9c1d94e..d4652eb 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -48,7 +48,9 @@ export function workflowCommand(): Command { nodes: (w.nodes || []).length, })) ); - process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get ' for details.\n`); + process.stdout.write( + `\n${list.length} workflow(s). Use 'workflow get ' for details.\n` + ); } }) ); @@ -175,7 +177,11 @@ export function workflowCommand(): Command { 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']]); + else + printKv([ + ['snapshotId', res.snapshotId], + ['status', 'activated'], + ]); }) ); @@ -232,8 +238,14 @@ export function workflowCommand(): Command { // ===================== trigger ===================== const TRIGGER_TYPES = [ - 'recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated', - 'formSubmitted', 'scheduledTime', 'buttonClick', 'webhook', + 'recordCreated', + 'recordUpdated', + 'recordCondition', + 'recordCreatedOrUpdated', + 'formSubmitted', + 'scheduledTime', + 'buttonClick', + 'webhook', ] as const; export function triggerCommand(): Command { @@ -245,7 +257,10 @@ export function triggerCommand(): Command { .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( + '--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)') @@ -255,24 +270,29 @@ export function triggerCommand(): Command { 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; + 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' }); + 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 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. @@ -308,7 +328,14 @@ export function triggerCommand(): Command { /** 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 } + 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 = {}; @@ -328,7 +355,9 @@ export function buildTriggerConfig( case 'recordCondition': { if (opts.tableId) config.tableId = opts.tableId; // conditions is required for recordCondition - config.conditions = opts.conditions ? parse(opts.conditions) : { conjunction: 'and', filterSet: [] }; + config.conditions = opts.conditions + ? parse(opts.conditions) + : { conjunction: 'and', filterSet: [] }; break; } case 'formSubmitted': { @@ -357,7 +386,10 @@ export function buildTriggerConfig( default: break; } - if (opts.batch && ['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)) { + if ( + opts.batch && + ['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type) + ) { config.batch = parse(opts.batch); } return config; @@ -366,7 +398,12 @@ export function buildTriggerConfig( // ===================== action ===================== const ACTION_TYPES = [ - 'script', 'sendEmail', 'httpRequest', 'createRecord', 'updateRecord', 'getRecords', + 'script', + 'sendEmail', + 'httpRequest', + 'createRecord', + 'updateRecord', + 'getRecords', ] as const; export function actionCommand(): Command { @@ -381,6 +418,10 @@ export function actionCommand(): Command { .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)') @@ -398,8 +439,10 @@ export function actionCommand(): Command { 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' }); + 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); @@ -444,6 +487,11 @@ export function actionCommand(): Command { .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( @@ -454,12 +502,21 @@ export function actionCommand(): Command { 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' }); + 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 }; + else { + // Granular update: merge only the flags the user actually passed. + config = { ...config }; + if (opts.code !== undefined || opts['code-file'] !== undefined) { + config.code = opts['code-file'] ? readFile(opts['code-file']) : 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, { @@ -482,7 +539,9 @@ export function actionCommand(): Command { 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 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`); @@ -498,12 +557,48 @@ function readFile(p: string): string { 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: string): Array<{ name: string; version?: string }> { + const trimmed = raw.trim(); + if (!trimmed || trimmed === '[]') return []; + // JSON form + if (trimmed[0] === '[') { + const parsed = JSON.parse(trimmed) as unknown; + 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. */ export function buildActionConfig(opts: Record): Record { 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: string): string | undefined => (opts[k] ?? opts[toCamel(k)]) as string | undefined; + const pick = (k: string): string | undefined => + (opts[k] ?? opts[toCamel(k)]) as string | undefined; const config: Record = {}; switch (type) { case 'script': { @@ -511,6 +606,7 @@ export function buildActionConfig(opts: Record): Record