@frontera-sdk/cli 1.50.19 → 1.50.20

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.
@@ -0,0 +1,172 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { parse as parseYaml } from 'yaml'
3
+
4
+ import { WorkflowApi, type LineageEdge } from '../../api/workflow-api'
5
+ import { CliError, UsageError } from '../../errors'
6
+ import type { Command, CommandContext } from '../types'
7
+
8
+ /**
9
+ * The slug a verb acts on, refused early rather than sent as an empty segment.
10
+ *
11
+ * `/v1/workflows//versions` is a different route from the one intended and
12
+ * answers something unrelated, so a missing slug has to fail here.
13
+ */
14
+ export function requireSlug(ctx: CommandContext): string {
15
+ const slug = ctx.positional[0]
16
+ if (!slug) {
17
+ throw new UsageError(
18
+ 'missing <slug>',
19
+ 'run `frontera workflow list` — then pass the slug of the one you mean',
20
+ )
21
+ }
22
+ return slug
23
+ }
24
+
25
+ /**
26
+ * Read a definition file as YAML or JSON.
27
+ *
28
+ * One parser for both: YAML is a superset of JSON, so a `.json` file parses
29
+ * correctly here and a caller never has to think about which flag to pass.
30
+ * The failure is reported against the FILE, because a parse error forwarded to
31
+ * the service arrives as "definition must be an object" — true, and useless
32
+ * for finding the line with the bad indent.
33
+ */
34
+ export async function readDefinitionFile(path: string): Promise<unknown> {
35
+ let text: string
36
+ try {
37
+ text = await readFile(path, 'utf8')
38
+ } catch {
39
+ throw new UsageError(`cannot read ${path}`, 'pass the path to the workflow definition, YAML or JSON')
40
+ }
41
+ try {
42
+ return parseYaml(text)
43
+ } catch (err) {
44
+ throw new UsageError(`${path} is not valid YAML or JSON`, (err as Error).message)
45
+ }
46
+ }
47
+
48
+ function requireFile(ctx: CommandContext): string {
49
+ const path = ctx.positional[1]
50
+ if (!path) {
51
+ throw new UsageError(
52
+ 'missing <file>',
53
+ 'frontera workflow push <slug> workflow.yaml',
54
+ )
55
+ }
56
+ return path
57
+ }
58
+
59
+ /**
60
+ * What a definition reaches, as the author should read it before promoting.
61
+ *
62
+ * Grants and lineage are DERIVED from the definition — nobody writes them —
63
+ * so the push is the first and only chance to notice that a step reaches an
64
+ * object type or an Action the author did not intend to touch.
65
+ */
66
+ export function describeReach(grants: string[] = [], lineage: LineageEdge[] = []): string {
67
+ const lines: string[] = []
68
+ if (grants.length > 0) lines.push(` grants ${grants.join(', ')}`)
69
+ for (const edge of lineage) {
70
+ const property = edge.property ? `.${edge.property}` : ''
71
+ const where = edge.nodeId ? ` (${edge.nodeId})` : ''
72
+ lines.push(` ${edge.kind.padEnd(7)} ${edge.resourceKind} ${edge.label ?? edge.resourceId}${property}${where}`)
73
+ }
74
+ return lines.join('\n')
75
+ }
76
+
77
+ /**
78
+ * Push a definition as a new draft version.
79
+ *
80
+ * A push never runs anything and never changes what is live — it validates,
81
+ * derives the grants and lineage, and stores a draft. `promote` is the verb
82
+ * that arms it, deliberately separate, because the definition an author is
83
+ * still editing and the one their organization executes should not be the
84
+ * same act.
85
+ *
86
+ * The workflow is created on first push, the way a Function's first deploy
87
+ * creates its shell. There is no `create` verb for that reason.
88
+ */
89
+ export const workflowPush: Command = {
90
+ meta: {
91
+ noun: 'workflow',
92
+ verb: 'push',
93
+ args: [
94
+ { name: 'slug', required: true, description: 'workflow slug' },
95
+ { name: 'file', required: true, description: 'definition file, YAML or JSON' },
96
+ ],
97
+ flags: {},
98
+ summary: 'Validate a definition and store it as a new draft version',
99
+ examples: [
100
+ 'frontera workflow push invoice-intake workflow.yaml',
101
+ 'frontera workflow push invoice-intake workflow.yaml --json',
102
+ ],
103
+ },
104
+
105
+ async run(ctx) {
106
+ const slug = requireSlug(ctx)
107
+ const definition = await readDefinitionFile(requireFile(ctx))
108
+ const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
109
+ const result = await client.push(slug, definition)
110
+
111
+ const reach = describeReach(result.grants, result.lineage)
112
+ return {
113
+ data: result,
114
+ text: `${slug}: pushed draft v${result.version?.number}\n`
115
+ + (reach ? `${reach}\n` : '')
116
+ + ` frontera workflow promote ${slug} ${result.version?.number} # to make it live`,
117
+ }
118
+ },
119
+ }
120
+
121
+ /**
122
+ * Check a definition without writing anything.
123
+ *
124
+ * Separate from `push --dry-run` because the two answer different questions and
125
+ * one of them is used in a loop: this is the verb an author runs while editing,
126
+ * and it must not leave a trail of draft versions behind. The service supports
127
+ * it directly (`?dryRun=true`), including the grants and lineage — so a reach
128
+ * an author did not intend is visible BEFORE a draft exists.
129
+ */
130
+ export const workflowValidate: Command = {
131
+ meta: {
132
+ noun: 'workflow',
133
+ verb: 'validate',
134
+ args: [
135
+ { name: 'slug', required: true, description: 'workflow slug the definition is for' },
136
+ { name: 'file', required: true, description: 'definition file, YAML or JSON' },
137
+ ],
138
+ flags: {},
139
+ summary: 'Validate a definition and report its reach — writes nothing',
140
+ examples: ['frontera workflow validate invoice-intake workflow.yaml'],
141
+ },
142
+
143
+ async run(ctx) {
144
+ const slug = requireSlug(ctx)
145
+ const definition = await readDefinitionFile(requireFile(ctx))
146
+ const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
147
+ // A dry run reports problems as DATA, not as a 400 — the service does this
148
+ // so the Console can show them beside the draft. So the refusal arrives
149
+ // here as `ok: false` and has to be rendered, not thrown.
150
+ const result = await client.push(slug, definition, { dryRun: true })
151
+
152
+ // Thrown, not returned. A command result carries no exit code, so a
153
+ // rendered refusal would leave `validate` exiting 0 on an invalid
154
+ // definition — and the one place this verb is worth having is a CI step
155
+ // that must fail. `push` gets the same status for the same input, from the
156
+ // 400 the non-dry route answers with.
157
+ if (!result.ok) {
158
+ throw new CliError(`${slug}: the definition is not valid`, {
159
+ code: 'INVALID_DEFINITION',
160
+ hint: (result.errors ?? []).join('\n '),
161
+ })
162
+ }
163
+
164
+ const reach = describeReach(result.grants, result.lineage)
165
+ return {
166
+ data: result,
167
+ text: `${slug}: the definition is valid\n`
168
+ + (reach ? `${reach}\n` : '')
169
+ + ` frontera workflow push ${slug} ${ctx.positional[1]} # to store it as a draft`,
170
+ }
171
+ },
172
+ }
@@ -0,0 +1,247 @@
1
+ import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode } from '../../api/workflow-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { readSecretValue } from '../../secrets'
4
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
5
+ import { requireSlug } from './push'
6
+
7
+ /**
8
+ * How long to wait for a run to reach a terminal state.
9
+ *
10
+ * The same budget `function run` uses, for the same reason: a CLI that blocks
11
+ * for the engine's own ceiling reads as hung, and giving up here loses
12
+ * nothing — the run is recorded and the timeout message says where to read it.
13
+ * Bounding the WAIT is not bounding the RUN.
14
+ */
15
+ const WAIT_TIMEOUT_MS = 120_000
16
+ const POLL_INTERVAL_MS = 2_000
17
+
18
+ const TERMINAL = new Set(['succeeded', 'failed', 'canceled'])
19
+
20
+ /** Has this run stopped, or might it still change? */
21
+ export function isTerminal(status: string): boolean {
22
+ return TERMINAL.has(status)
23
+ }
24
+
25
+ /**
26
+ * Is this the refusal that will still be a refusal in two seconds?
27
+ *
28
+ * The two codes `credentialFailure` mints. Kept as a predicate rather than an
29
+ * `instanceof CliError` check because most CliErrors ARE worth retrying — it
30
+ * is specifically the credential ones that are not.
31
+ */
32
+ export function isCredentialFailure(err: unknown): boolean {
33
+ const code = (err as { code?: string } | null)?.code
34
+ return code === 'UNAUTHORIZED' || code === 'FORBIDDEN'
35
+ }
36
+
37
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
38
+
39
+ /**
40
+ * A run id, refused locally when it is not one.
41
+ *
42
+ * The same principle `promote` applies to a version number: both run routes
43
+ * declare `t.String({ format: 'uuid' })`, and a bad value forwarded to them
44
+ * comes back as Elysia's validation payload — which is not shaped like the
45
+ * `details.errors` the definition routes send, so it renders as "Validation
46
+ * failed" plus a JSON dump of the validator's internals. The author typed a
47
+ * wrong id; that is the answer, and it is available here.
48
+ */
49
+ export function requireRunId(raw: string | undefined): string {
50
+ if (!raw) {
51
+ throw new UsageError(
52
+ 'missing <runId>',
53
+ 'run `frontera workflow runs <slug>` — the last column is the id',
54
+ )
55
+ }
56
+ if (!UUID.test(raw)) {
57
+ throw new UsageError(`"${raw}" is not a run id`, 'a run id is a uuid — the last column of `frontera workflow runs <slug>`')
58
+ }
59
+ return raw
60
+ }
61
+
62
+ /**
63
+ * `--input '<json>'`, `--input @file.json`, or `--input @-` (stdin).
64
+ *
65
+ * Parsed locally, before anything is sent, so a typo fails against the text
66
+ * the author typed rather than as a schema violation against a run that was
67
+ * already started. `@…` reads through `readSecretValue`, the helper the rest
68
+ * of the CLI's `@`-prefixed arguments use — which is also what makes `@-`
69
+ * work here without a second stdin implementation.
70
+ */
71
+ export async function parseInputFlag(ctx: CommandContext): Promise<Record<string, unknown> | undefined> {
72
+ const raw = flagString(ctx, 'input')
73
+ if (raw === undefined) return undefined
74
+ const text = raw.startsWith('@') ? await readSecretValue(raw) : raw
75
+ let parsed: unknown
76
+ try {
77
+ parsed = JSON.parse(text)
78
+ } catch {
79
+ throw new UsageError('--input must be a JSON object (or @file.json)', '--input \'{"key": "value"}\'')
80
+ }
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
+ throw new UsageError('--input must be a JSON object', '--input \'{"key": "value"}\'')
83
+ }
84
+ return parsed as Record<string, unknown>
85
+ }
86
+
87
+ /**
88
+ * `--version N`, or undefined for the live one.
89
+ *
90
+ * Rejected here rather than forwarded for the reason `promote` rejects its
91
+ * argument: `--version abc` arriving as `NaN` comes back naming a field the
92
+ * author never typed.
93
+ */
94
+ export function parseVersionFlag(ctx: CommandContext): number | undefined {
95
+ const raw = flagString(ctx, 'version')
96
+ if (raw === undefined) return undefined
97
+ const parsed = Number(raw)
98
+ if (!Number.isInteger(parsed) || parsed < 1) {
99
+ throw new UsageError(`"${raw}" is not a version number`, 'frontera workflow get <slug> lists the versions')
100
+ }
101
+ return parsed
102
+ }
103
+
104
+ /** One line per node, nested under the run each child step started. */
105
+ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
106
+ const lines: string[] = []
107
+ for (const node of nodes) {
108
+ const indent = ' '.repeat(depth)
109
+ const failed = node.status === 'failed' ? ' ✗' : ''
110
+ lines.push(`${indent}${node.nodeId} ${node.kind} ${node.status}${failed}`)
111
+ // A `forEach` or a `workflow` step is a run of its own, and its steps are
112
+ // where its failure is explained — flattening them would report a failed
113
+ // parent with no reason anywhere in the output.
114
+ if (node.childRun) lines.push(...describeNodes(node.childRun.nodes, depth + 1))
115
+ }
116
+ return lines
117
+ }
118
+
119
+ /**
120
+ * The subject of a bound run, as "<type> <pk>".
121
+ *
122
+ * NOT `label ?? pk`. `label` is the object TYPE's display name
123
+ * (`workflow-run-tree.ts:209` reads `subjectType.displayName`), and the type
124
+ * and the pk are set together — so a fallback means the pk never renders and
125
+ * every run of one workflow reads identically as "Shipment". The pk is the
126
+ * part that says WHICH shipment, and it is the part a caller needs.
127
+ */
128
+ export function describeSubject(subject: { pk: string; label: string | null } | null): string {
129
+ if (!subject) return ''
130
+ return [subject.label, subject.pk].filter(Boolean).join(' ')
131
+ }
132
+
133
+ /** A finished run, its steps, and what it returned. */
134
+ export function describeRun(run: RunTree): string {
135
+ const lines = [`${run.status} — v${run.versionNumber ?? '?'}, ${run.nodeCalls} node call(s)`]
136
+ if (run.subject) lines.push(` subject ${describeSubject(run.subject)}`)
137
+ // The error before the steps: it is the reason the reader opened this, and
138
+ // 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)}`)
140
+ lines.push(...describeNodes(run.nodes))
141
+ if (run.output !== null && run.output !== undefined) lines.push(` returned ${JSON.stringify(run.output)}`)
142
+ return lines.join('\n')
143
+ }
144
+
145
+ /**
146
+ * Start a run of the live version and, by default, wait for it.
147
+ *
148
+ * Waiting is the default because the reason to start a workflow by hand is to
149
+ * find out what it does; `--no-wait` is for the caller who only needs the id.
150
+ *
151
+ * There is no baseline-and-compare here, unlike `function run`: the route
152
+ * answers with the `runId` it created, so the run this command reports on is
153
+ * the run it started, even when a cron fires into the same second.
154
+ *
155
+ * A run that ends `failed` exits 0, deliberately. The command succeeded — it
156
+ * started a run and reported the outcome — and this is what `function run`
157
+ * already does, so the two sibling verbs cannot disagree about what an exit
158
+ * code means. It does read oddly beside `validate`, which throws so a CI step
159
+ * fails; the difference is that an invalid definition is a broken INPUT, while
160
+ * a failed run is a true ANSWER. A caller gating on the outcome should read
161
+ * `.status` from `--json`.
162
+ */
163
+ export const workflowRun: Command = {
164
+ meta: {
165
+ noun: 'workflow',
166
+ verb: 'run',
167
+ args: [{ name: 'slug', required: true, description: 'workflow slug' }],
168
+ 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',
170
+ examples: [
171
+ 'frontera workflow run invoice-intake',
172
+ 'frontera workflow run invoice-intake --input \'{"threshold": 500}\'',
173
+ 'frontera workflow run invoice-intake --input @input.json',
174
+ 'frontera workflow run shipment-review --subject SHP-0000001',
175
+ 'frontera workflow run invoice-intake --version 3 --no-wait',
176
+ ],
177
+ },
178
+
179
+ async run(ctx) {
180
+ const slug = requireSlug(ctx)
181
+ const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
182
+
183
+ const body: RunRequest = {}
184
+ const input = await parseInputFlag(ctx)
185
+ if (input) body.input = input
186
+ const subject = flagString(ctx, 'subject')
187
+ // The primary key, not a JSON object. A subject-bound workflow takes one
188
+ // pk and nothing else, and asking for `{"pk":"…"}` on a command line would
189
+ // be quoting ceremony around a single value.
190
+ if (subject) body.subject = { pk: subject }
191
+ const version = parseVersionFlag(ctx)
192
+ if (version !== undefined) body.version = version
193
+
194
+ const started = await client.run(slug, body)
195
+
196
+ if (flagBool(ctx, 'no-wait')) {
197
+ return {
198
+ data: started,
199
+ text: `Started ${slug} v${started.versionNumber}.\n`
200
+ + ` ${started.runId}\n`
201
+ + ` frontera workflow runs ${slug} ${started.runId} # to see how it went`,
202
+ }
203
+ }
204
+
205
+ const deadline = Date.now() + WAIT_TIMEOUT_MS
206
+ let seen: RunTree | undefined
207
+ let pollFailure: Error | undefined
208
+ while (Date.now() < deadline) {
209
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
210
+ // A failed POLL must not be reported as a failed RUN. The run was
211
+ // started — the id above is the only handle on it — and throwing here
212
+ // would lose that id to an error describing something else entirely.
213
+ // The concrete case is a key holding `execute` but not `view`: it starts
214
+ // the run and is then refused the read, which `credentialFailure` words
215
+ // as a rejected credential. A transient 5xx reads the same way.
216
+ try {
217
+ seen = await client.runById(started.runId)
218
+ } catch (err) {
219
+ pollFailure = err as Error
220
+ // A credential refusal will not heal, so retrying it only delays the
221
+ // answer by the rest of the wait budget. Anything else might be
222
+ // transient and is worth another poll.
223
+ if (isCredentialFailure(err)) break
224
+ continue
225
+ }
226
+ pollFailure = undefined
227
+ if (isTerminal(seen.status)) {
228
+ // The id, on the human path too. A failed run exits 0 by design, so
229
+ // this is the common way a caller learns something went wrong — and
230
+ // without the id printed here they have nothing to pass to
231
+ // `workflow runs` or `workflow cancel`. `--json` always carried it.
232
+ return { data: seen, text: `${slug}: ${describeRun(seen)}\n ${seen.id}` }
233
+ }
234
+ }
235
+
236
+ // An error about the WAIT, not about the workflow. The run may still
237
+ // succeed, and reporting a slow run as a failed one sends an author to
238
+ // debug working steps. Either way the run id is carried out, because it is
239
+ // the only way back to what was started.
240
+ throw new CliError(
241
+ pollFailure
242
+ ? `the run started, but reading it back failed: ${pollFailure.message}`
243
+ : `the run started but was still ${seen?.status ?? 'queued'} after ${WAIT_TIMEOUT_MS / 1000}s`,
244
+ { code: 'REQUEST_FAILED', hint: `frontera workflow runs ${slug} ${started.runId}` },
245
+ )
246
+ },
247
+ }
@@ -0,0 +1,84 @@
1
+ import { WorkflowApi } from '../../api/workflow-api'
2
+ import { CliError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import type { Command } from '../types'
5
+ import { describeRun, describeSubject, requireRunId } from './run'
6
+ import { requireSlug } from './push'
7
+
8
+ /**
9
+ * Recent runs, newest first — or one run and its steps.
10
+ *
11
+ * One verb rather than two, the way `function runs` does it: the list is where
12
+ * a failure is noticed and the step trail is where it is explained, and
13
+ * splitting them puts a second command name between the two halves of one
14
+ * question.
15
+ */
16
+ export const workflowRuns: Command = {
17
+ meta: {
18
+ noun: 'workflow',
19
+ verb: 'runs',
20
+ args: [
21
+ { name: 'slug', required: true, description: 'workflow slug' },
22
+ {
23
+ name: 'runId',
24
+ required: false,
25
+ description: 'one run to open, with its steps; omit to list recent runs',
26
+ },
27
+ ],
28
+ flags: {},
29
+ summary: 'List recent runs, newest first — or open one run and its steps',
30
+ examples: [
31
+ 'frontera workflow runs invoice-intake',
32
+ 'frontera workflow runs invoice-intake 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118',
33
+ ],
34
+ },
35
+
36
+ async run(ctx) {
37
+ const slug = requireSlug(ctx)
38
+ const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
39
+
40
+ if (ctx.positional[1] !== undefined) {
41
+ const runId = requireRunId(ctx.positional[1])
42
+ // `depth: 'all'`, so a step that started a child workflow explains itself
43
+ // here rather than pointing at another id to look up.
44
+ const run = await client.runById(runId, 'all')
45
+ // The route is workspace-global — it takes the id and ignores the slug —
46
+ // so without this check a mistyped slug silently prints a DIFFERENT
47
+ // workflow's run, and nothing in the output reveals it. The slug is not
48
+ // dropped from the signature instead, because it is what makes the
49
+ // `runs <slug>` listing and this detail view one verb.
50
+ if (run.slug !== slug) {
51
+ throw new CliError(`run ${runId} belongs to "${run.slug}", not to "${slug}"`, {
52
+ code: 'NOT_FOUND',
53
+ hint: `frontera workflow runs ${run.slug} ${runId}`,
54
+ })
55
+ }
56
+ return { data: run, text: describeRun(run) }
57
+ }
58
+
59
+ const rows = await client.runs(slug)
60
+ if (rows.length === 0) {
61
+ return {
62
+ data: { runs: [] },
63
+ text: `No runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
64
+ }
65
+ }
66
+
67
+ return {
68
+ data: { runs: rows },
69
+ text: table(
70
+ ['STARTED', 'STATUS', 'VERSION', 'TRIGGER', 'SUBJECT', 'RUN ID'],
71
+ rows.map((r) => [
72
+ r.startedAt,
73
+ r.status,
74
+ `v${r.versionNumber}`,
75
+ r.triggerSource,
76
+ describeSubject(r.subjectPk ? { pk: r.subjectPk, label: r.subjectLabel } : null),
77
+ // Never truncated: an id cut to fit the terminal cannot be pasted
78
+ // into the detail view, which is the only reason it is printed.
79
+ r.id,
80
+ ]),
81
+ ),
82
+ }
83
+ },
84
+ }
package/src/flag-help.ts CHANGED
@@ -75,8 +75,16 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
75
75
  'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
76
76
  'no-git': 'scaffold without creating a repository and an initial commit',
77
77
  'no-components': 'scaffold without fetching the baseline shadcn components',
78
- version: 'version to publish, overriding the one in package.json',
79
- input: "JSON object of the automation's declared inputs — inline ('{\"key\": 1}') or @file.json",
78
+ // Worded for every consumer, not for the first one. `FLAG_HELP` is keyed by
79
+ // flag name globally, and `--version` is now read by three commands: two
80
+ // choose which version to RUN, one chooses which to publish. The old text
81
+ // described only the third, so `workflow run --help` and `function run
82
+ // --help` both advertised package.json.
83
+ version: 'the version to act on — the one to run, or on a deploy the one to publish',
84
+ input: "JSON object of the declared inputs — inline ('{\"key\": 1}') or @file.json",
85
+ // The primary key alone. A subject-bound workflow takes exactly one, so the
86
+ // flag spends no characters on the `{"pk": …}` the route models it as.
87
+ subject: 'primary key of the object a subject-bound workflow runs for',
80
88
  'no-promote': 'publish the version without moving the live pointer',
81
89
  'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
82
90
  // Named for what it RUNS, not where it runs: an author reads this to decide
@@ -172,7 +180,10 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
172
180
  plan: 'path',
173
181
  dir: 'path',
174
182
  file: 'path',
175
- version: 'semver',
183
+ // Not `semver`: `app deploy` takes one, but `function run` and `workflow run`
184
+ // take an integer version number, and this map is keyed by flag name for all
185
+ // three. `<version>` is the only placeholder true of every consumer.
186
+ version: 'version',
176
187
  framework: 'next|react',
177
188
  port: 'number',
178
189
  origin: 'url',