bod-cli 0.3.1

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.
@@ -0,0 +1,277 @@
1
+ import { defineCommand } from 'citty'
2
+ import { select, input, confirm } from '@inquirer/prompts'
3
+ import chalk from 'chalk'
4
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
5
+ import { basename, join } from 'path'
6
+ import { loadConfig, getResolvedInstance, configExists } from '../../config'
7
+ import { BodClient } from '../../client'
8
+ import { TEMPLATES, type TemplateId } from './templates'
9
+
10
+ type AppType = 'caab' | 'static' | 'server' | 'mixed' | 'unknown'
11
+
12
+ function detectAppType(dir: string): AppType {
13
+ if (existsSync(join(dir, 'api.ts'))) return 'caab'
14
+ if (existsSync(join(dir, 'packages'))) return 'mixed'
15
+ if (existsSync(join(dir, 'dist')) || existsSync(join(dir, 'public'))) return 'static'
16
+ if (existsSync(join(dir, 'server.ts')) || existsSync(join(dir, 'index.ts'))) return 'server'
17
+ return 'unknown'
18
+ }
19
+
20
+ function isEmpty(dir: string): boolean {
21
+ const entries = readdirSync(dir).filter(e => !e.startsWith('.'))
22
+ return entries.length === 0
23
+ }
24
+
25
+ function generateBodifyYaml(name: string, opts: { database?: boolean; repo?: string } = {}): string {
26
+ const lines = [`name: ${name}`]
27
+ if (opts.repo) lines.push(`repo: ${opts.repo}`)
28
+ if (opts.database) lines.push(`database: true`)
29
+ return lines.join('\n') + '\n'
30
+ }
31
+
32
+ function generateClaudeMd(appName: string, type: string, opts: { database?: boolean } = {}): string {
33
+ let md = `# ${appName}
34
+
35
+ Deployed on Bodify. Type: \`${type}\`.
36
+
37
+ ## Commands
38
+ - \`bod deploy\` — deploy current branch
39
+ - \`bod logs ${appName} -f\` — tail logs
40
+ - \`bod apps status ${appName}\` — status + instances
41
+ - \`bod env set ${appName} KEY=VALUE\` — set env var
42
+ - \`bod rollback ${appName}\` — rollback to previous deployment
43
+
44
+ ## MCP Integration
45
+ This project has a Bodify MCP server configured in \`.claude/mcp_servers.json\`.
46
+ Set the \`BODIFY_API_KEY\` env var to authenticate.
47
+ `
48
+
49
+ if (type === 'caab' || type === 'caab-db') {
50
+ md += `
51
+ ## CaaB Convention
52
+ - Methods become REST endpoints: \`get()\` → GET, \`post(body)\` → POST, etc.
53
+ - Class name = route segment (kebab-cased): \`UserProfile\` → \`/user-profile\`
54
+ - Primitive args = path params: \`getById(id)\` → \`GET /by-id/:id\`
55
+ - Object args = JSON body: \`post(body: {...})\` → POST with body
56
+ - Extend \`Resource\` base class (symbol-based marker)
57
+ - Return plain objects for JSON, \`new Response()\` for custom status codes
58
+ `
59
+ }
60
+
61
+ if (type === 'caab-db' || opts.database) {
62
+ md += `
63
+ ## BodDB
64
+ \`\`\`typescript
65
+ import { BodDB } from '@bod.ee/db';
66
+ const db = new BodDB({ path: process.env.BODDB_PATH ?? ':memory:' });
67
+
68
+ await db.push('/items', { title: 'Hello', createdAt: Date.now() }); // create
69
+ await db.get('/items/id'); // read
70
+ await db.query('/items').order('createdAt', 'desc').get(); // query
71
+ await db.update('/items/id', { title: 'Updated' }); // update
72
+ await db.delete('/items/id'); // delete
73
+ \`\`\`
74
+ `
75
+ }
76
+
77
+ if (type === 'server') {
78
+ md += `
79
+ ## Server Requirements
80
+ - Read port from \`process.env.PORT\`
81
+ - Expose \`GET /healthz\` returning JSON (Bodify health-checks this)
82
+ `
83
+ }
84
+
85
+ if (type === 'static') {
86
+ md += `
87
+ ## Static Site
88
+ - Bodify runs \`bun install\` + \`bun run build\` (if build script exists)
89
+ - Serves \`dist/\` with SPA fallback
90
+ `
91
+ }
92
+
93
+ return md
94
+ }
95
+
96
+ function generateMcpConfig(url: string): string {
97
+ return JSON.stringify({
98
+ bodify: {
99
+ type: 'http',
100
+ url: `${url}/api/mcp`,
101
+ headers: { Authorization: 'Bearer ${BODIFY_API_KEY}' },
102
+ },
103
+ }, null, 2)
104
+ }
105
+
106
+ function generateGitignore(): string {
107
+ return `node_modules/
108
+ dist/
109
+ .env
110
+ .env.local
111
+ *.log
112
+ `
113
+ }
114
+
115
+ export default defineCommand({
116
+ meta: { name: 'init', description: 'Initialize a Bodify project' },
117
+ async run() {
118
+ const cwd = process.cwd()
119
+ const folderName = basename(cwd)
120
+ const empty = isEmpty(cwd)
121
+
122
+ if (!configExists()) {
123
+ console.log(chalk.yellow('No Bodify instance configured. Run "bod login <url>" first.'))
124
+ process.exit(1)
125
+ }
126
+ const config = loadConfig()
127
+ const { url, apiKey, instance } = getResolvedInstance(config)
128
+ const client = new BodClient(url, apiKey)
129
+
130
+ if (empty) {
131
+ // ── Empty folder flow ────────────────────────────────────────
132
+ const templateId = await select<TemplateId>({
133
+ message: 'Project template:',
134
+ choices: TEMPLATES.map(t => ({ value: t.id, name: `${t.label} — ${t.description}` })),
135
+ })
136
+ const template = TEMPLATES.find(t => t.id === templateId)!
137
+
138
+ const appName = await input({ message: 'App name:', default: folderName })
139
+ const useDb = templateId === 'caab-db' || (templateId === 'caab' && await confirm({ message: 'Enable database?', default: false }))
140
+
141
+ // Write template files
142
+ for (const [path, content] of Object.entries(template.files)) {
143
+ const fullPath = join(cwd, path)
144
+ mkdirSync(join(fullPath, '..'), { recursive: true })
145
+ writeFileSync(fullPath, content)
146
+ }
147
+
148
+ // package.json
149
+ const pkg: any = {
150
+ name: appName,
151
+ version: '0.1.0',
152
+ type: 'module',
153
+ private: true,
154
+ scripts: template.scripts ?? {},
155
+ dependencies: template.deps ?? {},
156
+ devDependencies: template.devDeps ?? {},
157
+ }
158
+ writeFileSync(join(cwd, 'package.json'), JSON.stringify(pkg, null, 2))
159
+
160
+ // tsconfig.json
161
+ writeFileSync(join(cwd, 'tsconfig.json'), JSON.stringify({
162
+ compilerOptions: {
163
+ target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler',
164
+ strict: true, esModuleInterop: true, skipLibCheck: true,
165
+ jsx: template.deps?.react ? 'react-jsx' : undefined,
166
+ },
167
+ include: ['src', '.'],
168
+ }, null, 2))
169
+
170
+ // bodify.yaml
171
+ writeFileSync(join(cwd, 'bodify.yaml'), generateBodifyYaml(appName, { database: useDb }))
172
+
173
+ // .claude/
174
+ mkdirSync(join(cwd, '.claude'), { recursive: true })
175
+ writeFileSync(join(cwd, '.claude', 'CLAUDE.md'), generateClaudeMd(appName, templateId, { database: useDb }))
176
+ writeFileSync(join(cwd, '.claude', 'mcp_servers.json'), generateMcpConfig(url))
177
+
178
+ // .gitignore
179
+ writeFileSync(join(cwd, '.gitignore'), generateGitignore())
180
+
181
+ // git init
182
+ Bun.spawnSync(['git', 'init'], { cwd, stdio: ['ignore', 'ignore', 'ignore'] })
183
+ Bun.spawnSync(['git', 'add', '.'], { cwd, stdio: ['ignore', 'ignore', 'ignore'] })
184
+ Bun.spawnSync(['git', 'commit', '-m', 'Initial commit (bod init)'], { cwd, stdio: ['ignore', 'ignore', 'ignore'] })
185
+
186
+ // Detect git remote (may exist if user pre-configured)
187
+ let repo = ''
188
+ try {
189
+ const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { cwd })
190
+ repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
191
+ } catch { /* no remote */ }
192
+
193
+ // Register with bodify
194
+ console.log(chalk.dim('Registering app with Bodify...'))
195
+ try {
196
+ const app = await client.post<any>('/apps', {
197
+ name: appName,
198
+ repo,
199
+ database: useDb,
200
+ })
201
+ console.log(chalk.green(`✓ App registered (id: ${app.id})`))
202
+ if (app.domain) console.log(chalk.dim(` Domain: ${app.domain}`))
203
+ } catch (e) {
204
+ console.warn(chalk.yellow(`Warning: Could not register app: ${(e as Error).message}`))
205
+ }
206
+
207
+ // .npmrc for registry (scoped to @bod.ee only)
208
+ if (instance.capabilities.registryEnabled) {
209
+ writeFileSync(join(cwd, '.npmrc'), `@bod.ee:registry=${url}/api/registry/\n`)
210
+ console.log(chalk.dim('Created .npmrc with Bodify registry'))
211
+ }
212
+
213
+ // Install deps
214
+ console.log(chalk.dim('Installing dependencies...'))
215
+ Bun.spawnSync(['bun', 'install'], { cwd, stdio: ['inherit', 'inherit', 'inherit'] })
216
+
217
+ console.log(chalk.green(`\n✓ Project "${appName}" initialized!`))
218
+ console.log(chalk.dim(' Set BODIFY_API_KEY env var for MCP integration.'))
219
+ console.log(chalk.dim(` bod deploy`))
220
+
221
+ } else {
222
+ // ── Existing folder flow ─────────────────────────────────────
223
+ const detected = detectAppType(cwd)
224
+ console.log(chalk.dim(`Detected type: ${detected}`))
225
+
226
+ const appName = await input({ message: 'App name:', default: folderName })
227
+
228
+ // Detect git remote
229
+ let repo = ''
230
+ try {
231
+ const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { cwd })
232
+ repo = proc.stdout.toString().trim()
233
+ } catch { /* no git remote */ }
234
+
235
+ // bodify.yaml
236
+ if (existsSync(join(cwd, 'bodify.yaml'))) {
237
+ console.log(chalk.dim('bodify.yaml already exists, skipping'))
238
+ } else {
239
+ const useDb = detected === 'caab' && await confirm({ message: 'Enable database?', default: false })
240
+ writeFileSync(join(cwd, 'bodify.yaml'), generateBodifyYaml(appName, { database: useDb, repo }))
241
+ console.log(chalk.green('✓ Created bodify.yaml'))
242
+ }
243
+
244
+ // Register
245
+ console.log(chalk.dim('Registering app with Bodify...'))
246
+ try {
247
+ const app = await client.post<any>('/apps', {
248
+ name: appName,
249
+ repo,
250
+ database: detected === 'caab',
251
+ })
252
+ console.log(chalk.green(`✓ App registered (id: ${app.id})`))
253
+ if (app.domain) console.log(chalk.dim(` Domain: ${app.domain}`))
254
+ } catch (e) {
255
+ console.warn(chalk.yellow(`Warning: Could not register: ${(e as Error).message}`))
256
+ }
257
+
258
+ // .npmrc (scoped to @bod.ee only)
259
+ if (instance.capabilities.registryEnabled && !existsSync(join(cwd, '.npmrc'))) {
260
+ writeFileSync(join(cwd, '.npmrc'), `@bod.ee:registry=${url}/api/registry/\n`)
261
+ console.log(chalk.dim('Created .npmrc with Bodify registry'))
262
+ }
263
+
264
+ // .claude/ files
265
+ if (!existsSync(join(cwd, '.claude', 'CLAUDE.md'))) {
266
+ mkdirSync(join(cwd, '.claude'), { recursive: true })
267
+ writeFileSync(join(cwd, '.claude', 'CLAUDE.md'), generateClaudeMd(appName, detected))
268
+ writeFileSync(join(cwd, '.claude', 'mcp_servers.json'), generateMcpConfig(url))
269
+ console.log(chalk.green('✓ Created .claude/ config'))
270
+ }
271
+
272
+ console.log(chalk.green(`\n✓ Project "${appName}" connected to Bodify!`))
273
+ console.log(chalk.dim(' Set BODIFY_API_KEY env var for MCP integration.'))
274
+ console.log(chalk.dim(` bod deploy`))
275
+ }
276
+ },
277
+ })
@@ -0,0 +1,171 @@
1
+ export type TemplateId = 'caab' | 'caab-db' | 'static' | 'server'
2
+
3
+ export interface Template {
4
+ id: TemplateId
5
+ label: string
6
+ description: string
7
+ files: Record<string, string>
8
+ deps?: Record<string, string>
9
+ devDeps?: Record<string, string>
10
+ scripts?: Record<string, string>
11
+ }
12
+
13
+ const RESOURCE_BASE = `abstract class Resource {
14
+ static readonly [Symbol.for('bodify.Resource')] = true;
15
+ }
16
+ `
17
+
18
+ export const TEMPLATES: Template[] = [
19
+ {
20
+ id: 'caab',
21
+ label: 'CaaB (Class-as-a-Backend)',
22
+ description: 'API backend — methods become REST endpoints',
23
+ files: {
24
+ 'api.ts': `${RESOURCE_BASE}
25
+ class Items extends Resource {
26
+ private items = [
27
+ { id: '1', title: 'Hello from Bodify', createdAt: Date.now() },
28
+ ];
29
+
30
+ async get() { return this.items; }
31
+ async getById(id: string) {
32
+ const item = this.items.find(i => i.id === id);
33
+ if (!item) return new Response('Not found', { status: 404 });
34
+ return item;
35
+ }
36
+ async post(body: { title: string }) {
37
+ if (!body?.title) return new Response('title required', { status: 400 });
38
+ const item = { id: String(Date.now()), title: body.title, createdAt: Date.now() };
39
+ this.items.push(item);
40
+ return item;
41
+ }
42
+ async delete(id: string) {
43
+ this.items = this.items.filter(i => i.id !== id);
44
+ return { deleted: true, id };
45
+ }
46
+ }
47
+
48
+ export default class Api extends Resource {
49
+ items = new Items();
50
+
51
+ async getHealthz() {
52
+ return { status: 'ok', branch: process.env.BODIFY_BRANCH ?? 'dev', ts: Date.now() };
53
+ }
54
+ }
55
+ `,
56
+ },
57
+ scripts: { dev: 'echo "Deploy with: bod deploy"' },
58
+ },
59
+ {
60
+ id: 'caab-db',
61
+ label: 'CaaB + BodDB',
62
+ description: 'API backend with embedded database',
63
+ files: {
64
+ 'api.ts': `import { BodDB } from '@bod.ee/db';
65
+
66
+ const db = new BodDB({ path: process.env.BODDB_PATH ?? ':memory:' });
67
+
68
+ ${RESOURCE_BASE}
69
+ class Items extends Resource {
70
+ async get() {
71
+ return (await db.query('/items').order('createdAt', 'desc').get()) ?? [];
72
+ }
73
+ async getById(id: string) {
74
+ const item = await db.get(\`/items/\${id}\`);
75
+ if (!item) return new Response('Not found', { status: 404 });
76
+ return item;
77
+ }
78
+ async post(body: { title: string }) {
79
+ if (!body?.title) return new Response('title required', { status: 400 });
80
+ const id = await db.push('/items', { title: body.title, createdAt: Date.now() });
81
+ return db.get(\`/items/\${id}\`);
82
+ }
83
+ async delete(id: string) {
84
+ const existed = !!(await db.get(\`/items/\${id}\`));
85
+ await db.delete(\`/items/\${id}\`);
86
+ return { deleted: existed, id };
87
+ }
88
+ }
89
+
90
+ export default class Api extends Resource {
91
+ items = new Items();
92
+
93
+ async getHealthz() {
94
+ const items = (await db.query('/items').get()) as unknown[] | null;
95
+ return {
96
+ status: 'ok',
97
+ branch: process.env.BODIFY_BRANCH ?? 'dev',
98
+ ts: Date.now(),
99
+ itemCount: items?.length ?? 0,
100
+ };
101
+ }
102
+ }
103
+ `,
104
+ },
105
+ deps: { '@bod.ee/db': '*' },
106
+ scripts: { dev: 'echo "Deploy with: bod deploy"' },
107
+ },
108
+ {
109
+ id: 'static',
110
+ label: 'Static Site (React + Vite)',
111
+ description: 'SPA with Vite build',
112
+ files: {
113
+ 'src/App.tsx': `export default function App() {
114
+ return (
115
+ <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
116
+ <h1>Hello from Bodify!</h1>
117
+ <p>Edit src/App.tsx to get started.</p>
118
+ </div>
119
+ );
120
+ }
121
+ `,
122
+ 'src/main.tsx': `import { createRoot } from 'react-dom/client';
123
+ import App from './App';
124
+
125
+ createRoot(document.getElementById('root')!).render(<App />);
126
+ `,
127
+ 'index.html': `<!DOCTYPE html>
128
+ <html lang="en">
129
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>App</title></head>
130
+ <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
131
+ </html>`,
132
+ 'vite.config.ts': `import { defineConfig } from 'vite';
133
+ import react from '@vitejs/plugin-react';
134
+
135
+ export default defineConfig({
136
+ plugins: [react()],
137
+ });
138
+ `,
139
+ },
140
+ deps: { react: '^19', 'react-dom': '^19' },
141
+ devDeps: { vite: '^6', '@vitejs/plugin-react': '^4', '@types/react': '^19', '@types/react-dom': '^19' },
142
+ scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
143
+ },
144
+ {
145
+ id: 'server',
146
+ label: 'Bun Server',
147
+ description: 'Custom Bun.serve() server',
148
+ files: {
149
+ 'server.ts': `const port = Number(process.env.PORT ?? 3000);
150
+
151
+ Bun.serve({
152
+ port,
153
+ fetch(req) {
154
+ const url = new URL(req.url);
155
+ if (url.pathname === '/healthz') {
156
+ return Response.json({
157
+ status: 'ok',
158
+ branch: process.env.BODIFY_BRANCH ?? 'dev',
159
+ ts: Date.now(),
160
+ });
161
+ }
162
+ return Response.json({ hello: 'world' });
163
+ },
164
+ });
165
+
166
+ console.log(\`Server running on :\${port}\`);
167
+ `,
168
+ },
169
+ scripts: { dev: 'bun --watch server.ts', start: 'bun server.ts' },
170
+ },
171
+ ]
@@ -0,0 +1,60 @@
1
+ import { defineCommand } from 'citty'
2
+ import { password as promptPassword } from '@inquirer/prompts'
3
+ import chalk from 'chalk'
4
+ import { loadConfig, writeConfig, configExists, type Config } from '../config'
5
+ import { BodClient } from '../client'
6
+ import { printKv } from '../utils/output'
7
+
8
+ export default defineCommand({
9
+ meta: { name: 'login', description: 'Authenticate with a Bodify instance' },
10
+ args: {
11
+ url: { type: 'positional', description: 'Bodify instance URL (e.g. https://bodify.example.com)', required: true },
12
+ key: { type: 'string', description: 'API key (or will prompt)' },
13
+ name: { type: 'string', description: 'Instance alias (defaults to hostname)' },
14
+ },
15
+ async run({ args }) {
16
+ const url = args.url.replace(/\/+$/, '')
17
+ const alias = args.name || new URL(url).hostname
18
+
19
+ const apiKey = args.key || await promptPassword({ message: 'API key:' })
20
+ if (!apiKey) { console.error(chalk.red('API key required.')); process.exit(1) }
21
+
22
+ // Verify connectivity
23
+ const client = new BodClient(url, apiKey)
24
+ console.log(chalk.dim('Verifying connection...'))
25
+
26
+ try {
27
+ await client.get<{ status: string }>('/healthz')
28
+ } catch (e) {
29
+ console.error(chalk.red(`Cannot reach ${url}: ${(e as Error).message}`))
30
+ process.exit(1)
31
+ }
32
+
33
+ // Fetch capabilities
34
+ let capabilities = { registryEnabled: false, baseDomain: null as string | null, githubConfigured: false }
35
+ try {
36
+ capabilities = await client.get<typeof capabilities>('/config/public')
37
+ } catch { /* older instances may not have this endpoint */ }
38
+
39
+ // Save config
40
+ let config: Config
41
+ if (configExists()) {
42
+ config = loadConfig()
43
+ } else {
44
+ config = { instances: {}, defaultInstance: null }
45
+ }
46
+
47
+ config.instances[alias] = { url, apiKey, capabilities }
48
+ config.defaultInstance = alias
49
+ writeConfig(config)
50
+
51
+ console.log(chalk.green(`\n✓ Logged in to ${alias}`))
52
+ printKv({
53
+ URL: url,
54
+ Alias: alias,
55
+ Registry: capabilities.registryEnabled ? 'enabled' : 'disabled',
56
+ 'Base Domain': capabilities.baseDomain ?? '(none)',
57
+ GitHub: capabilities.githubConfigured ? 'configured' : 'not configured',
58
+ })
59
+ },
60
+ })
@@ -0,0 +1,46 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { loadConfig, getResolvedInstance } from '../config'
4
+ import { BodClient } from '../client'
5
+ import { resolveAppId, resolveAppName } from '../utils/resolve'
6
+
7
+ export default defineCommand({
8
+ meta: { name: 'logs', description: 'View app logs' },
9
+ args: {
10
+ app: { type: 'positional', description: 'App name or ID (or reads from bodify.yaml)', required: false },
11
+ follow: { type: 'boolean', alias: 'f', description: 'Follow log output' },
12
+ lines: { type: 'string', default: '100', description: 'Number of lines' },
13
+ },
14
+ async run({ args }) {
15
+ const { url, apiKey } = getResolvedInstance(loadConfig())
16
+ const client = new BodClient(url, apiKey)
17
+ const appName = resolveAppName(args.app)
18
+ const appId = await resolveAppId(client, appName)
19
+
20
+ const formatLog = (l: any) => {
21
+ const time = new Date(l.ts).toISOString().slice(11, 23)
22
+ const stream = l.stream === 'stderr' ? chalk.red(l.stream) : chalk.dim(l.stream)
23
+ return `${chalk.dim(time)} [${stream}] ${l.line ?? l.message ?? ''}`
24
+ }
25
+
26
+ if (!args.follow) {
27
+ const logs = await client.get<any[]>(`/apps/${appId}/logs?limit=${args.lines}`)
28
+ if (!Array.isArray(logs) || logs.length === 0) { console.log(chalk.dim('No logs.')); return }
29
+ for (const l of logs.reverse()) console.log(formatLog(l))
30
+ return
31
+ }
32
+
33
+ console.log(chalk.dim(`Tailing logs for ${appName}... (Ctrl+C to stop)`))
34
+ let lastTs = Date.now()
35
+ while (true) {
36
+ const logs = await client.get<any[]>(`/apps/${appId}/logs?limit=50`)
37
+ if (!Array.isArray(logs)) { await Bun.sleep(2000); continue }
38
+ const fresh = logs.filter(l => l.ts > lastTs)
39
+ if (fresh.length) {
40
+ lastTs = Math.max(...fresh.map(l => l.ts))
41
+ for (const l of [...fresh].reverse()) console.log(formatLog(l))
42
+ }
43
+ await Bun.sleep(2000)
44
+ }
45
+ },
46
+ })
@@ -0,0 +1,29 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { loadConfig, getResolvedInstance } from '../config'
4
+ import { BodClient } from '../client'
5
+ import { resolveAppId, resolveAppName } from '../utils/resolve'
6
+
7
+ export default defineCommand({
8
+ meta: { name: 'open', description: 'Open app in browser' },
9
+ args: {
10
+ app: { type: 'positional', description: 'App name or ID (or reads from bodify.yaml)', required: false },
11
+ },
12
+ async run({ args }) {
13
+ const config = loadConfig()
14
+ const { url, apiKey, instance } = getResolvedInstance(config)
15
+ const client = new BodClient(url, apiKey)
16
+ const appId = await resolveAppId(client, resolveAppName(args.app))
17
+ const detail = await client.get<any>(`/apps/${appId}`)
18
+
19
+ const appUrl = detail.domain
20
+ ? `https://${detail.domain}`
21
+ : instance.capabilities.baseDomain
22
+ ? `https://${detail.name}.${instance.capabilities.baseDomain}`
23
+ : `${url}` // fallback to instance URL
24
+
25
+ console.log(chalk.dim(`Opening ${appUrl}`))
26
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
27
+ Bun.spawn([cmd, appUrl], { stdio: ['ignore', 'ignore', 'ignore'] })
28
+ },
29
+ })
@@ -0,0 +1,16 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+
4
+ export default defineCommand({
5
+ meta: { name: 'remove', description: 'Remove a package' },
6
+ args: {
7
+ pkg: { type: 'positional', description: 'Package name', required: true },
8
+ },
9
+ async run({ args }) {
10
+ const cmd = ['bun', 'remove', args.pkg]
11
+ console.log(chalk.dim(`$ ${cmd.join(' ')}`))
12
+ const proc = Bun.spawn(cmd, { stdio: ['inherit', 'inherit', 'inherit'] })
13
+ const code = await proc.exited
14
+ process.exit(code)
15
+ },
16
+ })
@@ -0,0 +1,28 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { loadConfig, getResolvedInstance } from '../config'
4
+ import { BodClient } from '../client'
5
+ import { resolveAppId, resolveAppName } from '../utils/resolve'
6
+
7
+ export default defineCommand({
8
+ meta: { name: 'rollback', description: 'Rollback to previous deployment' },
9
+ args: {
10
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
11
+ branch: { type: 'string', description: 'Branch to rollback (default: production branch)' },
12
+ },
13
+ async run({ args }) {
14
+ const { url, apiKey } = getResolvedInstance(loadConfig())
15
+ const client = new BodClient(url, apiKey)
16
+
17
+ const appName = resolveAppName(args.app)
18
+
19
+ const appId = await resolveAppId(client, appName)
20
+ const body: any = {}
21
+ if (args.branch) body.branch = args.branch
22
+
23
+ console.log(chalk.dim(`Rolling back ${appName}...`))
24
+ const result = await client.post<any>(`/apps/${appId}/rollback`, body)
25
+ console.log(chalk.green(`✓ Rollback queued`))
26
+ if (result.rollingBackTo) console.log(chalk.dim(` Rolling back to deployment: ${result.rollingBackTo}`))
27
+ },
28
+ })
@@ -0,0 +1,36 @@
1
+ import { defineCommand } from 'citty'
2
+ import { join } from 'path'
3
+ import { existsSync } from 'fs'
4
+
5
+ function resolveBodifyServe(): string {
6
+ // Sibling directory convention: bod-cli and bodify live side by side
7
+ const sibling = join(import.meta.dir, '../../../bodify/src/serve.ts')
8
+ if (existsSync(sibling)) return sibling
9
+ // Fallback: try global BODIFY_DIR env
10
+ const envDir = process.env.BODIFY_DIR
11
+ if (envDir) {
12
+ const fromEnv = join(envDir, 'src/serve.ts')
13
+ if (existsSync(fromEnv)) return fromEnv
14
+ }
15
+ throw new Error('Could not find bodify/src/serve.ts. Set BODIFY_DIR or ensure bodify is a sibling directory.')
16
+ }
17
+
18
+ export default defineCommand({
19
+ meta: { name: 'serve', description: 'Run app locally (auto-detects type)' },
20
+ args: {
21
+ dir: { type: 'positional', description: 'Directory to serve (default: .)', required: false },
22
+ port: { type: 'string', description: 'Port (default: 3000)' },
23
+ mount: { type: 'string', description: 'API mount path (default: /api)' },
24
+ 'no-build': { type: 'boolean', description: 'Skip build step' },
25
+ },
26
+ async run({ args }) {
27
+ const servePath = resolveBodifyServe()
28
+ const cmd = ['bun', 'run', servePath, args.dir || '.']
29
+ if (args.port) cmd.push(`--port=${args.port}`)
30
+ if (args['no-build']) cmd.push('--no-build')
31
+ if (args.mount) cmd.push(`--mount=${args.mount}`)
32
+
33
+ const proc = Bun.spawn(cmd, { stdout: 'inherit', stderr: 'inherit', stdin: 'inherit' })
34
+ await proc.exited
35
+ },
36
+ })