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.
- package/.cursor/skills/using-bod-cli/SKILL.md +10 -0
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +4 -1
- package/src/commands/deploy.ts +48 -1
- package/src/commands/env.ts +2 -2
- package/src/commands/host.ts +93 -0
|
@@ -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
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
|
-
|
|
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}`)
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/env.ts
CHANGED
|
@@ -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:
|
|
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
|
+
})
|