bod-cli 0.9.2 → 0.10.2

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.
@@ -124,6 +124,16 @@ Open an app in the default browser. Resolves the URL from the app's domain.
124
124
  bod open my-api # opens https://my-api.bodify.example.com
125
125
  ```
126
126
 
127
+ ### `bod host <path> [--slug <s>] [--app <appId>]`
128
+ Publish a file or folder to a public CDN URL (`https://serve.bod.ee/s/<slug>/…`). Handles create → upload → finalize and prints the live URL. Ephemeral (48h) unless `--app` links it to an app (then persistent). See the **bod-serve** skill for the full flow.
129
+
130
+ ```bash
131
+ bod host ./dist # a static site → live URL
132
+ bod host ./clip.mp3 # a single file (video/audio/pdf/image)
133
+ bod host ./dist --slug my-site # reuse/update an existing slug
134
+ bod host ./assets --app my-api # persistent app assets
135
+ ```
136
+
127
137
  ### `bod env list|set|unset <app>`
128
138
  Manage environment variables.
129
139
 
package/CLAUDE.md CHANGED
@@ -33,7 +33,7 @@ src/
33
33
  ├── rollback.ts # bod rollback [app]
34
34
  ├── apps.ts # bod apps list|status
35
35
  ├── logs.ts # bod logs <app> [-f]
36
- ├── env.ts # bod env list|set|unset (set supports -f .env)
36
+ ├── env.ts # bod env list|set|unset; scoped by <app>/--global/--group (+ --env); set supports -f .env
37
37
  ├── open.ts # bod open <app>
38
38
  ├── add.ts # bod add <pkg>
39
39
  └── remove.ts # bod remove <pkg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import addCmd from './commands/add'
13
13
  import removeCmd from './commands/remove'
14
14
  import openCmd from './commands/open'
15
15
  import serveCmd from './commands/serve'
16
+ import hostCmd from './commands/host'
16
17
  import sshCmd from './commands/ssh'
17
18
  import publishCmd from './commands/publish'
18
19
  import dbCmd from './commands/db'
@@ -46,6 +47,7 @@ const subCommands = {
46
47
  remove: removeCmd,
47
48
  open: openCmd,
48
49
  serve: serveCmd,
50
+ host: hostCmd,
49
51
  ssh: sshCmd,
50
52
  publish: publishCmd,
51
53
  db: dbCmd,
@@ -108,7 +110,8 @@ const main = defineCommand({
108
110
  // Commands with subcommands need a default, commands with required positionals need prompting
109
111
  const interactiveArgs = await getInteractiveArgs(command)
110
112
  if (interactiveArgs === null) continue // user cancelled
111
- await runCommand(subCommands[command as keyof typeof subCommands], { rawArgs: interactiveArgs })
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- citty's CommandDef union isn't assignable to itself across a heterogeneous subCommands map
114
+ await runCommand(subCommands[command as keyof typeof subCommands] as any, { rawArgs: interactiveArgs })
112
115
  } catch (e) {
113
116
  if ((e as Error).name === 'ExitPromptError') continue
114
117
  console.error(`Error: ${(e as Error).message}`)
@@ -138,13 +138,60 @@ function branchToSubdomain(branch: string, domain: string, productionBranch: str
138
138
  return `${sanitized}--${domain}`
139
139
  }
140
140
 
141
+ /**
142
+ * A transport-level failure (socket reset, DNS blip, 5xx/429 from the edge) as opposed to a
143
+ * definitive answer from the server. `BodClient.request` throws `METHOD /path → <status>: body`
144
+ * for HTTP errors and lets fetch's own errors (`socket connection was closed unexpectedly`, …)
145
+ * propagate as-is — so anything without a 4xx status marker is retryable.
146
+ */
147
+ function isTransientError(err: unknown): boolean {
148
+ const msg = err instanceof Error ? err.message : String(err)
149
+ const status = msg.match(/→ (\d{3}):/)?.[1]
150
+ if (!status) return true // fetch threw — never reached the server, or the reply was cut off
151
+ return status === '408' || status === '429' || status.startsWith('5')
152
+ }
153
+
154
+ /**
155
+ * The poll loop only OBSERVES a deployment the server already accepted — a dead poll socket says
156
+ * nothing about whether the deploy succeeded. So transient reads are retried instead of aborting
157
+ * the command. Deliberately NOT in `BodClient`: the deploy upload/POST are non-idempotent and must
158
+ * not be blindly replayed.
159
+ */
160
+ async function getWithRetry<T>(client: BodClient, path: string, attempts = 4): Promise<T> {
161
+ let lastErr: unknown
162
+ for (let i = 0; i < attempts; i++) {
163
+ try {
164
+ return await client.get<T>(path)
165
+ } catch (err) {
166
+ lastErr = err
167
+ if (!isTransientError(err) || i === attempts - 1) throw err
168
+ const backoff = 1000 * 2 ** i // 1s, 2s, 4s
169
+ console.log(chalk.dim(` (poll transport hiccup: ${err instanceof Error ? err.message : err} — retrying in ${backoff / 1000}s)`))
170
+ await Bun.sleep(backoff)
171
+ }
172
+ }
173
+ throw lastErr
174
+ }
175
+
141
176
  async function pollDeploy(client: BodClient, appId: string, since: number, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
142
177
  console.log(chalk.dim('Waiting for deployment...'))
143
178
  let lastStatus = ''
144
179
  let lastLogTs = since
145
180
  for (let i = 0; i < 120; i++) {
146
181
  await Bun.sleep(2000)
147
- const detail = await client.get<any>(`/apps/${appId}`)
182
+ let detail: any
183
+ try {
184
+ detail = await getWithRetry<any>(client, `/apps/${appId}`)
185
+ } catch (err) {
186
+ if (!isTransientError(err)) throw err
187
+ // The deploy was accepted; we simply lost the ability to watch it. Failing here would report
188
+ // a healthy deploy as broken — and, worse, make a genuinely broken one look identical.
189
+ // Verify the deployed app itself (smoke test / `bod apps status`) — that is the real gate.
190
+ console.log(chalk.yellow(`⚠ Lost contact with the API while watching the deployment (${err instanceof Error ? err.message : err}).`))
191
+ console.log(chalk.yellow(' The deployment was accepted and is still running server-side — this is a POLL failure, not a deploy failure.'))
192
+ console.log(chalk.yellow(' Verify with "bod apps status" or by hitting the app.'))
193
+ return
194
+ }
148
195
  const deps = detail.deployments ?? []
149
196
  const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
150
197
  if (!latest) continue
@@ -78,7 +78,7 @@ function quoteDotEnvValue(value: string): string {
78
78
  }
79
79
 
80
80
  const listCmd = defineCommand({
81
- meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global: all variables with scopes (masked).' },
81
+ meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group: variables in that scope (masked).' },
82
82
  args: {
83
83
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
84
84
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
@@ -122,7 +122,7 @@ const listCmd = defineCommand({
122
122
  })
123
123
 
124
124
  const setCmd = defineCommand({
125
- meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, and/or --env.' },
125
+ meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, and/or --env.' },
126
126
  args: {
127
127
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
128
128
  pair: { type: 'positional', description: 'KEY=VALUE', required: false },
@@ -0,0 +1,93 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { statSync, readdirSync, readFileSync, existsSync } from 'fs'
4
+ import { join, relative, basename, extname } from 'path'
5
+ import { loadConfig, getResolvedInstance } from '../config'
6
+
7
+ const MIME: Record<string, string> = {
8
+ '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
10
+ '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json',
11
+ '.map': 'application/json', '.xml': 'application/xml', '.txt': 'text/plain; charset=utf-8',
12
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',
14
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
15
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
16
+ '.pdf': 'application/pdf', '.wasm': 'application/wasm',
17
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
18
+ }
19
+ function mimeFor(path: string): string { return MIME[extname(path).toLowerCase()] ?? 'application/octet-stream' }
20
+
21
+ // Collect { rel, abs } for a file or (recursively) a directory. Skips dotfiles + node_modules.
22
+ function collect(root: string): Array<{ rel: string; abs: string }> {
23
+ const st = statSync(root)
24
+ if (st.isFile()) return [{ rel: basename(root), abs: root }]
25
+ const out: Array<{ rel: string; abs: string }> = []
26
+ const walk = (dir: string) => {
27
+ for (const name of readdirSync(dir)) {
28
+ if (name.startsWith('.') || name === 'node_modules') continue
29
+ const abs = join(dir, name)
30
+ const s = statSync(abs)
31
+ if (s.isDirectory()) walk(abs)
32
+ else out.push({ rel: relative(root, abs).split('\\').join('/'), abs })
33
+ }
34
+ }
35
+ walk(root)
36
+ return out
37
+ }
38
+
39
+ export default defineCommand({
40
+ meta: { name: 'host', description: 'Publish a file or folder to the web (serve.bod.ee) and get a CDN URL' },
41
+ args: {
42
+ path: { type: 'positional', description: 'File or directory to publish', required: true },
43
+ slug: { type: 'string', description: 'Reuse/update an existing site slug' },
44
+ app: { type: 'string', description: 'Link to a Bodify app id → persistent (else ephemeral, 48h)' },
45
+ },
46
+ async run({ args }) {
47
+ const root = args.path
48
+ if (!existsSync(root)) { console.error(chalk.red(`Path not found: ${root}`)); process.exit(1) }
49
+
50
+ const files = collect(root)
51
+ if (!files.length) { console.error(chalk.red('No files to publish')); process.exit(1) }
52
+
53
+ const { url, apiKey, name: instanceName } = getResolvedInstance(loadConfig())
54
+ const auth: Record<string, string> = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
55
+ const totalBytes = files.reduce((n, f) => n + statSync(f.abs).size, 0)
56
+ console.log(chalk.dim(`Publishing ${files.length} file(s), ${(totalBytes / 1024).toFixed(1)} KB to ${instanceName}...`))
57
+
58
+ // 1. Create the site + presigned uploads.
59
+ const manifest = files.map(f => ({ path: f.rel, contentType: mimeFor(f.rel), size: statSync(f.abs).size }))
60
+ const createRes = await fetch(`${url}/api/v1/publish`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json', ...auth },
63
+ body: JSON.stringify({ files: manifest, slug: args.slug, appId: args.app }),
64
+ })
65
+ if (!createRes.ok) { console.error(chalk.red(`Publish failed: ${createRes.status} ${await createRes.text()}`)); process.exit(1) }
66
+ const site = await createRes.json() as {
67
+ slug: string; siteUrl: string; expiresAt?: number
68
+ upload: { uploads: Array<{ path: string; url: string | null; key: string }>; finalizeUrl: string }
69
+ }
70
+
71
+ // 2. Upload each file — presigned PUT (R2) or direct-to-agent PUT (local provider).
72
+ const byPath = new Map(files.map(f => [f.rel, f.abs]))
73
+ for (const u of site.upload.uploads) {
74
+ const abs = byPath.get(u.path)!
75
+ const data = new Uint8Array(readFileSync(abs))
76
+ const ct = mimeFor(u.path)
77
+ const target = u.url ?? `${url}/api/v1/upload/${site.slug}/${u.path.split('/').map(encodeURIComponent).join('/')}`
78
+ const putRes = await fetch(target, {
79
+ method: 'PUT',
80
+ headers: { 'Content-Type': ct, ...(u.url ? {} : auth) },
81
+ body: data,
82
+ })
83
+ if (!putRes.ok) { console.error(chalk.red(`Upload failed for ${u.path}: ${putRes.status} ${await putRes.text()}`)); process.exit(1) }
84
+ }
85
+
86
+ // 3. Finalize.
87
+ const finRes = await fetch(`${url}${site.upload.finalizeUrl}`, { method: 'POST', headers: { ...auth } })
88
+ if (!finRes.ok) { console.error(chalk.red(`Finalize failed: ${finRes.status} ${await finRes.text()}`)); process.exit(1) }
89
+
90
+ console.log(chalk.green(`✓ Live: ${site.siteUrl}`))
91
+ console.log(chalk.dim(` slug: ${site.slug}${args.app ? ` (persistent, app ${args.app})` : site.expiresAt ? ` (expires ${new Date(site.expiresAt).toISOString()})` : ''}`))
92
+ },
93
+ })