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:
+127
-31
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user