primitive-admin 1.0.53 → 1.0.54

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.
Files changed (133) hide show
  1. package/README.md +38 -10
  2. package/dist/bin/primitive.js +6 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/auth.js +16 -6
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/blob-buckets.js +84 -7
  7. package/dist/src/commands/blob-buckets.js.map +1 -1
  8. package/dist/src/commands/catalog.js +3 -3
  9. package/dist/src/commands/catalog.js.map +1 -1
  10. package/dist/src/commands/collections.js +12 -9
  11. package/dist/src/commands/collections.js.map +1 -1
  12. package/dist/src/commands/databases.js +191 -74
  13. package/dist/src/commands/databases.js.map +1 -1
  14. package/dist/src/commands/group-type-configs.js +1 -9
  15. package/dist/src/commands/group-type-configs.js.map +1 -1
  16. package/dist/src/commands/groups.js +12 -6
  17. package/dist/src/commands/groups.js.map +1 -1
  18. package/dist/src/commands/guides.d.ts +161 -0
  19. package/dist/src/commands/guides.js +502 -84
  20. package/dist/src/commands/guides.js.map +1 -1
  21. package/dist/src/commands/integrations.js +2 -2
  22. package/dist/src/commands/integrations.js.map +1 -1
  23. package/dist/src/commands/metadata.d.ts +2 -0
  24. package/dist/src/commands/metadata.js +344 -0
  25. package/dist/src/commands/metadata.js.map +1 -0
  26. package/dist/src/commands/prompts.js +2 -2
  27. package/dist/src/commands/prompts.js.map +1 -1
  28. package/dist/src/commands/rule-sets.d.ts +1 -0
  29. package/dist/src/commands/rule-sets.js +25 -3
  30. package/dist/src/commands/rule-sets.js.map +1 -1
  31. package/dist/src/commands/scripts.d.ts +2 -0
  32. package/dist/src/commands/scripts.js +667 -0
  33. package/dist/src/commands/scripts.js.map +1 -0
  34. package/dist/src/commands/sync.d.ts +189 -9
  35. package/dist/src/commands/sync.js +1418 -256
  36. package/dist/src/commands/sync.js.map +1 -1
  37. package/dist/src/commands/vars.d.ts +8 -0
  38. package/dist/src/commands/vars.js +110 -0
  39. package/dist/src/commands/vars.js.map +1 -0
  40. package/dist/src/commands/webhooks.js +17 -3
  41. package/dist/src/commands/webhooks.js.map +1 -1
  42. package/dist/src/commands/workflows.d.ts +30 -0
  43. package/dist/src/commands/workflows.js +645 -59
  44. package/dist/src/commands/workflows.js.map +1 -1
  45. package/dist/src/lib/api-client.d.ts +120 -18
  46. package/dist/src/lib/api-client.js +125 -2
  47. package/dist/src/lib/api-client.js.map +1 -1
  48. package/dist/src/lib/batch.d.ts +26 -0
  49. package/dist/src/lib/batch.js +32 -0
  50. package/dist/src/lib/batch.js.map +1 -0
  51. package/dist/src/lib/block-layout.d.ts +160 -0
  52. package/dist/src/lib/block-layout.js +451 -0
  53. package/dist/src/lib/block-layout.js.map +1 -0
  54. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
  55. package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
  56. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  57. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  58. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  59. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  60. package/dist/src/lib/config-toml.d.ts +10 -0
  61. package/dist/src/lib/config-toml.js +42 -0
  62. package/dist/src/lib/config-toml.js.map +1 -0
  63. package/dist/src/lib/config.d.ts +16 -0
  64. package/dist/src/lib/config.js +41 -0
  65. package/dist/src/lib/config.js.map +1 -1
  66. package/dist/src/lib/constants.d.ts +9 -0
  67. package/dist/src/lib/constants.js +9 -0
  68. package/dist/src/lib/constants.js.map +1 -1
  69. package/dist/src/lib/csv.d.ts +1 -2
  70. package/dist/src/lib/csv.js +3 -8
  71. package/dist/src/lib/csv.js.map +1 -1
  72. package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
  73. package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
  74. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  75. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  76. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  77. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  78. package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
  79. package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
  80. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  81. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  82. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  83. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  84. package/dist/src/lib/generated-allowlist.js +43 -0
  85. package/dist/src/lib/generated-allowlist.js.map +1 -1
  86. package/dist/src/lib/init-config.js +2 -2
  87. package/dist/src/lib/init-config.js.map +1 -1
  88. package/dist/src/lib/output.d.ts +25 -5
  89. package/dist/src/lib/output.js +32 -4
  90. package/dist/src/lib/output.js.map +1 -1
  91. package/dist/src/lib/query-operators.d.ts +43 -0
  92. package/dist/src/lib/query-operators.js +80 -0
  93. package/dist/src/lib/query-operators.js.map +1 -0
  94. package/dist/src/lib/toml-database-config.d.ts +10 -19
  95. package/dist/src/lib/toml-database-config.js +28 -59
  96. package/dist/src/lib/toml-database-config.js.map +1 -1
  97. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  98. package/dist/src/lib/toml-metadata-config.js +463 -0
  99. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  100. package/dist/src/lib/toml-native-form.d.ts +46 -0
  101. package/dist/src/lib/toml-native-form.js +78 -0
  102. package/dist/src/lib/toml-native-form.js.map +1 -0
  103. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  104. package/dist/src/lib/toml-params-validator.js +118 -3
  105. package/dist/src/lib/toml-params-validator.js.map +1 -1
  106. package/dist/src/lib/workflow-apply.d.ts +86 -0
  107. package/dist/src/lib/workflow-apply.js +127 -0
  108. package/dist/src/lib/workflow-apply.js.map +1 -0
  109. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  110. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  111. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  112. package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
  113. package/dist/src/lib/workflow-codegen/generator.js +215 -0
  114. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  115. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  116. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  117. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  118. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  119. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  120. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  121. package/dist/src/lib/workflow-fragments.js +3 -3
  122. package/dist/src/lib/workflow-fragments.js.map +1 -1
  123. package/dist/src/lib/workflow-payload.d.ts +68 -0
  124. package/dist/src/lib/workflow-payload.js +150 -0
  125. package/dist/src/lib/workflow-payload.js.map +1 -0
  126. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  127. package/dist/src/lib/workflow-toml-validator.js +37 -1
  128. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  129. package/dist/src/types/index.d.ts +11 -0
  130. package/dist/src/validators.d.ts +4 -3
  131. package/dist/src/validators.js +6 -5
  132. package/dist/src/validators.js.map +1 -1
  133. package/package.json +5 -3
@@ -1,11 +1,16 @@
1
- import { readFileSync, writeFileSync, statSync } from "fs";
1
+ import { readFileSync, writeFileSync, statSync, readdirSync, } from "fs";
2
2
  import { basename } from "path";
3
- import * as TOML from "@iarna/toml";
3
+ import * as path from "path";
4
+ import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
4
5
  import { lookup as mimeLookup } from "mime-types";
6
+ import { generateWorkflowTypes, } from "../lib/workflow-codegen/generator.js";
5
7
  import { ApiClient } from "../lib/api-client.js";
6
8
  import { resolveAppId } from "../lib/config.js";
9
+ import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
7
10
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
8
11
  import { expandWorkflow } from "../lib/workflow-fragments.js";
12
+ import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
13
+ import { applyWorkflowBody, workflowKeyMismatchError, parseCapabilitiesFlag, } from "../lib/workflow-apply.js";
9
14
  import { confirmPrompt } from "../lib/confirm-prompt.js";
10
15
  import chalk from "chalk";
11
16
  import { success, error, printApiError, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
@@ -88,6 +93,75 @@ export function renderRunStatusStepResults(stepResults) {
88
93
  }
89
94
  return lines;
90
95
  }
96
+ /**
97
+ * #1367 — annotate step-run rows with the inter-step gap: the wall-clock time
98
+ * between the previous step's `endedAt` and this step's `startedAt`. Sorts by
99
+ * `startedAt` when every row has a parseable one (tie-break `stepIndex`),
100
+ * otherwise by `stepIndex` for the whole list — the ordering mode must be
101
+ * chosen once for all rows, not per pair: a per-pair timestamp-vs-index
102
+ * fallback is not transitive when some rows lack a timestamp (A<B by index,
103
+ * B<C by index, C<A by time), and a non-transitive comparator leaves
104
+ * Array.prototype.sort free to return an arbitrary order, computing gaps
105
+ * against the wrong predecessor. The first step has no predecessor, so its
106
+ * `gapMs` is `null`. A gap is only meaningful when both adjacent timestamps
107
+ * parse and the gap is positive. When the immediate predecessor lacks a valid
108
+ * `endedAt`, the gap is left `null` rather than measured from an older,
109
+ * non-adjacent step — otherwise a later step could raise a false stall
110
+ * warning against a step it does not actually follow. Exported for
111
+ * testability.
112
+ */
113
+ export function annotateStepGaps(steps) {
114
+ const rows = [...(steps || [])];
115
+ const allTimed = rows.every((r) => r?.startedAt && !Number.isNaN(new Date(r.startedAt).getTime()));
116
+ rows.sort((a, b) => {
117
+ if (allTimed) {
118
+ const sa = new Date(a.startedAt).getTime();
119
+ const sb = new Date(b.startedAt).getTime();
120
+ if (sa !== sb)
121
+ return sa - sb;
122
+ }
123
+ return (a?.stepIndex ?? 0) - (b?.stepIndex ?? 0);
124
+ });
125
+ let prevEnded = null;
126
+ for (const row of rows) {
127
+ const started = row?.startedAt ? new Date(row.startedAt).getTime() : NaN;
128
+ if (prevEnded != null && !Number.isNaN(started)) {
129
+ const gap = started - prevEnded;
130
+ row.gapMs = gap > 0 ? gap : 0;
131
+ }
132
+ else {
133
+ row.gapMs = null;
134
+ }
135
+ // Only genuinely adjacent steps yield a gap: if this step lacks a valid
136
+ // `endedAt`, clear the predecessor so the NEXT step's gap is left null
137
+ // instead of being measured from a non-adjacent earlier step.
138
+ const ended = row?.endedAt ? new Date(row.endedAt).getTime() : NaN;
139
+ prevEnded = Number.isNaN(ended) ? null : ended;
140
+ }
141
+ return rows;
142
+ }
143
+ /**
144
+ * #1367 — compute min / p50 / p95 / max over a set of latency samples (ms).
145
+ * Ignores non-finite values. Returns `null` when there are no valid samples.
146
+ * Percentiles use nearest-rank on the sorted samples. Exported for testability.
147
+ */
148
+ export function summarizeLatencyDistribution(values) {
149
+ const nums = (values || []).filter((v) => typeof v === "number" && Number.isFinite(v));
150
+ if (nums.length === 0)
151
+ return null;
152
+ nums.sort((a, b) => a - b);
153
+ const pct = (p) => {
154
+ const rank = Math.ceil((p / 100) * nums.length);
155
+ return nums[Math.min(nums.length - 1, Math.max(0, rank - 1))];
156
+ };
157
+ return {
158
+ count: nums.length,
159
+ min: nums[0],
160
+ p50: pct(50),
161
+ p95: pct(95),
162
+ max: nums[nums.length - 1],
163
+ };
164
+ }
91
165
  /**
92
166
  * Issue #687 (review feedback): render an unambiguous label for a workflow
93
167
  * config slot ("active config", "draft config", etc.) when the slot may be
@@ -177,7 +251,7 @@ Examples:
177
251
  let tomlData;
178
252
  try {
179
253
  const content = readFileSync(options.fromFile, "utf-8");
180
- tomlData = TOML.parse(content);
254
+ tomlData = parseConfigToml(content);
181
255
  }
182
256
  catch (err) {
183
257
  error(`Failed to read TOML file: ${err.message}`);
@@ -194,39 +268,15 @@ Examples:
194
268
  process.exit(1);
195
269
  }
196
270
  const workflow = tomlData.workflow || tomlData;
271
+ // #1177: build the `[workflow]`-derived fields through the shared
272
+ // builder so this create path can't drift from `sync push` again
273
+ // (`status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`, `runAs`,
274
+ // `accessRule`, `syncCallable` all flow from one field list). Structural
275
+ // fields (`workflowKey`, `steps`) are attached here.
197
276
  payload = {
198
277
  workflowKey: workflow.key || workflow.workflowKey,
199
- name: workflow.name,
200
- description: workflow.description,
201
278
  steps: tomlData.steps || [],
202
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
203
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
204
- perUserMaxRunning: workflow.perUserMaxRunning,
205
- perUserMaxQueued: workflow.perUserMaxQueued,
206
- perAppMaxRunning: workflow.perAppMaxRunning,
207
- perAppMaxQueued: workflow.perAppMaxQueued,
208
- queueTtlSeconds: workflow.queueTtlSeconds,
209
- dequeueOrder: workflow.dequeueOrder,
210
- requiresClientApply: workflow.requiresClientApply,
211
- // #807: align create --from-file with sync/update — read syncCallable
212
- // from the TOML so all three entry points behave identically.
213
- syncCallable: workflow.syncCallable,
214
- // #571: carry accessRule through so a TOML's access-control rule
215
- // lands on create instead of being silently dropped. On create the
216
- // server default is already null, so coalesce a falsy/absent rule to
217
- // undefined (omit) rather than sending "" — both clear, but undefined
218
- // is the precise "no rule declared" signal.
219
- accessRule: workflow.accessRule || undefined,
220
- // #1173: forward the workflow principal mode (`runAs`) from the TOML,
221
- // matching `sync push` create (sync.ts ~3345). Without this, a
222
- // `runAs="system"` TOML reached the server as non-system, so the
223
- // identity validator correctly rejected system-only steps (e.g.
224
- // `iterate-users`). Create semantics — `workflow.runAs || undefined`
225
- // (absent/empty → server default), NOT the presence-aware clearing
226
- // form that `sync push` update uses. `capabilities` is intentionally
227
- // NOT forwarded: `sync push` doesn't serialize/persist it today, so
228
- // forwarding it only here would create a new asymmetry (see #1173).
229
- runAs: workflow.runAs || undefined,
279
+ ...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
230
280
  };
231
281
  }
232
282
  else {
@@ -268,7 +318,7 @@ Examples:
268
318
  workflows
269
319
  .command("get")
270
320
  .description("Get workflow details")
271
- .argument("<workflow-id>", "Workflow ID")
321
+ .argument("<workflow-id>", "Workflow ID or key")
272
322
  .option("--app <app-id>", "App ID (uses current app if not specified)")
273
323
  .option("--json", "Output as JSON")
274
324
  .action(async (workflowId, options) => {
@@ -290,6 +340,12 @@ Examples:
290
340
  printResult("Latest Revision", wf.latestRevision || "-");
291
341
  printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
292
342
  printResult("Sync Callable", wf.syncCallable === true ? "yes" : "no");
343
+ // #1233 — runAs unset means the engine defaults to "caller"; show that
344
+ // default rather than a blank so the deployed principal mode is visible.
345
+ printResult("Run As", wf.runAs || "caller");
346
+ printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
347
+ ? wf.capabilities.join(", ")
348
+ : "-");
293
349
  if (wf.accessRule) {
294
350
  printResult("Access Rule", wf.accessRule);
295
351
  }
@@ -342,8 +398,8 @@ Examples:
342
398
  // Update workflow
343
399
  workflows
344
400
  .command("update")
345
- .description("Update workflow metadata")
346
- .argument("<workflow-id>", "Workflow ID")
401
+ .description("Update workflow metadata, or push a new body with --from-file")
402
+ .argument("<workflow-id>", "Workflow ID or key")
347
403
  .option("--app <app-id>", "App ID (uses current app if not specified)")
348
404
  .option("--name <name>", "Display name")
349
405
  .option("--description <desc>", "Description")
@@ -354,9 +410,117 @@ Examples:
354
410
  .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
355
411
  .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
356
412
  .option("--access-rule <expr>", "CEL expression for workflow access (empty string to clear)")
413
+ .option("--capabilities <list>", "Comma-separated capability grants (e.g. membership,secrets); empty string revokes all")
414
+ .option("--from-file <path>", "Push a revised body (metadata + steps) from a TOML file, keeping the same key/id. " +
415
+ "Overwrites the active config in place — live immediately (no separate publish). " +
416
+ "Explicit metadata flags above override values in the TOML.")
357
417
  .option("--json", "Output as JSON")
358
418
  .action(async (workflowId, options) => {
359
419
  const resolvedAppId = resolveAppId(undefined, options);
420
+ // --from-file: push a full body (metadata + steps) to the existing
421
+ // workflow, keeping its key/id. Symmetric with `create --from-file` and
422
+ // mirrors the `sync push` update-existing path via the shared
423
+ // `applyWorkflowBody` helper (#1249).
424
+ if (options.fromFile) {
425
+ let tomlData;
426
+ try {
427
+ // Expand top-level `include = [...]` workflow fragments before
428
+ // deriving steps, matching what `sync push` does (`parseTomlFile`
429
+ // runs `expandWorkflowTomlData`). `update --from-file` overwrites an
430
+ // existing workflow's live active config, so parsing the raw TOML
431
+ // and reading only `tomlData.steps` would drop fragment-supplied
432
+ // steps — an include-only workflow would overwrite the live body
433
+ // with an empty step list (silent data loss, #1286). `expandWorkflow`
434
+ // reads + parses + splices fragments; a TOML with no `include` key is
435
+ // returned unchanged, so the non-fragment case is unaffected.
436
+ tomlData = expandWorkflow(options.fromFile);
437
+ }
438
+ catch (err) {
439
+ error(`Failed to read TOML file: ${err.message}`);
440
+ process.exit(1);
441
+ }
442
+ // Issue #685: reject misnested headers before sending anything to the
443
+ // server — identical validation to `create --from-file`. Runs on the
444
+ // expanded data (post fragment splice), matching `sync push`.
445
+ const tomlErrors = validateWorkflowToml(tomlData);
446
+ if (tomlErrors.length > 0) {
447
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
448
+ process.exit(1);
449
+ }
450
+ const tomlWorkflow = tomlData.workflow || tomlData;
451
+ const steps = tomlData.steps || [];
452
+ const client = new ApiClient();
453
+ // Fetch the existing workflow: we need its real workflowKey (Fork 2
454
+ // key-mismatch guard) and its activeConfigId (live body overwrite).
455
+ let existing;
456
+ try {
457
+ const got = await client.getWorkflow(resolvedAppId, workflowId);
458
+ existing = got?.workflow;
459
+ }
460
+ catch (err) {
461
+ printApiError(err);
462
+ process.exit(1);
463
+ }
464
+ if (!existing) {
465
+ error(`Workflow not found: ${workflowId}`);
466
+ process.exit(1);
467
+ }
468
+ // Fork 2: a TOML key that contradicts the target's key is almost
469
+ // certainly the wrong file — error rather than silently ignore it.
470
+ const mismatch = workflowKeyMismatchError(tomlWorkflow, existing.workflowKey);
471
+ if (mismatch) {
472
+ error(mismatch);
473
+ process.exit(1);
474
+ }
475
+ // Fork 1 (B): explicit `update` flags override TOML-derived metadata.
476
+ const mergedWorkflow = { ...tomlWorkflow };
477
+ if (options.name)
478
+ mergedWorkflow.name = options.name;
479
+ if (options.description)
480
+ mergedWorkflow.description = options.description;
481
+ if (options.status)
482
+ mergedWorkflow.status = options.status;
483
+ if (options.perUserMaxRunning) {
484
+ mergedWorkflow.perUserMaxRunning = parseInt(options.perUserMaxRunning);
485
+ }
486
+ if (options.perUserMaxQueued) {
487
+ mergedWorkflow.perUserMaxQueued = parseInt(options.perUserMaxQueued);
488
+ }
489
+ if (options.dequeueOrder)
490
+ mergedWorkflow.dequeueOrder = options.dequeueOrder;
491
+ if (options.requiresClientApply !== undefined) {
492
+ mergedWorkflow.requiresClientApply = options.requiresClientApply === "true";
493
+ }
494
+ if (options.syncCallable !== undefined) {
495
+ mergedWorkflow.syncCallable = options.syncCallable === "true";
496
+ }
497
+ if (options.accessRule !== undefined) {
498
+ mergedWorkflow.accessRule = options.accessRule;
499
+ }
500
+ // #1232: an explicit --capabilities flag overrides whatever the TOML
501
+ // declares (grant/revoke on top of a --from-file body push).
502
+ if (options.capabilities !== undefined) {
503
+ mergedWorkflow.capabilities = parseCapabilitiesFlag(options.capabilities);
504
+ }
505
+ try {
506
+ const { updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, mergedWorkflow, steps, { activeConfigId: existing.activeConfigId });
507
+ if (options.json) {
508
+ json(fullWorkflow);
509
+ return;
510
+ }
511
+ success(`Workflow updated from ${options.fromFile} (${updateSlotLabel}).`);
512
+ keyValue("Workflow ID", workflowId);
513
+ keyValue("Key", existing.workflowKey);
514
+ keyValue("Steps", steps.length);
515
+ }
516
+ catch (err) {
517
+ // Surface the server's structured `details[]` (e.g. syncCallable /
518
+ // identity validation reasons) like create/sync push do.
519
+ printApiError(err);
520
+ process.exit(1);
521
+ }
522
+ return;
523
+ }
360
524
  const payload = {};
361
525
  if (options.name)
362
526
  payload.name = options.name;
@@ -383,6 +547,14 @@ Examples:
383
547
  if (options.accessRule !== undefined) {
384
548
  payload.accessRule = options.accessRule;
385
549
  }
550
+ // #1232: the CLI fallback the issue asked for — grant/revoke a workflow's
551
+ // sensitive `capabilities` (e.g. `membership`) on an existing workflow.
552
+ // Presence-guarded like --access-rule: an omitted flag stays undefined so
553
+ // the server's hasOwnProperty guard leaves the existing grant untouched;
554
+ // `--capabilities ""` sends [] to revoke. The server validates the names.
555
+ if (options.capabilities !== undefined) {
556
+ payload.capabilities = parseCapabilitiesFlag(options.capabilities);
557
+ }
386
558
  if (Object.keys(payload).length === 0) {
387
559
  error("No update options specified.");
388
560
  process.exit(1);
@@ -406,7 +578,7 @@ Examples:
406
578
  workflows
407
579
  .command("delete")
408
580
  .description("Delete or archive a workflow")
409
- .argument("<workflow-id>", "Workflow ID")
581
+ .argument("<workflow-id>", "Workflow ID or key")
410
582
  .option("--app <app-id>", "App ID (uses current app if not specified)")
411
583
  .option("--hard", "Permanently delete instead of archive")
412
584
  .option("-y, --yes", "Skip confirmation prompt")
@@ -429,6 +601,10 @@ Examples:
429
601
  else {
430
602
  await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
431
603
  success("Workflow archived.");
604
+ // #1238 — archiving keeps the workflowKey reserved, so recreating
605
+ // under the same key fails with "workflowKey already exists". Point
606
+ // at the remedy so the reserved-key behavior is discoverable.
607
+ info(`The workflowKey stays reserved while archived. To free it for reuse, run: primitive workflows delete ${workflowId} --hard`);
432
608
  }
433
609
  }
434
610
  catch (err) {
@@ -452,7 +628,7 @@ Examples:
452
628
  if (options.format === "toml") {
453
629
  // Strip the `include` key (already removed by expander) and emit
454
630
  // valid TOML so the result is round-trippable.
455
- console.log(TOML.stringify(expanded));
631
+ console.log(stringifyConfigToml(expanded));
456
632
  }
457
633
  else {
458
634
  console.log(JSON.stringify(expanded, null, 2));
@@ -469,7 +645,7 @@ Examples:
469
645
  draft
470
646
  .command("update")
471
647
  .description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
472
- .argument("<workflow-id>", "Workflow ID")
648
+ .argument("<workflow-id>", "Workflow ID or key")
473
649
  .option("--app <app-id>", "App ID (uses current app if not specified)")
474
650
  .option("--from-file <path>", "Load steps from TOML file")
475
651
  .option("--json", "Output as JSON")
@@ -482,7 +658,7 @@ Examples:
482
658
  let tomlData;
483
659
  try {
484
660
  const content = readFileSync(options.fromFile, "utf-8");
485
- tomlData = TOML.parse(content);
661
+ tomlData = parseConfigToml(content);
486
662
  }
487
663
  catch (err) {
488
664
  error(`Failed to read TOML file: ${err.message}`);
@@ -538,7 +714,7 @@ Examples:
538
714
  workflows
539
715
  .command("publish")
540
716
  .description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
541
- .argument("<workflow-id>", "Workflow ID")
717
+ .argument("<workflow-id>", "Workflow ID or key")
542
718
  .option("--app <app-id>", "App ID (uses current app if not specified)")
543
719
  .option("--json", "Output as JSON")
544
720
  .action(async (workflowId, options) => {
@@ -582,7 +758,7 @@ Examples:
582
758
  workflows
583
759
  .command("preview")
584
760
  .description("Run a preview execution of the workflow")
585
- .argument("<workflow-id>", "Workflow ID")
761
+ .argument("<workflow-id>", "Workflow ID or key")
586
762
  .option("--app <app-id>", "App ID (uses current app if not specified)")
587
763
  .option("--config <config-id>", "Use specific configuration")
588
764
  .option("--draft", "Force preview of the draft version, even if active is newer")
@@ -731,13 +907,195 @@ Examples:
731
907
  process.exit(1);
732
908
  }
733
909
  });
910
+ // #1367 — hidden queue-latency measurement tool. Fires a batch of runs of a
911
+ // (trivial, no-op) workflow, polls until each has an executionStartedAt, and
912
+ // reports the observed queueDelayMs / createCallDurationMs distribution. This
913
+ // is the post-deploy verification vehicle: local miniflare does not exhibit
914
+ // real Cloudflare scheduling delay, so run it against an agents/alpha scratch
915
+ // app. Creates are paced (default 10/s) to stay clear of CF's ~100/s shared
916
+ // instance-creation limit; a create failure (e.g. 429) is recorded, not fatal.
917
+ workflows
918
+ .command("queue-latency-test", { hidden: true })
919
+ .description("(hidden) Fire N no-op runs and report the queue-delay distribution (post-deploy tool)")
920
+ .argument("<workflow-id>", "Workflow ID or key of a trivial no-op workflow")
921
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
922
+ .option("--count <n>", "Number of runs to fire", "50")
923
+ .option("--rate <r>", "Max creates per second (<=10 recommended)", "10")
924
+ .option("--input <json>", "Root input as JSON")
925
+ .option("--timeout <sec>", "Seconds to wait for all runs to start", "120")
926
+ .option("--verbose", "Print a per-run row")
927
+ .option("--json", "Output as JSON")
928
+ .action(async (workflowId, options) => {
929
+ const resolvedAppId = resolveAppId(undefined, options);
930
+ const client = new ApiClient();
931
+ const count = Math.max(1, parseInt(options.count, 10) || 50);
932
+ const rate = Math.max(1, parseInt(options.rate, 10) || 10);
933
+ const timeoutSec = Math.max(1, parseInt(options.timeout, 10) || 120);
934
+ const intervalMs = Math.ceil(1000 / rate);
935
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
936
+ let rootInput;
937
+ if (options.input) {
938
+ try {
939
+ rootInput = JSON.parse(options.input);
940
+ }
941
+ catch {
942
+ error("Invalid JSON in --input");
943
+ process.exit(1);
944
+ }
945
+ }
946
+ const fired = [];
947
+ if (!options.json) {
948
+ info(`Firing ${count} run(s) of ${workflowId} at up to ${rate}/s (interval ${intervalMs}ms)...`);
949
+ }
950
+ for (let i = 0; i < count; i++) {
951
+ try {
952
+ const res = await client.previewWorkflow(resolvedAppId, workflowId, {
953
+ rootInput,
954
+ });
955
+ fired.push({
956
+ index: i,
957
+ instanceId: res?.instanceId,
958
+ createFailed: false,
959
+ started: false,
960
+ });
961
+ }
962
+ catch (err) {
963
+ // A create failure (e.g. HTTP 500 from CF's shared-class rate limit) is
964
+ // a recorded datapoint, not a reason to abort the whole batch.
965
+ fired.push({
966
+ index: i,
967
+ createFailed: true,
968
+ createError: err?.message ? String(err.message) : String(err),
969
+ started: false,
970
+ });
971
+ }
972
+ if (i < count - 1)
973
+ await sleep(intervalMs);
974
+ }
975
+ // Poll the run list until every non-failed run has executionStartedAt set
976
+ // (queueDelayMs present) or we hit the timeout.
977
+ const byInstance = new Map();
978
+ for (const f of fired) {
979
+ if (f.instanceId)
980
+ byInstance.set(f.instanceId, f);
981
+ }
982
+ const deadline = Date.now() + timeoutSec * 1000;
983
+ const pending = () => fired.filter((f) => !f.createFailed && !f.started).length;
984
+ // The admin run-list endpoint caps each page at 200, so a single large
985
+ // request cannot cover a batch of more than 200 runs — the earliest ones
986
+ // would never be matched and would be falsely reported as not started.
987
+ // Page through with the cursor (runs come back newest-first, so our batch
988
+ // is at the front) until every pending run is matched or we run out of
989
+ // pages. Bound the scan so we never walk the whole run history.
990
+ const PAGE_LIMIT = 200;
991
+ const scanBudget = count + PAGE_LIMIT;
992
+ while (pending() > 0 && Date.now() < deadline) {
993
+ await sleep(1000);
994
+ try {
995
+ let cursor;
996
+ let scanned = 0;
997
+ do {
998
+ const { items, cursor: nextCursor } = await client.listWorkflowRuns(resolvedAppId, workflowId, { limit: PAGE_LIMIT, cursor: cursor ?? undefined });
999
+ for (const run of items || []) {
1000
+ const f = run?.instanceId ? byInstance.get(run.instanceId) : undefined;
1001
+ if (f && !f.started && run.executionStartedAt) {
1002
+ f.started = true;
1003
+ f.queueDelayMs =
1004
+ typeof run.queueDelayMs === "number" ? run.queueDelayMs : null;
1005
+ f.createCallDurationMs =
1006
+ typeof run.createCallDurationMs === "number"
1007
+ ? run.createCallDurationMs
1008
+ : null;
1009
+ }
1010
+ }
1011
+ scanned += items?.length || 0;
1012
+ cursor = nextCursor;
1013
+ } while (pending() > 0 && cursor && scanned < scanBudget);
1014
+ }
1015
+ catch {
1016
+ // Transient list error — keep polling until the deadline.
1017
+ }
1018
+ }
1019
+ const createFailures = fired.filter((f) => f.createFailed).length;
1020
+ const startedRuns = fired.filter((f) => f.started);
1021
+ const queueDelays = startedRuns.map((f) => f.queueDelayMs);
1022
+ const createDurations = startedRuns.map((f) => f.createCallDurationMs);
1023
+ const queueDist = summarizeLatencyDistribution(queueDelays);
1024
+ const createDist = summarizeLatencyDistribution(createDurations);
1025
+ const notStarted = fired.filter((f) => !f.createFailed && !f.started).length;
1026
+ if (options.json) {
1027
+ json({
1028
+ workflowId,
1029
+ requested: count,
1030
+ created: count - createFailures,
1031
+ createFailures,
1032
+ started: startedRuns.length,
1033
+ notStartedWithinTimeout: notStarted,
1034
+ queueDelayMs: queueDist,
1035
+ createCallDurationMs: createDist,
1036
+ runs: options.verbose
1037
+ ? fired.map((f) => ({
1038
+ index: f.index,
1039
+ instanceId: f.instanceId ?? null,
1040
+ createFailed: f.createFailed,
1041
+ createError: f.createError ?? null,
1042
+ started: f.started,
1043
+ queueDelayMs: f.queueDelayMs ?? null,
1044
+ createCallDurationMs: f.createCallDurationMs ?? null,
1045
+ }))
1046
+ : undefined,
1047
+ });
1048
+ return;
1049
+ }
1050
+ divider();
1051
+ keyValue("Requested", count);
1052
+ keyValue("Created", count - createFailures);
1053
+ keyValue("Create failures", createFailures);
1054
+ keyValue("Started", startedRuns.length);
1055
+ keyValue("Not started (timeout)", notStarted);
1056
+ divider();
1057
+ if (queueDist) {
1058
+ info("Queue delay (executionStartedAt - requested):");
1059
+ keyValue(" min", formatDuration(queueDist.min));
1060
+ keyValue(" p50", formatDuration(queueDist.p50));
1061
+ keyValue(" p95", formatDuration(queueDist.p95));
1062
+ keyValue(" max", formatDuration(queueDist.max));
1063
+ }
1064
+ else {
1065
+ warn("No runs started — no queue-delay samples collected.");
1066
+ }
1067
+ if (createDist) {
1068
+ divider();
1069
+ info("Create-call duration:");
1070
+ keyValue(" min", formatDuration(createDist.min));
1071
+ keyValue(" p50", formatDuration(createDist.p50));
1072
+ keyValue(" p95", formatDuration(createDist.p95));
1073
+ keyValue(" max", formatDuration(createDist.max));
1074
+ }
1075
+ if (options.verbose) {
1076
+ divider();
1077
+ console.log(formatTable(fired.map((f) => ({
1078
+ run: f.index,
1079
+ instanceId: f.instanceId ?? "-",
1080
+ status: f.createFailed ? "create-failed" : f.started ? "started" : "pending",
1081
+ queueDelayMs: f.queueDelayMs,
1082
+ createCallDurationMs: f.createCallDurationMs,
1083
+ })), [
1084
+ { header: "RUN", key: "run" },
1085
+ { header: "INSTANCE", key: "instanceId", format: formatId },
1086
+ { header: "STATE", key: "status" },
1087
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
1088
+ { header: "CREATE", key: "createCallDurationMs", format: formatDuration },
1089
+ ]));
1090
+ }
1091
+ });
734
1092
  // Runs subcommand
735
1093
  const runs = workflows.command("runs").description("Manage workflow runs");
736
1094
  // List runs
737
1095
  runs
738
1096
  .command("list")
739
1097
  .description("List workflow runs")
740
- .argument("<workflow-id>", "Workflow ID")
1098
+ .argument("<workflow-id>", "Workflow ID or key")
741
1099
  .option("--app <app-id>", "App ID (uses current app if not specified)")
742
1100
  .option("--status <status>", "Filter by status: pending, running, completed, failed")
743
1101
  .option("--limit <n>", "Number of runs to show", "20")
@@ -762,6 +1120,8 @@ Examples:
762
1120
  { header: "RUN ID", key: "runId", format: formatId },
763
1121
  { header: "STATUS", key: "status", format: formatStatus },
764
1122
  { header: "STARTED", key: "startedAt", format: formatDate },
1123
+ // #1367 — queue delay (requested → execution start).
1124
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
765
1125
  { header: "ENDED", key: "endedAt", format: formatDate },
766
1126
  { header: "PREVIEW", key: "isPreview", format: (v) => v ? "yes" : "" },
767
1127
  ]));
@@ -775,7 +1135,7 @@ Examples:
775
1135
  runs
776
1136
  .command("status")
777
1137
  .description("Get status of a workflow run")
778
- .argument("<workflow-id>", "Workflow ID")
1138
+ .argument("<workflow-id>", "Workflow ID or key")
779
1139
  .argument("<run-id>", "Run ID")
780
1140
  .option("--app <app-id>", "App ID (uses current app if not specified)")
781
1141
  .option("--json", "Output as JSON")
@@ -791,7 +1151,13 @@ Examples:
791
1151
  const run = result.run;
792
1152
  keyValue("Run ID", run.runId);
793
1153
  keyValue("Status", formatStatus(run.status));
794
- keyValue("Started", formatDate(run.startedAt));
1154
+ // #1367 — `startedAt` is the request time; `executionStartedAt` is when
1155
+ // Cloudflare actually started running the instance. Queue delay is the
1156
+ // gap. A still-queued run shows "-" for execution start / queue delay.
1157
+ keyValue("Requested", formatDate(run.startedAt));
1158
+ keyValue("Execution started", formatDate(run.executionStartedAt));
1159
+ keyValue("Queue delay", formatDuration(run.queueDelayMs));
1160
+ keyValue("Create call", formatDuration(run.createCallDurationMs));
795
1161
  keyValue("Ended", formatDate(run.endedAt));
796
1162
  keyValue("Preview", run.isPreview ? "yes" : "no");
797
1163
  if (result.instanceStatus) {
@@ -824,7 +1190,7 @@ Examples:
824
1190
  runs
825
1191
  .command("steps")
826
1192
  .description("Show step-level details for a workflow run")
827
- .argument("<workflow-id>", "Workflow ID")
1193
+ .argument("<workflow-id>", "Workflow ID or key")
828
1194
  .argument("<run-id>", "Run ID")
829
1195
  .option("--app <app-id>", "App ID")
830
1196
  .option("--json", "Output as JSON")
@@ -841,7 +1207,12 @@ Examples:
841
1207
  info("No step runs found.");
842
1208
  return;
843
1209
  }
844
- console.log(formatTable(items, [
1210
+ // #1367 — inter-step gap: idle time between one step ending and the
1211
+ // next starting. Only surfaced above a 1s threshold so normal
1212
+ // back-to-back steps stay quiet; a large gap flags a mid-run stall.
1213
+ const STEP_GAP_THRESHOLD_MS = 1000;
1214
+ const rows = annotateStepGaps(items);
1215
+ console.log(formatTable(rows, [
845
1216
  { header: "STEP", key: "stepId" },
846
1217
  { header: "KIND", key: "stepKind" },
847
1218
  {
@@ -852,6 +1223,13 @@ Examples:
852
1223
  v === "skipped" ? chalk.gray(v) :
853
1224
  v === "error_captured" ? chalk.yellow(v) : v,
854
1225
  },
1226
+ {
1227
+ header: "GAP",
1228
+ key: "gapMs",
1229
+ format: (v) => typeof v === "number" && v >= STEP_GAP_THRESHOLD_MS
1230
+ ? chalk.yellow(formatDuration(v))
1231
+ : chalk.dim("-"),
1232
+ },
855
1233
  { header: "DURATION", key: "durationMs", format: formatDuration },
856
1234
  {
857
1235
  header: "TOKENS",
@@ -874,7 +1252,7 @@ Examples:
874
1252
  runs
875
1253
  .command("step-detail")
876
1254
  .description("Show full details for a single step")
877
- .argument("<workflow-id>", "Workflow ID")
1255
+ .argument("<workflow-id>", "Workflow ID or key")
878
1256
  .argument("<run-id>", "Run ID")
879
1257
  .argument("<step-id>", "Step ID")
880
1258
  .option("--app <app-id>", "App ID")
@@ -996,7 +1374,7 @@ Examples:
996
1374
  runs
997
1375
  .command("error")
998
1376
  .description("Show error details for a failed workflow run")
999
- .argument("<workflow-id>", "Workflow ID")
1377
+ .argument("<workflow-id>", "Workflow ID or key")
1000
1378
  .argument("<run-id>", "Run ID")
1001
1379
  .option("--app <app-id>", "App ID")
1002
1380
  .option("--json", "Output as JSON")
@@ -1065,7 +1443,7 @@ Examples:
1065
1443
  runs
1066
1444
  .command("failures")
1067
1445
  .description("List recent workflow run failures")
1068
- .argument("<workflow-id>", "Workflow ID")
1446
+ .argument("<workflow-id>", "Workflow ID or key")
1069
1447
  .option("--app <app-id>", "App ID")
1070
1448
  .option("--limit <n>", "Number of failures to show", "10")
1071
1449
  .option("--json", "Output as JSON")
@@ -1097,6 +1475,111 @@ Examples:
1097
1475
  process.exit(1);
1098
1476
  }
1099
1477
  });
1478
+ // ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
1479
+ const iterations = workflows
1480
+ .command("iterations")
1481
+ .description("Inspect and reset iterate-users iterations");
1482
+ iterations
1483
+ .command("list")
1484
+ .description("List iterate-users iterations for the app")
1485
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1486
+ .option("--json", "Output as JSON")
1487
+ .action(async (options) => {
1488
+ const resolvedAppId = resolveAppId(undefined, options);
1489
+ const client = new ApiClient();
1490
+ try {
1491
+ const { items } = await client.listIterations(resolvedAppId);
1492
+ if (options.json) {
1493
+ json(items);
1494
+ return;
1495
+ }
1496
+ if (!items || items.length === 0) {
1497
+ info("No iterations found.");
1498
+ return;
1499
+ }
1500
+ console.log(formatTable(items, [
1501
+ { header: "ITERATION", key: "iterationName" },
1502
+ { header: "STATUS", key: "status", format: formatStatus },
1503
+ { header: "ACQUIRED", key: "lastAcquireMode", format: (v) => v || "" },
1504
+ { header: "PROCESSED", key: "totalProcessed" },
1505
+ { header: "FAILED", key: "failedCount" },
1506
+ { header: "LAST RUN", key: "lastRunAt", format: formatDate },
1507
+ ]));
1508
+ }
1509
+ catch (err) {
1510
+ error(err.message);
1511
+ process.exit(1);
1512
+ }
1513
+ });
1514
+ iterations
1515
+ .command("get")
1516
+ .description("Show details for a single iterate-users iteration")
1517
+ .argument("<name>", "Iteration name")
1518
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1519
+ .option("--json", "Output as JSON")
1520
+ .action(async (name, options) => {
1521
+ const resolvedAppId = resolveAppId(undefined, options);
1522
+ const client = new ApiClient();
1523
+ try {
1524
+ const it = await client.getIteration(resolvedAppId, name);
1525
+ if (options.json) {
1526
+ json(it);
1527
+ return;
1528
+ }
1529
+ keyValue("Iteration", it.iterationName);
1530
+ keyValue("Status", formatStatus(it.status));
1531
+ keyValue("Acquired as", it.lastAcquireMode || "(unknown)");
1532
+ keyValue("Run ID", it.runId);
1533
+ keyValue("Instance ID", it.instanceId);
1534
+ keyValue("Started", formatDate(it.startedAt));
1535
+ keyValue("Last run", formatDate(it.lastRunAt));
1536
+ keyValue("Lock expires", formatDate(it.lockExpiresAt));
1537
+ keyValue("Processed", String(it.totalProcessed ?? 0));
1538
+ keyValue("Succeeded", String(it.succeededCount ?? 0));
1539
+ keyValue("Failed (total)", String(it.failedCount ?? 0));
1540
+ keyValue("Skipped", String(it.skippedCount ?? 0));
1541
+ if (it.lastError) {
1542
+ keyValue("Last error", it.lastError);
1543
+ }
1544
+ const sample = Array.isArray(it.failedUserIdsSample)
1545
+ ? it.failedUserIdsSample
1546
+ : [];
1547
+ if (sample.length > 0) {
1548
+ const label = it.failedUserIdsTruncated
1549
+ ? `Failed user IDs (sample, truncated; ${it.failedCount} total)`
1550
+ : `Failed user IDs (sample)`;
1551
+ keyValue(label, sample.join(", "));
1552
+ }
1553
+ }
1554
+ catch (err) {
1555
+ error(err.message);
1556
+ process.exit(1);
1557
+ }
1558
+ });
1559
+ iterations
1560
+ .command("reset")
1561
+ .description("Reset a terminal (completed/failed) iteration so the next trigger runs fresh")
1562
+ .argument("<name>", "Iteration name")
1563
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1564
+ .option("--json", "Output as JSON")
1565
+ .action(async (name, options) => {
1566
+ const resolvedAppId = resolveAppId(undefined, options);
1567
+ const client = new ApiClient();
1568
+ try {
1569
+ const res = await client.resetIteration(resolvedAppId, name);
1570
+ if (options.json) {
1571
+ json(res);
1572
+ return;
1573
+ }
1574
+ success(`Reset iteration "${name}" (was ${res.previousStatus}). The next trigger will run fresh.`);
1575
+ }
1576
+ catch (err) {
1577
+ // A running iteration is refused with 409 + lock metadata — surface a
1578
+ // clear message rather than a raw error.
1579
+ error(err.message);
1580
+ process.exit(1);
1581
+ }
1582
+ });
1100
1583
  // Analytics overview
1101
1584
  const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
1102
1585
  analyticsCmd
@@ -1189,7 +1672,7 @@ Examples:
1189
1672
  configs
1190
1673
  .command("list")
1191
1674
  .description("List configurations for a workflow")
1192
- .argument("<workflow-id>", "Workflow ID")
1675
+ .argument("<workflow-id>", "Workflow ID or key")
1193
1676
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1194
1677
  .option("--json", "Output as JSON")
1195
1678
  .action(async (workflowId, options) => {
@@ -1229,7 +1712,7 @@ Examples:
1229
1712
  configs
1230
1713
  .command("get")
1231
1714
  .description("Get configuration details")
1232
- .argument("<workflow-id>", "Workflow ID")
1715
+ .argument("<workflow-id>", "Workflow ID or key")
1233
1716
  .argument("<config-id>", "Configuration ID")
1234
1717
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1235
1718
  .option("--json", "Output as JSON")
@@ -1265,7 +1748,7 @@ Examples:
1265
1748
  configs
1266
1749
  .command("create")
1267
1750
  .description("Create a new configuration")
1268
- .argument("<workflow-id>", "Workflow ID")
1751
+ .argument("<workflow-id>", "Workflow ID or key")
1269
1752
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1270
1753
  .option("--name <name>", "Configuration name (required)")
1271
1754
  .option("--description <desc>", "Description")
@@ -1282,7 +1765,7 @@ Examples:
1282
1765
  let tomlData;
1283
1766
  try {
1284
1767
  const content = readFileSync(options.fromFile, "utf-8");
1285
- tomlData = TOML.parse(content);
1768
+ tomlData = parseConfigToml(content);
1286
1769
  }
1287
1770
  catch (err) {
1288
1771
  error(`Failed to read TOML file: ${err.message}`);
@@ -1319,7 +1802,7 @@ Examples:
1319
1802
  configs
1320
1803
  .command("update")
1321
1804
  .description("Update a configuration")
1322
- .argument("<workflow-id>", "Workflow ID")
1805
+ .argument("<workflow-id>", "Workflow ID or key")
1323
1806
  .argument("<config-id>", "Configuration ID")
1324
1807
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1325
1808
  .option("--name <name>", "Configuration name")
@@ -1337,7 +1820,7 @@ Examples:
1337
1820
  let tomlData;
1338
1821
  try {
1339
1822
  const content = readFileSync(options.fromFile, "utf-8");
1340
- tomlData = TOML.parse(content);
1823
+ tomlData = parseConfigToml(content);
1341
1824
  }
1342
1825
  catch (err) {
1343
1826
  error(`Failed to read TOML file: ${err.message}`);
@@ -1375,7 +1858,7 @@ Examples:
1375
1858
  configs
1376
1859
  .command("activate")
1377
1860
  .description("Set a configuration as the active (default) configuration")
1378
- .argument("<workflow-id>", "Workflow ID")
1861
+ .argument("<workflow-id>", "Workflow ID or key")
1379
1862
  .argument("<config-id>", "Configuration ID")
1380
1863
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1381
1864
  .option("--json", "Output as JSON")
@@ -1400,7 +1883,7 @@ Examples:
1400
1883
  configs
1401
1884
  .command("duplicate")
1402
1885
  .description("Duplicate a configuration")
1403
- .argument("<workflow-id>", "Workflow ID")
1886
+ .argument("<workflow-id>", "Workflow ID or key")
1404
1887
  .argument("<config-id>", "Configuration ID to duplicate")
1405
1888
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1406
1889
  .option("--name <name>", "Name for the new configuration")
@@ -1428,7 +1911,7 @@ Examples:
1428
1911
  configs
1429
1912
  .command("archive")
1430
1913
  .description("Archive a configuration")
1431
- .argument("<workflow-id>", "Workflow ID")
1914
+ .argument("<workflow-id>", "Workflow ID or key")
1432
1915
  .argument("<config-id>", "Configuration ID")
1433
1916
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1434
1917
  .option("-y, --yes", "Skip confirmation prompt")
@@ -2247,6 +2730,109 @@ Examples:
2247
2730
  process.exit(1);
2248
2731
  }
2249
2732
  });
2733
+ // ============================================
2734
+ // Codegen command (issue #1442)
2735
+ // ============================================
2736
+ workflows
2737
+ .command("codegen")
2738
+ .description("Generate typed TypeScript signatures from the local workflows/*.toml schemas. Emits one <key>.generated.ts per workflow (<Key>Input/<Key>Output types + a <key>(client) typed invoker factory returning { runSync?, start }).")
2739
+ .argument("[workflow-key]", "Generate for a single workflow (defaults to every workflows/*.toml found)")
2740
+ .option("--app <app-id>", "App ID")
2741
+ .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
2742
+ .option("-o, --output <dir>", "Output directory for the <key>.generated.ts files (defaults to <sync-dir>/workflows/generated/)")
2743
+ .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write.")
2744
+ .option("--json", "Output the result summary as JSON")
2745
+ .action(async (workflowKey, options) => {
2746
+ try {
2747
+ // 1. Resolve the single source workflows/ directory via the shared
2748
+ // active-environment resolver (issue #1510). Honors --sync-dir /
2749
+ // --app overrides, resolves the active env
2750
+ // (--env → PRIMITIVE_ENV → defaultEnvironment → single-env), and
2751
+ // preserves the legacy per-app scan fallback in bare-dir mode.
2752
+ const workflowsSourceDir = resolveCodegenSourceDir({
2753
+ subdir: "workflows",
2754
+ options: { app: options.app, syncDir: options.syncDir },
2755
+ });
2756
+ // 2. Collect the source .toml files (one per workflow), filtered to a
2757
+ // single workflow when an argument is given. Match on the file stem;
2758
+ // the generator resolves the real key from `[workflow].key`.
2759
+ const inputs = [];
2760
+ for (const fileName of readdirSync(workflowsSourceDir)) {
2761
+ if (!fileName.endsWith(".toml"))
2762
+ continue;
2763
+ const stem = fileName.slice(0, -".toml".length);
2764
+ if (workflowKey && stem !== workflowKey)
2765
+ continue;
2766
+ const tomlPath = path.join(workflowsSourceDir, fileName);
2767
+ inputs.push({
2768
+ fileStem: stem,
2769
+ tomlPath,
2770
+ tomlContent: readFileSync(tomlPath, "utf-8"),
2771
+ });
2772
+ }
2773
+ if (inputs.length === 0) {
2774
+ error(workflowKey
2775
+ ? `No workflows/${workflowKey}.toml found under .primitive/sync/.`
2776
+ : "No workflows/*.toml files found to generate from.");
2777
+ process.exit(1);
2778
+ }
2779
+ // 3. Resolve the output directory. Default: a `generated/` subdir of
2780
+ // the first source dir (keeps generated output beside the schema).
2781
+ const outputDir = options.output
2782
+ ? path.resolve(options.output)
2783
+ : path.join(path.dirname(inputs[0].tomlPath), "generated");
2784
+ // 4. Run codegen (or --check).
2785
+ const codegenResult = await generateWorkflowTypes({
2786
+ inputs,
2787
+ outputDir,
2788
+ check: !!options.check,
2789
+ singleWorkflow: !!workflowKey,
2790
+ });
2791
+ if (options.check) {
2792
+ if (codegenResult.mismatches.length > 0) {
2793
+ if (options.json) {
2794
+ json({ ok: false, mismatches: codegenResult.mismatches });
2795
+ }
2796
+ else {
2797
+ error(`Check failed: ${codegenResult.mismatches.length} file(s) out of date.`);
2798
+ for (const m of codegenResult.mismatches) {
2799
+ error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
2800
+ }
2801
+ info("Run `primitive workflows codegen` to regenerate.");
2802
+ }
2803
+ process.exit(1);
2804
+ }
2805
+ if (options.json) {
2806
+ json({ ok: true, checked: codegenResult.writtenFiles.length });
2807
+ }
2808
+ else {
2809
+ success(`Check passed: ${codegenResult.writtenFiles.length} file(s) up to date.`);
2810
+ }
2811
+ return;
2812
+ }
2813
+ if (options.json) {
2814
+ json({
2815
+ written: codegenResult.writtenFiles,
2816
+ deleted: codegenResult.deletedFiles,
2817
+ });
2818
+ return;
2819
+ }
2820
+ success(`Generated ${codegenResult.writtenFiles.length} file(s)` +
2821
+ (codegenResult.deletedFiles.length > 0
2822
+ ? `, deleted ${codegenResult.deletedFiles.length} stale file(s).`
2823
+ : "."));
2824
+ for (const f of codegenResult.writtenFiles) {
2825
+ keyValue(" wrote", path.relative(process.cwd(), f));
2826
+ }
2827
+ for (const f of codegenResult.deletedFiles) {
2828
+ keyValue(" deleted", path.relative(process.cwd(), f));
2829
+ }
2830
+ }
2831
+ catch (err) {
2832
+ error(err.message);
2833
+ process.exit(1);
2834
+ }
2835
+ });
2250
2836
  }
2251
2837
  function formatFileSize(bytes) {
2252
2838
  if (bytes < 1024)