@frontera-sdk/cli 1.45.9 → 1.45.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.45.9",
3
+ "version": "1.45.10",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -38,8 +38,8 @@
38
38
  "build:release": "bun run scripts/build-release.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@frontera-sdk/automation": "1.45.7",
42
- "@frontera-sdk/core": "1.45.7",
41
+ "@frontera-sdk/automation": "1.45.9",
42
+ "@frontera-sdk/core": "1.45.9",
43
43
  "gray-matter": "^4.0.3",
44
44
  "yaml": "^2.9.0"
45
45
  },
@@ -56,6 +56,13 @@ export interface AutomationRunSummary {
56
56
  startedAt: string
57
57
  finishedAt: string | null
58
58
  version: number
59
+ /**
60
+ * The frozen input this run was started with, or absent/null when the
61
+ * automation declares none. Optional in the type rather than required
62
+ * because an older service predating this field would omit it entirely —
63
+ * current ones serve it on both the list and the detail route.
64
+ */
65
+ input?: Record<string, unknown> | null
59
66
  }
60
67
 
61
68
  /** A row of `GET /v1/automations/runs/<id>/steps`. */
@@ -145,13 +152,26 @@ export class AutomationApi {
145
152
  * cron.
146
153
  */
147
154
  /** `version` omitted runs the live one — the behaviour every caller had before
148
- * the parameter existed, and the one the service still defaults to. */
155
+ * the parameter existed, and the one the service still defaults to.
156
+ *
157
+ * `input` rides in the same body for both the live and the dev shape, but
158
+ * the service does NOT validate it against declared `inputs` either way:
159
+ * for a live run it does (schema check, defaults applied, frozen onto the
160
+ * row `startRun` produces); for `dev: true` this route only enforces the
161
+ * size cap — schema validation of a dev run's input happens on the
162
+ * author's own machine, in the dev worker, against the local file's
163
+ * freshly built manifest (the live schema has no bearing on it). */
149
164
  run(
150
165
  slug: string,
151
166
  version?: number,
152
167
  dev?: boolean,
168
+ input?: Record<string, unknown>,
153
169
  ): Promise<{ slug: string; version: number | null; queued: boolean; dev?: boolean }> {
154
- const body = dev ? { dev: true } : version === undefined ? undefined : { version }
170
+ const body = dev
171
+ ? { dev: true, ...(input !== undefined ? { input } : {}) }
172
+ : version === undefined && input === undefined
173
+ ? undefined
174
+ : { ...(version === undefined ? {} : { version }), ...(input === undefined ? {} : { input }) }
155
175
  return this.client.request(`/v1/automations/${encodeURIComponent(slug)}/run`, {
156
176
  method: 'POST',
157
177
  body,
@@ -195,7 +215,15 @@ export class AutomationApi {
195
215
  manifest: Record<string, unknown>,
196
216
  ): Promise<{
197
217
  held: boolean
198
- run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
218
+ run: {
219
+ runId: string
220
+ runToken: string
221
+ grants: string[]
222
+ workspaceId: string
223
+ /** The frozen input this run was started with, or absent/null when the
224
+ * automation declares none — same shape as `AutomationRunSummary.input`. */
225
+ input?: Record<string, unknown> | null
226
+ } | null
199
227
  }> {
200
228
  const q = `sessionId=${encodeURIComponent(sessionId)}&holderId=${encodeURIComponent(holderId)}`
201
229
  return this.client.request(
@@ -35,6 +35,12 @@ export const AUTOMATION_SDK_FILES = [
35
35
  // in, shared with the runner.
36
36
  'frontera/automation/messages.ts',
37
37
  'frontera/automation/testing.ts',
38
+ // `inputs.ts` is authoring surface too: `validateInputValue` is what a
39
+ // handler's own tests and the Console call to check a value against the
40
+ // manifest's `inputs` schema, and `index.ts` re-exports it — so omitting it
41
+ // breaks a scaffolded project's typecheck the same way skipping `testing.ts`
42
+ // would.
43
+ 'frontera/automation/inputs.ts',
38
44
  'frontera/blueprint/types.ts',
39
45
  ] as const
40
46
 
@@ -6,7 +6,11 @@ import { CliError, UsageError } from './errors'
6
6
  export const BLUEPRINT_TYPES_MARKER = '// Generated by `frontera blueprint generate-types`. Do not edit.'
7
7
  export const DEFAULT_BLUEPRINT_TYPES_OUTPUT = 'src/generated/frontera-blueprint.ts'
8
8
 
9
- export type BlueprintDataType = 'string' | 'number' | 'boolean' | 'date' | 'timestamp' | 'json'
9
+ /** Mirrors the service's `DataType`. `media_reference` is first-class (spec
10
+ * §4/§12): it generates a named struct rather than `unknown`, and the schema
11
+ * publishes it as neither filterable nor sortable, so the generated
12
+ * `BlueprintRegistry` cannot offer a predicate the query layer refuses. */
13
+ export type BlueprintDataType = 'string' | 'number' | 'boolean' | 'date' | 'timestamp' | 'json' | 'media_reference'
10
14
  export type BlueprintPropertyType = 'attribute' | 'measure' | 'time'
11
15
 
12
16
  export interface BlueprintSchemaProperty {
@@ -33,7 +37,7 @@ export interface BlueprintSchemaResponse {
33
37
  objectTypes: BlueprintSchemaObject[]
34
38
  }
35
39
 
36
- const DATA_TYPES = new Set<BlueprintDataType>(['string', 'number', 'boolean', 'date', 'timestamp', 'json'])
40
+ const DATA_TYPES = new Set<BlueprintDataType>(['string', 'number', 'boolean', 'date', 'timestamp', 'json', 'media_reference'])
37
41
  const PROPERTY_TYPES = new Set<BlueprintPropertyType>(['attribute', 'measure', 'time'])
38
42
 
39
43
  function schemaContractError(detail: string): never {
@@ -99,6 +103,29 @@ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
99
103
  return input as BlueprintSchemaResponse
100
104
  }
101
105
 
106
+ /**
107
+ * The canonical wire shape of a media reference (spec §3.3), emitted as a named
108
+ * type so an App reads `file.mediaItemId` rather than narrowing `unknown`.
109
+ *
110
+ * The set and item ids are strings, not a branded id type: the reference is an
111
+ * identity handle and nothing in the App may act on it except by handing it
112
+ * back to an authorized content route. `kind` is pinned to the literal so a
113
+ * hand-built object cannot masquerade as one.
114
+ */
115
+ const MEDIA_REFERENCE_TYPE_NAME = 'FronteraMediaReference'
116
+
117
+ const MEDIA_REFERENCE_TYPE_DECLARATION = [
118
+ '/** An opaque handle to one immutable item in a Media Set. Possessing it grants',
119
+ ' * nothing: every preview or download reauthorizes. It cannot be filtered,',
120
+ ' * sorted or compared — ask `!== null` to test presence. */',
121
+ `export interface ${MEDIA_REFERENCE_TYPE_NAME} {`,
122
+ " kind: 'media'",
123
+ ' mediaSetId: string',
124
+ ' mediaItemId: string',
125
+ ' mimeType: string',
126
+ '}',
127
+ ]
128
+
102
129
  const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
103
130
  string: 'string',
104
131
  number: 'number',
@@ -106,6 +133,7 @@ const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
106
133
  date: 'string',
107
134
  timestamp: 'string',
108
135
  json: 'unknown',
136
+ media_reference: MEDIA_REFERENCE_TYPE_NAME,
109
137
  }
110
138
 
111
139
  function jsDoc(value: string | null): string[] {
@@ -224,6 +252,15 @@ export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string
224
252
  '',
225
253
  ]
226
254
 
255
+ // Declared only when something uses it, so a Blueprint with no media field
256
+ // generates byte-identical output to what it generated before the type
257
+ // existed — a generated file that churns is a generated file nobody trusts.
258
+ if (objects.some((objectType) => objectType.properties.some(
259
+ (property) => property.dataType === 'media_reference',
260
+ ))) {
261
+ lines.push(...MEDIA_REFERENCE_TYPE_DECLARATION, '')
262
+ }
263
+
227
264
  for (const objectType of objects) {
228
265
  lines.push(...jsDoc(objectType.description), `export interface ${objectType.apiName} {`)
229
266
  for (const property of objectType.properties) {
@@ -6,7 +6,7 @@ import { buildContext } from '@frontera-sdk/automation/runtime'
6
6
  import { AutomationApi } from '../../api/automation-api'
7
7
  import { CliError, UsageError } from '../../errors'
8
8
  import { type Command, type CommandContext } from '../types'
9
- import { validateManifest } from '@frontera-sdk/automation'
9
+ import { validateInputValue, validateManifest, type InputsSchema } from '@frontera-sdk/automation'
10
10
  import { buildAndExtract } from './build-entry'
11
11
 
12
12
  /**
@@ -37,7 +37,15 @@ const POLL_MS = 1_000
37
37
  * of step with it. */
38
38
  export interface DevPollResult {
39
39
  held: boolean
40
- run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
40
+ run: {
41
+ runId: string
42
+ runToken: string
43
+ grants: string[]
44
+ workspaceId: string
45
+ /** The frozen input this run was started with, or absent/null when the
46
+ * automation declares none — mirrors `AutomationApi.devPoll`'s return type. */
47
+ input?: Record<string, unknown> | null
48
+ } | null
41
49
  }
42
50
 
43
51
  /**
@@ -110,7 +118,10 @@ export function newDevStepState(): DevStepState {
110
118
  */
111
119
  export function localStepTools(
112
120
  state: DevStepState = newDevStepState(),
113
- ): { run<T>(id: string, fn: () => Promise<T>): Promise<unknown> } {
121
+ ): {
122
+ run<T>(id: string, fn: () => Promise<T>): Promise<unknown>
123
+ sleep(id: string, ms: number): Promise<void>
124
+ } {
114
125
  // Reset per EXECUTION, not per run: it counts what this pass has replayed, so
115
126
  // the next unmemoized name can be checked against the recorded order.
116
127
  let replayed = 0
@@ -166,6 +177,34 @@ export function localStepTools(
166
177
  // this exists to reproduce.
167
178
  throw new StepYield()
168
179
  },
180
+
181
+ async sleep(id: string, _ms: number): Promise<void> {
182
+ // Dev runs are DRY end to end — a real wait here would make the author
183
+ // sit through production pacing to test a branch. Recorded for the
184
+ // determinism check, resolved immediately.
185
+ //
186
+ // Same order/replay bookkeeping as `run`, above, storing `null` as the
187
+ // memoized value: there is no body result to round-trip, only a name to
188
+ // hold the position steady across resumptions.
189
+ const expected = state.order[replayed]
190
+ if (expected !== undefined && expected !== id) {
191
+ throw new Error(
192
+ `this automation is not deterministic: step ${replayed + 1} was `
193
+ + `"${expected}" on an earlier execution and is "${id}" now. The platform `
194
+ + 'replays completed steps by position, so the two runs would be handed '
195
+ + "each other's results. Move anything conditional INSIDE a step.",
196
+ )
197
+ }
198
+
199
+ if (state.memo.has(id)) {
200
+ replayed += 1
201
+ return
202
+ }
203
+
204
+ state.order.push(id)
205
+ state.memo.set(id, null)
206
+ throw new StepYield()
207
+ },
169
208
  }
170
209
  }
171
210
 
@@ -255,6 +294,32 @@ function mtimeOf(path: string): number {
255
294
  }
256
295
  }
257
296
 
297
+ /**
298
+ * Applies the dev-run startup gate to a `validateManifest` result.
299
+ *
300
+ * Same "cron has nobody to ask" carve-out the poll applies at
301
+ * automation-router.ts (`/dev/session/poll`): that rule exists for an
302
+ * UNATTENDED fire — a cron trigger has nobody to prompt, so a required
303
+ * field with no default would fail every scheduled tick. A dev run is the
304
+ * opposite of unattended — it is a manual fire by definition, and the value
305
+ * the rule worries about missing is exactly what `--input` (or the
306
+ * Console's dev-run form) supplies at claim time.
307
+ *
308
+ * Without this carve-out HERE too — at the file this command reads before a
309
+ * session is even opened — the router-side exemption never gets a chance to
310
+ * matter: a local file that adds `trigger: { cron }` plus such a field could
311
+ * never START a dev worker at all, so `run --dev` just timed out with no
312
+ * session for it to claim against. Any OTHER manifest error still refuses to
313
+ * start, same as before.
314
+ */
315
+ export function devStartupManifestGate(errors: string[]): { blocking: string[]; warning?: string } {
316
+ const blocking = errors.filter((e) => !e.includes('cron has nobody to ask'))
317
+ if (blocking.length === 0 && errors.length > 0) {
318
+ return { blocking, warning: errors.join('; ') }
319
+ }
320
+ return { blocking }
321
+ }
322
+
258
323
  export const automationDev: Command = {
259
324
  meta: {
260
325
  noun: 'automation',
@@ -277,6 +342,7 @@ export const automationDev: Command = {
277
342
  throw new UsageError('missing <file>', 'frontera automation dev <file>')
278
343
  }
279
344
  const entry = resolve(file)
345
+ const out = ctx.output
280
346
 
281
347
  // Built once up front for its MANIFEST: the slug is what the session is
282
348
  // opened against, and building later would mean claiming a session for a
@@ -285,10 +351,14 @@ export const automationDev: Command = {
285
351
  let { manifest } = await buildAndExtract(entry)
286
352
  const invalid = validateManifest(manifest)
287
353
  if (!invalid.valid) {
288
- throw new UsageError(
289
- `${file} does not describe a valid automation:\n ${invalid.errors.join('\n ')}`,
290
- 'fix the manifest, then run the command again',
291
- )
354
+ const gate = devStartupManifestGate(invalid.errors)
355
+ if (gate.blocking.length > 0) {
356
+ throw new UsageError(
357
+ `${file} does not describe a valid automation:\n ${gate.blocking.join('\n ')}`,
358
+ 'fix the manifest, then run the command again',
359
+ )
360
+ }
361
+ out.note(`[dev] ${gate.warning} — allowed for a dev run, which is always manual`)
292
362
  }
293
363
  let lastMtime = mtimeOf(entry)
294
364
  // VALIDATED HERE, once, before a session is opened.
@@ -318,7 +388,6 @@ export const automationDev: Command = {
318
388
  const holderId = crypto.randomUUID()
319
389
  const session = await api.devSessionOpen(slug, entry, holderId)
320
390
 
321
- const out = ctx.output
322
391
  out.note(`[dev] ${slug} — running from ${file}`)
323
392
  out.note(`[dev] connected to ${ctx.apiUrl}`)
324
393
  // The first question an author has is whether this disturbs production, and
@@ -332,7 +401,7 @@ export const automationDev: Command = {
332
401
  // and then watches runs scroll past; a caveat printed per run is noise, and
333
402
  // one printed nowhere is a surprise the first time they wonder why a query
334
403
  // came back empty.
335
- out.note('[dev] runs are DRY — ctx.agent, ctx.http and ctx.blueprint return empty and send nothing')
404
+ out.note('[dev] runs are DRY — ctx.agent, ctx.plugin, ctx.http and ctx.blueprint return empty and send nothing')
336
405
  if (session.created) out.note(`[dev] created the automation "${slug}" (no version yet)`)
337
406
  out.note(`[dev] waiting — press Run dev in the Console, or \`frontera automation run ${slug} --dev\``)
338
407
 
@@ -417,6 +486,24 @@ export const automationDev: Command = {
417
486
  throw new Error(`${file} has no automation() default export`)
418
487
  }
419
488
 
489
+ // The rule this PR advertises — "the author's local file is the
490
+ // authority for a dev run" — is broken by anything short of running
491
+ // the SAME check live runs get, against THIS build's manifest. Without
492
+ // it a `default: false` field arrives as `false` live and `undefined`
493
+ // here, and there is nothing to catch it: this build never went
494
+ // through the run route's fast 400, only the poll's manifest check.
495
+ // Same validator, same wording as the run route / `startRun`
496
+ // (`Invalid run input: …`) — thrown so it lands in the catch below,
497
+ // which is the one place that already reports a handler failure back
498
+ // to both the terminal and the Console.
499
+ const inputCheck = validateInputValue(
500
+ (built.manifest as { inputs?: InputsSchema }).inputs,
501
+ run.input ?? undefined,
502
+ )
503
+ if (!inputCheck.ok) {
504
+ throw new Error(`Invalid run input: ${inputCheck.errors.join('; ')}`)
505
+ }
506
+
420
507
  // Entered once per step, not once per run — see `localStepTools`. A
421
508
  // fresh context each time, because production builds one per resumption
422
509
  // and its duplicate-name set is scoped to a single execution.
@@ -428,6 +515,9 @@ export const automationDev: Command = {
428
515
  grants: run.grants,
429
516
  step,
430
517
  serviceUrl: ctx.apiUrl,
518
+ // Validated and defaulted, not the raw claim — `inputCheck.value`
519
+ // is `{}` only when the automation truly declares no inputs.
520
+ input: inputCheck.value,
431
521
  }),
432
522
  )
433
523
  await finishDevRun(ctx.apiUrl, run.runId, run.runToken, { status: 'succeeded', result })
@@ -437,9 +527,10 @@ export const automationDev: Command = {
437
527
  out.note(`[dev] run ${run.runId.slice(0, 8)} finished (dry) in ${Date.now() - started}ms`)
438
528
  } catch (err) {
439
529
  const message = err instanceof Error ? err.message : String(err)
440
- // A build error is reported as a FAILED RUN, not only here: the person
441
- // who pressed Run is looking at the Console, and an error that appears
442
- // only in a terminal they may not be watching reads as a hang.
530
+ // A build error, a bad input, or a handler throw is reported as a
531
+ // FAILED RUN, not only here: the person who pressed Run is looking at
532
+ // the Console, and an error that appears only in a terminal they may
533
+ // not be watching reads as a hang.
443
534
  await finishDevRun(ctx.apiUrl, run.runId, run.runToken, {
444
535
  status: 'failed',
445
536
  errorMessage: message,
@@ -5,6 +5,7 @@ import {
5
5
  type AutomationRunSummary,
6
6
  type RegistryStatus,
7
7
  } from '../../api/automation-api'
8
+ import { readSecretValue } from '../../secrets'
8
9
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
9
10
 
10
11
  /**
@@ -135,6 +136,42 @@ export function parseVersionFlag(ctx: CommandContext): number | undefined {
135
136
  return parsed
136
137
  }
137
138
 
139
+ /**
140
+ * `--input '<json>'`, `--input @file.json`, or `--input @-` (stdin), as the
141
+ * object the run starts with.
142
+ *
143
+ * Parsed and shape-checked here, before anything reaches the network, for the
144
+ * same reason `parseVersionFlag` rejects locally: forwarded as-is, malformed
145
+ * JSON or a non-object value would arrive at the service as a 400 whose
146
+ * message describes the WRONG thing — either a parse failure with no line the
147
+ * author can see, or a schema violation against a field that does not exist.
148
+ *
149
+ * `@…` reads through `readSecretValue` — the same helper `secret set` and
150
+ * `login` use to turn an unwrapped `readFileSync`'s raw ENOENT into a
151
+ * `UsageError` the dispatcher has a case for, rather than an internal error
152
+ * with no hint, for what is simply a typoed path. Reusing it rather than
153
+ * hand-rolling the read a second time is also what makes `--input @-` (stdin)
154
+ * work here at all: `readSecretValue` already treats `-` as the stdin
155
+ * convention, the same one the rest of the CLI's `@`-prefixed arguments use.
156
+ */
157
+ export async function parseInputFlag(ctx: CommandContext): Promise<Record<string, unknown> | undefined> {
158
+ const raw = flagString(ctx, 'input')
159
+ if (raw === undefined) return undefined
160
+ const text = raw.startsWith('@') ? await readSecretValue(raw) : raw
161
+ let parsed: unknown
162
+ try {
163
+ parsed = JSON.parse(text)
164
+ } catch {
165
+ throw new UsageError(
166
+ '--input must be a JSON object (or @file.json)',
167
+ '--input \'{"key": "value"}\'',
168
+ )
169
+ }
170
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
171
+ throw new UsageError('--input must be a JSON object', '--input \'{"key": "value"}\'')
172
+ }
173
+ return parsed as Record<string, unknown>
174
+ }
138
175
 
139
176
  /**
140
177
  * Wait for the run this invoke started, and read its trail.
@@ -177,6 +214,15 @@ export function describeRun(run: AutomationRunSummary, steps: AutomationRunStep[
177
214
  const where = run.version === null ? 'from a dev session' : `v${run.version}`
178
215
  const lines = [`${run.status} — ${where}, ${run.ctxCalls} ctx call(s)`]
179
216
  if (run.errorMessage) lines.push(` ${run.errorMessage}`)
217
+ // What the run STARTED with, alongside what it returned. Present only when
218
+ // the detail response carries one — an automation with no declared inputs,
219
+ // or a listing route that omits the field, prints nothing extra here.
220
+ // Emptiness, not presence: the service coalesces the column to {} on every
221
+ // row, so a presence check would print `input {}` on every run of every
222
+ // automation — the web run panel guards the same way.
223
+ if (run.input && Object.keys(run.input).length > 0) {
224
+ lines.push(` input ${JSON.stringify(run.input)}`)
225
+ }
180
226
  // The trail is why an author ran this by hand: which steps executed, in what
181
227
  // order, and where the time went. Without it the CLI reported an outcome and
182
228
  // sent them to the Console to find out what happened.
@@ -206,13 +252,15 @@ export const automationRun: Command = {
206
252
  noun: 'automation',
207
253
  verb: 'run',
208
254
  args: [{ name: 'slug', required: true, description: 'automation slug' }],
209
- flags: { 'no-wait': 'boolean', version: 'string', dev: 'boolean' },
255
+ flags: { 'no-wait': 'boolean', version: 'string', dev: 'boolean', input: 'string' },
210
256
  summary: 'Run the live version now — or a named one, or the dev session — and report what it did',
211
257
  examples: [
212
258
  'frontera automation run daily-digest',
213
259
  'frontera automation run daily-digest --dev',
214
260
  'frontera automation run daily-digest --version 7',
215
261
  'frontera automation run daily-digest --no-wait',
262
+ 'frontera automation run ktp-extraction --input \'{"file_path":"files/…"}\'',
263
+ 'frontera automation run ktp-extraction --input @input.json',
216
264
  ],
217
265
  },
218
266
 
@@ -224,6 +272,7 @@ export const automationRun: Command = {
224
272
  // service as `NaN` would come back as a schema error naming a field the
225
273
  // author never typed.
226
274
  const version = parseVersionFlag(ctx)
275
+ const input = await parseInputFlag(ctx)
227
276
  const dev = flagBool(ctx, 'dev')
228
277
  if (dev && version !== undefined) {
229
278
  throw new UsageError(
@@ -243,7 +292,7 @@ export const automationRun: Command = {
243
292
  // the dev run.
244
293
  const before = await client.runs(slug, 1, 'dev')
245
294
  const priorId = before[0]?.id ?? null
246
- const queued = await client.run(slug, undefined, true)
295
+ const queued = await client.run(slug, undefined, true, input)
247
296
  if (flagBool(ctx, 'no-wait')) {
248
297
  return {
249
298
  data: queued,
@@ -289,7 +338,7 @@ export const automationRun: Command = {
289
338
  const before = await client.runs(slug, 1)
290
339
  const priorId = before[0]?.id ?? null
291
340
 
292
- const queued = await client.run(slug, version)
341
+ const queued = await client.run(slug, version, undefined, input)
293
342
 
294
343
  if (flagBool(ctx, 'no-wait')) {
295
344
  return {
package/src/exit.ts CHANGED
@@ -102,7 +102,84 @@ const SERVICE_HINTS: Record<string, string> = {
102
102
  SERVICE_UNAVAILABLE: 'the service is unavailable — retry shortly',
103
103
  }
104
104
 
105
- function isFronteraError(err: unknown): err is { code: string; message: string } {
105
+ /**
106
+ * Hints for the organization-key lane's refusals, keyed by the `detail`
107
+ * discriminator the service attaches to a 403.
108
+ *
109
+ * These exist because the FORBIDDEN hint above is wrong for them, not merely
110
+ * vague. It sends the operator to an admin, and for a lane refusal there is no
111
+ * admin action and no other organization key that changes the answer: the
112
+ * refusal is a property of the ROUTE, not of the credential. Telling somebody
113
+ * to ask for a grant they may already hold costs a support round-trip and ends
114
+ * where it started.
115
+ *
116
+ * A grant refusal — `check-permission.ts`, the ordinary "this key was not
117
+ * granted that capability" — carries no `detail` at all, and for that one the
118
+ * existing hint is exactly right. So absence is meaningful here, and it must
119
+ * fall through to the FORBIDDEN hint untouched rather than acquire one of its
120
+ * own.
121
+ */
122
+ const ORG_KEY_REFUSAL_HINTS: Record<string, string> = {
123
+ 'org-key-route-closed':
124
+ 'this route is not open to organization keys — use a session login or a workspace key, '
125
+ + 'or open the lane (docs/specs/2026-08-25-org-key-lane-coverage-spec.md)',
126
+ 'org-key-route-ungated':
127
+ 'this route performs no permission check an organization key can satisfy — '
128
+ + 'no grant and no admin changes that',
129
+ 'org-key-workspace-not-listed':
130
+ '--workspace names a workspace this key does not list — name one it lists, '
131
+ + 'or drop the flag to run at organization scope',
132
+ }
133
+
134
+ /**
135
+ * Read the refusal discriminator out of a service error's body.
136
+ *
137
+ * TWO SHAPES, because the service states the same kind of fact through two
138
+ * mechanisms and they serialize differently. A lane refusal is a hook's
139
+ * `status(403, …)`, so `detail` sits at the top level of the body. The
140
+ * workspace refusal is an `AppError` carrying `details`, and the service's
141
+ * `createErrorResponse` nests whatever that was under a `details` key — so the
142
+ * same string arrives one level deeper. Reading only the shape one happened to
143
+ * test against silently loses the other, and the one it would lose is the
144
+ * header mistake, which is the most fixable of the three.
145
+ *
146
+ * `details` on a `FronteraError` is the parsed response body, whatever it was:
147
+ * `errorFromResponse` in the SDK and every construction site in `src/api` put
148
+ * the body there verbatim. It is therefore unenumerated and untrusted, which is
149
+ * why this checks shapes rather than casting.
150
+ */
151
+ function refusalDetail(details: unknown): string | undefined {
152
+ if (typeof details !== 'object' || details === null) return undefined
153
+ const body = details as { detail?: unknown; details?: unknown }
154
+ if (typeof body.detail === 'string') return body.detail
155
+ const nested = body.details
156
+ if (typeof nested === 'object' && nested !== null) {
157
+ const inner = (nested as { detail?: unknown }).detail
158
+ if (typeof inner === 'string') return inner
159
+ }
160
+ return undefined
161
+ }
162
+
163
+ /**
164
+ * The best hint available for a service error: the refusal-specific one when
165
+ * the body names a refusal this CLI knows, the per-code one otherwise.
166
+ *
167
+ * An unrecognised `detail` falls through to the code's hint rather than to
168
+ * nothing, on the same reasoning `exitCodeFor` records: the service can add a
169
+ * discriminator without a CLI release, so "never seen it" has to stay ordinary.
170
+ */
171
+ function hintFor(code: string, details: unknown): string | undefined {
172
+ const detail = refusalDetail(details)
173
+ if (detail !== undefined) {
174
+ const specific = ORG_KEY_REFUSAL_HINTS[detail]
175
+ if (specific) return specific
176
+ }
177
+ return SERVICE_HINTS[code]
178
+ }
179
+
180
+ function isFronteraError(
181
+ err: unknown,
182
+ ): err is { code: string; message: string; details?: unknown } {
106
183
  return (
107
184
  err instanceof Error &&
108
185
  'code' in err &&
@@ -122,7 +199,7 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
122
199
  }
123
200
 
124
201
  if (isFronteraError(err)) {
125
- const hint = SERVICE_HINTS[err.code]
202
+ const hint = hintFor(err.code, err.details)
126
203
  return { error: true, code: err.code, message: err.message, ...(hint ? { hint } : {}) }
127
204
  }
128
205
 
package/src/flag-help.ts CHANGED
@@ -67,6 +67,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
67
67
  'no-git': 'scaffold without creating a repository and an initial commit',
68
68
  'no-components': 'scaffold without fetching the baseline shadcn components',
69
69
  version: 'version to publish, overriding the one in package.json',
70
+ input: "JSON object of the automation's declared inputs — inline ('{\"key\": 1}') or @file.json",
70
71
  'no-promote': 'publish the version without moving the live pointer',
71
72
  'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
72
73
  // Named for what it RUNS, not where it runs: an author reads this to decide