@frontera-sdk/cli 1.50.24 → 1.50.26

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.50.24",
3
+ "version": "1.50.26",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.50.24",
43
- "@frontera-sdk/core": "1.50.24",
42
+ "@frontera-sdk/functions": "1.50.26",
43
+ "@frontera-sdk/core": "1.50.26",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.50.24",
49
+ "@frontera-sdk/forge-contracts": "1.50.26",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -29,6 +29,17 @@ export interface WorkflowSummary {
29
29
  cron: string | null
30
30
  subjectApiName: string | null
31
31
  subjectLabel: string | null
32
+ /** One per webhook trigger. Absent from a service that predates webhooks. */
33
+ webhooks?: WorkflowWebhook[]
34
+ }
35
+
36
+ export interface WorkflowWebhook {
37
+ methods: string[]
38
+ path: string
39
+ auth: string
40
+ respond: string
41
+ url: string
42
+ testUrl: string
32
43
  }
33
44
 
34
45
  /** A row of `GET /v1/workflows/:slug/versions`. */
@@ -238,8 +238,9 @@ export const appInit: Command = {
238
238
 
239
239
  let installed = false
240
240
  if (!flagBool(ctx, 'no-install')) {
241
- ctx.output.note('Installing dependencies with bun…')
242
- const result = await installDependencies(target)
241
+ const result = await ctx.output.task('Installing dependencies with bun', () =>
242
+ installDependencies(target),
243
+ )
243
244
  installed = result.installed
244
245
  ctx.output.note(
245
246
  result.installed
@@ -255,8 +256,10 @@ export const appInit: Command = {
255
256
  // command that finishes the job instead of being swallowed.
256
257
  let components = false
257
258
  if (framework === 'next' && !flagBool(ctx, 'no-components')) {
258
- ctx.output.note(`Adding shadcn components: ${BASELINE_COMPONENTS.join(', ')}…`)
259
- const result = await addShadcnComponents(target)
259
+ const result = await ctx.output.task(
260
+ `Adding shadcn components: ${BASELINE_COMPONENTS.join(', ')}`,
261
+ () => addShadcnComponents(target),
262
+ )
260
263
  components = result.added
261
264
  ctx.output.note(result.added ? ` ${result.summary}` : ` not added: ${result.summary}`)
262
265
  ctx.output.note('')
@@ -25,12 +25,17 @@ export const workflowGet: Command = {
25
25
  const slug = requireSlug(ctx)
26
26
  const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
27
27
  const [workflow, versions] = await Promise.all([client.get(slug), client.versions(slug)])
28
+ const webhooks = (workflow.webhooks ?? []).flatMap((h) => {
29
+ const methods = h.methods.join(',')
30
+ return [`${methods} ${h.url} (${h.auth}, ${h.respond})`, `${'test'.padEnd(methods.length)} ${h.testUrl}`]
31
+ })
28
32
 
29
33
  const facts = [
30
34
  `${workflow.slug}${workflow.enabled ? '' : ' (disabled)'}`,
31
35
  workflow.description ? ` ${workflow.description}` : '',
32
36
  ` live ${workflow.liveVersion === null ? 'nothing — no version has been promoted' : `v${workflow.liveVersion}`}`,
33
37
  ` triggers ${(workflow.triggerKinds ?? []).join(', ') || 'none'}`,
38
+ webhooks.length > 0 ? ` webhooks ${webhooks.join('\n ')}` : '',
34
39
  // Only when bound. An unbound workflow has no subject line to print, and
35
40
  // an empty one reads as a missing value rather than an absent concept.
36
41
  workflow.subjectApiName ? ` subject ${workflow.subjectLabel ?? workflow.subjectApiName}` : '',
package/src/output.ts CHANGED
@@ -12,10 +12,51 @@ export interface Output {
12
12
  render(data: unknown, toText: (data: unknown) => string): void
13
13
  /** Progress and warnings. Always stderr, so piping stays clean. */
14
14
  note(message: string): void
15
+ /**
16
+ * Run something slow and say so while it runs.
17
+ *
18
+ * `note` announces and then goes quiet, which is fine for a step that takes
19
+ * a moment and wrong for one that takes half a minute: installing packages
20
+ * and adding components both sit there with a cursor and no evidence the
21
+ * command is alive. This keeps a line moving for exactly as long as the work
22
+ * takes, then replaces it with the outcome and how long it took.
23
+ *
24
+ * The value and the failure both pass straight through — this is a wrapper
25
+ * around work, not a place where errors are handled.
26
+ */
27
+ task<T>(label: string, run: () => Promise<T>): Promise<T>
15
28
  /** Report a failure and return the exit code the shell should use. */
16
29
  fail(err: unknown): ExitCode
17
30
  }
18
31
 
32
+ /**
33
+ * Braille frames: one character wide in every terminal font, so the line does
34
+ * not jump as it spins.
35
+ */
36
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
37
+ const SPINNER_INTERVAL_MS = 80
38
+
39
+ /**
40
+ * Colour for the progress line, decided against STDERR.
41
+ *
42
+ * `help.ts` asks the same question of stdout, which is right for help text and
43
+ * wrong here: progress is written to stderr, and a run whose stdout is piped
44
+ * into a file still has a terminal on the other stream. Honouring NO_COLOR the
45
+ * same way keeps one rule across the CLI.
46
+ */
47
+ function colorOnErr(): boolean {
48
+ if (process.env.NO_COLOR !== undefined) return false
49
+ if (process.env.FORCE_COLOR !== undefined) return true
50
+ return process.stderr.isTTY === true
51
+ }
52
+
53
+ const paintErr = (code: string) => (text: string) =>
54
+ colorOnErr() ? `\u001b[${code}m${text}\u001b[39m` : text
55
+
56
+ const green = paintErr('32')
57
+ const red = paintErr('31')
58
+ const grey = paintErr('90')
59
+
19
60
  const defaultStreams: Streams = {
20
61
  out: (chunk) => process.stdout.write(chunk),
21
62
  err: (chunk) => process.stderr.write(chunk),
@@ -51,6 +92,52 @@ export function createOutput(
51
92
  streams.err(`${message}\n`)
52
93
  },
53
94
 
95
+ async task(label, run) {
96
+ // Animation only where a human is watching. Piped or redirected, the
97
+ // carriage returns would land in the file as one unreadable line, and in
98
+ // CI logs as thousands of them — so those get the label once and the
99
+ // result once, which is what a log is for.
100
+ const animate = !quiet && streams === defaultStreams && process.stderr.isTTY === true
101
+ if (quiet) return run()
102
+ if (!animate) {
103
+ streams.err(`${label}\n`)
104
+ return run()
105
+ }
106
+
107
+ const started = Date.now()
108
+ let frame = 0
109
+ const paint = () => {
110
+ // The frame itself stays the terminal's own colour: it is motion, not
111
+ // status. Colour arrives once with the ✓ or ✗, where it means something.
112
+ const spin = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length]!
113
+ streams.err(`\r\u001b[2K${spin} ${label}`)
114
+ }
115
+ paint()
116
+ const timer = setInterval(paint, SPINNER_INTERVAL_MS)
117
+
118
+ // `unref` so a spinner can never be the reason the process stays alive:
119
+ // a timer is a handle, and one left running outlives the work it was
120
+ // describing.
121
+ timer.unref?.()
122
+
123
+ const stop = (mark: string) => {
124
+ clearInterval(timer)
125
+ const seconds = ((Date.now() - started) / 1000).toFixed(1)
126
+ streams.err(`\r\u001b[2K${mark} ${label} ${grey(`[${seconds}s]`)}\n`)
127
+ }
128
+
129
+ try {
130
+ const result = await run()
131
+ stop(green('✓'))
132
+ return result
133
+ } catch (err) {
134
+ // The line is closed before the error travels on, or the failure prints
135
+ // over a half-finished spinner.
136
+ stop(red('✗'))
137
+ throw err
138
+ }
139
+ },
140
+
54
141
  fail(err) {
55
142
  const envelope = toEnvelope(err)
56
143
  if (mode === 'json') {