feat(cli): support --dependencies for script actions
- action add/update: new --dependencies <json> 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)
This commit is contained in:
@@ -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.
|
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.<NAME>`.
|
9. **Secrets values are write-only** — `secret list` never returns the value. Scripts read them via `process.env.<NAME>`.
|
||||||
10. **PAT format is `teable_{id}_{encryptedSign}`** — create via `auth token create`, the full string is returned **once**.
|
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
|
## 4. Execution Rules
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ inkreach-teable action add <wfId> --type script \
|
|||||||
inkreach-teable action add <wfId> --type script --code-file ./my-script.js --timeout 120000
|
inkreach-teable action add <wfId> --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 <wfId> --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 <wfId> --type script --code-file ./sync.js --dependencies 'axios@1.6.0,dayjs'
|
||||||
|
# replace / clear deps on an existing node
|
||||||
|
inkreach-teable action update <wfId> <nodeId> --dependencies '[{"name":"axios"}]'
|
||||||
|
inkreach-teable action update <wfId> <nodeId> --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=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service` (see [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting).
|
||||||
|
|
||||||
**Config:**
|
**Config:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -59,7 +74,7 @@ inkreach-teable action add <wfId> --type script --code-file ./my-script.js --tim
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `timeout`: default 60000 (120000 in batch mode).
|
- `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:**
|
**Output:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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`,
|
`triggerInput._apiBaseUrl` for back-channel API calls. No `fs`, `eval`,
|
||||||
`Function`, or `child_process`.
|
`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/<hash>`)
|
||||||
|
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 <wfId>` on a script with `require('dayjs')` →
|
||||||
|
`succeeded`.
|
||||||
|
|
||||||
### scheduledTime backend is a simplified implementation
|
### scheduledTime backend is a simplified implementation
|
||||||
The backend `scheduled-time.trigger.ts` only honors `timing.type` + `timing.interval`.
|
The backend `scheduled-time.trigger.ts` only honors `timing.type` + `timing.interval`.
|
||||||
It ignores `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz`.
|
It ignores `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz`.
|
||||||
|
|||||||
Vendored
+54
-2
@@ -349,6 +349,7 @@ function actionCommand() {
|
|||||||
.option('--code <js>', 'Script JS code (script)')
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
.option('--code-file <path>', 'Read script code from a file (script)')
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
|
.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('--to <email>', 'Recipient (sendEmail)')
|
||||||
.option('--subject <s>', 'Subject (sendEmail)')
|
.option('--subject <s>', 'Subject (sendEmail)')
|
||||||
.option('--body <html>', 'HTML body (sendEmail)')
|
.option('--body <html>', 'HTML body (sendEmail)')
|
||||||
@@ -410,6 +411,8 @@ function actionCommand() {
|
|||||||
.option('--config <json>', 'Full replacement config JSON')
|
.option('--config <json>', 'Full replacement config JSON')
|
||||||
.option('--code <js>', 'Script JS code (script)')
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
.option('--code-file <path>', 'Read script code from a file (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('--name <n>', 'New node display name')
|
||||||
.option('--base-id <id>', 'Base id (overrides default)')
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
@@ -425,8 +428,17 @@ function actionCommand() {
|
|||||||
let config = node.config || {};
|
let config = node.config || {};
|
||||||
if (opts.config)
|
if (opts.config)
|
||||||
config = JSON.parse(opts.config);
|
config = JSON.parse(opts.config);
|
||||||
else if (opts.code !== undefined || opts['code-file'] !== undefined) {
|
else {
|
||||||
config = { ...config, code: opts['code-file'] ? readFile(opts['code-file']) : opts.code };
|
// 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 };
|
nodes[idx] = { ...node, config, name: opts.name ?? node.name };
|
||||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
@@ -463,6 +475,44 @@ function readFile(p) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
return require('node:fs').readFileSync(p, 'utf8');
|
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. */
|
/** Build action config from granular flags. */
|
||||||
function buildActionConfig(opts) {
|
function buildActionConfig(opts) {
|
||||||
const type = opts.type;
|
const type = opts.type;
|
||||||
@@ -479,6 +529,8 @@ function buildActionConfig(opts) {
|
|||||||
config.code = opts.code;
|
config.code = opts.code;
|
||||||
if (opts.timeout)
|
if (opts.timeout)
|
||||||
config.timeout = Number(opts.timeout);
|
config.timeout = Number(opts.timeout);
|
||||||
|
if (opts.dependencies)
|
||||||
|
config.dependencies = parseDependencies(opts.dependencies);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sendEmail': {
|
case 'sendEmail': {
|
||||||
|
|||||||
+121
-25
@@ -48,7 +48,9 @@ export function workflowCommand(): Command {
|
|||||||
nodes: (w.nodes || []).length,
|
nodes: (w.nodes || []).length,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`);
|
process.stdout.write(
|
||||||
|
`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -175,7 +177,11 @@ export function workflowCommand(): Command {
|
|||||||
const baseId = requireBaseId(ctx, opts.baseId);
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
const res = await ctx.client.activateWorkflow(baseId, workflowId);
|
const res = await ctx.client.activateWorkflow(baseId, workflowId);
|
||||||
if (ctx.json) printJson({ ok: true, ...res });
|
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 =====================
|
// ===================== trigger =====================
|
||||||
|
|
||||||
const TRIGGER_TYPES = [
|
const TRIGGER_TYPES = [
|
||||||
'recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated',
|
'recordCreated',
|
||||||
'formSubmitted', 'scheduledTime', 'buttonClick', 'webhook',
|
'recordUpdated',
|
||||||
|
'recordCondition',
|
||||||
|
'recordCreatedOrUpdated',
|
||||||
|
'formSubmitted',
|
||||||
|
'scheduledTime',
|
||||||
|
'buttonClick',
|
||||||
|
'webhook',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function triggerCommand(): Command {
|
export function triggerCommand(): Command {
|
||||||
@@ -245,7 +257,10 @@ export function triggerCommand(): Command {
|
|||||||
.description('Set the trigger type and config (replaces the existing trigger node)')
|
.description('Set the trigger type and config (replaces the existing trigger node)')
|
||||||
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
|
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
|
||||||
.option('--table-id <id>', 'Table id (most record/button triggers)')
|
.option('--table-id <id>', 'Table id (most record/button triggers)')
|
||||||
.option('--field-ids <json>', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)')
|
.option(
|
||||||
|
'--field-ids <json>',
|
||||||
|
'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)'
|
||||||
|
)
|
||||||
.option('--form-id <id>', 'Form view id (formSubmitted)')
|
.option('--form-id <id>', 'Form view id (formSubmitted)')
|
||||||
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
|
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
|
||||||
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
|
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
|
||||||
@@ -255,24 +270,29 @@ export function triggerCommand(): Command {
|
|||||||
run(async (ctx, args, o) => {
|
run(async (ctx, args, o) => {
|
||||||
const workflowId = args[0] as string;
|
const workflowId = args[0] as string;
|
||||||
const opts = o as {
|
const opts = o as {
|
||||||
type: string; tableId?: string; fieldIds?: string; formId?: string;
|
type: string;
|
||||||
conditions?: string; schedule?: string; batch?: string; baseId?: string;
|
tableId?: string;
|
||||||
|
fieldIds?: string;
|
||||||
|
formId?: string;
|
||||||
|
conditions?: string;
|
||||||
|
schedule?: string;
|
||||||
|
batch?: string;
|
||||||
|
baseId?: string;
|
||||||
};
|
};
|
||||||
const baseId = requireBaseId(ctx, opts.baseId);
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
if (!TRIGGER_TYPES.includes(opts.type as typeof TRIGGER_TYPES[number])) {
|
if (!TRIGGER_TYPES.includes(opts.type as (typeof TRIGGER_TYPES)[number])) {
|
||||||
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), { code: 'INVALID_TYPE' });
|
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), {
|
||||||
|
code: 'INVALID_TYPE',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const config = buildTriggerConfig(
|
const config = buildTriggerConfig(opts.type, {
|
||||||
opts.type,
|
|
||||||
{
|
|
||||||
tableId: opts.tableId,
|
tableId: opts.tableId,
|
||||||
fieldIds: opts.fieldIds,
|
fieldIds: opts.fieldIds,
|
||||||
formId: opts.formId,
|
formId: opts.formId,
|
||||||
conditions: opts.conditions,
|
conditions: opts.conditions,
|
||||||
schedule: opts.schedule,
|
schedule: opts.schedule,
|
||||||
batch: opts.batch,
|
batch: opts.batch,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
const nodes = (wf.nodes || []).slice();
|
const nodes = (wf.nodes || []).slice();
|
||||||
// Find the trigger node (there is exactly one) and replace its category + config.
|
// 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. */
|
/** Build the trigger node config JSON using the SELF-BUILT contract keys. */
|
||||||
export function buildTriggerConfig(
|
export function buildTriggerConfig(
|
||||||
type: string,
|
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<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const parse = (s?: string): unknown => (s ? JSON.parse(s) : undefined);
|
const parse = (s?: string): unknown => (s ? JSON.parse(s) : undefined);
|
||||||
const config: Record<string, unknown> = {};
|
const config: Record<string, unknown> = {};
|
||||||
@@ -328,7 +355,9 @@ export function buildTriggerConfig(
|
|||||||
case 'recordCondition': {
|
case 'recordCondition': {
|
||||||
if (opts.tableId) config.tableId = opts.tableId;
|
if (opts.tableId) config.tableId = opts.tableId;
|
||||||
// conditions is required for recordCondition
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
case 'formSubmitted': {
|
case 'formSubmitted': {
|
||||||
@@ -357,7 +386,10 @@ export function buildTriggerConfig(
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (opts.batch && ['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)) {
|
if (
|
||||||
|
opts.batch &&
|
||||||
|
['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)
|
||||||
|
) {
|
||||||
config.batch = parse(opts.batch);
|
config.batch = parse(opts.batch);
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@@ -366,7 +398,12 @@ export function buildTriggerConfig(
|
|||||||
// ===================== action =====================
|
// ===================== action =====================
|
||||||
|
|
||||||
const ACTION_TYPES = [
|
const ACTION_TYPES = [
|
||||||
'script', 'sendEmail', 'httpRequest', 'createRecord', 'updateRecord', 'getRecords',
|
'script',
|
||||||
|
'sendEmail',
|
||||||
|
'httpRequest',
|
||||||
|
'createRecord',
|
||||||
|
'updateRecord',
|
||||||
|
'getRecords',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function actionCommand(): Command {
|
export function actionCommand(): Command {
|
||||||
@@ -381,6 +418,10 @@ export function actionCommand(): Command {
|
|||||||
.option('--code <js>', 'Script JS code (script)')
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
.option('--code-file <path>', 'Read script code from a file (script)')
|
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||||
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
|
.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('--to <email>', 'Recipient (sendEmail)')
|
||||||
.option('--subject <s>', 'Subject (sendEmail)')
|
.option('--subject <s>', 'Subject (sendEmail)')
|
||||||
.option('--body <html>', 'HTML body (sendEmail)')
|
.option('--body <html>', 'HTML body (sendEmail)')
|
||||||
@@ -398,8 +439,10 @@ export function actionCommand(): Command {
|
|||||||
const workflowId = args[0] as string;
|
const workflowId = args[0] as string;
|
||||||
const opts = o as Record<string, string>;
|
const opts = o as Record<string, string>;
|
||||||
const baseId = requireBaseId(ctx, opts.baseId);
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
if (!ACTION_TYPES.includes(opts.type as typeof ACTION_TYPES[number])) {
|
if (!ACTION_TYPES.includes(opts.type as (typeof ACTION_TYPES)[number])) {
|
||||||
throw Object.assign(new Error(`Invalid action type: ${opts.type}`), { code: 'INVALID_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 config = opts.config ? JSON.parse(opts.config) : buildActionConfig(opts);
|
||||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
@@ -444,6 +487,11 @@ export function actionCommand(): Command {
|
|||||||
.option('--config <json>', 'Full replacement config JSON')
|
.option('--config <json>', 'Full replacement config JSON')
|
||||||
.option('--code <js>', 'Script JS code (script)')
|
.option('--code <js>', 'Script JS code (script)')
|
||||||
.option('--code-file <path>', 'Read script code from a file (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('--name <n>', 'New node display name')
|
||||||
.option('--base-id <id>', 'Base id (overrides default)')
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
.action(
|
.action(
|
||||||
@@ -454,12 +502,21 @@ export function actionCommand(): Command {
|
|||||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
const nodes = (wf.nodes || []).slice();
|
const nodes = (wf.nodes || []).slice();
|
||||||
const idx = nodes.findIndex((n) => n.id === nodeId);
|
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];
|
const node = nodes[idx];
|
||||||
let config = node.config || {};
|
let config = node.config || {};
|
||||||
if (opts.config) config = JSON.parse(opts.config);
|
if (opts.config) config = JSON.parse(opts.config);
|
||||||
else if (opts.code !== undefined || opts['code-file'] !== undefined) {
|
else {
|
||||||
config = { ...config, code: opts['code-file'] ? readFile(opts['code-file']) : opts.code };
|
// 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 };
|
nodes[idx] = { ...node, config, name: opts.name ?? node.name };
|
||||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||||
@@ -482,7 +539,9 @@ export function actionCommand(): Command {
|
|||||||
const baseId = requireBaseId(ctx, opts.baseId);
|
const baseId = requireBaseId(ctx, opts.baseId);
|
||||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||||
const nodes = (wf.nodes || []).filter((n) => n.id !== nodeId);
|
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 });
|
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||||
if (ctx.json) printJson(updated);
|
if (ctx.json) printJson(updated);
|
||||||
else process.stdout.write(`✓ Node ${nodeId} deleted\n`);
|
else process.stdout.write(`✓ Node ${nodeId} deleted\n`);
|
||||||
@@ -498,12 +557,48 @@ function readFile(p: string): string {
|
|||||||
return require('node:fs').readFileSync(p, 'utf8');
|
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. */
|
/** Build action config from granular flags. */
|
||||||
export function buildActionConfig(opts: Record<string, string>): Record<string, unknown> {
|
export function buildActionConfig(opts: Record<string, string>): Record<string, unknown> {
|
||||||
const type = opts.type;
|
const type = opts.type;
|
||||||
// Helper: commander stores --kebab-case flags as camelCase in opts (e.g.
|
// Helper: commander stores --kebab-case flags as camelCase in opts (e.g.
|
||||||
// --target-table -> opts.targetTable). Accept both forms for safety.
|
// --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<string, unknown> = {};
|
const config: Record<string, unknown> = {};
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'script': {
|
case 'script': {
|
||||||
@@ -511,6 +606,7 @@ export function buildActionConfig(opts: Record<string, string>): Record<string,
|
|||||||
if (codeFile) config.code = readFile(codeFile);
|
if (codeFile) config.code = readFile(codeFile);
|
||||||
else if (opts.code) config.code = opts.code;
|
else if (opts.code) config.code = opts.code;
|
||||||
if (opts.timeout) config.timeout = Number(opts.timeout);
|
if (opts.timeout) config.timeout = Number(opts.timeout);
|
||||||
|
if (opts.dependencies) config.dependencies = parseDependencies(opts.dependencies);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sendEmail': {
|
case 'sendEmail': {
|
||||||
|
|||||||
Reference in New Issue
Block a user