@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -1,8 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises'
2
+ import { isAbsolute, join, relative, resolve } from 'node:path'
2
3
  import { parse as parseYaml } from 'yaml'
3
4
 
4
5
  import { WorkflowApi, type LineageEdge } from '../../api/workflow-api'
5
6
  import { CliError, UsageError } from '../../errors'
7
+ import { localDirectory, resolveTarget } from '../../local/local-dev-references'
8
+ import { localRuntimeAt } from '../../local/local-runtime-target'
6
9
  import type { Command, CommandContext } from '../types'
7
10
 
8
11
  /**
@@ -107,6 +110,9 @@ export const workflowPush: Command = {
107
110
  const definition = await readDefinitionFile(requireFile(ctx))
108
111
  const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
109
112
  const result = await client.push(slug, definition)
113
+ // What the service accepted but wants said (a confirm step with no one to ask, say): one line each,
114
+ // on stderr as ` warning: …`, which is also how `local dev` reads them from a push it runs.
115
+ for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
110
116
 
111
117
  const reach = describeReach(result.grants, result.lineage)
112
118
  return {
@@ -118,6 +124,39 @@ export const workflowPush: Command = {
118
124
  },
119
125
  }
120
126
 
127
+ /**
128
+ * A project file's definition as the local runtime takes it: the names in it
129
+ * (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`,
130
+ * `function/…`) become this runtime's ids, as `local dev` resolves them
131
+ * before a push. Null when the command does not reach the project's own
132
+ * local runtime, or the file is outside the project: the file is sent as
133
+ * written. The lines are what the resolving adapted, said as warnings.
134
+ */
135
+ export async function localDefinition(ctx: CommandContext, slug: string, path: string): Promise<{ definition: unknown; lines: string[] } | null> {
136
+ const local = localRuntimeAt(ctx.cwd, ctx.apiUrl)
137
+ if (!local) return null
138
+ const absolute = isAbsolute(path) ? path : resolve(ctx.cwd, path)
139
+ const file = relative(local.project.root, absolute)
140
+ if (!file || file.startsWith('..') || isAbsolute(file)) return null
141
+ const resolved = await resolveTarget(
142
+ local.project,
143
+ { kind: 'workflow', key: `workflow:${slug}`, slug, file },
144
+ () => localDirectory(local.serviceUrl, local.creds),
145
+ local.creds.workspaceId,
146
+ local.creds.organizationId,
147
+ )
148
+ if (!resolved.ok) {
149
+ const [first, ...rest] = resolved.output.split('\n')
150
+ throw new CliError(`${slug}: ${(first ?? '').replace(/^error \[[A-Z_]+\]:\s*/, '')}`, {
151
+ code: 'UNRESOLVED_REFERENCE',
152
+ hint: rest.map((l) => l.trim()).filter(Boolean).join('\n ') || 'add what it names to the project, then run `frontera local dev`',
153
+ })
154
+ }
155
+ const lines = resolved.output.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (/^warning:/i.test(l) ? ` ${l}` : ` warning: ${l}`))
156
+ const definition = resolved.file ? await readDefinitionFile(join(local.project.root, resolved.file)) : (resolved.doc ?? (await readDefinitionFile(absolute)))
157
+ return { definition, lines }
158
+ }
159
+
121
160
  /**
122
161
  * Check a definition without writing anything.
123
162
  *
@@ -137,12 +176,19 @@ export const workflowValidate: Command = {
137
176
  ],
138
177
  flags: {},
139
178
  summary: 'Validate a definition and report its reach — writes nothing',
140
- examples: ['frontera workflow validate invoice-intake workflow.yaml'],
179
+ details: [
180
+ 'Inside a project whose local runtime is running (the directory is bound to it), the file is read as `frontera local dev` reads it: the names it uses (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`, `function/nearest-technician`) become the runtime\'s ids before the check, and what that adapts is said as a warning. Elsewhere the file is sent as written.',
181
+ 'Warnings are what the service accepts but wants said, such as a confirm step with no `onUnavailable` in a workflow no person can answer. They print on stderr as ` warning: …` and come back as `warnings` with --json.',
182
+ ],
183
+ examples: ['frontera workflow validate invoice-intake workflow.yaml', 'frontera workflow validate overdue-sweep workflows/overdue-sweep.yaml --json'],
141
184
  },
142
185
 
143
186
  async run(ctx) {
144
187
  const slug = requireSlug(ctx)
145
- const definition = await readDefinitionFile(requireFile(ctx))
188
+ const path = requireFile(ctx)
189
+ const local = await localDefinition(ctx, slug, path)
190
+ const definition = local ? local.definition : await readDefinitionFile(path)
191
+ for (const line of local?.lines ?? []) ctx.output.note(line)
146
192
  const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
147
193
  // A dry run reports problems as DATA, not as a 400 — the service does this
148
194
  // so the Console can show them beside the draft. So the refusal arrives
@@ -161,6 +207,7 @@ export const workflowValidate: Command = {
161
207
  })
162
208
  }
163
209
 
210
+ for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
164
211
  const reach = describeReach(result.grants, result.lineage)
165
212
  return {
166
213
  data: result,
@@ -1,4 +1,4 @@
1
- import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode } from '../../api/workflow-api'
1
+ import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode, type VersionSummary } from '../../api/workflow-api'
2
2
  import { CliError, UsageError } from '../../errors'
3
3
  import { readSecretValue } from '../../secrets'
4
4
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
@@ -85,22 +85,54 @@ export async function parseInputFlag(ctx: CommandContext): Promise<Record<string
85
85
  }
86
86
 
87
87
  /**
88
- * `--version N`, or undefined for the live one.
88
+ * `--version N`, `--version draft`, or undefined for the live one.
89
89
  *
90
90
  * Rejected here rather than forwarded for the reason `promote` rejects its
91
91
  * argument: `--version abc` arriving as `NaN` comes back naming a field the
92
92
  * author never typed.
93
+ *
94
+ * `draft` (and its synonym `latest`) is the alias the MCP `workflow_run` and
95
+ * `frontera local test` already take: the newest version that is not retired,
96
+ * which `resolveVersion` looks up. Without it an author had to read the number
97
+ * from `workflow get` first, and that number moves on every push or apply.
93
98
  */
94
- export function parseVersionFlag(ctx: CommandContext): number | undefined {
99
+ export function parseVersionFlag(ctx: CommandContext): number | 'draft' | undefined {
95
100
  const raw = flagString(ctx, 'version')
96
101
  if (raw === undefined) return undefined
102
+ const word = raw.trim().toLowerCase()
103
+ if (word === 'draft' || word === 'latest') return 'draft'
97
104
  const parsed = Number(raw)
98
105
  if (!Number.isInteger(parsed) || parsed < 1) {
99
- throw new UsageError(`"${raw}" is not a version number`, 'frontera workflow get <slug> lists the versions')
106
+ throw new UsageError(`"${raw}" is not a version number or "draft"`, 'frontera workflow get <slug> lists the versions; `--version draft` runs the newest')
100
107
  }
101
108
  return parsed
102
109
  }
103
110
 
111
+ /**
112
+ * The version number a `--version` flag names: a number as given, and `draft` as
113
+ * the newest version that is not retired. That is the draft just pushed, or the
114
+ * live version when a person promoted it (promoting retires the older drafts),
115
+ * the same rule as the MCP `workflow_run`'s `pickVersion`.
116
+ */
117
+ export async function resolveVersion(
118
+ requested: number | 'draft' | undefined,
119
+ versions: () => Promise<VersionSummary[]>,
120
+ slug: string,
121
+ ): Promise<number | undefined> {
122
+ if (requested !== 'draft') return requested
123
+ const rows = await versions()
124
+ const newest = [...rows].sort((a, b) => b.number - a.number).find((v) => v.status !== 'retired')
125
+ if (!newest) {
126
+ throw new CliError(
127
+ rows.length
128
+ ? `workflow "${slug}" has only retired versions (${rows.map((v) => `v${v.number}`).join(', ')}), so there is no draft to run`
129
+ : `workflow "${slug}" has no version yet, so there is no draft to run`,
130
+ { code: 'NOT_FOUND', hint: `push its definition first: \`frontera workflow push ${slug} <file>\`, or in a local project \`frontera local dev --once\`` },
131
+ )
132
+ }
133
+ return newest.number
134
+ }
135
+
104
136
  /** One line per node, nested under the run each child step started. */
105
137
  export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
106
138
  const lines: string[] = []
@@ -108,6 +140,8 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
108
140
  const indent = ' '.repeat(depth)
109
141
  const failed = node.status === 'failed' ? ' ✗' : ''
110
142
  lines.push(`${indent}${node.nodeId} ${node.kind} ${node.status}${failed}`)
143
+ // A failed step's error, and each input it named (`issues`): the reason, where it happened.
144
+ if (node.error) lines.push(...stepErrorLines(node.error, `${indent} `))
111
145
  // A `forEach` or a `workflow` step is a run of its own, and its steps are
112
146
  // where its failure is explained — flattening them would report a failed
113
147
  // parent with no reason anywhere in the output.
@@ -116,6 +150,26 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
116
150
  return lines
117
151
  }
118
152
 
153
+ /**
154
+ * A step's recorded error as lines: `{ code, message }`, then each of its
155
+ * `issues` (`{ path, message }`, the input it named and the rule it broke).
156
+ */
157
+ export function stepErrorLines(error: unknown, indent: string): string[] {
158
+ if (typeof error === 'string') return [`${indent}error ${error}`]
159
+ if (!error || typeof error !== 'object') return []
160
+ const e = error as { code?: unknown; message?: unknown; issues?: unknown }
161
+ const head = [typeof e.code === 'string' ? e.code : '', typeof e.message === 'string' ? e.message : ''].filter(Boolean).join(': ')
162
+ const issues = Array.isArray(e.issues)
163
+ ? e.issues.flatMap((i) => {
164
+ const issue = (i && typeof i === 'object' ? i : {}) as { path?: unknown; message?: unknown }
165
+ const message = typeof issue.message === 'string' ? issue.message : ''
166
+ if (!message) return []
167
+ return [`${indent} · ${typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''}${message}`]
168
+ })
169
+ : []
170
+ return [`${indent}error ${head || JSON.stringify(error)}`, ...issues]
171
+ }
172
+
119
173
  /**
120
174
  * The subject of a bound run, as "<type> <pk>".
121
175
  *
@@ -136,7 +190,7 @@ export function describeRun(run: RunTree): string {
136
190
  if (run.subject) lines.push(` subject ${describeSubject(run.subject)}`)
137
191
  // The error before the steps: it is the reason the reader opened this, and
138
192
  // burying it under a trail of successful steps is how it gets missed.
139
- if (run.error) lines.push(` error ${typeof run.error === 'string' ? run.error : JSON.stringify(run.error)}`)
193
+ if (run.error) lines.push(...stepErrorLines(run.error, ' '))
140
194
  lines.push(...describeNodes(run.nodes))
141
195
  if (run.output !== null && run.output !== undefined) lines.push(` returned ${JSON.stringify(run.output)}`)
142
196
  return lines.join('\n')
@@ -166,13 +220,17 @@ export const workflowRun: Command = {
166
220
  verb: 'run',
167
221
  args: [{ name: 'slug', required: true, description: 'workflow slug' }],
168
222
  flags: { 'no-wait': 'boolean', input: 'string', subject: 'string', version: 'string' },
169
- summary: 'Run the live version now — or a named one — and report what it did',
223
+ summary: 'Run the live version now — or a named one, or the newest draft — and report what it did',
224
+ details: [
225
+ '`--version <n>` runs that version. `--version draft` runs the newest version that is not retired: the draft you last pushed or applied, or the live version once a person promoted it (`latest` means the same). Without `--version`, the live version runs.',
226
+ ],
170
227
  examples: [
171
228
  'frontera workflow run invoice-intake',
172
229
  'frontera workflow run invoice-intake --input \'{"threshold": 500}\'',
173
230
  'frontera workflow run invoice-intake --input @input.json',
174
231
  'frontera workflow run shipment-review --subject SHP-0000001',
175
232
  'frontera workflow run invoice-intake --version 3 --no-wait',
233
+ 'frontera workflow run invoice-intake --version draft --input @input.json',
176
234
  ],
177
235
  },
178
236
 
@@ -188,7 +246,7 @@ export const workflowRun: Command = {
188
246
  // pk and nothing else, and asking for `{"pk":"…"}` on a command line would
189
247
  // be quoting ceremony around a single value.
190
248
  if (subject) body.subject = { pk: subject }
191
- const version = parseVersionFlag(ctx)
249
+ const version = await resolveVersion(parseVersionFlag(ctx), () => client.versions(slug), slug)
192
250
  if (version !== undefined) body.version = version
193
251
 
194
252
  const started = await client.run(slug, body)
@@ -1,7 +1,7 @@
1
1
  import { WorkflowApi } from '../../api/workflow-api'
2
- import { CliError } from '../../errors'
2
+ import { CliError, UsageError } from '../../errors'
3
3
  import { table } from '../../table'
4
- import type { Command } from '../types'
4
+ import { flagString, type Command } from '../types'
5
5
  import { describeRun, describeSubject, requireRunId } from './run'
6
6
  import { requireSlug } from './push'
7
7
 
@@ -25,10 +25,11 @@ export const workflowRuns: Command = {
25
25
  description: 'one run to open, with its steps; omit to list recent runs',
26
26
  },
27
27
  ],
28
- flags: {},
29
- summary: 'List recent runs, newest first — or open one run and its steps',
28
+ flags: { only: 'string' },
29
+ summary: 'List recent runs, newest first — or open one run and its steps (--only live|test: live runs, or test runs)',
30
30
  examples: [
31
31
  'frontera workflow runs invoice-intake',
32
+ 'frontera workflow runs invoice-intake --only live',
32
33
  'frontera workflow runs invoice-intake 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118',
33
34
  ],
34
35
  },
@@ -56,11 +57,13 @@ export const workflowRuns: Command = {
56
57
  return { data: run, text: describeRun(run) }
57
58
  }
58
59
 
59
- const rows = await client.runs(slug)
60
+ const source = flagString(ctx, 'only')
61
+ if (source !== undefined && source !== 'live' && source !== 'test') throw new UsageError(`--only takes live or test, not "${source}"`, `e.g. \`frontera workflow runs ${slug} --only live\``)
62
+ const rows = await client.runs(slug, source)
60
63
  if (rows.length === 0) {
61
64
  return {
62
65
  data: { runs: [] },
63
- text: `No runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
66
+ text: `No ${source ? `${source} ` : ''}runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
64
67
  }
65
68
  }
66
69
 
@@ -0,0 +1,166 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { WorkflowApi, type RunSummary, type WebhookListen } from '../../api/workflow-api'
4
+ import { CliError, UsageError } from '../../errors'
5
+ import { flagString, type Command } from '../types'
6
+
7
+ /**
8
+ * Send one delivery to a workflow's webhook on its DRAFT, and show what came
9
+ * back: the run the mapping (`$request.body`, headers, query) produced.
10
+ *
11
+ * The draft's test door (`/v1/hooks-test/…`) answers only while a Listen is
12
+ * armed, which was Studio's button alone, so a webhook's mapping could not be
13
+ * tried before a version went live. This arms it (`POST
14
+ * /v1/workflows/:slug/listen`: one delivery, within 120 seconds) and delivers
15
+ * straight away. The trigger's own auth still applies: pass what the sender
16
+ * would send with `--header`.
17
+ */
18
+
19
+ /** The door to send to: the one at `path`, or the only one. */
20
+ export function pickDoor(listen: WebhookListen, path: string | undefined, slug: string): WebhookListen['doors'][number] {
21
+ const doors = listen.doors
22
+ if (!doors.length) throw new CliError(`workflow ${slug} v${listen.version} has no webhook trigger`, { code: 'USAGE', hint: 'add a `kind: webhook` trigger to its file, or run it with `frontera workflow run`' })
23
+ const wanted = path?.replace(/^\/+/, '')
24
+ if (wanted !== undefined) {
25
+ const door = doors.find((d) => d.path.replace(/^\/+/, '') === wanted)
26
+ if (!door) throw new UsageError(`workflow ${slug} has no webhook at "${wanted}"`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
27
+ return door
28
+ }
29
+ if (doors.length > 1) throw new UsageError(`workflow ${slug} has ${doors.length} webhooks; say which with --path`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
30
+ return doors[0]!
31
+ }
32
+
33
+ /** The test door on the address this CLI talks to: the service names its public address, which a local runtime may not answer on. */
34
+ export function doorUrl(test: string, apiUrl: string, query: string | undefined): string {
35
+ const url = new URL(test)
36
+ const at = new URL(url.pathname, apiUrl.replace(/\/+$/, '') + '/')
37
+ if (query) for (const [k, v] of new URLSearchParams(query.replace(/^\?/, ''))) at.searchParams.append(k, v)
38
+ return at.toString()
39
+ }
40
+
41
+ /** `Name: value`, as a sender's header. */
42
+ export function parseHeader(raw: string | undefined): Record<string, string> {
43
+ if (!raw) return {}
44
+ const at = raw.indexOf(':')
45
+ if (at <= 0) throw new UsageError('--header takes `Name: value`', "e.g. --header 'X-Signature: abc123'")
46
+ return { [raw.slice(0, at).trim()]: raw.slice(at + 1).trim() }
47
+ }
48
+
49
+ /** `--version`: a draft's number, or `draft` (and `latest`) for the latest draft, as the MCP tool takes it. */
50
+ export function readVersionFlag(raw: string | undefined): number | undefined {
51
+ if (raw === undefined || /^(draft|latest)$/i.test(raw.trim())) return undefined
52
+ const version = Number(raw)
53
+ if (!Number.isInteger(version) || version < 1) throw new UsageError('--version takes a draft’s number, or draft for the latest one', 'e.g. --version 3 or --version draft; leave it out for the latest draft')
54
+ return version
55
+ }
56
+
57
+ /**
58
+ * The run a delivery started: the id the door answered with (`respond:
59
+ * immediately` gives it), else the newest test run of that version since the
60
+ * delivery was sent. Null when none shows up within a few seconds.
61
+ */
62
+ export async function findDeliveredRun(list: () => Promise<RunSummary[] | unknown>, version: number, sentAt: number, answered: unknown, tries = 6, waitMs = 500): Promise<string | null> {
63
+ const fromBody = answered && typeof answered === 'object' && typeof (answered as { runId?: unknown }).runId === 'string' ? (answered as { runId: string }).runId : null
64
+ if (fromBody) return fromBody
65
+ for (let attempt = 0; attempt < tries; attempt++) {
66
+ const page = await Promise.resolve().then(list).catch(() => null)
67
+ const rows = (Array.isArray(page) ? page : ((page as { items?: unknown[] } | null)?.items ?? [])) as Array<Partial<RunSummary>>
68
+ const mine = rows
69
+ .filter((r) => typeof r.id === 'string' && (r.versionNumber === undefined || r.versionNumber === null || r.versionNumber === version) && Date.parse(r.startedAt ?? '') >= sentAt)
70
+ .sort((a, b) => Date.parse(b.startedAt ?? '') - Date.parse(a.startedAt ?? ''))
71
+ if (mine[0]) return mine[0].id!
72
+ if (attempt < tries - 1) await new Promise((r) => setTimeout(r, waitMs))
73
+ }
74
+ return null
75
+ }
76
+
77
+ function readBody(from: string | undefined): string | undefined {
78
+ if (from === undefined) return undefined
79
+ if (from === '-') return readFileSync(0, 'utf8')
80
+ try {
81
+ return readFileSync(from, 'utf8')
82
+ } catch {
83
+ throw new UsageError(`could not read ${from}`, 'pass a JSON file with --body, or - for standard input')
84
+ }
85
+ }
86
+
87
+ export const workflowWebhookTest: Command = {
88
+ meta: {
89
+ noun: 'workflow',
90
+ verb: 'webhook-test',
91
+ args: [{ name: 'workflow', required: true, description: 'the workflow’s slug' }],
92
+ flags: { body: 'string', path: 'string', method: 'string', header: 'string', query: 'string', version: 'string' },
93
+ summary: 'Send one test delivery to a workflow’s webhook on its draft, and show the run it made',
94
+ examples: [
95
+ 'frontera workflow webhook-test work-order-intake --body fault.json',
96
+ 'frontera workflow webhook-test intake --path fault-reports --body -',
97
+ "frontera workflow webhook-test intake --body a.json --header 'X-Key: k1'",
98
+ 'frontera workflow webhook-test intake --body a.json --version draft --json',
99
+ ],
100
+ },
101
+
102
+ async run(ctx) {
103
+ const slug = ctx.positional[0]
104
+ if (!slug) throw new UsageError('missing <workflow>', 'frontera workflow webhook-test <slug> --body payload.json')
105
+ const version = readVersionFlag(flagString(ctx, 'version'))
106
+ const body = readBody(flagString(ctx, 'body'))
107
+ if (body !== undefined) {
108
+ try {
109
+ JSON.parse(body)
110
+ } catch {
111
+ // A sender may post text or a form: sent as it is, said here.
112
+ ctx.output.note('[webhook-test] the body is not JSON: it is sent as text')
113
+ }
114
+ }
115
+ const headers = parseHeader(flagString(ctx, 'header'))
116
+
117
+ const api = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
118
+ const listen = await api.listen(slug, version).catch((err: unknown) => {
119
+ if (err instanceof CliError && /not.?found|404/i.test(`${err.code} ${err.message}`) && /listen/i.test(err.message)) {
120
+ throw new CliError('this platform release cannot arm a draft’s webhook from the CLI', { code: 'UNSUPPORTED', hint: 'use Listen on the workflow’s page, or update to a release that supports `frontera workflow webhook-test`' })
121
+ }
122
+ throw err
123
+ })
124
+ const door = pickDoor(listen, flagString(ctx, 'path'), slug)
125
+ const method = (flagString(ctx, 'method') ?? (door.methods.includes('POST') ? 'POST' : door.methods[0] ?? 'POST')).toUpperCase()
126
+ if (door.methods.length && !door.methods.includes(method)) throw new UsageError(`the webhook at "${door.path}" takes ${door.methods.join(', ')}, not ${method}`, `--method ${door.methods[0]}`)
127
+ const url = doorUrl(door.test, ctx.apiUrl, flagString(ctx, 'query'))
128
+ const sent = method === 'GET' || method === 'HEAD' ? undefined : body
129
+ // A second back, so a run the runtime stamps a moment before the delivery returns still counts.
130
+ const sentAt = Date.now() - 1_000
131
+ const res = await fetch(url, {
132
+ method,
133
+ headers: { ...(sent !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
134
+ ...(sent !== undefined ? { body: sent } : {}),
135
+ })
136
+ const text = await res.text()
137
+ let answer: unknown = text
138
+ try {
139
+ answer = text ? JSON.parse(text) : null
140
+ } catch {
141
+ // Kept as text.
142
+ }
143
+ // The run the delivery made, so `--json` shows it (a refused delivery made none).
144
+ const runId = res.status === 401 || res.status === 403 || res.status === 404 ? null : await findDeliveredRun(() => api.runs(slug, 'test'), listen.version, sentAt, answer)
145
+ const data = { workflow: slug, version: listen.version, path: door.path, method, status: res.status, response: answer, runId }
146
+ if (!res.ok) {
147
+ throw new CliError(`the webhook answered ${res.status} on the draft (v${listen.version})`, {
148
+ code: 'WEBHOOK_TEST_FAILED',
149
+ hint: res.status === 401 || res.status === 403
150
+ ? 'the trigger’s auth refused the delivery: pass what the sender sends with --header'
151
+ : res.status === 404
152
+ ? 'the test door did not answer: the Listen may have ended, or the path or method differs from the trigger’s'
153
+ : `${typeof answer === 'string' ? answer.slice(0, 300) : JSON.stringify(answer).slice(0, 300)}`,
154
+ details: data,
155
+ })
156
+ }
157
+ return {
158
+ data,
159
+ text: [
160
+ `${method} ${door.path ? `/${door.path.replace(/^\/+/, '')}` : '(no path)'} on workflow ${slug} v${listen.version} (draft): ${res.status}`,
161
+ typeof answer === 'string' ? answer : JSON.stringify(answer, null, 2),
162
+ runId ? ` the run: frontera workflow runs ${slug} ${runId}` : ` the run: frontera workflow runs ${slug} --only test`,
163
+ ].join('\n'),
164
+ }
165
+ },
166
+ }
package/src/config.ts CHANGED
@@ -4,9 +4,11 @@ import { credentialStore, type CredentialSource } from './credential-store'
4
4
  import { CliError, UsageError } from './errors'
5
5
  import { migrateLegacyConfig } from './profile-migration'
6
6
  import { configPath, type Env } from './paths'
7
+ import { localRuntimeProfileOf } from './local/local-project'
7
8
  import { findContext } from './project-context'
8
9
  import {
9
10
  assertOriginMatches,
11
+ getProfile,
10
12
  isLegacyConfig,
11
13
  listProfiles,
12
14
  normalizeOrigin,
@@ -72,6 +74,25 @@ export interface CredentialDeps {
72
74
  export interface ResolveOptions {
73
75
  apiUrl?: string
74
76
  profile?: string
77
+ /**
78
+ * The command acts at organization grain. In a directory bound to a local
79
+ * runtime's workspace profile (`local-<project>`), the runtime's
80
+ * organization profile beside it (`local-<project>-org`) is selected
81
+ * instead: the same runtime, the key its organization commands need.
82
+ */
83
+ organization?: boolean
84
+ }
85
+
86
+ /**
87
+ * The organization profile `local start` registers beside a local runtime's
88
+ * workspace profile, when this is one and it has one.
89
+ */
90
+ export function localOrganizationCompanion(profile: string, env: Env = process.env): string | null {
91
+ if (!profile.startsWith('local-')) return null
92
+ const org = `${profile}-org`.slice(0, 64)
93
+ if (org === profile || getProfile(profile, env)?.credentialKind !== 'workspace') return null
94
+ const companion = getProfile(org, env)
95
+ return companion?.credentialKind === 'organization' && companion.apiUrl === getProfile(profile, env)?.apiUrl ? org : null
75
96
  }
76
97
 
77
98
  /**
@@ -103,16 +124,24 @@ export function resolveProfileName(
103
124
  * question rather than an answer, and `auth use` is how it gets answered.
104
125
  */
105
126
  if (!found.trusted) {
127
+ // A copied or pulled project carries the machine it came from's selection. When this directory
128
+ // has its own local runtime, ITS profile is the one to bind: the named one belongs to another
129
+ // directory (another project's runtime, or another machine's).
130
+ const own = localRuntimeProfileOf(found.root)
131
+ const ownHere = own && own !== found.profile && getProfile(own, env) ? own : null
106
132
  throw new CliError(
107
133
  `${found.path} selects "${found.profile}", but this machine never bound that directory`,
108
134
  {
109
135
  code: 'PROJECT_CONTEXT_UNTRUSTED',
110
- hint:
111
- `if you meant to use it, run \`frontera auth use ${found.profile}\` in ${found.root} — `
112
- + 'a context file committed to a repository is not proof that you chose it',
136
+ hint: ownHere
137
+ ? `this directory has its own local runtime, whose profile is ${ownHere} (\`frontera local status\` names it): run \`frontera auth use ${ownHere}\` in ${found.root}. "${found.profile}" was bound where this project came from, and would reach that runtime instead`
138
+ : `if you meant to use it, run \`frontera auth use ${found.profile}\` in ${found.root} — `
139
+ + 'a context file committed to a repository is not proof that you chose it',
113
140
  },
114
141
  )
115
142
  }
143
+ const companion = opts.organization ? localOrganizationCompanion(found.profile, env) : null
144
+ if (companion) return { profile: companion, source: 'directory', path: found.path }
116
145
  return { profile: found.profile, source: 'directory', path: found.path }
117
146
  }
118
147
 
@@ -144,9 +173,18 @@ export async function resolveCredential(
144
173
  await ensureMigrated(env)
145
174
 
146
175
  const store = deps.store ?? credentialStore(env)
147
- const selection = resolveProfileName(opts, deps)
148
-
149
176
  const explicitOrigin = opts.apiUrl ?? env.FRONTERA_API_URL
177
+ const envToken = env.FRONTERA_TOKEN?.trim()
178
+ // A raw token with its own origin uses no stored key, so a context file this machine did not
179
+ // bind is nothing to refuse: it only names the profile the result reports (none, here).
180
+ let selection: ReturnType<typeof resolveProfileName>
181
+ try {
182
+ selection = resolveProfileName(opts, deps)
183
+ } catch (err) {
184
+ if (!(envToken && explicitOrigin && err instanceof CliError && err.code === 'PROJECT_CONTEXT_UNTRUSTED')) throw err
185
+ selection = { profile: null, source: 'none' }
186
+ }
187
+
150
188
  const originSource: OriginSource = opts.apiUrl ? 'flag' : env.FRONTERA_API_URL ? 'environment' : 'profile'
151
189
 
152
190
  /**
@@ -155,7 +193,6 @@ export async function resolveCredential(
155
193
  * would be sent to whatever deployment the checked-out directory happens to
156
194
  * select. That is a cross-customer credential leak with no visible symptom.
157
195
  */
158
- const envToken = env.FRONTERA_TOKEN?.trim()
159
196
  if (envToken) {
160
197
  if (!explicitOrigin) {
161
198
  throw new UsageError(
package/src/dev-broker.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto'
2
+ import { createServer } from 'node:net'
2
3
 
3
4
  import type { BridgeInit } from '@frontera-sdk/core/bridge-protocol'
4
5
 
@@ -69,3 +70,54 @@ export function createDevSessionBroker(options: {
69
70
 
70
71
  return { endpoint: `http://127.0.0.1:${server.port}${path}`, server }
71
72
  }
73
+
74
+ /** The addresses an App's port must be free on: the browser resolves `localhost` to either loopback, and a wildcard listener answers both. */
75
+ export const LOOPBACK_ADDRESSES = ['127.0.0.1', '::1', '0.0.0.0', '::'] as const
76
+
77
+ function freeOn(port: number, host: string): Promise<boolean> {
78
+ return new Promise((resolve) => {
79
+ const server = createServer()
80
+ server.once('error', (err: NodeJS.ErrnoException) => {
81
+ // No IPv6 on this machine: nothing can hold the port there either.
82
+ resolve(err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')
83
+ })
84
+ server.listen({ port, host, exclusive: true, ...(host === '::' ? { ipv6Only: true } : {}) }, () => server.close(() => resolve(true)))
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Whether nothing listens on `port` on either loopback, IPv4 or IPv6, nor on
90
+ * a wildcard. Probing 127.0.0.1 alone let a dev server start beside another
91
+ * process holding the port on ::1, and `localhost` and `127.0.0.1` then
92
+ * served two different Apps.
93
+ */
94
+ export async function isLoopbackPortFree(port: number, hosts: readonly string[] = LOOPBACK_ADDRESSES): Promise<boolean> {
95
+ for (const host of hosts) if (!(await freeOn(port, host))) return false
96
+ return true
97
+ }
98
+
99
+ /** A port the system hands out on 127.0.0.1 (`listen(0)`), closed again at once. */
100
+ function systemPort(): Promise<number> {
101
+ return new Promise((resolve, reject) => {
102
+ const server = createServer()
103
+ server.once('error', reject)
104
+ server.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
105
+ const address = server.address()
106
+ const port = typeof address === 'object' && address ? address.port : 0
107
+ server.close(() => (port ? resolve(port) : reject(new Error('the system gave no port'))))
108
+ })
109
+ })
110
+ }
111
+
112
+ /**
113
+ * A port free on every loopback address (`isLoopbackPortFree`), for `--port 0`:
114
+ * the system picks it on 127.0.0.1, and one something holds on ::1 or a
115
+ * wildcard is passed over for the next.
116
+ */
117
+ export async function freeLoopbackPort(isFree: (port: number) => Promise<boolean> = isLoopbackPortFree): Promise<number> {
118
+ for (let attempt = 0; attempt < 20; attempt += 1) {
119
+ const port = await systemPort()
120
+ if (await isFree(port)) return port
121
+ }
122
+ throw new Error('no port was free on every loopback address after 20 tries')
123
+ }