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.
- package/.cursor/skills/using-bod-cli/SKILL.md +274 -0
- package/.github/workflows/publish.yml +54 -0
- package/CLAUDE.md +50 -0
- package/dist/cli.js +12903 -0
- package/package.json +21 -0
- package/src/cli.ts +146 -0
- package/src/client.ts +42 -0
- package/src/commands/add.ts +79 -0
- package/src/commands/apps.ts +71 -0
- package/src/commands/deploy.ts +103 -0
- package/src/commands/env.ts +107 -0
- package/src/commands/init/init.ts +277 -0
- package/src/commands/init/templates.ts +171 -0
- package/src/commands/login.ts +60 -0
- package/src/commands/logs.ts +46 -0
- package/src/commands/open.ts +29 -0
- package/src/commands/remove.ts +16 -0
- package/src/commands/rollback.ts +28 -0
- package/src/commands/serve.ts +36 -0
- package/src/commands/ssh.ts +48 -0
- package/src/config.ts +82 -0
- package/src/utils/logger.ts +20 -0
- package/src/utils/output.ts +26 -0
- package/src/utils/resolve.ts +32 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
})
|