@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 +3 -3
- package/src/api/automation-api.ts +31 -3
- package/src/automation-template.ts +6 -0
- package/src/blueprint-types.ts +39 -2
- package/src/commands/automation/dev.ts +103 -12
- package/src/commands/automation/run.ts +52 -3
- package/src/exit.ts +79 -2
- package/src/flag-help.ts +1 -0
- package/src/vendor/kit-assets.json +4 -4
- package/src/vendor/sdk-sources.json +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.45.
|
|
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.
|
|
42
|
-
"@frontera-sdk/core": "1.45.
|
|
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
|
|
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: {
|
|
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
|
|
package/src/blueprint-types.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
-
): {
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|