@withone/cli 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -447,6 +447,38 @@ var execAsync = promisify(exec);
447
447
  function sleep2(ms) {
448
448
  return new Promise((resolve) => setTimeout(resolve, ms));
449
449
  }
450
+ var StepTimeoutError = class extends Error {
451
+ errorCode = "TIMEOUT";
452
+ constructor(stepId, timeoutMs) {
453
+ super(`Step "${stepId}" exceeded timeout of ${timeoutMs}ms`);
454
+ this.name = "StepTimeoutError";
455
+ }
456
+ };
457
+ function withTimeout(promise, timeoutMs, stepId) {
458
+ let timer;
459
+ const timeoutPromise = new Promise((_, reject) => {
460
+ timer = setTimeout(() => reject(new StepTimeoutError(stepId, timeoutMs)), timeoutMs);
461
+ });
462
+ return Promise.race([promise, timeoutPromise]).finally(() => {
463
+ if (timer) clearTimeout(timer);
464
+ });
465
+ }
466
+ function computeRetryDelay(onError, attempt) {
467
+ const base = onError.retryDelayMs ?? 1e3;
468
+ const max = onError.maxDelayMs ?? 3e4;
469
+ const backoff = onError.backoff ?? "fixed";
470
+ const retryIndex = attempt - 2;
471
+ let delay;
472
+ if (backoff === "exponential" || backoff === "exponential-jitter") {
473
+ delay = Math.min(base * Math.pow(2, retryIndex), max);
474
+ if (backoff === "exponential-jitter") {
475
+ delay = delay * (0.5 + Math.random() * 0.5);
476
+ }
477
+ } else {
478
+ delay = base;
479
+ }
480
+ return Math.round(delay);
481
+ }
450
482
  function resolveSelector(selectorPath, context) {
451
483
  if (!selectorPath.startsWith("$.")) return selectorPath;
452
484
  const parts = selectorPath.slice(2).split(/\.|\[/).map((p) => p.replace(/\]$/, ""));
@@ -474,12 +506,22 @@ function resolveSelector(selectorPath, context) {
474
506
  }
475
507
  return current;
476
508
  }
509
+ function shellQuote(value) {
510
+ return `'${value.replace(/'/g, `'\\''`)}'`;
511
+ }
477
512
  function interpolateString(str, context) {
478
- return str.replace(/\{\{(\$\.[^}]+)\}\}/g, (_match, selector) => {
513
+ return str.replace(/\{\{\s*(q\s+)?(\$\.[^}\s]+)\s*\}\}/g, (_match, qFlag, selector) => {
479
514
  const value = resolveSelector(selector, context);
480
- if (value === void 0 || value === null) return "";
481
- if (typeof value === "object") return JSON.stringify(value);
482
- return String(value);
515
+ if (value === void 0 || value === null) return qFlag ? `''` : "";
516
+ if (typeof value === "object") {
517
+ console.warn(
518
+ `[flow] WARNING: Handlebars expression "{{${qFlag ? "q " : ""}${selector}}}" resolved to ${Array.isArray(value) ? "an array" : "an object"} and was stringified as JSON. To pass objects/arrays as native values, use a direct selector without {{ }}: "${selector}"`
519
+ );
520
+ const json = JSON.stringify(value);
521
+ return qFlag ? shellQuote(json) : json;
522
+ }
523
+ const str2 = String(value);
524
+ return qFlag ? shellQuote(str2) : str2;
483
525
  });
484
526
  }
485
527
  function resolveValue(value, context) {
@@ -487,7 +529,7 @@ function resolveValue(value, context) {
487
529
  if (value.startsWith("$.") && !value.includes("{{")) {
488
530
  return resolveSelector(value, context);
489
531
  }
490
- if (value.includes("{{$.")) {
532
+ if (value.includes("{{$.") || /\{\{\s*q\s+\$\./.test(value)) {
491
533
  return interpolateString(value, context);
492
534
  }
493
535
  return value;
@@ -609,8 +651,8 @@ function executeTransformStep(step, context) {
609
651
  async function executeCodeStep(step, context, options) {
610
652
  const config = step.code;
611
653
  if (config.module) {
612
- const output2 = await executeCodeModule(step.id, config.module, context, options);
613
- return { status: "success", output: output2, response: output2 };
654
+ const output = await executeCodeModule(step.id, config.module, context, options);
655
+ return { status: "success", output, response: output };
614
656
  }
615
657
  if (typeof config.source !== "string") {
616
658
  throw new Error(`Code step "${step.id}" must define either "source" or "module"`);
@@ -618,9 +660,41 @@ async function executeCodeStep(step, context, options) {
618
660
  const AsyncFunction = Object.getPrototypeOf(async function() {
619
661
  }).constructor;
620
662
  const sandboxedRequire = createSandboxedRequire();
621
- const fn = new AsyncFunction("$", "require", config.source);
622
- const output = await fn(context, sandboxedRequire);
623
- return { status: "success", output, response: output };
663
+ const sourceURL = `code:${step.id}`;
664
+ const taggedSource = `${config.source}
665
+ //# sourceURL=${sourceURL}`;
666
+ const fn = new AsyncFunction("$", "require", taggedSource);
667
+ try {
668
+ const output = await fn(context, sandboxedRequire);
669
+ return { status: "success", output, response: output };
670
+ } catch (err) {
671
+ throw rewriteCodeStepError(err, step.id, config.source, sourceURL);
672
+ }
673
+ }
674
+ function rewriteCodeStepError(err, stepId, source, sourceURL) {
675
+ if (!(err instanceof Error)) return new Error(String(err));
676
+ const WRAPPER_LINE_OFFSET = 2;
677
+ const sourceLines = source.split("\n");
678
+ const stack = err.stack || "";
679
+ const re = new RegExp(`${sourceURL.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}:(\\d+):(\\d+)`);
680
+ const match = stack.match(re);
681
+ if (!match) {
682
+ err.message = `Code step "${stepId}" failed: ${err.message}`;
683
+ return err;
684
+ }
685
+ const wrappedLine = parseInt(match[1], 10);
686
+ const col = parseInt(match[2], 10);
687
+ const userLine = wrappedLine - WRAPPER_LINE_OFFSET;
688
+ const lineContent = sourceLines[userLine - 1] ?? "";
689
+ const trimmed = lineContent.trim();
690
+ err.message = `Code step "${stepId}" failed at line ${userLine}:${col}
691
+ ${trimmed}
692
+ ${err.message}`;
693
+ err.stack = stack.replace(
694
+ new RegExp(`(${sourceURL.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}:)(\\d+)`, "g"),
695
+ (_m, prefix, l) => `${prefix}${parseInt(l, 10) - WRAPPER_LINE_OFFSET}`
696
+ );
697
+ return err;
624
698
  }
625
699
  async function executeCodeModule(stepId, modulePath, context, options) {
626
700
  const rootDir = options.rootDir;
@@ -840,7 +914,7 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
840
914
  if (flowStack.includes(resolvedKey)) {
841
915
  throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
842
916
  }
843
- const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-CXZ6AWXT.js");
917
+ const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-LCDHSLUT.js");
844
918
  const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
845
919
  const subContext = await executeFlow(
846
920
  subFlow,
@@ -852,10 +926,35 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
852
926
  void 0,
853
927
  [...flowStack, resolvedKey]
854
928
  );
929
+ const finalStep = subFlow.steps[subFlow.steps.length - 1];
930
+ const finalOutput = finalStep ? subContext.steps[finalStep.id]?.output : void 0;
931
+ let flattenedOutput;
932
+ if (finalOutput && typeof finalOutput === "object" && !Array.isArray(finalOutput)) {
933
+ const collisions = Object.keys(finalOutput).filter(
934
+ (k) => k in subContext.steps
935
+ );
936
+ if (collisions.length > 0) {
937
+ options.onEvent?.({
938
+ event: "flow:warning",
939
+ message: `Sub-flow "${resolvedKey}" final step output fields [${collisions.join(", ")}] collide with sub-step ids \u2014 flattened fields take precedence.`
940
+ });
941
+ }
942
+ flattenedOutput = {
943
+ ...subContext.steps,
944
+ ...finalOutput,
945
+ _steps: subContext.steps
946
+ };
947
+ } else {
948
+ flattenedOutput = {
949
+ ...subContext.steps,
950
+ _steps: subContext.steps,
951
+ ...finalOutput !== void 0 ? { _finalOutput: finalOutput } : {}
952
+ };
953
+ }
855
954
  return {
856
955
  status: "success",
857
- output: subContext.steps,
858
- response: subContext
956
+ output: flattenedOutput,
957
+ response: flattenedOutput
859
958
  };
860
959
  }
861
960
  async function executePaginateStep(step, context, api, permissions, allowedActionIds, options) {
@@ -951,13 +1050,14 @@ async function executeSingleStep(step, context, api, permissions, allowedActionI
951
1050
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
952
1051
  try {
953
1052
  if (attempt > 1) {
1053
+ const delay = computeRetryDelay(step.onError, attempt);
954
1054
  options.onEvent?.({
955
1055
  event: "step:retry",
956
1056
  stepId: step.id,
957
1057
  attempt,
958
- maxRetries: step.onError.retries
1058
+ maxRetries: step.onError.retries,
1059
+ delayMs: delay
959
1060
  });
960
- const delay = step.onError?.retryDelayMs || 1e3;
961
1061
  await sleep2(delay);
962
1062
  }
963
1063
  if (options.mock && (step.type === "action" || step.type === "paginate" || step.type === "bash")) {
@@ -972,49 +1072,46 @@ async function executeSingleStep(step, context, api, permissions, allowedActionI
972
1072
  context.steps[step.id] = result2;
973
1073
  return result2;
974
1074
  }
975
- let result;
976
- switch (step.type) {
977
- case "action":
978
- result = await executeActionStep(step, context, api, permissions, allowedActionIds);
979
- break;
980
- case "transform":
981
- result = executeTransformStep(step, context);
982
- break;
983
- case "code":
984
- result = await executeCodeStep(step, context, options);
985
- break;
986
- case "condition":
987
- result = await executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack);
988
- break;
989
- case "loop":
990
- result = await executeLoopStep(step, context, api, permissions, allowedActionIds, options, flowStack);
991
- break;
992
- case "parallel":
993
- result = await executeParallelStep(step, context, api, permissions, allowedActionIds, options, flowStack);
994
- break;
995
- case "file-read":
996
- result = executeFileReadStep(step, context);
997
- break;
998
- case "file-write":
999
- result = executeFileWriteStep(step, context);
1000
- break;
1001
- case "while":
1002
- result = await executeWhileStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1003
- break;
1004
- case "flow":
1005
- result = await executeSubflowStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1006
- break;
1007
- case "paginate":
1008
- result = await executePaginateStep(step, context, api, permissions, allowedActionIds, options);
1009
- break;
1010
- case "bash":
1011
- result = await executeBashStep(step, context, options);
1012
- break;
1013
- default:
1014
- throw new Error(`Unknown step type: ${step.type}`);
1015
- }
1075
+ const dispatch = async () => {
1076
+ switch (step.type) {
1077
+ case "action":
1078
+ return await executeActionStep(step, context, api, permissions, allowedActionIds);
1079
+ case "transform":
1080
+ return executeTransformStep(step, context);
1081
+ case "code":
1082
+ return await executeCodeStep(step, context, options);
1083
+ case "condition":
1084
+ return await executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1085
+ case "loop":
1086
+ return await executeLoopStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1087
+ case "parallel":
1088
+ return await executeParallelStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1089
+ case "file-read":
1090
+ return executeFileReadStep(step, context);
1091
+ case "file-write":
1092
+ return executeFileWriteStep(step, context);
1093
+ case "while":
1094
+ return await executeWhileStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1095
+ case "flow":
1096
+ return await executeSubflowStep(step, context, api, permissions, allowedActionIds, options, flowStack);
1097
+ case "paginate":
1098
+ return await executePaginateStep(step, context, api, permissions, allowedActionIds, options);
1099
+ case "bash":
1100
+ return await executeBashStep(step, context, options);
1101
+ default:
1102
+ throw new Error(`Unknown step type: ${step.type}`);
1103
+ }
1104
+ };
1105
+ const result = step.timeoutMs ? await withTimeout(dispatch(), step.timeoutMs, step.id) : await dispatch();
1016
1106
  result.durationMs = Date.now() - startTime;
1017
- if (attempt > 1) result.retries = attempt - 1;
1107
+ if (attempt > 1) {
1108
+ result.retries = attempt - 1;
1109
+ options.onEvent?.({
1110
+ event: "step:retry-success",
1111
+ stepId: step.id,
1112
+ retries: attempt - 1
1113
+ });
1114
+ }
1018
1115
  context.steps[step.id] = result;
1019
1116
  return result;
1020
1117
  } catch (err) {
@@ -1026,20 +1123,27 @@ async function executeSingleStep(step, context, api, permissions, allowedActionI
1026
1123
  }
1027
1124
  const errorMessage = lastError?.message || "Unknown error";
1028
1125
  const strategy = step.onError?.strategy || "fail";
1126
+ const retriesUsed = Math.max(0, maxAttempts - 1);
1127
+ const isTimeout = lastError instanceof StepTimeoutError;
1128
+ const errorCode = lastError?.errorCode;
1029
1129
  if (strategy === "continue") {
1030
1130
  const result = {
1031
- status: "failed",
1131
+ status: isTimeout ? "timeout" : "failed",
1032
1132
  error: errorMessage,
1033
- durationMs: Date.now() - startTime
1133
+ ...errorCode ? { errorCode } : {},
1134
+ durationMs: Date.now() - startTime,
1135
+ retries: retriesUsed
1034
1136
  };
1035
1137
  context.steps[step.id] = result;
1036
1138
  return result;
1037
1139
  }
1038
1140
  if (strategy === "fallback" && step.onError?.fallbackStepId) {
1039
1141
  const result = {
1040
- status: "failed",
1142
+ status: isTimeout ? "timeout" : "failed",
1041
1143
  error: errorMessage,
1042
- durationMs: Date.now() - startTime
1144
+ ...errorCode ? { errorCode } : {},
1145
+ durationMs: Date.now() - startTime,
1146
+ retries: retriesUsed
1043
1147
  };
1044
1148
  context.steps[step.id] = result;
1045
1149
  return result;
@@ -1174,7 +1278,8 @@ var FLOW_SCHEMA = {
1174
1278
  name: { type: "string", required: true, description: "Human-readable step label" },
1175
1279
  type: { type: "string", required: true, description: "Step type (determines which config object is required)" },
1176
1280
  if: { type: "string", required: false, description: "JS expression \u2014 skip step if falsy" },
1177
- unless: { type: "string", required: false, description: "JS expression \u2014 skip step if truthy" }
1281
+ unless: { type: "string", required: false, description: "JS expression \u2014 skip step if truthy" },
1282
+ timeoutMs: { type: "number", required: false, description: 'Wall-clock timeout (ms). On expiry the step fails with errorCode:"TIMEOUT"; with onError:continue the result gets status:"timeout".' }
1178
1283
  },
1179
1284
  stepTypes: [
1180
1285
  {
@@ -1614,7 +1719,7 @@ Every step MUST have \`id\`, \`name\`, and \`type\`. The \`type\` determines whi
1614
1719
  for (const [name, fd] of Object.entries(FLOW_SCHEMA.stepCommonFields)) {
1615
1720
  sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
1616
1721
  }
1617
- sections.push(`| \`onError\` | object | no | Error handling: \`{ "strategy": "${FLOW_SCHEMA.errorStrategies.join(" | ")}", "retries": 3, "retryDelayMs": 1000 }\` |`);
1722
+ sections.push(`| \`onError\` | object | no | Error handling: \`{ "strategy": "${FLOW_SCHEMA.errorStrategies.join(" | ")}", "retries": 3, "retryDelayMs": 1000, "backoff": "fixed \\| exponential \\| exponential-jitter", "maxDelayMs": 30000 }\` |`);
1618
1723
  sections.push(`
1619
1724
  ## Step Types
1620
1725
 
@@ -1682,6 +1787,39 @@ Every completed step produces both \`output\` and \`response\`:
1682
1787
  - **Code/transform steps**: \`output\` is the return value. \`response\` is an alias for \`output\`.
1683
1788
  - **In practice**: Use \`$.steps.stepId.response\` for action steps (API data) and \`$.steps.stepId.output\` for code/transform steps (computed data). Both work interchangeably, but using the semantically correct one makes flows easier to read.
1684
1789
 
1790
+ ### Step result metadata (\`status\`, \`error\`, \`errorCode\`)
1791
+
1792
+ Every step result also exposes execution metadata that downstream steps can inspect:
1793
+
1794
+ | Field | Values | When set |
1795
+ |-------|--------|----------|
1796
+ | \`$.steps.X.status\` | \`"success"\` \\| \`"skipped"\` \\| \`"failed"\` \\| \`"timeout"\` | Always |
1797
+ | \`$.steps.X.error\` | error message string | When status is \`failed\` or \`timeout\` |
1798
+ | \`$.steps.X.errorCode\` | machine-readable code (e.g. \`"TIMEOUT"\`) | When the error has a code |
1799
+ | \`$.steps.X.durationMs\` | number | Always |
1800
+ | \`$.steps.X.retries\` | number | When the step was retried |
1801
+
1802
+ This lets downstream steps distinguish \`skipped\` (\`if\` condition false) from \`failed\` (error, \`onError:continue\`) from \`timeout\` (exceeded \`timeoutMs\`) \u2014 e.g. \`"if": "$.steps.enrichment.status === 'timeout'"\` to retry with a longer window.
1803
+
1804
+ ### Sub-flow output (flattened)
1805
+
1806
+ When a step has \`type: "flow"\`, the sub-flow's final step output is flattened onto the parent step's \`output\`:
1807
+
1808
+ \`\`\`jsonc
1809
+ // Sub-flow "sub-consts" has a final step "load" that returns { CHART_URL, API_KEY }
1810
+
1811
+ // Preferred (flattened):
1812
+ "{{$.steps.loadConfig.output.CHART_URL}}"
1813
+
1814
+ // Legacy nested path (still works for backward compatibility):
1815
+ "{{$.steps.loadConfig.output.load.output.CHART_URL}}"
1816
+
1817
+ // Escape hatch for programmatic access to the full sub-flow steps map:
1818
+ "{{$.steps.loadConfig.output._steps.load.output.CHART_URL}}"
1819
+ \`\`\`
1820
+
1821
+ If a sub-step id collides with a flattened field name, the flattened field wins and the engine emits a \`flow:warning\` event.
1822
+
1685
1823
  ## Error Handling
1686
1824
 
1687
1825
  \`\`\`json
@@ -11,7 +11,7 @@ import {
11
11
  saveFlow,
12
12
  summarizeFlowInputs,
13
13
  walkSteps
14
- } from "./chunk-KZOFPEHD.js";
14
+ } from "./chunk-AVOGNLC7.js";
15
15
  export {
16
16
  FlowRunner,
17
17
  collectStepTypes,
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  loadFlowWithMeta,
17
17
  resolveFlowPath,
18
18
  saveFlow
19
- } from "./chunk-KZOFPEHD.js";
19
+ } from "./chunk-AVOGNLC7.js";
20
20
 
21
21
  // src/index.ts
22
22
  import { createRequire as createRequire2 } from "module";
@@ -2243,6 +2243,9 @@ function colorMethod(method) {
2243
2243
  import pc7 from "picocolors";
2244
2244
 
2245
2245
  // src/lib/flow-validator.ts
2246
+ import fs6 from "fs";
2247
+ import path6 from "path";
2248
+ import { spawnSync } from "child_process";
2246
2249
  function validateFlowSchema(flow2) {
2247
2250
  const errors = [];
2248
2251
  if (!flow2 || typeof flow2 !== "object") {
@@ -2301,26 +2304,26 @@ function validateStepsArray(steps, pathPrefix, errors) {
2301
2304
  const validTypes = FLOW_SCHEMA.stepTypes.map((st) => st.type);
2302
2305
  for (let i = 0; i < steps.length; i++) {
2303
2306
  const step = steps[i];
2304
- const path6 = `${pathPrefix}[${i}]`;
2307
+ const path8 = `${pathPrefix}[${i}]`;
2305
2308
  if (!step || typeof step !== "object" || Array.isArray(step)) {
2306
- errors.push({ path: path6, message: "Step must be an object" });
2309
+ errors.push({ path: path8, message: "Step must be an object" });
2307
2310
  continue;
2308
2311
  }
2309
2312
  const s = step;
2310
2313
  if (!s.id || typeof s.id !== "string") {
2311
- errors.push({ path: `${path6}.id`, message: 'Step must have a string "id"' });
2314
+ errors.push({ path: `${path8}.id`, message: 'Step must have a string "id"' });
2312
2315
  }
2313
2316
  if (!s.name || typeof s.name !== "string") {
2314
- errors.push({ path: `${path6}.name`, message: 'Step must have a string "name"' });
2317
+ errors.push({ path: `${path8}.name`, message: 'Step must have a string "name"' });
2315
2318
  }
2316
2319
  if (!s.type || !validTypes.includes(s.type)) {
2317
- errors.push({ path: `${path6}.type`, message: `Step type must be one of: ${validTypes.join(", ")}` });
2320
+ errors.push({ path: `${path8}.type`, message: `Step type must be one of: ${validTypes.join(", ")}` });
2318
2321
  continue;
2319
2322
  }
2320
2323
  if (s.onError && typeof s.onError === "object") {
2321
2324
  const oe = s.onError;
2322
2325
  if (!FLOW_SCHEMA.errorStrategies.includes(oe.strategy)) {
2323
- errors.push({ path: `${path6}.onError.strategy`, message: `Error strategy must be one of: ${FLOW_SCHEMA.errorStrategies.join(", ")}` });
2326
+ errors.push({ path: `${path8}.onError.strategy`, message: `Error strategy must be one of: ${FLOW_SCHEMA.errorStrategies.join(", ")}` });
2324
2327
  }
2325
2328
  }
2326
2329
  const descriptor = getStepTypeDescriptor(s.type);
@@ -2330,14 +2333,14 @@ function validateStepsArray(steps, pathPrefix, errors) {
2330
2333
  if (!configObj || typeof configObj !== "object") {
2331
2334
  const hint = detectFlatConfigHint(s, descriptor);
2332
2335
  errors.push({
2333
- path: `${path6}.${configKey}`,
2336
+ path: `${path8}.${configKey}`,
2334
2337
  message: `${capitalize(descriptor.type)} step must have a "${configKey}" config object${hint}`
2335
2338
  });
2336
2339
  continue;
2337
2340
  }
2338
2341
  const config2 = configObj;
2339
2342
  for (const [fieldName, fd] of Object.entries(descriptor.fields)) {
2340
- const fieldPath = `${path6}.${configKey}.${fieldName}`;
2343
+ const fieldPath = `${path8}.${configKey}.${fieldName}`;
2341
2344
  const value = config2[fieldName];
2342
2345
  if (fd.required && (value === void 0 || value === null || value === "")) {
2343
2346
  errors.push({ path: fieldPath, message: `${capitalize(descriptor.type)} must have ${fd.type === "string" ? "a string" : fd.type === "array" ? "a" : "a"} "${fieldName}"` });
@@ -2373,18 +2376,24 @@ function validateStepsArray(steps, pathPrefix, errors) {
2373
2376
  const hasSource = typeof config2.source === "string" && config2.source.length > 0;
2374
2377
  const hasModule = typeof config2.module === "string" && config2.module.length > 0;
2375
2378
  if (!hasSource && !hasModule) {
2376
- errors.push({ path: `${path6}.${configKey}`, message: 'Code step must define either "source" (inline JS) or "module" (path to .mjs file)' });
2379
+ errors.push({ path: `${path8}.${configKey}`, message: 'Code step must define either "source" (inline JS) or "module" (path to .mjs file)' });
2377
2380
  } else if (hasSource && hasModule) {
2378
- errors.push({ path: `${path6}.${configKey}`, message: 'Code step cannot define both "source" and "module" \u2014 pick one' });
2381
+ errors.push({ path: `${path8}.${configKey}`, message: 'Code step cannot define both "source" and "module" \u2014 pick one' });
2379
2382
  }
2380
2383
  if (hasModule) {
2381
2384
  const m = config2.module;
2382
2385
  if (m.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(m)) {
2383
- errors.push({ path: `${path6}.${configKey}.module`, message: "Code module path must be relative to the flow folder (no absolute paths)" });
2386
+ errors.push({ path: `${path8}.${configKey}.module`, message: "Code module path must be relative to the flow folder (no absolute paths)" });
2384
2387
  } else if (m.split(/[\\/]/).includes("..")) {
2385
- errors.push({ path: `${path6}.${configKey}.module`, message: 'Code module path must not escape the flow folder ("..")' });
2388
+ errors.push({ path: `${path8}.${configKey}.module`, message: 'Code module path must not escape the flow folder ("..")' });
2386
2389
  } else if (!m.endsWith(".mjs")) {
2387
- errors.push({ path: `${path6}.${configKey}.module`, message: "Code module must be a .mjs file" });
2390
+ errors.push({ path: `${path8}.${configKey}.module`, message: "Code module must be a .mjs file" });
2391
+ }
2392
+ }
2393
+ if (hasSource) {
2394
+ const syntaxError = checkCodeSourceSyntax(config2.source);
2395
+ if (syntaxError) {
2396
+ errors.push({ path: `${path8}.${configKey}.source`, message: `Syntax error in code step: ${syntaxError}` });
2388
2397
  }
2389
2398
  }
2390
2399
  }
@@ -2401,6 +2410,17 @@ function detectFlatConfigHint(step, descriptor) {
2401
2410
  function capitalize(s) {
2402
2411
  return s.charAt(0).toUpperCase() + s.slice(1);
2403
2412
  }
2413
+ var AsyncFunctionCtor = Object.getPrototypeOf(async function() {
2414
+ }).constructor;
2415
+ function checkCodeSourceSyntax(source) {
2416
+ try {
2417
+ new AsyncFunctionCtor("$", "require", source);
2418
+ return null;
2419
+ } catch (err) {
2420
+ if (err instanceof SyntaxError) return err.message;
2421
+ return err.message;
2422
+ }
2423
+ }
2404
2424
  function validateStepIds(flow2) {
2405
2425
  const errors = [];
2406
2426
  const seen = /* @__PURE__ */ new Set();
@@ -2408,16 +2428,16 @@ function validateStepIds(flow2) {
2408
2428
  function collectIds(steps, pathPrefix) {
2409
2429
  for (let i = 0; i < steps.length; i++) {
2410
2430
  const step = steps[i];
2411
- const path6 = `${pathPrefix}[${i}]`;
2431
+ const path8 = `${pathPrefix}[${i}]`;
2412
2432
  if (seen.has(step.id)) {
2413
- errors.push({ path: `${path6}.id`, message: `Duplicate step ID: "${step.id}"` });
2433
+ errors.push({ path: `${path8}.id`, message: `Duplicate step ID: "${step.id}"` });
2414
2434
  } else {
2415
2435
  seen.add(step.id);
2416
2436
  }
2417
2437
  for (const { configKey, fieldName } of nestedKeys) {
2418
2438
  const config2 = step[configKey];
2419
2439
  if (config2 && Array.isArray(config2[fieldName])) {
2420
- collectIds(config2[fieldName], `${path6}.${configKey}.${fieldName}`);
2440
+ collectIds(config2[fieldName], `${path8}.${configKey}.${fieldName}`);
2421
2441
  }
2422
2442
  }
2423
2443
  }
@@ -2465,7 +2485,7 @@ function validateSelectorReferences(flow2) {
2465
2485
  }
2466
2486
  return selectors;
2467
2487
  }
2468
- function checkSelectors(selectors, path6) {
2488
+ function checkSelectors(selectors, path8, precedingStepIds) {
2469
2489
  for (const selector of selectors) {
2470
2490
  const parts = selector.split(".");
2471
2491
  if (parts.length < 3) continue;
@@ -2473,37 +2493,42 @@ function validateSelectorReferences(flow2) {
2473
2493
  if (root === "input") {
2474
2494
  const inputName = parts[2];
2475
2495
  if (!inputNames.has(inputName)) {
2476
- errors.push({ path: path6, message: `Selector "${selector}" references undefined input "${inputName}"` });
2496
+ errors.push({ path: path8, message: `Selector "${selector}" references undefined input "${inputName}"` });
2477
2497
  }
2478
2498
  } else if (root === "steps") {
2479
- const stepId = parts[2];
2499
+ const stepId = parts[2].replace(/[\[\]]/g, "").split(/[\[\]]/)[0];
2480
2500
  if (!allStepIds.has(stepId)) {
2481
- errors.push({ path: path6, message: `Selector "${selector}" references undefined step "${stepId}"` });
2501
+ errors.push({ path: path8, message: `Selector "${selector}" references undefined step "${stepId}"` });
2502
+ } else if (precedingStepIds && !precedingStepIds.has(stepId)) {
2503
+ errors.push({
2504
+ path: path8,
2505
+ message: `Selector "${selector}" references step "${stepId}" which is declared after the current step. Steps execute in declaration order, so this will always resolve to undefined at runtime \u2014 move the dependency earlier in the steps array.`
2506
+ });
2482
2507
  }
2483
2508
  }
2484
2509
  }
2485
2510
  }
2486
2511
  const EXPRESSION_FIELDS = /* @__PURE__ */ new Set(["condition.expression", "while.condition"]);
2487
- function checkOperatorsInSelectorField(value, path6) {
2512
+ function checkOperatorsInSelectorField(value, path8) {
2488
2513
  if (typeof value === "string" && value.startsWith("$.")) {
2489
2514
  if (value.includes("||")) {
2490
- errors.push({ path: path6, message: `Selector "${value}" contains unsupported operator "||". Selectors in data fields use dot-path resolution, not JS evaluation. Use the "default" field on the input definition instead, or use a "code" step for complex expressions.` });
2515
+ errors.push({ path: path8, message: `Selector "${value}" contains unsupported operator "||". Selectors in data fields use dot-path resolution, not JS evaluation. Use the "default" field on the input definition instead, or use a "code" step for complex expressions.` });
2491
2516
  } else if (value.includes("&&")) {
2492
- errors.push({ path: path6, message: `Selector "${value}" contains unsupported operator "&&". Selectors in data fields use dot-path resolution, not JS evaluation. Use a "condition" step or "code" step for complex expressions.` });
2517
+ errors.push({ path: path8, message: `Selector "${value}" contains unsupported operator "&&". Selectors in data fields use dot-path resolution, not JS evaluation. Use a "condition" step or "code" step for complex expressions.` });
2493
2518
  }
2494
2519
  } else if (value && typeof value === "object" && !Array.isArray(value)) {
2495
2520
  for (const [k, v] of Object.entries(value)) {
2496
- checkOperatorsInSelectorField(v, `${path6}.${k}`);
2521
+ checkOperatorsInSelectorField(v, `${path8}.${k}`);
2497
2522
  }
2498
2523
  } else if (Array.isArray(value)) {
2499
2524
  for (let i = 0; i < value.length; i++) {
2500
- checkOperatorsInSelectorField(value[i], `${path6}[${i}]`);
2525
+ checkOperatorsInSelectorField(value[i], `${path8}[${i}]`);
2501
2526
  }
2502
2527
  }
2503
2528
  }
2504
- function checkStep(step, pathPrefix) {
2505
- if (step.if) checkSelectors(extractSelectors(step.if), `${pathPrefix}.if`);
2506
- if (step.unless) checkSelectors(extractSelectors(step.unless), `${pathPrefix}.unless`);
2529
+ function checkStep(step, pathPrefix, preceding2) {
2530
+ if (step.if) checkSelectors(extractSelectors(step.if), `${pathPrefix}.if`, preceding2);
2531
+ if (step.unless) checkSelectors(extractSelectors(step.unless), `${pathPrefix}.unless`, preceding2);
2507
2532
  const descriptor = getStepTypeDescriptor(step.type);
2508
2533
  if (descriptor) {
2509
2534
  const config2 = step[descriptor.configKey];
@@ -2515,41 +2540,95 @@ function validateSelectorReferences(flow2) {
2515
2540
  if (value !== void 0) {
2516
2541
  const fieldKey = `${descriptor.configKey}.${fieldName}`;
2517
2542
  const fieldPath = `${pathPrefix}.${fieldKey}`;
2518
- checkSelectors(extractSelectors(value), fieldPath);
2543
+ checkSelectors(extractSelectors(value), fieldPath, preceding2);
2519
2544
  if (!EXPRESSION_FIELDS.has(fieldKey)) {
2520
2545
  checkOperatorsInSelectorField(value, fieldPath);
2521
2546
  }
2522
2547
  }
2523
2548
  }
2549
+ } else {
2550
+ const c = config2;
2551
+ const codeSource = step.type === "code" ? c.source : void 0;
2552
+ const transformExpr = step.type === "transform" ? c.expression : void 0;
2553
+ const text4 = codeSource ?? transformExpr;
2554
+ if (typeof text4 === "string") {
2555
+ const fieldName = step.type === "code" ? "source" : "expression";
2556
+ checkSelectors(extractSelectors(text4), `${pathPrefix}.${descriptor.configKey}.${fieldName}`, preceding2);
2557
+ }
2524
2558
  }
2525
2559
  }
2526
2560
  for (const { configKey, fieldName } of nestedKeys) {
2527
2561
  if (configKey === descriptor.configKey) {
2528
2562
  const c = step[configKey];
2529
2563
  if (c && Array.isArray(c[fieldName])) {
2530
- c[fieldName].forEach(
2531
- (s, i) => checkStep(s, `${pathPrefix}.${configKey}.${fieldName}[${i}]`)
2532
- );
2564
+ const childPreceding = new Set(preceding2);
2565
+ c[fieldName].forEach((s, i) => {
2566
+ checkStep(s, `${pathPrefix}.${configKey}.${fieldName}[${i}]`, childPreceding);
2567
+ childPreceding.add(s.id);
2568
+ });
2533
2569
  }
2534
2570
  }
2535
2571
  }
2536
2572
  }
2537
2573
  }
2538
- flow2.steps.forEach((step, i) => checkStep(step, `steps[${i}]`));
2574
+ const preceding = /* @__PURE__ */ new Set();
2575
+ flow2.steps.forEach((step, i) => {
2576
+ checkStep(step, `steps[${i}]`, preceding);
2577
+ preceding.add(step.id);
2578
+ });
2539
2579
  return errors;
2540
2580
  }
2541
- function validateFlow(flow2) {
2581
+ function validateFlow(flow2, rootDir) {
2542
2582
  const schemaErrors = validateFlowSchema(flow2);
2543
2583
  if (schemaErrors.length > 0) return schemaErrors;
2544
2584
  const f = flow2;
2545
2585
  return [
2546
2586
  ...validateStepIds(f),
2547
- ...validateSelectorReferences(f)
2587
+ ...validateSelectorReferences(f),
2588
+ ...rootDir ? validateCodeModules(f, rootDir) : []
2548
2589
  ];
2549
2590
  }
2591
+ function validateCodeModules(flow2, rootDir) {
2592
+ const errors = [];
2593
+ const nestedKeys = getNestedStepsKeys();
2594
+ function walk(steps, pathPrefix) {
2595
+ for (let i = 0; i < steps.length; i++) {
2596
+ const step = steps[i];
2597
+ const stepPath = `${pathPrefix}[${i}]`;
2598
+ if (step.type === "code" && step.code?.module) {
2599
+ const m = step.code.module;
2600
+ const abs = path6.resolve(rootDir, m);
2601
+ if (!fs6.existsSync(abs)) {
2602
+ errors.push({
2603
+ path: `${stepPath}.code.module`,
2604
+ message: `Code module "${m}" not found at ${abs}`
2605
+ });
2606
+ } else {
2607
+ const res = spawnSync(process.execPath, ["--check", abs], { encoding: "utf-8" });
2608
+ if (res.status !== 0) {
2609
+ const msg = (res.stderr || "").split("\n").find((l) => l.includes("SyntaxError") || l.includes("Error")) || res.stderr || "Syntax check failed";
2610
+ errors.push({
2611
+ path: `${stepPath}.code.module`,
2612
+ message: `Syntax error in code module "${m}": ${msg.trim()}`
2613
+ });
2614
+ }
2615
+ }
2616
+ }
2617
+ for (const { configKey, fieldName } of nestedKeys) {
2618
+ const config2 = step[configKey];
2619
+ if (config2 && Array.isArray(config2[fieldName])) {
2620
+ walk(config2[fieldName], `${stepPath}.${configKey}.${fieldName}`);
2621
+ }
2622
+ }
2623
+ }
2624
+ }
2625
+ walk(flow2.steps, "steps");
2626
+ return errors;
2627
+ }
2550
2628
 
2551
2629
  // src/commands/flow.ts
2552
- import fs6 from "fs";
2630
+ import fs7 from "fs";
2631
+ import path7 from "path";
2553
2632
  function getConfig2() {
2554
2633
  const apiKey = getApiKey();
2555
2634
  if (!apiKey) {
@@ -2607,7 +2686,7 @@ async function flowCreateCommand(key, options) {
2607
2686
  if (raw.startsWith("@")) {
2608
2687
  const filePath = raw.slice(1);
2609
2688
  try {
2610
- raw = fs6.readFileSync(filePath, "utf-8");
2689
+ raw = fs7.readFileSync(filePath, "utf-8");
2611
2690
  } catch (err) {
2612
2691
  error(`Cannot read file "${filePath}": ${err.message}`);
2613
2692
  }
@@ -2672,6 +2751,15 @@ async function flowExecuteCommand(keyOrPath, options) {
2672
2751
  return;
2673
2752
  }
2674
2753
  spinner5.stop(`Workflow: ${flow2.name} (${flow2.steps.length} steps)`);
2754
+ const preflightErrors = validateFlow(flow2, rootDir);
2755
+ if (preflightErrors.length > 0) {
2756
+ if (isAgentMode()) {
2757
+ json({ error: "Validation failed", errors: preflightErrors });
2758
+ process.exit(1);
2759
+ }
2760
+ error(`Validation failed:
2761
+ ${preflightErrors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
2762
+ }
2675
2763
  if (flowFilePath.endsWith(".flow.json")) {
2676
2764
  const msg = `Workflow "${flow2.key}" uses the deprecated single-file layout. Migrate to .one/flows/${flow2.key}/flow.json (see: one guide flows).`;
2677
2765
  if (isAgentMode()) {
@@ -2812,15 +2900,23 @@ async function flowValidateCommand(keyOrPath) {
2812
2900
  const spinner5 = createSpinner();
2813
2901
  spinner5.start(`Validating "${keyOrPath}"...`);
2814
2902
  let flowData;
2903
+ let rootDir;
2815
2904
  try {
2816
- const flowPath = resolveFlowPath(keyOrPath);
2817
- const content = fs6.readFileSync(flowPath, "utf-8");
2818
- flowData = JSON.parse(content);
2905
+ try {
2906
+ const loaded = loadFlowWithMeta(keyOrPath);
2907
+ flowData = loaded.flow;
2908
+ rootDir = loaded.rootDir;
2909
+ } catch {
2910
+ const flowPath = resolveFlowPath(keyOrPath);
2911
+ const content = fs7.readFileSync(flowPath, "utf-8");
2912
+ flowData = JSON.parse(content);
2913
+ rootDir = path7.dirname(flowPath);
2914
+ }
2819
2915
  } catch (err) {
2820
2916
  spinner5.stop("Validation failed");
2821
2917
  error(`Could not read workflow: ${err instanceof Error ? err.message : String(err)}`);
2822
2918
  }
2823
- const errors = validateFlow(flowData);
2919
+ const errors = validateFlow(flowData, rootDir);
2824
2920
  if (errors.length > 0) {
2825
2921
  spinner5.stop("Validation failed");
2826
2922
  if (isAgentMode()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -95,6 +95,21 @@ process.stdout.write(JSON.stringify(items.filter(i => i.active)));
95
95
 
96
96
  The module runs as a child `node` process: the flow context `$` is piped to stdin as JSON, and stdout is parsed as JSON and used as the step's output. Modules have full Node APIs available (unlike inline `code.source`, which is sandboxed). Use `code.module` for anything non-trivial; keep `code.source` for one-liners.
97
97
 
98
+ #### Inline `code.source` sandbox
99
+
100
+ Inline `code.source` runs inside an async function with a restricted `require`. Only the following Node built-ins are importable:
101
+
102
+ - `node:buffer`
103
+ - `node:crypto`
104
+ - `node:url`
105
+ - `node:path`
106
+
107
+ Everything else — `fs`, `http`, `https`, `net`, `child_process`, `process`, `os`, `cluster`, `dgram`, `tls`, `vm`, `worker_threads` — is **blocked** and will throw `Module "<name>" is blocked in code steps`. The runtime also does not expose `process`, `__dirname`, `__filename`, `setTimeout`, or `fetch`.
108
+
109
+ If you need any of those (filesystem reads, network calls, timers, etc.), use a `code.module` step instead — modules run as a real child `node` process and have the full Node API surface.
110
+
111
+ When an inline `code.source` step throws at runtime, the error message reports the user-relative line and column plus the offending line of source — e.g. `Code step "blowup" failed at line 3:34\n const c = $.steps.mk.output.data.score;\n Cannot read properties of null (reading 'score')`. No need to bisect the step manually.
112
+
98
113
  Whatever JSON a module writes to stdout becomes both `$.steps.<id>.output` and `$.steps.<id>.response` (aliases). Downstream steps can reference either.
99
114
 
100
115
  ### Migrating a legacy single-file flow
@@ -130,6 +145,8 @@ Two rules: (1) prepend the stdin-read line, (2) replace `return X` with `process
130
145
  one --agent flow validate <key>
131
146
  ```
132
147
 
148
+ `flow validate` parses every inline `code.source` and runs `node --check` on every `code.module` file, so syntax errors (brace/paren mismatches, duplicate `let`, etc.) surface here instead of after upstream steps have already run. It also extracts `$.steps.X` and `$.input.X` references from inside `code.source` and `transform.expression` and reports any reference to an undefined step/input or to a step declared **after** the current one (forward references resolve to `undefined` at runtime — silent data loss). The same checks run automatically at the start of `flow execute` so a broken step in position 15 fails the run immediately rather than 15 minutes in.
149
+
133
150
  ### Step 6: Execute
134
151
 
135
152
  ```bash
@@ -187,6 +204,16 @@ Connection inputs with a `connection` field auto-resolve if the user has exactly
187
204
 
188
205
  A pure `$.xxx` value resolves to the raw type. A string containing `{{$.xxx}}` does string interpolation.
189
206
 
207
+ **Passing objects and arrays:** `{{ }}` interpolation always produces a string — if the resolved value is an object or array it will be JSON-stringified and the engine will log a warning. To pass an object/array as a native value to the next step, use a **direct selector without `{{ }}`**:
208
+
209
+ ```json
210
+ // ✗ Wrong — becomes a JSON string, triggers a runtime warning
211
+ "files": "{{$.steps.extract.output.allFiles}}"
212
+
213
+ // ✓ Right — passes the array as an array
214
+ "files": "$.steps.extract.output.allFiles"
215
+ ```
216
+
190
217
  ### Selectors vs expressions
191
218
 
192
219
  Selectors in data fields (`data`, `queryParams`, `pathVars`, `connectionKey`) are **dot-path lookups only** — they do not support JavaScript operators like `||` or `&&`. For default values, use the `default` field on the input definition:
@@ -343,6 +370,14 @@ After a parallel step, access each substep's output by its `id`: `$.steps.fetchE
343
370
  }
344
371
  ```
345
372
 
373
+ A sub-flow step exposes the sub-flow's **step results map** at both `.output` and `.response` (they are aliases — pick whichever reads better). Access a specific sub-step's data with:
374
+
375
+ ```
376
+ $.steps.<parent>.output.<subStepId>.output.<field>
377
+ ```
378
+
379
+ e.g. if sub-flow `enrich-customer` has a step `load` that returns `{ TEAM: "acme" }`, the caller reads it as `$.steps.enrich.output.load.output.TEAM`. There is no longer any `.response.<subStepId>` vs `.output.<subStepId>` ambiguity.
380
+
346
381
  ### `paginate` — Auto-collect paginated results
347
382
 
348
383
  ```json
@@ -369,6 +404,26 @@ After a parallel step, access each substep's output by its `id`: `$.steps.fetchE
369
404
  }
370
405
  ```
371
406
 
407
+ **Safe interpolation.** Plain `{{$.input.x}}` does string substitution and is **unsafe** for bash — values containing quotes, `$`, backticks, `&`, etc. will break the command (or worse). Use the `q` helper to POSIX-shell-quote the value:
408
+
409
+ ```json
410
+ { "command": "echo {{q $.input.companyName}} | tr '[:upper:]' '[:lower:]'" }
411
+ ```
412
+
413
+ `{{q $.input.companyName}}` resolves `O'Reilly Media & Co` to `'O'\''Reilly Media & Co'` — a single argv token bash will parse cleanly. Use `{{q ...}}` for **every** interpolation of user-controlled data into a bash command.
414
+
415
+ Alternatively, pass values as environment variables (also shell-safe) and reference them with `$VAR`:
416
+
417
+ ```json
418
+ {
419
+ "type": "bash",
420
+ "bash": {
421
+ "env": { "COMPANY": "$.input.companyName" },
422
+ "command": "echo \"$COMPANY\" | tr '[:upper:]' '[:lower:]'"
423
+ }
424
+ }
425
+ ```
426
+
372
427
  ## Error Handling
373
428
 
374
429
  ```json
@@ -377,6 +432,30 @@ After a parallel step, access each substep's output by its `id`: `$.steps.fetchE
377
432
 
378
433
  Strategies: `fail` (default), `continue`, `retry`, `fallback`.
379
434
 
435
+ **Retry backoff.** By default each retry waits exactly `retryDelayMs`. For rate-limited APIs add `"backoff": "exponential"` (or `"exponential-jitter"`) and an optional `"maxDelayMs"` cap (defaults to 30000):
436
+
437
+ ```json
438
+ {
439
+ "onError": {
440
+ "strategy": "retry",
441
+ "retries": 4,
442
+ "retryDelayMs": 1000,
443
+ "backoff": "exponential-jitter",
444
+ "maxDelayMs": 10000
445
+ }
446
+ }
447
+ ```
448
+
449
+ `exponential` waits `retryDelayMs * 2^(retryIndex)` (1s, 2s, 4s, 8s…) capped at `maxDelayMs`. `exponential-jitter` multiplies each wait by a random factor in [0.5, 1.0) so concurrent retries spread out.
450
+
451
+ **Inspecting retry outcomes.** Every retried step exposes how it ended on its `StepResult`:
452
+
453
+ - `$.steps.<id>.status` — `"success"` or `"failed"`
454
+ - `$.steps.<id>.retries` — number of retries actually performed (0 if first attempt succeeded)
455
+ - `$.steps.<id>.error` — last error message (only set when `status === "failed"` under `continue`/`fallback` strategies)
456
+
457
+ A successful-after-retry step also emits a `step:retry-success` event with the retry count, so you can distinguish a clean first-attempt success from a recovered one in logs.
458
+
380
459
  Conditional execution: `"if": "$.steps.find.response.data.length > 0"`
381
460
 
382
461
  ## AI-Augmented Patterns