@frontera-sdk/cli 0.1.0

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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. package/src/vendor/sdk-sources.json +25 -0
@@ -0,0 +1,31 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { UsageError } from '../../errors'
3
+ import { projectApp, requireProjectFrom } from './shared'
4
+ import type { Command } from '../types'
5
+
6
+ export const appPromote: Command = {
7
+ meta: {
8
+ noun: 'app',
9
+ verb: 'promote',
10
+ args: [{ name: 'version', required: true, description: 'version to make live' }],
11
+ flags: {},
12
+ summary: 'Move the live pointer to an already-published version',
13
+ examples: ['frontera app promote 1.4.0'],
14
+ needsProject: true,
15
+ },
16
+
17
+ async run(ctx) {
18
+ const version = ctx.positional[0]
19
+ if (!version) throw new UsageError('missing <version>', 'frontera app promote <version>')
20
+
21
+ const project = requireProjectFrom(ctx)
22
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
23
+ const app = await projectApp(client, project)
24
+ const res = await client.deploy(app.id, version)
25
+
26
+ return {
27
+ data: { app: app.slug, appId: app.id, deployedVersion: res.deployedVersion },
28
+ text: `Promoted ${app.slug}@${res.deployedVersion}`,
29
+ }
30
+ },
31
+ }
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { gunzipSync } from 'node:zlib'
4
+
5
+ import { AppsApi } from '../../api/apps-api'
6
+ import { CliError, UsageError } from '../../errors'
7
+ import { healProject } from '../../heal'
8
+ import { dirtyFiles, writeState } from '../../project'
9
+ import { flagBool, type Command } from '../types'
10
+
11
+ /** Unpack a gzipped ustar archive onto disk. */
12
+ function unpackTo(dir: string, gz: Uint8Array): number {
13
+ const buf = new Uint8Array(gunzipSync(Buffer.from(gz)))
14
+ const dec = new TextDecoder()
15
+ const readStr = (start: number, len: number) => {
16
+ let end = start
17
+ while (end < start + len && buf[end] !== 0) end++
18
+ return dec.decode(buf.subarray(start, end))
19
+ }
20
+ let off = 0
21
+ let count = 0
22
+ while (off + 512 <= buf.length) {
23
+ const name = readStr(off, 100)
24
+ if (name === '') break
25
+ const prefix = readStr(off + 345, 155)
26
+ const size = parseInt(readStr(off + 124, 12).trim() || '0', 8)
27
+ const dataStart = off + 512
28
+ if (buf[off + 156] === 0x30 || buf[off + 156] === 0) {
29
+ const rel = (prefix ? `${prefix}/${name}` : name).replace(/^\.?\//, '')
30
+ if (rel && !rel.split('/').includes('..')) {
31
+ const full = join(dir, rel)
32
+ mkdirSync(dirname(full), { recursive: true })
33
+ writeFileSync(full, buf.subarray(dataStart, dataStart + size))
34
+ count++
35
+ }
36
+ }
37
+ off = dataStart + Math.ceil(size / 512) * 512
38
+ }
39
+ return count
40
+ }
41
+
42
+ export const appPull: Command = {
43
+ meta: {
44
+ noun: 'app',
45
+ verb: 'pull',
46
+ args: [{ name: 'app[@version]', required: true, description: 'app slug or id, optionally pinned to a version' }],
47
+ flags: { draft: 'boolean', force: 'boolean' },
48
+ summary:
49
+ 'Hydrate a working tree from a published version or your saved draft, into the current directory or --dir',
50
+ examples: [
51
+ 'frontera app pull shipments-console --dir ~/apps/shipments-console',
52
+ 'frontera app pull shipments-console',
53
+ 'frontera app pull shipments-console@1.4.0',
54
+ 'frontera app pull shipments-console --draft',
55
+ ],
56
+ // Pull hydrates a directory that is usually empty, so it must not require
57
+ // an existing project — but it is the command that most needs `--dir`.
58
+ //
59
+ // It unpacks INTO that directory and never creates a `<dir>/<slug>`
60
+ // beneath it. The summary says so because two other texts once assumed
61
+ // otherwise and sent an agent hunting for a subdirectory that was never
62
+ // made; `--dir` is first in the examples for the same reason.
63
+ optionalProject: true,
64
+ },
65
+
66
+ async run(ctx) {
67
+ const target = ctx.positional[0]
68
+ if (!target) throw new UsageError('missing <app>', 'frontera app pull <app>[@version]')
69
+ const [slug, pinned] = target.split('@')
70
+
71
+ const dir = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
72
+
73
+ // Refuse before destroying. Inside a Computer sandbox there is often no
74
+ // git to recover from, so erring toward refusal costs one --force and
75
+ // erring the other way costs someone's afternoon.
76
+ const dirty = dirtyFiles(dir)
77
+ if (dirty.length > 0 && !flagBool(ctx, 'force')) {
78
+ const shown = dirty.slice(0, 10).map((f) => ` ${f}`).join('\n')
79
+ throw new CliError(
80
+ `refusing to pull: ${dirty.length} file(s) in the working tree would be overwritten\n${shown}` +
81
+ (dirty.length > 10 ? `\n …and ${dirty.length - 10} more` : ''),
82
+ { code: 'CONFLICT', hint: 'commit or stash them, or re-run with --force' },
83
+ )
84
+ }
85
+
86
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
87
+ // Resolve, never ensure: pulling an unknown or renamed slug must fail,
88
+ // not quietly create an empty app to pull nothing from.
89
+ const app = await client.resolveApp(slug!)
90
+
91
+ let bytes: Uint8Array
92
+ let parentVersion: string | null
93
+
94
+ if (flagBool(ctx, 'draft')) {
95
+ const draft = await client.downloadDraft(app.id).catch(() => null)
96
+ if (!draft) {
97
+ // The generic NOT_FOUND hint ("list the resource first") is wrong here:
98
+ // the app resolved fine, it simply has no source stored. That happens
99
+ // for an app created in the console and never deployed from the CLI.
100
+ throw new CliError(`no source stored for ${app.slug}`, {
101
+ code: 'NOT_FOUND',
102
+ hint: 'this app has no saved draft and no deployed version — start from `frontera app init <name>`',
103
+ })
104
+ }
105
+ bytes = draft.bytes
106
+ parentVersion = draft.parentVersion
107
+ } else {
108
+ const versions = await client.listVersions(app.id)
109
+ const chosen = pinned
110
+ ? versions.find((v) => v.version === pinned)
111
+ : versions.find((v) => v.deployed) ?? versions[0]
112
+ if (!chosen) {
113
+ // NOT `app versions`: that needs a project, and the directory being
114
+ // pulled into is empty by definition — the hint would send the caller
115
+ // to a command that cannot run where they are.
116
+ throw new CliError(`no ${pinned ?? 'deployed'} version to pull for ${slug}`, {
117
+ code: 'NOT_FOUND',
118
+ hint: pinned
119
+ ? `run \`frontera app list\` to see apps, or omit @${pinned} to take the live one`
120
+ : `this app has never been deployed — pull the saved draft with \`frontera app pull ${slug} --draft\``,
121
+ })
122
+ }
123
+ bytes = await client.downloadSource(app.id, chosen.version)
124
+ parentVersion = chosen.version
125
+ }
126
+
127
+ const count = unpackTo(dir, bytes)
128
+ writeState(dir, { appId: app.id, parentVersion })
129
+
130
+ // Pull's contract is "give me a working copy", and a copy that cannot
131
+ // `bun install` is not one. Apps stored by an older toolchain carry
132
+ // `@frontera-sdk/*` dependencies pinned to absolute paths on the machine that
133
+ // published them, and no dev host. Repair on the way out rather than
134
+ // leaving the caller to discover it — see heal.ts.
135
+ const { repairs } = healProject(dir)
136
+
137
+ const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`
138
+ return {
139
+ data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs },
140
+ text: repairs.length
141
+ ? `${pulled}\nRepaired legacy packaging:\n${repairs.map((r) => ` ${r}`).join('\n')}`
142
+ : pulled,
143
+ }
144
+ },
145
+ }
@@ -0,0 +1,36 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { packDirectory } from '../../pack'
3
+ import { writeAppId, writeState } from '../../project'
4
+ import { requireProjectFrom } from './shared'
5
+ import type { Command } from '../types'
6
+
7
+ export const appSave: Command = {
8
+ meta: {
9
+ noun: 'app',
10
+ verb: 'save',
11
+ args: [],
12
+ flags: {},
13
+ summary: 'Store the working tree as your draft, without building',
14
+ examples: ['frontera app save'],
15
+ needsProject: true,
16
+ },
17
+
18
+ async run(ctx) {
19
+ const project = requireProjectFrom(ctx)
20
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
21
+
22
+ // save and deploy are the only verbs that may bring an app into
23
+ // existence; every read verb resolves instead.
24
+ const app = await client.ensureApp(project.slug, project.displayName, project.appId)
25
+ const { tgz } = packDirectory(project.root, ctx.output, 'packaged')
26
+ const res = await client.saveDraft(app.id, tgz, project.parentVersion)
27
+
28
+ writeState(project.root, { appId: app.id })
29
+ writeAppId(project.root, app.id)
30
+
31
+ return {
32
+ data: { app: app.slug, appId: app.id, fileCount: res.fileCount },
33
+ text: `Saved draft for ${app.slug} (${res.fileCount} files)`,
34
+ }
35
+ },
36
+ }
@@ -0,0 +1,25 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { UsageError } from '../../errors'
3
+ import type { AppProject } from '../../context'
4
+ import type { CommandContext } from '../types'
5
+
6
+ export function requireProjectFrom(ctx: CommandContext): AppProject {
7
+ if (!ctx.project) {
8
+ throw new UsageError(
9
+ 'not in a Frontera app directory',
10
+ 'cd into an app project, or run `frontera app init <name>`',
11
+ )
12
+ }
13
+ return ctx.project
14
+ }
15
+
16
+ /**
17
+ * The app this project belongs to, without creating one.
18
+ *
19
+ * Only `save` and `deploy` may bring an app into existence. A read-only verb
20
+ * that ensured instead would turn a typo, or a slug the platform had since
21
+ * renamed, into a brand new empty app.
22
+ */
23
+ export function projectApp(client: AppsApi, project: AppProject) {
24
+ return client.resolveApp(project.appId ?? project.slug)
25
+ }
@@ -0,0 +1,38 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { formatBytes } from '../../packaging'
3
+ import { projectApp, requireProjectFrom } from './shared'
4
+ import type { Command } from '../types'
5
+
6
+ export const appVersions: Command = {
7
+ meta: {
8
+ noun: 'app',
9
+ verb: 'versions',
10
+ args: [],
11
+ flags: {},
12
+ summary: 'List published versions, newest first; * marks the live one',
13
+ examples: ['frontera app versions', 'frontera app versions --json'],
14
+ needsProject: true,
15
+ },
16
+
17
+ async run(ctx) {
18
+ const project = requireProjectFrom(ctx)
19
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
20
+ const app = await projectApp(client, project)
21
+ const versions = await client.listVersions(app.id)
22
+
23
+ return {
24
+ data: versions,
25
+ text:
26
+ versions.length === 0
27
+ ? 'No versions published yet.'
28
+ : versions
29
+ .map(
30
+ (v) =>
31
+ `${v.deployed ? '*' : ' '} ${v.version.padEnd(12)} ` +
32
+ `parent=${(v.parentVersion ?? '-').padEnd(10)} ` +
33
+ `${v.fileCount} files ${formatBytes(v.totalBytes)}`,
34
+ )
35
+ .join('\n'),
36
+ }
37
+ },
38
+ }
@@ -0,0 +1,325 @@
1
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import { validateManifest } from '@frontera-sdk/automation'
6
+
7
+ import { AutomationApi } from '../../api/automation-api'
8
+ import { UsageError } from '../../errors'
9
+ import { formatBytes } from '../../packaging'
10
+ import { table } from '../../table'
11
+ import { flagBool, type Command, type CommandContext } from '../types'
12
+
13
+ /** The one required argument every verb but `list` takes. */
14
+ function requireSlug(ctx: CommandContext): string {
15
+ const slug = ctx.positional[0]
16
+ if (!slug) {
17
+ throw new UsageError(
18
+ 'missing <slug>',
19
+ 'run `frontera automation list` — then pass the slug of the one you mean',
20
+ )
21
+ }
22
+ return slug
23
+ }
24
+
25
+ /**
26
+ * Build the entry file, then read the manifest off the BUILT module.
27
+ *
28
+ * The artifact is the thing the runner will execute, so it is the thing worth
29
+ * inspecting: a manifest read from the source could differ from the one that
30
+ * ships. The cost is that importing it evaluates the module here, so anything
31
+ * an author does at import time runs on this machine — calling `automation()`
32
+ * and nothing else is the contract.
33
+ */
34
+ export async function buildAndExtract(file: string): Promise<{
35
+ manifest: Record<string, unknown>
36
+ code: string
37
+ }> {
38
+ const entry = resolve(file)
39
+ if (!existsSync(entry)) {
40
+ throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
41
+ }
42
+
43
+ // `throw: false`. The default raises an AggregateError whose message is the
44
+ // bare string "Bundle failed" — no file, no reason — and it escapes as an
45
+ // INTERNAL_ERROR, telling the caller to RETRY a build that will never
46
+ // succeed. A build error is something the caller fixes, so it exits 2.
47
+ const built = await Bun.build({
48
+ entrypoints: [entry],
49
+ target: 'bun',
50
+ minify: false,
51
+ throw: false,
52
+ })
53
+ if (!built.success) {
54
+ throw new UsageError(
55
+ `could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
56
+ 'fix the errors above, then run the command again',
57
+ )
58
+ }
59
+
60
+ const code = await built.outputs[0]!.text()
61
+
62
+ const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
63
+ try {
64
+ const bundle = join(dir, 'bundle.js')
65
+ await Bun.write(bundle, code)
66
+ const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
67
+ const manifest = mod.default?.manifest
68
+
69
+ if (!manifest || typeof manifest !== 'object') {
70
+ throw new UsageError(
71
+ `${file} has no automation as its default export`,
72
+ 'export the result of `automation({ … }, handler)` as the default export',
73
+ )
74
+ }
75
+ return { manifest: manifest as Record<string, unknown>, code }
76
+ } finally {
77
+ rmSync(dir, { recursive: true, force: true })
78
+ }
79
+ }
80
+
81
+ /** How stale a registry poll may be before a deploy is worth warning about.
82
+ * The runner's default interval is 15s, so a minute is several missed polls —
83
+ * long enough not to fire on a restart, short enough to catch a dead runner. */
84
+ const RUNNER_STALE_MS = 60_000
85
+
86
+ /**
87
+ * The warning to print for a deploy, or null when a runner is clearly alive.
88
+ *
89
+ * Two distinct states, because the remedies differ: nothing has EVER polled
90
+ * (no runner is deployed in this environment at all) versus one polled but has
91
+ * since stopped (it is deployed and unhealthy).
92
+ */
93
+ export function runnerWarningFor(
94
+ lastSeen: string | null | undefined,
95
+ now: number = Date.now(),
96
+ ): string | null {
97
+ // `undefined` and `null` are NOT the same thing here. `undefined` means the
98
+ // service did not send the field — an older build — and inferring "no runner"
99
+ // from silence would warn falsely against every environment predating it.
100
+ // `null` is the service explicitly saying nothing has ever polled.
101
+ if (lastSeen === undefined) return null
102
+ if (lastSeen === null) {
103
+ return 'no automation runner has ever polled this environment — the automation is stored and live, but nothing will execute it'
104
+ }
105
+ const age = now - new Date(lastSeen).getTime()
106
+ if (Number.isNaN(age) || age <= RUNNER_STALE_MS) return null
107
+ return `no automation runner has polled for ${Math.round(age / 1000)}s — the automation may not execute`
108
+ }
109
+
110
+ const deploy: Command = {
111
+ meta: {
112
+ noun: 'automation',
113
+ verb: 'deploy',
114
+ args: [
115
+ {
116
+ name: 'file',
117
+ required: true,
118
+ description: 'entry file whose default export is an `automation(…)`',
119
+ },
120
+ ],
121
+ flags: { 'no-promote': 'boolean' },
122
+ summary: 'Build and deploy an automation, promoting it live by default',
123
+ examples: [
124
+ 'frontera automation deploy src/daily-digest.ts',
125
+ 'frontera automation deploy src/daily-digest.ts --no-promote',
126
+ ],
127
+ },
128
+
129
+ async run(ctx) {
130
+ const file = ctx.positional[0]
131
+ if (!file) {
132
+ throw new UsageError('missing <file>', 'frontera automation deploy <file>')
133
+ }
134
+
135
+ const { manifest, code } = await buildAndExtract(file)
136
+
137
+ // A courtesy check — the service validates again and is authoritative. It
138
+ // earns its place by failing before the upload, and by printing the
139
+ // warnings the service discards: an unknown key is not an error, so
140
+ // `concurrancy: 100` would otherwise deploy "successfully" and run at the
141
+ // default of 1 with nothing on screen to say so.
142
+ const check = validateManifest(manifest)
143
+ for (const warning of check.warnings) ctx.output.note(` warning: ${warning}`)
144
+ if (!check.valid) {
145
+ throw new UsageError(
146
+ `invalid manifest: ${check.errors.join('; ')}`,
147
+ 'fix the manifest passed to `automation()`, then deploy again',
148
+ )
149
+ }
150
+
151
+ const slug = manifest.name as string
152
+ const bundle = new TextEncoder().encode(code)
153
+ ctx.output.note(` bundle ${formatBytes(bundle.byteLength)}`)
154
+
155
+ const client = new AutomationApi(ctx.apiUrl, ctx.token)
156
+ const result = await client.deploy(slug, {
157
+ manifest,
158
+ bundle,
159
+ promote: !flagBool(ctx, 'no-promote'),
160
+ })
161
+
162
+ // A deploy into an environment with no runner succeeds at every layer —
163
+ // the bundle is stored, the version is live, `list` says ENABLED — and
164
+ // nothing ever fires, because nothing polls the registry. `runnerLastSeenAt`
165
+ // is the only signal that distinguishes "scheduled" from "scheduled and
166
+ // will actually run", so say it out loud rather than reporting success.
167
+ const runnerWarning = runnerWarningFor(result.runnerLastSeenAt)
168
+ if (runnerWarning) ctx.output.note(` warning: ${runnerWarning}`)
169
+
170
+ return {
171
+ data: { slug, ...result },
172
+ // `result.promoted` rather than the flag: the service decides, and
173
+ // reporting the request instead of the outcome is how a CLI comes to
174
+ // claim something is live when it is not.
175
+ text:
176
+ `Deployed ${slug} v${result.version}` +
177
+ (result.promoted
178
+ ? ' and promoted it'
179
+ : ` (not promoted — use \`frontera automation promote ${slug} ${result.version}\`)`),
180
+ }
181
+ },
182
+ }
183
+
184
+ const list: Command = {
185
+ meta: {
186
+ noun: 'automation',
187
+ verb: 'list',
188
+ args: [],
189
+ flags: {},
190
+ summary: 'List automations in this workspace',
191
+ examples: ['frontera automation list', 'frontera automation list --json'],
192
+ },
193
+
194
+ async run(ctx) {
195
+ const rows = await new AutomationApi(ctx.apiUrl, ctx.token).list()
196
+
197
+ return {
198
+ data: rows,
199
+ text:
200
+ rows.length === 0
201
+ ? 'No automations in this workspace.'
202
+ : // LIVE is its own column because it is a separate fact from
203
+ // ENABLED: a deploy with --no-promote leaves an automation enabled
204
+ // with nothing live, and nothing runs. One column would hide that.
205
+ table(
206
+ ['slug', 'enabled', 'live', 'description'],
207
+ rows.map((a) => [
208
+ a.slug,
209
+ String(a.enabled),
210
+ a.liveVersionId ? 'yes' : 'no',
211
+ a.description ?? '',
212
+ ]),
213
+ [undefined, undefined, undefined, 60],
214
+ ),
215
+ }
216
+ },
217
+ }
218
+
219
+ const versions: Command = {
220
+ meta: {
221
+ noun: 'automation',
222
+ verb: 'versions',
223
+ args: [{ name: 'slug', required: true, description: 'automation slug, from `frontera automation list`' }],
224
+ flags: {},
225
+ summary: 'List deployed versions, newest first; * marks the live one',
226
+ examples: ['frontera automation versions daily-digest'],
227
+ },
228
+
229
+ async run(ctx) {
230
+ const slug = requireSlug(ctx)
231
+ const client = new AutomationApi(ctx.apiUrl, ctx.token)
232
+
233
+ // Which version is LIVE is on the automation row, not on any version, so
234
+ // it takes a second read. Worth it: "which one is running" is the question
235
+ // this command exists to answer, and the alternative is making the caller
236
+ // run `list` and match a uuid by eye.
237
+ const [rows, all] = await Promise.all([client.versions(slug), client.list()])
238
+ const liveVersionId = all.find((a) => a.slug === slug)?.liveVersionId ?? null
239
+
240
+ return {
241
+ data: rows.map((v) => ({ ...v, live: v.id === liveVersionId })),
242
+ text:
243
+ rows.length === 0
244
+ ? `No versions deployed for ${slug}.`
245
+ : table(
246
+ ['live', 'version', 'digest', 'size', 'created'],
247
+ rows.map((v) => [
248
+ v.id === liveVersionId ? '*' : '',
249
+ String(v.version),
250
+ v.contentDigest.slice(0, 12),
251
+ formatBytes(v.totalBytes),
252
+ v.createdAt,
253
+ ]),
254
+ ),
255
+ }
256
+ },
257
+ }
258
+
259
+ const promote: Command = {
260
+ meta: {
261
+ noun: 'automation',
262
+ verb: 'promote',
263
+ args: [
264
+ { name: 'slug', required: true, description: 'automation slug' },
265
+ { name: 'version', required: true, description: 'version number, from `frontera automation versions`' },
266
+ ],
267
+ flags: {},
268
+ summary: 'Move the live pointer to an already-deployed version',
269
+ examples: ['frontera automation promote daily-digest 3'],
270
+ },
271
+
272
+ async run(ctx) {
273
+ const slug = requireSlug(ctx)
274
+ const raw = ctx.positional[1]
275
+ const version = Number(raw)
276
+ // Caught here rather than by the service so a typo costs no round trip,
277
+ // and so the message names the command that lists the real ones.
278
+ if (raw === undefined || !Number.isInteger(version)) {
279
+ throw new UsageError(
280
+ `version must be an integer, got ${raw === undefined ? '(none)' : `"${raw}"`}`,
281
+ `run \`frontera automation versions ${slug}\` to see the versions that exist`,
282
+ )
283
+ }
284
+
285
+ const result = await new AutomationApi(ctx.apiUrl, ctx.token).promote(slug, version)
286
+ return { data: result, text: `Promoted ${slug} to v${result.version}` }
287
+ },
288
+ }
289
+
290
+ /**
291
+ * `disable` is the kill switch, so it is its own verb rather than
292
+ * `enable --false`: stopping a misbehaving automation is done under pressure,
293
+ * and a flag is one more thing to get wrong.
294
+ */
295
+ function enabledCommand(verb: 'enable' | 'disable', summary: string): Command {
296
+ const enabled = verb === 'enable'
297
+ return {
298
+ meta: {
299
+ noun: 'automation',
300
+ verb,
301
+ args: [{ name: 'slug', required: true, description: 'automation slug' }],
302
+ flags: {},
303
+ summary,
304
+ examples: [`frontera automation ${verb} daily-digest`],
305
+ },
306
+
307
+ async run(ctx) {
308
+ const slug = requireSlug(ctx)
309
+ const result = await new AutomationApi(ctx.apiUrl, ctx.token).setEnabled(slug, enabled)
310
+ return {
311
+ data: result,
312
+ text: `${enabled ? 'Enabled' : 'Disabled'} ${slug}`,
313
+ }
314
+ },
315
+ }
316
+ }
317
+
318
+ export const automationCommands: Command[] = [
319
+ deploy,
320
+ list,
321
+ versions,
322
+ promote,
323
+ enabledCommand('enable', 'Resume scheduled execution'),
324
+ enabledCommand('disable', 'Stop scheduled execution immediately — the kill switch'),
325
+ ]