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:
yeuimu
2026-08-11 05:51:27 +00:00
parent aff4d70b71
commit cf25c8ff93
5 changed files with 213 additions and 35 deletions
+127 -31
View File
@@ -48,7 +48,9 @@ export function workflowCommand(): Command {
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 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 <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(
'--field-ids <json>',
'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)'
)
.option('--form-id <id>', 'Form view id (formSubmitted)')
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
.option('--schedule <json>', '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<string, unknown> {
const parse = (s?: string): unknown => (s ? JSON.parse(s) : undefined);
const config: Record<string, unknown> = {};
@@ -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 <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)')
@@ -398,8 +439,10 @@ export function actionCommand(): Command {
const workflowId = args[0] as string;
const opts = o as Record<string, string>;
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 <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(
@@ -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<string, string>): Record<string, unknown> {
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<string, unknown> = {};
switch (type) {
case 'script': {
@@ -511,6 +606,7 @@ export function buildActionConfig(opts: Record<string, string>): Record<string,
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': {