@frontera-sdk/cli 1.43.6 → 1.43.7

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,73 @@
1
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import { UsageError } from '../../errors'
6
+
7
+ /**
8
+ * Building an automation entry file, extracted from `index-commands`.
9
+ *
10
+ * Its own module because two verbs need it — `deploy` and `dev` — and
11
+ * `index-commands` imports `dev` to register the command. Left where it was,
12
+ * that is a require cycle whose resolution order is Bun-version-dependent: the
13
+ * command list evaluated before `automationDev` was initialised and threw
14
+ * `Cannot access 'automationDev' before initialization`. The same class of bug
15
+ * `binary-smoke.test.ts` exists to catch, and the same reason
16
+ * `function-builder.ts` takes its executor as a parameter.
17
+ */
18
+
19
+ /**
20
+ * Build the entry file, then read the manifest off the BUILT module.
21
+ *
22
+ * The artifact is the thing the runner will execute, so it is the thing worth
23
+ * inspecting: a manifest read from the source could differ from the one that
24
+ * ships. The cost is that importing it evaluates the module here, so anything
25
+ * an author does at import time runs on this machine — calling `automation()`
26
+ * and nothing else is the contract.
27
+ */
28
+ export async function buildAndExtract(file: string): Promise<{
29
+ manifest: Record<string, unknown>
30
+ code: string
31
+ }> {
32
+ const entry = resolve(file)
33
+ if (!existsSync(entry)) {
34
+ throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
35
+ }
36
+
37
+ // `throw: false`. The default raises an AggregateError whose message is the
38
+ // bare string "Bundle failed" — no file, no reason — and it escapes as an
39
+ // INTERNAL_ERROR, telling the caller to RETRY a build that will never
40
+ // succeed. A build error is something the caller fixes, so it exits 2.
41
+ const built = await Bun.build({
42
+ entrypoints: [entry],
43
+ target: 'bun',
44
+ minify: false,
45
+ throw: false,
46
+ })
47
+ if (!built.success) {
48
+ throw new UsageError(
49
+ `could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
50
+ 'fix the errors above, then run the command again',
51
+ )
52
+ }
53
+
54
+ const code = await built.outputs[0]!.text()
55
+
56
+ const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
57
+ try {
58
+ const bundle = join(dir, 'bundle.js')
59
+ await Bun.write(bundle, code)
60
+ const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
61
+ const manifest = mod.default?.manifest
62
+
63
+ if (!manifest || typeof manifest !== 'object') {
64
+ throw new UsageError(
65
+ `${file} has no automation as its default export`,
66
+ 'export the result of `automation({ … }, handler)` as the default export',
67
+ )
68
+ }
69
+ return { manifest: manifest as Record<string, unknown>, code }
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true })
72
+ }
73
+ }
@@ -0,0 +1,453 @@
1
+ import { mkdtempSync, statSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import { buildContext } from '@frontera-sdk/automation/runtime'
6
+ import { AutomationApi } from '../../api/automation-api'
7
+ import { CliError, UsageError } from '../../errors'
8
+ import { type Command, type CommandContext } from '../types'
9
+ import { validateManifest } from '@frontera-sdk/automation'
10
+ import { buildAndExtract } from './build-entry'
11
+
12
+ /**
13
+ * `frontera automation dev` — run the handler in THIS file, from this machine,
14
+ * triggered from the Console.
15
+ *
16
+ * The loop it replaces is a deploy: build, upload, mint a version, bump the
17
+ * registry, wait for the broker. Fifteen seconds and an immutable row, for
18
+ * questions as small as "is this field a number or a string". Measured on
19
+ * 2026-08-10: five versions to get one automation working, four of them
20
+ * discarded, none of them logic bugs.
21
+ *
22
+ * Resumption IS reproduced — see `localStepTools`. The handler is re-entered
23
+ * after every step, exactly as production does, so code outside a step repeats
24
+ * here the way it repeats live.
25
+ *
26
+ * What this still does NOT test, and why one real deploy before promoting
27
+ * remains worth doing: the bundle (this imports the module directly), and broker
28
+ * registration (nothing here reaches Inngest). And every ctx call is DRY, so the
29
+ * data a handler branches on is empty rather than real.
30
+ */
31
+
32
+ /** How often to ask for work. Fast enough that pressing Run feels immediate,
33
+ * slow enough that an idle session is not a busy loop against the service. */
34
+ const POLL_MS = 1_000
35
+
36
+ /** Lease renewal rides on the same poll, so there is no second timer to get out
37
+ * of step with it. */
38
+ export interface DevPollResult {
39
+ held: boolean
40
+ run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
41
+ }
42
+
43
+ /**
44
+ * Thrown to end an execution the moment a new step completes.
45
+ *
46
+ * Not an error the author can see or catch meaningfully — `runHandlerToCompletion`
47
+ * is the only thing that ever observes it. A class rather than a sentinel value
48
+ * so a handler wrapping its body in `try/catch` and swallowing everything is
49
+ * caught by the driver as the determinism bug it is, rather than silently
50
+ * finishing an execution that was supposed to yield.
51
+ */
52
+ class StepYield extends Error {
53
+ constructor() {
54
+ super('step yielded')
55
+ this.name = 'StepYield'
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A backstop, not a policy.
61
+ *
62
+ * Every yield memoizes exactly one step, so a handler with N steps settles in
63
+ * N+1 executions and this is never reached — the case it looks like it guards
64
+ * against, a step name built from a counter, is caught by the determinism check
65
+ * instead, which says something far more useful. It stays because the driver is
66
+ * an unbounded loop around author code, and an unbounded loop wants a ceiling
67
+ * even when the reasoning says it cannot spin.
68
+ */
69
+ const MAX_EXECUTIONS = 1_000
70
+
71
+ export interface DevStepState {
72
+ /** Step name → its JSON-round-tripped result, in completion order. */
73
+ memo: Map<string, unknown>
74
+ /** Names in the order the handler produced them, for the determinism check. */
75
+ order: string[]
76
+ }
77
+
78
+ export function newDevStepState(): DevStepState {
79
+ return { memo: new Map(), order: [] }
80
+ }
81
+
82
+ /**
83
+ * Step tools that resume, the way the deployed runner's do.
84
+ *
85
+ * They used to just call the body: the dev worker invoked the handler ONCE,
86
+ * straight through, and memoization had nothing to replay. That made dev a
87
+ * different execution model from production, which re-enters the handler after
88
+ * every step (`function-builder.ts` hands Inngest's own `step` down to `ctx`, so
89
+ * the author's `ctx.step.run` calls ARE the run's steps).
90
+ *
91
+ * The difference was not academic. Code OUTSIDE a step runs once per resumption
92
+ * in production and once in total here, so a counter incremented between two
93
+ * steps read `40` on a laptop and `120` live — no error, no warning, and only
94
+ * visible after a promote. Any `ctx` call outside a step cost 1 here and N+1
95
+ * there, so an automation that fitted the budget in dev could exceed it live.
96
+ *
97
+ * So: one new step per execution. A completed step is memoized and returns its
98
+ * stored value without running the body again; a step that has not run yet runs
99
+ * and then YIELDS, ending the execution. `runHandlerToCompletion` calls the
100
+ * handler again, and again, until it returns without yielding.
101
+ *
102
+ * Two things this buys beyond fidelity:
103
+ *
104
+ * - The memo is a JSON round trip, because that is what the platform stores
105
+ * and replays. `Date` comes back a string and `undefined` disappears, here,
106
+ * rather than after a deploy.
107
+ * - Re-execution makes non-determinism VISIBLE. A handler that produces a
108
+ * different step order on the second pass is broken in production in a way
109
+ * that reports nothing; here it names the step that moved.
110
+ */
111
+ export function localStepTools(
112
+ state: DevStepState = newDevStepState(),
113
+ ): { run<T>(id: string, fn: () => Promise<T>): Promise<unknown> } {
114
+ // Reset per EXECUTION, not per run: it counts what this pass has replayed, so
115
+ // the next unmemoized name can be checked against the recorded order.
116
+ let replayed = 0
117
+ return {
118
+ async run<T>(id: string, fn: () => Promise<T>): Promise<unknown> {
119
+ // The determinism check, and the reason re-execution is worth its cost.
120
+ // Production replays memoized results POSITIONALLY; a handler that emits a
121
+ // different order on the second pass gets another step's result handed to
122
+ // it, silently. Named here instead.
123
+ const expected = state.order[replayed]
124
+ if (expected !== undefined && expected !== id) {
125
+ throw new Error(
126
+ `this automation is not deterministic: step ${replayed + 1} was `
127
+ + `"${expected}" on an earlier execution and is "${id}" now. The platform `
128
+ + 'replays completed steps by position, so the two runs would be handed '
129
+ + "each other's results. Move anything conditional INSIDE a step.",
130
+ )
131
+ }
132
+
133
+ if (state.memo.has(id)) {
134
+ replayed += 1
135
+ return state.memo.get(id)
136
+ }
137
+
138
+ const out = await fn()
139
+ // Through JSON, because that is the boundary the platform puts here — the
140
+ // author gets back what a resumption would give them, not the object the
141
+ // body happened to return.
142
+ //
143
+ // Stringify FIRST and check. `JSON.stringify` returns `undefined` for a
144
+ // function, a symbol, or an object whose `toJSON()` returns undefined —
145
+ // and `JSON.parse(undefined)` then throws `SyntaxError: "undefined" is
146
+ // not valid JSON`, which escaped as the author's own error and failed the
147
+ // run naming neither the step nor the cause. The platform stores an
148
+ // unserialisable value as nothing rather than exploding, so parsing
149
+ // blindly was also a divergence in the one helper built for fidelity.
150
+ let json: string | undefined
151
+ try {
152
+ json = JSON.stringify(out)
153
+ } catch (err) {
154
+ // A `BigInt` or a circular structure cannot be stored by the platform
155
+ // either, so failing is right — anonymously is not. Named, because the
156
+ // author otherwise reads a `TypeError` from a line they did not write.
157
+ throw new Error(
158
+ `step "${id}" returned a value the platform cannot store: ${(err as Error).message}. `
159
+ + 'Step results make a JSON round trip between resumptions.',
160
+ )
161
+ }
162
+ state.memo.set(id, json === undefined ? undefined : JSON.parse(json))
163
+ state.order.push(id)
164
+ // Ends the execution. Everything after this call in the author's handler
165
+ // belongs to the NEXT one, which is precisely the production behaviour
166
+ // this exists to reproduce.
167
+ throw new StepYield()
168
+ },
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Call the handler until it finishes without yielding.
174
+ *
175
+ * The other half of `localStepTools`. One execution ends at the first step that
176
+ * has not run before; this brings it back, with that step now memoized, and the
177
+ * handler gets one step further. For N steps the handler is entered N+1 times —
178
+ * the same shape as a production run, and the reason code outside a step is now
179
+ * seen to repeat.
180
+ *
181
+ * `buildContext` is called per execution rather than once, because production
182
+ * builds a fresh `ctx` on every resumption too — the duplicate-step-name set
183
+ * inside it is scoped to one execution, and reusing a context would make the
184
+ * second pass reject every name it had already seen.
185
+ */
186
+ export async function runHandlerToCompletion(
187
+ handler: (ctx: unknown) => Promise<unknown>,
188
+ buildContextFor: (step: ReturnType<typeof localStepTools>) => unknown,
189
+ state: DevStepState = newDevStepState(),
190
+ ): Promise<unknown> {
191
+ for (let execution = 0; ; execution += 1) {
192
+ if (execution > MAX_EXECUTIONS) {
193
+ throw new Error(
194
+ `this automation did not settle after ${MAX_EXECUTIONS} executions, which `
195
+ + 'should not be reachable — please report it with the handler that caused it.',
196
+ )
197
+ }
198
+ try {
199
+ return await handler(buildContextFor(localStepTools(state)))
200
+ } catch (err) {
201
+ // Anything that is NOT the yield is the author's error, and belongs to
202
+ // them: it fails the run and reaches the Console with its message.
203
+ if (!(err instanceof StepYield)) throw err
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * A fresh module path per build.
210
+ *
211
+ * Bun's module registry is keyed by resolved PATH: re-writing the same path with
212
+ * new bytes and importing again returns the FIRST module — stale code, with no
213
+ * error to read. The deployed runner escapes this by accident of design, since
214
+ * every deploy mints a random version id and therefore a new path. A worker
215
+ * rebuilding one slug has no such key and must make one.
216
+ */
217
+ export function devModulePath(dir: string, counter: number): string {
218
+ return join(dir, `build-${counter}.js`)
219
+ }
220
+
221
+
222
+ /**
223
+ * Close a dev run.
224
+ *
225
+ * A plain `fetch`, not the workspace client: this is the runner protocol, and
226
+ * the only credential the local process holds for it is the RUN token the claim
227
+ * handed over. Routing it through `FronteraClient` would attach the workspace
228
+ * key to a call that must not need one.
229
+ */
230
+ async function finishDevRun(
231
+ apiUrl: string,
232
+ runId: string,
233
+ runToken: string,
234
+ body: { status: 'succeeded' | 'failed'; result?: unknown; errorMessage?: string },
235
+ ): Promise<void> {
236
+ await fetch(`${apiUrl}/v1/automations/runner/runs/${runId}/finish`, {
237
+ method: 'POST',
238
+ headers: { 'content-type': 'application/json', 'x-automation-run-token': runToken },
239
+ body: JSON.stringify(body),
240
+ })
241
+ }
242
+
243
+ /**
244
+ * How often to rebuild the entry so the manifest sent with each poll is current.
245
+ *
246
+ * Not every poll: `Bun.build` is milliseconds but a second is a lot of them, and
247
+ * an idle session would spend its life rebuilding a file nobody touched. The
248
+ * entry's mtime is the cheap proxy — an edit moves it, and nothing else does.
249
+ */
250
+ function mtimeOf(path: string): number {
251
+ try {
252
+ return statSync(path).mtimeMs
253
+ } catch {
254
+ return 0
255
+ }
256
+ }
257
+
258
+ export const automationDev: Command = {
259
+ meta: {
260
+ noun: 'automation',
261
+ verb: 'dev',
262
+ args: [
263
+ {
264
+ name: 'file',
265
+ required: true,
266
+ description: 'entry file whose default export is an `automation(…)`',
267
+ },
268
+ ],
269
+ flags: {},
270
+ summary: 'Serve this file to dev runs — no deploy, no version',
271
+ examples: ['frontera automation dev src/index.ts'],
272
+ },
273
+
274
+ async run(ctx: CommandContext) {
275
+ const file = ctx.positional[0]
276
+ if (!file) {
277
+ throw new UsageError('missing <file>', 'frontera automation dev <file>')
278
+ }
279
+ const entry = resolve(file)
280
+
281
+ // Built once up front for its MANIFEST: the slug is what the session is
282
+ // opened against, and building later would mean claiming a session for a
283
+ // name the file may not carry. The bundle from this build is discarded —
284
+ // every run rebuilds, so an edit made while waiting is picked up.
285
+ let { manifest } = await buildAndExtract(entry)
286
+ const invalid = validateManifest(manifest)
287
+ 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
+ )
292
+ }
293
+ let lastMtime = mtimeOf(entry)
294
+ // VALIDATED HERE, once, before a session is opened.
295
+ //
296
+ // The comment this replaces said `validateManifest` had already run inside
297
+ // the build. It had not: `buildAndExtract` only asserts the default export
298
+ // carries a `manifest` object, and `automation()` checks `name` and nothing
299
+ // else. So a file with no trigger, or a six-field cron, built fine, opened a
300
+ // session fine, and then failed EVERY poll — which the worker printed as
301
+ // "poll failed, retrying" until the lease lapsed and the author was told
302
+ // they had been taken over.
303
+ const slug = typeof manifest.name === 'string' ? manifest.name : ''
304
+ if (!slug) {
305
+ throw new UsageError(
306
+ `${file} does not name an automation`,
307
+ 'the default export must be `automation({ name: "…" }, handler)`',
308
+ )
309
+ }
310
+
311
+ const api = new AutomationApi(ctx.apiUrl, ctx.token)
312
+ // Identity of THIS process, minted once and sent with every dev call.
313
+ //
314
+ // The session id cannot serve: take-over is an upsert on the automation, so
315
+ // the row and its id outlive the hand-over and a displaced worker's
316
+ // heartbeat still matches. Nor can the user id, which two terminals belonging
317
+ // to one developer share — the common case, and how this was found.
318
+ const holderId = crypto.randomUUID()
319
+ const session = await api.devSessionOpen(slug, entry, holderId)
320
+
321
+ const out = ctx.output
322
+ out.note(`[dev] ${slug} — running from ${file}`)
323
+ out.note(`[dev] connected to ${ctx.apiUrl}`)
324
+ // The first question an author has is whether this disturbs production, and
325
+ // no verb name can answer it. Stated before anything else happens.
326
+ out.note(
327
+ session.liveVersionId
328
+ ? '[dev] the live version keeps running on schedule; this only serves dev runs'
329
+ : '[dev] nothing is deployed yet, so nothing runs on a schedule',
330
+ )
331
+ // Said at START, not only on the run line. An author reads the header once
332
+ // and then watches runs scroll past; a caveat printed per run is noise, and
333
+ // one printed nowhere is a surprise the first time they wonder why a query
334
+ // came back empty.
335
+ out.note('[dev] runs are DRY — ctx.agent, ctx.http and ctx.blueprint return empty and send nothing')
336
+ if (session.created) out.note(`[dev] created the automation "${slug}" (no version yet)`)
337
+ out.note(`[dev] waiting — press Run dev in the Console, or \`frontera automation run ${slug} --dev\``)
338
+
339
+ const workDir = mkdtempSync(join(tmpdir(), 'frontera-dev-'))
340
+ let builds = 0
341
+ let stopping = false
342
+
343
+ // Released explicitly so the Console banner clears at once rather than
344
+ // waiting out the lease. Best-effort by nature — SIGKILL never arrives here,
345
+ // and the lease is what covers that.
346
+ const release = async () => {
347
+ if (stopping) return
348
+ stopping = true
349
+ out.note('\n[dev] releasing the session')
350
+ await api.devSessionClose(slug, session.sessionId, holderId).catch(() => {})
351
+ process.exit(0)
352
+ }
353
+ process.on('SIGINT', () => void release())
354
+ process.on('SIGTERM', () => void release())
355
+
356
+ while (!stopping) {
357
+ // Rebuild only when the file moved. The manifest goes up with every
358
+ // poll so the service can set a claimed run's grants from what the file
359
+ // says NOW — but rebuilding once a second for an idle session would be
360
+ // work nobody asked for.
361
+ const mtime = mtimeOf(entry)
362
+ if (mtime !== lastMtime) {
363
+ try {
364
+ manifest = (await buildAndExtract(entry)).manifest
365
+ lastMtime = mtime
366
+ } catch (err) {
367
+ // A broken file must not end the session — the author is mid-edit.
368
+ // The previous manifest stays in force until the file parses again.
369
+ out.note(`[dev] build failed, keeping the last manifest: ${(err as Error).message}`)
370
+ lastMtime = mtime
371
+ }
372
+ }
373
+
374
+ let poll: DevPollResult
375
+ try {
376
+ poll = await api.devPoll(slug, session.sessionId, holderId, manifest)
377
+ } catch (err) {
378
+ // A service blip must not end a session the author still has open.
379
+ out.note(`[dev] poll failed, retrying: ${(err as Error).message}`)
380
+ await new Promise((r) => setTimeout(r, POLL_MS))
381
+ continue
382
+ }
383
+
384
+ if (!poll.held) {
385
+ throw new CliError(
386
+ 'this dev session is no longer held — another `automation dev` took it over, '
387
+ + 'or the lease expired',
388
+ {
389
+ code: 'REQUEST_FAILED',
390
+ // Re-opening silently would let two processes ping-pong a session
391
+ // neither can keep, and a run would execute against whichever file
392
+ // won the last exchange.
393
+ hint: 'start it again if you still want to serve this automation',
394
+ },
395
+ )
396
+ }
397
+
398
+ if (!poll.run) {
399
+ await new Promise((r) => setTimeout(r, POLL_MS))
400
+ continue
401
+ }
402
+
403
+ const run = poll.run
404
+ out.note(`[dev] run ${run.runId.slice(0, 8)} — building`)
405
+ const started = Date.now()
406
+ try {
407
+ // REBUILT per run, not once at startup. `Bun.build` on one automation is
408
+ // milliseconds, and the alternative — edit, press Run, watch the
409
+ // previous code execute — is silent staleness with no error to read.
410
+ const built = await buildAndExtract(entry)
411
+ const path = devModulePath(workDir, ++builds)
412
+ await Bun.write(path, built.code)
413
+ const mod = (await import(path)) as {
414
+ default?: { handler?: (c: unknown) => Promise<unknown> }
415
+ }
416
+ if (typeof mod.default?.handler !== 'function') {
417
+ throw new Error(`${file} has no automation() default export`)
418
+ }
419
+
420
+ // Entered once per step, not once per run — see `localStepTools`. A
421
+ // fresh context each time, because production builds one per resumption
422
+ // and its duplicate-name set is scoped to a single execution.
423
+ const result = await runHandlerToCompletion(mod.default.handler, (step) =>
424
+ buildContext({
425
+ runId: run.runId,
426
+ workspaceId: run.workspaceId,
427
+ runToken: run.runToken,
428
+ grants: run.grants,
429
+ step,
430
+ serviceUrl: ctx.apiUrl,
431
+ }),
432
+ )
433
+ await finishDevRun(ctx.apiUrl, run.runId, run.runToken, { status: 'succeeded', result })
434
+ // "succeeded" is the wrong word for a run whose every ctx call returned
435
+ // empty. It finished; whether it works is not something a dry run can
436
+ // say, and the terminal is where an author decides to promote.
437
+ out.note(`[dev] run ${run.runId.slice(0, 8)} finished (dry) in ${Date.now() - started}ms`)
438
+ } catch (err) {
439
+ 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.
443
+ await finishDevRun(ctx.apiUrl, run.runId, run.runToken, {
444
+ status: 'failed',
445
+ errorMessage: message,
446
+ }).catch(() => {})
447
+ out.note(`[dev] run ${run.runId.slice(0, 8)} failed: ${message}`)
448
+ }
449
+ }
450
+
451
+ return { data: { slug, sessionId: session.sessionId }, text: '' }
452
+ },
453
+ }
@@ -10,6 +10,10 @@ import { packDirectory } from '../../pack'
10
10
  import { automationInit } from './init'
11
11
  import { automationPull } from './pull'
12
12
  import { automationRun, automationRuns } from './run'
13
+ import { automationDev } from './dev'
14
+ import { buildAndExtract } from './build-entry'
15
+
16
+ export { buildAndExtract } from './build-entry'
13
17
  import { findAutomationProjectRoot } from './project-root'
14
18
  import { formatBytes } from '../../packaging'
15
19
  import { table } from '../../table'
@@ -27,61 +31,6 @@ function requireSlug(ctx: CommandContext): string {
27
31
  return slug
28
32
  }
29
33
 
30
- /**
31
- * Build the entry file, then read the manifest off the BUILT module.
32
- *
33
- * The artifact is the thing the runner will execute, so it is the thing worth
34
- * inspecting: a manifest read from the source could differ from the one that
35
- * ships. The cost is that importing it evaluates the module here, so anything
36
- * an author does at import time runs on this machine — calling `automation()`
37
- * and nothing else is the contract.
38
- */
39
- export async function buildAndExtract(file: string): Promise<{
40
- manifest: Record<string, unknown>
41
- code: string
42
- }> {
43
- const entry = resolve(file)
44
- if (!existsSync(entry)) {
45
- throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
46
- }
47
-
48
- // `throw: false`. The default raises an AggregateError whose message is the
49
- // bare string "Bundle failed" — no file, no reason — and it escapes as an
50
- // INTERNAL_ERROR, telling the caller to RETRY a build that will never
51
- // succeed. A build error is something the caller fixes, so it exits 2.
52
- const built = await Bun.build({
53
- entrypoints: [entry],
54
- target: 'bun',
55
- minify: false,
56
- throw: false,
57
- })
58
- if (!built.success) {
59
- throw new UsageError(
60
- `could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
61
- 'fix the errors above, then run the command again',
62
- )
63
- }
64
-
65
- const code = await built.outputs[0]!.text()
66
-
67
- const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
68
- try {
69
- const bundle = join(dir, 'bundle.js')
70
- await Bun.write(bundle, code)
71
- const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
72
- const manifest = mod.default?.manifest
73
-
74
- if (!manifest || typeof manifest !== 'object') {
75
- throw new UsageError(
76
- `${file} has no automation as its default export`,
77
- 'export the result of `automation({ … }, handler)` as the default export',
78
- )
79
- }
80
- return { manifest: manifest as Record<string, unknown>, code }
81
- } finally {
82
- rmSync(dir, { recursive: true, force: true })
83
- }
84
- }
85
34
 
86
35
  /** How stale a registry poll may be before a deploy is worth warning about.
87
36
  * The runner's default interval is 15s, so a minute is several missed polls —
@@ -363,4 +312,5 @@ export const automationCommands: Command[] = [
363
312
  automationRuns,
364
313
  automationPull,
365
314
  automationInit,
315
+ automationDev,
366
316
  ]