@frontera-sdk/cli 0.1.0 → 1.43.5

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 (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
@@ -7,6 +7,7 @@ import { resolveManifest } from '../../manifest'
7
7
  import { collectSourceFiles, formatBytes } from '../../packaging'
8
8
  import { packDirectory } from '../../pack'
9
9
  import { readPackageVersion, writeAppId, writeState } from '../../project'
10
+ import { describeRenderEvidence, readRenderEvidence } from '../../render-evidence'
10
11
  import { createTarGz } from '../../tar'
11
12
  import { requireProjectFrom } from './shared'
12
13
  import { flagBool, flagString, type Command } from '../types'
@@ -23,7 +24,7 @@ async function publish(
23
24
  client: AppsApi,
24
25
  appId: string,
25
26
  args: Parameters<AppsApi['publish']>[1],
26
- ): Promise<{ version: string; promoted: boolean }> {
27
+ ): Promise<Awaited<ReturnType<AppsApi['publish']>>> {
27
28
  try {
28
29
  return await client.publish(appId, args)
29
30
  } catch (err) {
@@ -77,6 +78,14 @@ export const appDeploy: Command = {
77
78
  const reach = [...manifest.connectDomains, ...manifest.resourceDomains]
78
79
  if (reach.length > 0) ctx.output.note(` may reach ${reach.join(', ')}`)
79
80
 
81
+ // Asked BEFORE the upload, so the answer is about the tree that is being
82
+ // packaged rather than about whatever the disk looks like afterwards.
83
+ // Reported whichever way it comes out: "rendered" is worth saying too, and
84
+ // a line that only ever appears when something is wrong is a line people
85
+ // learn to skim past.
86
+ const renderEvidence = readRenderEvidence(project.root)
87
+ ctx.output.note(describeRenderEvidence(renderEvidence))
88
+
80
89
  const promote = !flagBool(ctx, 'no-promote')
81
90
  const res = await publish(client, app.id, {
82
91
  version,
@@ -85,6 +94,7 @@ export const appDeploy: Command = {
85
94
  source,
86
95
  manifest,
87
96
  promote,
97
+ renderEvidence,
88
98
  })
89
99
 
90
100
  writeState(project.root, { appId: app.id, parentVersion: res.version })
@@ -92,14 +102,44 @@ export const appDeploy: Command = {
92
102
  // the slug is renamed — and any stale slug is dropped.
93
103
  writeAppId(project.root, app.id)
94
104
 
105
+ // The one line that turns "published" into something a person can act on.
106
+ // A version that was published and not promoted used to be described to
107
+ // users as one nobody could see — so the turn ended by asking to make an
108
+ // unreviewed build live for the whole workspace, rather than showing it to
109
+ // the person who asked for it. It was always viewable; only the URL was
110
+ // missing.
111
+ if (res.previewUrl) {
112
+ ctx.output.note(
113
+ res.promoted
114
+ ? ` live at ${res.liveUrl ?? ''} · this exact build: ${res.previewUrl}`
115
+ : ` open it: ${res.previewUrl}`,
116
+ )
117
+ }
118
+
95
119
  return {
96
- data: { app: app.slug, appId: app.id, version: res.version, promoted: res.promoted },
120
+ data: {
121
+ app: app.slug,
122
+ appId: app.id,
123
+ version: res.version,
124
+ promoted: res.promoted,
125
+ ...(res.previewUrl ? { previewUrl: res.previewUrl } : {}),
126
+ ...(res.liveUrl && res.promoted ? { liveUrl: res.liveUrl } : {}),
127
+ // In `data` as well as in the note: an agent reads the JSON, and this
128
+ // is the fact it most needs to carry into what it tells the user.
129
+ rendered: renderEvidence.rendered,
130
+ renderEvidence,
131
+ },
97
132
  // The slug as the PLATFORM knows it: `project.slug` is a bootstrap name
98
133
  // and goes stale the moment the app is renamed, so printing it would
99
134
  // report a deploy to an app that no longer exists.
100
135
  text:
101
136
  `Published ${app.slug}@${res.version}` +
102
- (res.promoted ? ' and promoted it' : ' (not promoted — use `frontera app promote`)'),
137
+ (res.promoted ? ' and promoted it' : '') +
138
+ (renderEvidence.rendered ? '' : ' — nobody has looked at this version') +
139
+ // Not "use `frontera app promote`". That read as "invisible until you
140
+ // promote", which is false and is what sent an agent asking to publish
141
+ // an unreviewed build to everyone.
142
+ (res.previewUrl ? `\n\nOpen it: ${res.previewUrl}` : ''),
103
143
  }
104
144
  },
105
145
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
  import { UsageError } from '../../errors'
4
4
  import { scaffold, scaffoldFiles } from '../../template'
5
5
  import { writeHarnessFiles } from '../../harness'
6
+ import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
6
7
  import type { Command } from '../types'
7
8
 
8
9
  export const appInit: Command = {
@@ -38,8 +39,29 @@ export const appInit: Command = {
38
39
  ).length
39
40
  ctx.output.note(` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by alias`)
40
41
 
42
+ // The dev host reads VITE_FRONTERA_TOKEN and 401s on every platform read
43
+ // without it. The credential is already on this machine; only a scaffolded
44
+ // project never received it, so every app built from scratch rendered
45
+ // empty — and an agent looking at a page with no numbers on it cannot see
46
+ // that one of the numbers is wrong.
47
+ //
48
+ // Best-effort by construction: `offline: true` above means this command
49
+ // must work before anyone has logged in.
50
+ const devEnv = writeDevEnv(target)
51
+ ctx.output.note(
52
+ devEnv.written
53
+ ? ` ${DEV_ENV_FILE} written — the dev host will read real data`
54
+ : ` no ${DEV_ENV_FILE} (${devEnv.reason === 'no-credential' ? 'no stored credential' : devEnv.reason}) — the dev host will mount but platform reads will 401`,
55
+ )
56
+
41
57
  return {
42
- data: { name, path: target, harnessFiles: harness.written, vendoredSdkFiles: vendored },
58
+ data: {
59
+ name,
60
+ path: target,
61
+ harnessFiles: harness.written,
62
+ vendoredSdkFiles: vendored,
63
+ devEnvWritten: devEnv.written,
64
+ },
43
65
  text: [
44
66
  `Scaffolded ${name}`,
45
67
  '',
@@ -1,43 +1,12 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import { gunzipSync } from 'node:zlib'
1
+ import { join } from 'node:path'
4
2
 
5
3
  import { AppsApi } from '../../api/apps-api'
6
4
  import { CliError, UsageError } from '../../errors'
7
5
  import { healProject } from '../../heal'
8
6
  import { dirtyFiles, writeState } from '../../project'
7
+ import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
9
8
  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
- }
9
+ import { unpackTo } from '../../untar'
41
10
 
42
11
  export const appPull: Command = {
43
12
  meta: {
@@ -134,9 +103,17 @@ export const appPull: Command = {
134
103
  // leaving the caller to discover it — see heal.ts.
135
104
  const { repairs } = healProject(dir)
136
105
 
106
+ // Same reason as `app init`: the dev host reads VITE_FRONTERA_TOKEN and
107
+ // 401s without it. `.env.local` is never packaged, so a pulled copy never
108
+ // carries the publisher's — every working copy has to be given one here.
109
+ // Never overwrites, so an author who pointed this project somewhere else
110
+ // keeps their setting.
111
+ const devEnv = writeDevEnv(dir)
112
+ if (devEnv.written) ctx.output.note(` ${DEV_ENV_FILE} written — the dev host will read real data`)
113
+
137
114
  const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`
138
115
  return {
139
- data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs },
116
+ data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs, devEnvWritten: devEnv.written },
140
117
  text: repairs.length
141
118
  ? `${pulled}\nRepaired legacy packaging:\n${repairs.map((r) => ` ${r}`).join('\n')}`
142
119
  : pulled,
@@ -6,6 +6,11 @@ import { validateManifest } from '@frontera-sdk/automation'
6
6
 
7
7
  import { AutomationApi } from '../../api/automation-api'
8
8
  import { UsageError } from '../../errors'
9
+ import { packDirectory } from '../../pack'
10
+ import { automationInit } from './init'
11
+ import { automationPull } from './pull'
12
+ import { automationRun, automationRuns } from './run'
13
+ import { findAutomationProjectRoot } from './project-root'
9
14
  import { formatBytes } from '../../packaging'
10
15
  import { table } from '../../table'
11
16
  import { flagBool, type Command, type CommandContext } from '../types'
@@ -152,10 +157,37 @@ const deploy: Command = {
152
157
  const bundle = new TextEncoder().encode(code)
153
158
  ctx.output.note(` bundle ${formatBytes(bundle.byteLength)}`)
154
159
 
160
+ // The PROJECT root, not the entry file's directory. Packing `src/` omits
161
+ // package.json and tsconfig.json, so the pulled project cannot build — a
162
+ // first pass did exactly that and stored a one-member archive.
163
+ //
164
+ // `collectSourceFiles` excludes node_modules, build output, and `.env` /
165
+ // `.env.*` at any depth, which is what makes source retention safe to turn
166
+ // on: an FDE's project-local credential must never reach the bucket. That
167
+ // exclusion is deliberately not optional (see packaging.ts).
168
+ const projectRoot = findAutomationProjectRoot(resolve(file))
169
+ let source: Uint8Array | undefined
170
+ if (projectRoot) {
171
+ source = packDirectory(projectRoot, ctx.output, 'source').tgz
172
+ } else {
173
+ // Degrade honestly rather than refuse. Authoring inside this monorepo is
174
+ // still supported and simply cannot retain source — there is no
175
+ // package.json between the file and a workspace root, and packing a
176
+ // workspace root would upload the whole monorepo. Saying so is the point:
177
+ // the alternative is a version that silently cannot be pulled.
178
+ ctx.output.note(
179
+ ' warning: no automation project found (no package.json above '
180
+ + `${file}, or the nearest one is a workspace root) — deploying WITHOUT `
181
+ + 'source, so `frontera automation pull` will not work for this version. '
182
+ + 'Run `frontera automation init` to author in a project.',
183
+ )
184
+ }
185
+
155
186
  const client = new AutomationApi(ctx.apiUrl, ctx.token)
156
187
  const result = await client.deploy(slug, {
157
188
  manifest,
158
189
  bundle,
190
+ source,
159
191
  promote: !flagBool(ctx, 'no-promote'),
160
192
  })
161
193
 
@@ -243,10 +275,15 @@ const versions: Command = {
243
275
  rows.length === 0
244
276
  ? `No versions deployed for ${slug}.`
245
277
  : table(
246
- ['live', 'version', 'digest', 'size', 'created'],
278
+ ['live', 'version', 'state', 'digest', 'size', 'created'],
247
279
  rows.map((v) => [
248
280
  v.id === liveVersionId ? '*' : '',
249
281
  String(v.version),
282
+ // A retired version is still LISTED — the row is the record that
283
+ // the version number was used, and numbers are never reused. But
284
+ // listing it unmarked implies it can be pulled or promoted, and
285
+ // it can be neither: retention deleted its bundle and source.
286
+ v.retiredAt ? 'retired' : '',
250
287
  v.contentDigest.slice(0, 12),
251
288
  formatBytes(v.totalBytes),
252
289
  v.createdAt,
@@ -322,4 +359,8 @@ export const automationCommands: Command[] = [
322
359
  promote,
323
360
  enabledCommand('enable', 'Resume scheduled execution'),
324
361
  enabledCommand('disable', 'Stop scheduled execution immediately — the kill switch'),
362
+ automationRun,
363
+ automationRuns,
364
+ automationPull,
365
+ automationInit,
325
366
  ]
@@ -0,0 +1,52 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { automationScaffoldFiles, scaffoldAutomation } from '../../automation-template'
4
+ import { UsageError } from '../../errors'
5
+ import type { Command } from '../types'
6
+
7
+ export const automationInit: Command = {
8
+ meta: {
9
+ noun: 'automation',
10
+ verb: 'init',
11
+ args: [{
12
+ name: 'name',
13
+ required: true,
14
+ description: 'directory and automation slug for the new project',
15
+ }],
16
+ flags: { dir: 'string' },
17
+ summary: 'Scaffold a new automation project',
18
+ examples: ['frontera automation init daily-digest'],
19
+ // Scaffolding must work before a credential exists.
20
+ offline: true,
21
+ },
22
+
23
+ async run(ctx) {
24
+ const name = ctx.positional[0]
25
+ if (!name) throw new UsageError('missing <name>', 'frontera automation init <name>')
26
+
27
+ const base = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
28
+ const target = join(base, name)
29
+ scaffoldAutomation(target, name)
30
+
31
+ // Reported because it is the one surprising thing about the tree: an agent or
32
+ // engineer who does not know the SDK is vendored will try to "fix" the
33
+ // imports into a package dependency that resolves nowhere.
34
+ const vendored = Object.keys(automationScaffoldFiles(name))
35
+ .filter((f) => f.startsWith('src/frontera/')).length
36
+ ctx.output.note(
37
+ ` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by tsconfig paths`,
38
+ )
39
+
40
+ return {
41
+ data: { name, path: target, vendoredSdkFiles: vendored },
42
+ text: [
43
+ `Scaffolded ${name}`,
44
+ '',
45
+ ` cd ${name}`,
46
+ ' bun install',
47
+ ' bun run typecheck',
48
+ ' frontera automation deploy src/index.ts',
49
+ ].join('\n'),
50
+ }
51
+ },
52
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, parse } from 'node:path'
3
+
4
+ /**
5
+ * The project directory to pack as an automation's source, or null.
6
+ *
7
+ * Walks up from the entry file to the nearest `package.json`. Two rules, and both
8
+ * exist because the obvious implementations are actively harmful:
9
+ *
10
+ * - NOT the entry file's own directory. `src/index.ts` would pack `src/` and
11
+ * omit `package.json` and `tsconfig.json` — so a pulled project would
12
+ * contain a lone source file and could not build. Measured: a first pass did
13
+ * exactly this and stored a one-member archive.
14
+ *
15
+ * - NOT a workspace root. An automation authored inside this monorepo has no
16
+ * `package.json` between it and the repo root, so an unguarded walk would
17
+ * pack the WHOLE monorepo into a bucket. A `package.json` declaring
18
+ * `workspaces` is therefore refused as a root.
19
+ *
20
+ * Returning null rather than throwing is deliberate: authoring inside the
21
+ * monorepo is still supported, it simply cannot retain source. The caller says
22
+ * so out loud and deploys anyway, and `pull` reports the missing source
23
+ * actionably. Refusing would break a workflow that works.
24
+ */
25
+ export function findAutomationProjectRoot(entryFile: string): string | null {
26
+ const { root } = parse(entryFile)
27
+ let dir = dirname(entryFile)
28
+
29
+ while (true) {
30
+ const manifest = join(dir, 'package.json')
31
+ if (existsSync(manifest)) {
32
+ let parsed: unknown
33
+ try {
34
+ parsed = JSON.parse(readFileSync(manifest, 'utf8'))
35
+ } catch {
36
+ // Cannot verify this is NOT a workspace root, and the hazard being
37
+ // guarded is uploading an entire monorepo. Packing it and walking past it
38
+ // both risk that — packing directly, walking past by reaching a parent
39
+ // project the author never meant to publish. So refuse: deploy proceeds
40
+ // without source and says so.
41
+ return null
42
+ }
43
+ const isWorkspaceRoot =
44
+ typeof parsed === 'object'
45
+ && parsed !== null
46
+ && 'workspaces' in (parsed as Record<string, unknown>)
47
+ // A workspace root is never the answer. Stop rather than continue: every
48
+ // ancestor above it is even less likely to be the project, and walking on
49
+ // risks finding some unrelated package.json further up the filesystem.
50
+ if (isWorkspaceRoot) return null
51
+ return dir
52
+ }
53
+ if (dir === root) return null
54
+ const parent = dirname(dir)
55
+ if (parent === dir) return null
56
+ dir = parent
57
+ }
58
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, readdirSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { AutomationApi } from '../../api/automation-api'
5
+ import { UsageError } from '../../errors'
6
+ import { unpackTo } from '../../untar'
7
+ import { flagBool, type Command } from '../types'
8
+
9
+ /** Split `slug` or `slug@3` into its parts. Exported for its own tests. */
10
+ export function parseTarget(raw: string): { slug: string; version: number | null } {
11
+ const at = raw.lastIndexOf('@')
12
+ if (at === -1) {
13
+ if (!raw) throw new UsageError('missing slug', 'frontera automation pull <slug>[@version]')
14
+ return { slug: raw, version: null }
15
+ }
16
+ const slug = raw.slice(0, at)
17
+ const rest = raw.slice(at + 1)
18
+ if (!slug) throw new UsageError('missing slug', 'frontera automation pull <slug>[@version]')
19
+ if (!/^\d+$/.test(rest)) {
20
+ // Not silently falling back to latest: handing back a different version than
21
+ // the one named is the failure mode this phase exists to prevent.
22
+ throw new UsageError(
23
+ `version must be a number, got "${rest}"`,
24
+ 'frontera automation pull <slug>@3',
25
+ )
26
+ }
27
+ return { slug, version: Number(rest) }
28
+ }
29
+
30
+ /**
31
+ * Where a pull writes, refusing to clobber.
32
+ *
33
+ * There are no server-side drafts for automations, so uncommitted local work a
34
+ * pull overwrites is gone. Refusing by default and requiring `--force` puts that
35
+ * decision in the author's hands. `readdirSync` counts dotfiles, so a directory
36
+ * holding only a `.git` is non-empty — which is exactly the case where clobbering
37
+ * costs the most.
38
+ *
39
+ * `resolve`, not `join`: `join(cwd, '/tmp/x')` yields `<cwd>/tmp/x`, so an
40
+ * absolute `--dir` landed inside the working directory. Measured — a pull with
41
+ * `--dir /tmp/pulled` wrote to `/tmp/pulled/tmp/pulled`. Exported for its own
42
+ * tests.
43
+ */
44
+ export function resolveTargetDir(
45
+ cwd: string,
46
+ slug: string,
47
+ dirFlag: string | undefined,
48
+ force: boolean,
49
+ ): string {
50
+ // `--dir` is a GLOBAL flag: `main.ts` sets `cwd` to it before any command
51
+ // runs. So when it is present the target IS `cwd`, and resolving its VALUE
52
+ // again here appended it twice — the documented `--dir ./recovered` wrote to
53
+ // `./recovered/recovered`. Only its PRESENCE is signal; the value is already
54
+ // accounted for. An absolute flag hid this, because resolving an absolute path
55
+ // a second time is idempotent.
56
+ const target = dirFlag === undefined ? resolve(cwd, slug) : resolve(cwd)
57
+ if (!force && existsSync(target) && readdirSync(target).length > 0) {
58
+ throw new UsageError(
59
+ `${target} is not empty`,
60
+ 'pull into a new directory, or pass --force to overwrite',
61
+ )
62
+ }
63
+ return target
64
+ }
65
+
66
+ export const automationPull: Command = {
67
+ meta: {
68
+ noun: 'automation',
69
+ verb: 'pull',
70
+ args: [{
71
+ name: 'slug[@version]',
72
+ required: true,
73
+ description: 'automation slug, optionally pinned to a version',
74
+ }],
75
+ flags: { dir: 'string', force: 'boolean' },
76
+ summary: 'Hydrate an editable project from a deployed version',
77
+ examples: [
78
+ 'frontera automation pull daily-digest',
79
+ 'frontera automation pull daily-digest@3 --dir ./recovered',
80
+ ],
81
+ },
82
+
83
+ async run(ctx) {
84
+ const raw = ctx.positional[0]
85
+ if (!raw) throw new UsageError('missing <slug>', 'frontera automation pull <slug>[@version]')
86
+ const { slug, version } = parseTarget(raw)
87
+
88
+ const client = new AutomationApi(ctx.apiUrl, ctx.token)
89
+
90
+ // "Latest" is resolved against the version list rather than by inventing a
91
+ // server endpoint for it — `versions` already exists and is authoritative.
92
+ let resolved = version
93
+ if (resolved === null) {
94
+ const list = await client.versions(slug)
95
+ if (list.length === 0) throw new UsageError(`${slug} has no versions`, 'deploy it first')
96
+ resolved = Math.max(...list.map((v) => v.version))
97
+ }
98
+
99
+ // Refuse BEFORE downloading. Discovering the directory is occupied after a
100
+ // network round trip wastes it, and worse, invites a --force retry that the
101
+ // author has not actually thought about.
102
+ const target = resolveTargetDir(
103
+ ctx.cwd,
104
+ slug,
105
+ typeof ctx.flags.dir === 'string' ? ctx.flags.dir : undefined,
106
+ flagBool(ctx, 'force'),
107
+ )
108
+
109
+ const gz = await client.downloadSource(slug, resolved)
110
+ mkdirSync(target, { recursive: true })
111
+ const count = unpackTo(target, gz)
112
+
113
+ return {
114
+ data: { slug, version: resolved, path: target, files: count },
115
+ text: [
116
+ `Pulled ${slug} v${resolved} into ${target} (${count} files)`,
117
+ '',
118
+ ` cd ${target}`,
119
+ ' bun install',
120
+ ' frontera automation deploy src/index.ts',
121
+ ].join('\n'),
122
+ }
123
+ },
124
+ }