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/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "bod-cli",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "bod": "./dist/cli.js"
7
+ },
8
+ "scripts": {
9
+ "build": "bun build src/cli.ts --outdir dist --target node --format esm"
10
+ },
11
+ "dependencies": {
12
+ "@inquirer/prompts": "^7.0.0",
13
+ "chalk": "^5.3.0",
14
+ "citty": "^0.1.6",
15
+ "cli-table3": "^0.6.5",
16
+ "zod": "^3.23.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "latest"
20
+ }
21
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bun
2
+ import { defineCommand, runMain } from 'citty'
3
+ import { select, input } from '@inquirer/prompts'
4
+ import { configExists, setInstanceOverride } from './config'
5
+ import loginCmd from './commands/login'
6
+ import appsCmd from './commands/apps'
7
+ import deployCmd from './commands/deploy'
8
+ import rollbackCmd from './commands/rollback'
9
+ import logsCmd from './commands/logs'
10
+ import envCmd from './commands/env'
11
+ import initCmd from './commands/init/init'
12
+ import addCmd from './commands/add'
13
+ import removeCmd from './commands/remove'
14
+ import openCmd from './commands/open'
15
+ import serveCmd from './commands/serve'
16
+ import sshCmd from './commands/ssh'
17
+
18
+ // Parse --instance early so it's set before citty dispatches subcommands
19
+ const instanceFlag = process.argv.find(a => a.startsWith('--instance='))?.split('=').slice(1).join('=')
20
+ if (instanceFlag) setInstanceOverride(instanceFlag)
21
+
22
+ const subCommands = {
23
+ login: loginCmd,
24
+ init: initCmd,
25
+ deploy: deployCmd,
26
+ rollback: rollbackCmd,
27
+ apps: appsCmd,
28
+ logs: logsCmd,
29
+ env: envCmd,
30
+ add: addCmd,
31
+ remove: removeCmd,
32
+ open: openCmd,
33
+ serve: serveCmd,
34
+ ssh: sshCmd,
35
+ }
36
+
37
+ const main = defineCommand({
38
+ meta: {
39
+ name: 'bod',
40
+ version: '0.1.0',
41
+ description: 'Unified CLI for the Bod product family',
42
+ },
43
+ args: {
44
+ instance: { type: 'string', description: 'Bodify instance name (from config)' },
45
+ },
46
+ subCommands,
47
+ async run({ rawArgs }) {
48
+ // If a subcommand was given, citty already handled it
49
+ if (process.argv.slice(2).some(a => !a.startsWith('-') && a in subCommands)) return
50
+
51
+ const { runCommand } = await import('citty')
52
+
53
+ // First-run: redirect to login
54
+ if (!configExists()) {
55
+ console.log('Welcome to bod! Let\'s connect to your Bodify instance.\n')
56
+ const loginArgs = await getInteractiveArgs('login')
57
+ if (!loginArgs) return
58
+ await runCommand(subCommands.login, { rawArgs: loginArgs })
59
+ return
60
+ }
61
+
62
+ // Interactive menu
63
+ const choices = [
64
+ { value: 'apps', name: 'apps — List & inspect apps' },
65
+ { value: 'deploy', name: 'deploy — Trigger a deployment' },
66
+ { value: 'rollback', name: 'rollback — Rollback deployment' },
67
+ { value: 'logs', name: 'logs — View app logs' },
68
+ { value: 'open', name: 'open — Open app in browser' },
69
+ { value: 'env', name: 'env — Manage env vars' },
70
+ { value: 'serve', name: 'serve — Run app locally' },
71
+ { value: 'ssh', name: 'ssh — SSH into Bodify server' },
72
+ { value: 'init', name: 'init — Initialize a project' },
73
+ { value: 'add', name: 'add — Add a package' },
74
+ { value: 'remove', name: 'remove — Remove a package' },
75
+ { value: 'login', name: 'login — Add/switch instance' },
76
+ { value: 'exit', name: 'exit — Exit' },
77
+ ]
78
+
79
+ while (true) {
80
+ let command: string
81
+ try {
82
+ command = await select({ message: 'What would you like to do?', choices })
83
+ } catch (e) {
84
+ if ((e as Error).name === 'ExitPromptError') return
85
+ throw e
86
+ }
87
+ if (command === 'exit') return
88
+ try {
89
+ // Commands with subcommands need a default, commands with required positionals need prompting
90
+ const interactiveArgs = await getInteractiveArgs(command)
91
+ if (interactiveArgs === null) continue // user cancelled
92
+ await runCommand(subCommands[command as keyof typeof subCommands], { rawArgs: interactiveArgs })
93
+ } catch (e) {
94
+ if ((e as Error).name === 'ExitPromptError') continue
95
+ console.error(`Error: ${(e as Error).message}`)
96
+ }
97
+ console.log()
98
+ }
99
+ },
100
+ })
101
+
102
+ /** Build rawArgs for commands that need them in interactive mode */
103
+ async function getInteractiveArgs(command: string): Promise<string[] | null> {
104
+ try {
105
+ switch (command) {
106
+ case 'apps': return ['list']
107
+ case 'env': {
108
+ const app = await input({ message: 'App name:' })
109
+ if (!app) return null
110
+ return ['list', app]
111
+ }
112
+ case 'login': {
113
+ const url = await input({ message: 'Bodify instance URL:' })
114
+ if (!url) return null
115
+ return [url]
116
+ }
117
+ case 'logs': {
118
+ const app = await input({ message: 'App name:' })
119
+ if (!app) return null
120
+ return [app, '--follow']
121
+ }
122
+ case 'deploy':
123
+ case 'rollback': {
124
+ const app = await input({ message: 'App name (or empty for bodify.yaml):' })
125
+ return app ? [app] : []
126
+ }
127
+ case 'open': {
128
+ const app = await input({ message: 'App name:' })
129
+ if (!app) return null
130
+ return [app]
131
+ }
132
+ case 'add': return [] // add handles its own interactive prompting
133
+ case 'remove': {
134
+ const pkg = await input({ message: 'Package name:' })
135
+ if (!pkg) return null
136
+ return [pkg]
137
+ }
138
+ default: return []
139
+ }
140
+ } catch (e) {
141
+ if ((e as Error).name === 'ExitPromptError') return null
142
+ throw e
143
+ }
144
+ }
145
+
146
+ runMain(main)
package/src/client.ts ADDED
@@ -0,0 +1,42 @@
1
+ export class BodClient {
2
+ constructor(
3
+ public url: string,
4
+ public apiKey: string,
5
+ ) {}
6
+
7
+ async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
8
+ const res = await fetch(`${this.url}/api${path}`, {
9
+ method,
10
+ headers: {
11
+ 'Content-Type': 'application/json',
12
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
13
+ },
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ })
16
+ const text = await res.text()
17
+ if (!res.ok) {
18
+ throw new Error(`${method} ${path} → ${res.status}: ${text}`)
19
+ }
20
+ try { return JSON.parse(text) as T } catch { return text as T }
21
+ }
22
+
23
+ async upload<T = unknown>(path: string, body: ReadableStream | Uint8Array, headers: Record<string, string> = {}): Promise<T> {
24
+ const res = await fetch(`${this.url}/api${path}`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/gzip',
28
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
29
+ ...headers,
30
+ },
31
+ body,
32
+ })
33
+ const text = await res.text()
34
+ if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${text}`)
35
+ try { return JSON.parse(text) as T } catch { return text as T }
36
+ }
37
+
38
+ get<T = unknown>(path: string) { return this.request<T>('GET', path) }
39
+ post<T = unknown>(path: string, body?: unknown) { return this.request<T>('POST', path, body) }
40
+ put<T = unknown>(path: string, body?: unknown) { return this.request<T>('PUT', path, body) }
41
+ del<T = unknown>(path: string) { return this.request<T>('DELETE', path) }
42
+ }
@@ -0,0 +1,79 @@
1
+ import { defineCommand } from 'citty'
2
+ import { search, input, Separator } from '@inquirer/prompts'
3
+ import chalk from 'chalk'
4
+ import { loadConfig, getResolvedInstance, configExists } from '../config'
5
+ import { BodClient } from '../client'
6
+
7
+ function getRegistry(): { registryUrl: string; client: BodClient } | null {
8
+ if (!configExists()) return null
9
+ try {
10
+ const { url, apiKey, instance } = getResolvedInstance(loadConfig())
11
+ if (!instance.capabilities.registryEnabled) return null
12
+ return { registryUrl: `${url}/api/registry`, client: new BodClient(url, apiKey) }
13
+ } catch { return null }
14
+ }
15
+
16
+ type SearchResult = { total: number; objects: Array<{ package: { name: string; version: string; description?: string } }> }
17
+
18
+ export default defineCommand({
19
+ meta: { name: 'add', description: 'Add a package (registry-aware)' },
20
+ args: {
21
+ pkg: { type: 'positional', description: 'Package name', required: false },
22
+ },
23
+ async run({ args }) {
24
+ const registry = getRegistry()
25
+ let pkg = args.pkg
26
+
27
+ // Interactive: show registry packages with search
28
+ if (!pkg && registry) {
29
+ try {
30
+ pkg = await search<string>({
31
+ message: 'Package name (search registry or type any npm package):',
32
+ source: async (term) => {
33
+ const query = term ?? ''
34
+ const results = await registry.client.get<SearchResult>(
35
+ `/registry/-/v1/search?text=${encodeURIComponent(query)}&size=20`
36
+ ).catch(() => ({ total: 0, objects: [] }))
37
+
38
+ const choices: Array<{ value: string; name: string; description?: string } | Separator> = results.objects.map(o => ({
39
+ value: o.package.name,
40
+ name: `${o.package.name}@${o.package.version}`,
41
+ description: o.package.description,
42
+ }))
43
+
44
+ // Allow typing an arbitrary package name
45
+ if (query && !results.objects.some(o => o.package.name === query)) {
46
+ choices.push(new Separator())
47
+ choices.push({ value: query, name: `${query} (npm)`, description: 'Install from npm' })
48
+ }
49
+
50
+ return choices
51
+ },
52
+ })
53
+ } catch (e) {
54
+ if ((e as Error).name === 'ExitPromptError') return
55
+ throw e
56
+ }
57
+ }
58
+
59
+ // If still no package (no registry, user didn't pick), prompt plainly
60
+ if (!pkg) {
61
+ try {
62
+ pkg = await input({ message: 'Package name:' })
63
+ } catch (e) {
64
+ if ((e as Error).name === 'ExitPromptError') return
65
+ throw e
66
+ }
67
+ if (!pkg) { console.error(chalk.red('Package name required.')); process.exit(1) }
68
+ }
69
+
70
+ const cmd = registry
71
+ ? ['bun', 'add', pkg, '--registry', registry.registryUrl]
72
+ : ['bun', 'add', pkg]
73
+
74
+ console.log(chalk.dim(`$ ${cmd.join(' ')}`))
75
+ const proc = Bun.spawn(cmd, { stdio: ['inherit', 'inherit', 'inherit'] })
76
+ const code = await proc.exited
77
+ process.exit(code)
78
+ },
79
+ })
@@ -0,0 +1,71 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { loadConfig, getResolvedInstance } from '../config'
4
+ import { BodClient } from '../client'
5
+ import { printTable, printKv } from '../utils/output'
6
+ import { resolveAppId } from '../utils/resolve'
7
+
8
+ const listCmd = defineCommand({
9
+ meta: { name: 'list', description: 'List all apps' },
10
+ async run() {
11
+ const { url, apiKey } = getResolvedInstance(loadConfig())
12
+ const client = new BodClient(url, apiKey)
13
+ const apps = await client.get<any[]>('/apps')
14
+ printTable(apps.map(a => ({
15
+ name: a.name,
16
+ domain: a.domain ?? '',
17
+ branch: a.productionBranch ?? 'main',
18
+ type: a.type ?? '',
19
+ repo: a.repo ?? '',
20
+ })))
21
+ },
22
+ })
23
+
24
+ const statusCmd = defineCommand({
25
+ meta: { name: 'status', description: 'Show app details' },
26
+ args: {
27
+ app: { type: 'positional', description: 'App name or ID', required: true },
28
+ },
29
+ async run({ args }) {
30
+ const { url, apiKey } = getResolvedInstance(loadConfig())
31
+ const client = new BodClient(url, apiKey)
32
+ const appId = await resolveAppId(client, args.app)
33
+ const detail = await client.get<any>(`/apps/${appId}`)
34
+
35
+ printKv({
36
+ Name: detail.name,
37
+ ID: detail.id,
38
+ Domain: detail.domain ?? '(none)',
39
+ Type: detail.type ?? 'auto',
40
+ Branch: detail.productionBranch ?? 'main',
41
+ Repo: detail.repo ?? '(none)',
42
+ Database: detail.database ? 'yes' : 'no',
43
+ Preview: detail.preview ? 'yes' : 'no',
44
+ })
45
+
46
+ if (detail.instances?.length) {
47
+ console.log(chalk.bold('\nInstances:'))
48
+ printTable(detail.instances.map((i: any) => ({
49
+ id: i.id?.slice(0, 8),
50
+ branch: i.branch,
51
+ status: i.status,
52
+ port: i.port,
53
+ })))
54
+ }
55
+
56
+ if (detail.deployments?.length) {
57
+ console.log(chalk.bold('\nRecent Deployments:'))
58
+ printTable(detail.deployments.map((d: any) => ({
59
+ id: d.id?.slice(0, 8),
60
+ branch: d.branch,
61
+ status: d.status,
62
+ created: d.createdAt ? new Date(d.createdAt).toLocaleString() : '',
63
+ })))
64
+ }
65
+ },
66
+ })
67
+
68
+ export default defineCommand({
69
+ meta: { name: 'apps', description: 'Manage apps' },
70
+ subCommands: { list: listCmd, status: statusCmd },
71
+ })
@@ -0,0 +1,103 @@
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
+ async function detectBranch(explicit?: string): Promise<string> {
8
+ if (explicit) return explicit
9
+ const proc = Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
10
+ const branch = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
11
+ return branch || 'main'
12
+ }
13
+
14
+ async function uploadDeploy(client: BodClient, appId: string, branch: string) {
15
+ console.log(chalk.dim('Packing source...'))
16
+ const tar = Bun.spawn(
17
+ ['tar', 'cz', '--exclude=node_modules', '--exclude=.git', '--exclude=dist', '.'],
18
+ { stdout: 'pipe', stderr: 'pipe' },
19
+ )
20
+ await client.upload<any>(
21
+ `/apps/${appId}/deploy/upload`,
22
+ tar.stdout as ReadableStream,
23
+ { 'x-branch': branch },
24
+ )
25
+ const exitCode = await tar.exited
26
+ if (exitCode !== 0) {
27
+ const errText = await new Response(tar.stderr).text().catch(() => '')
28
+ throw new Error(`tar failed: ${errText}`)
29
+ }
30
+ }
31
+
32
+ async function gitDeploy(client: BodClient, appId: string, branch: string) {
33
+ await client.post<any>(`/apps/${appId}/deploy`, { branch })
34
+ }
35
+
36
+ async function pollDeploy(client: BodClient, appId: string, since: number) {
37
+ console.log(chalk.dim('Waiting for deployment...'))
38
+ let lastStatus = ''
39
+ for (let i = 0; i < 120; i++) {
40
+ await Bun.sleep(2000)
41
+ const detail = await client.get<any>(`/apps/${appId}`)
42
+ const deps = detail.deployments ?? []
43
+ // Find the deployment created after we triggered (don't fall back to older ones)
44
+ const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
45
+ if (!latest) continue
46
+ const status = latest.status
47
+ if (status !== lastStatus) {
48
+ console.log(chalk.dim(` Status: ${status}`))
49
+ lastStatus = status
50
+ }
51
+ if (status === 'live') {
52
+ console.log(chalk.green(`✓ Deployment live!`))
53
+ if (detail.domain) console.log(chalk.dim(` → https://${detail.domain}`))
54
+ return
55
+ }
56
+ if (status === 'failed') {
57
+ console.error(chalk.red(`✗ Deployment failed`))
58
+ process.exit(1)
59
+ }
60
+ }
61
+ console.log(chalk.yellow('Timed out waiting. Check "bod apps status" for updates.'))
62
+ }
63
+
64
+ export default defineCommand({
65
+ meta: { name: 'deploy', description: 'Trigger a deployment' },
66
+ args: {
67
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
68
+ branch: { type: 'string', description: 'Branch to deploy (default: current git branch)' },
69
+ },
70
+ async run({ args }) {
71
+ const { url, apiKey } = getResolvedInstance(loadConfig())
72
+ const client = new BodClient(url, apiKey)
73
+
74
+ const appName = resolveAppName(args.app)
75
+
76
+ const appId = await resolveAppId(client, appName)
77
+ const branch = await detectBranch(args.branch)
78
+ const detail = await client.get<any>(`/apps/${appId}`)
79
+
80
+ let hasRepo = !!detail.repo
81
+ if (!hasRepo) {
82
+ const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'])
83
+ const repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
84
+ if (repo) {
85
+ await client.put(`/apps/${appId}`, { repo })
86
+ console.log(chalk.dim(`Linked repo: ${repo}`))
87
+ hasRepo = true
88
+ }
89
+ }
90
+
91
+ console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${hasRepo ? '' : ' via upload'}...`))
92
+
93
+ const since = Date.now()
94
+ if (hasRepo) {
95
+ await gitDeploy(client, appId, branch)
96
+ } else {
97
+ await uploadDeploy(client, appId, branch)
98
+ }
99
+
100
+ console.log(chalk.green(`✓ Deployment queued`))
101
+ await pollDeploy(client, appId, since)
102
+ },
103
+ })
@@ -0,0 +1,107 @@
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
+ const listCmd = defineCommand({
8
+ meta: { name: 'list', description: 'List env vars' },
9
+ args: {
10
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
11
+ },
12
+ async run({ args }) {
13
+ const { url, apiKey } = getResolvedInstance(loadConfig())
14
+ const client = new BodClient(url, apiKey)
15
+ const appId = await resolveAppId(client, resolveAppName(args.app))
16
+ const detail = await client.get<any>(`/apps/${appId}`)
17
+ const env = detail.env ?? {}
18
+ if (Object.keys(env).length === 0) {
19
+ console.log(chalk.dim('No environment variables set.'))
20
+ return
21
+ }
22
+ for (const [k, v] of Object.entries(env)) console.log(`${k}=${v}`)
23
+ },
24
+ })
25
+
26
+ function parseDotEnv(content: string): Record<string, string> {
27
+ const env: Record<string, string> = {}
28
+ for (const line of content.split('\n')) {
29
+ const trimmed = line.trim()
30
+ if (!trimmed || trimmed.startsWith('#')) continue
31
+ const eqIdx = trimmed.indexOf('=')
32
+ if (eqIdx === -1) continue
33
+ const key = trimmed.slice(0, eqIdx).trim()
34
+ let value = trimmed.slice(eqIdx + 1).trim()
35
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
36
+ value = value.slice(1, -1)
37
+ env[key] = value
38
+ }
39
+ return env
40
+ }
41
+
42
+ const setCmd = defineCommand({
43
+ meta: { name: 'set', description: 'Set env var (KEY=VALUE or --file .env)' },
44
+ args: {
45
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
46
+ pair: { type: 'positional', description: 'KEY=VALUE', required: false },
47
+ file: { type: 'string', alias: 'f', description: 'Path to .env file' },
48
+ },
49
+ async run({ args }) {
50
+ const { url, apiKey } = getResolvedInstance(loadConfig())
51
+ const client = new BodClient(url, apiKey)
52
+ const appId = await resolveAppId(client, resolveAppName(args.app))
53
+ const detail = await client.get<any>(`/apps/${appId}`)
54
+ const existing = detail.env ?? {}
55
+
56
+ if (args.file) {
57
+ const f = Bun.file(args.file)
58
+ if (!(await f.exists())) {
59
+ console.error(chalk.red(`File not found: ${args.file}`))
60
+ process.exit(1)
61
+ }
62
+ const content = await f.text()
63
+ const parsed = parseDotEnv(content)
64
+ const keys = Object.keys(parsed)
65
+ if (keys.length === 0) {
66
+ console.error(chalk.red('No variables found in file.'))
67
+ process.exit(1)
68
+ }
69
+ await client.put(`/apps/${appId}`, { env: { ...existing, ...parsed } })
70
+ console.log(chalk.green(`✓ Set ${keys.length} var(s): ${keys.join(', ')}`))
71
+ return
72
+ }
73
+
74
+ if (!args.pair || !args.pair.includes('=')) {
75
+ console.error(chalk.red('Format: bod env set <app> KEY=VALUE or bod env set <app> -f .env'))
76
+ process.exit(1)
77
+ }
78
+ const [key, ...vals] = args.pair.split('=')
79
+ const value = vals.join('=')
80
+ await client.put(`/apps/${appId}`, { env: { ...existing, [key]: value } })
81
+ console.log(chalk.green(`✓ Set ${key}`))
82
+ },
83
+ })
84
+
85
+ const unsetCmd = defineCommand({
86
+ meta: { name: 'unset', description: 'Remove env var' },
87
+ args: {
88
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
89
+ key: { type: 'positional', description: 'Variable name', required: true },
90
+ },
91
+ async run({ args }) {
92
+ const { url, apiKey } = getResolvedInstance(loadConfig())
93
+ const client = new BodClient(url, apiKey)
94
+ const appId = await resolveAppId(client, resolveAppName(args.app))
95
+
96
+ const detail = await client.get<any>(`/apps/${appId}`)
97
+ const env = { ...(detail.env ?? {}) }
98
+ delete env[args.key]
99
+ await client.put(`/apps/${appId}`, { env })
100
+ console.log(chalk.green(`✓ Removed ${args.key}`))
101
+ },
102
+ })
103
+
104
+ export default defineCommand({
105
+ meta: { name: 'env', description: 'Manage environment variables' },
106
+ subCommands: { list: listCmd, set: setCmd, unset: unsetCmd },
107
+ })