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
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
|
+
})
|