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,48 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
import { loadConfig, getResolvedInstance } from '../config'
|
|
6
|
+
|
|
7
|
+
function resolveKeyPath(explicit?: string): string | undefined {
|
|
8
|
+
if (explicit && existsSync(explicit)) return explicit
|
|
9
|
+
if (process.env.SSH_KEY_PATH && existsSync(process.env.SSH_KEY_PATH)) return process.env.SSH_KEY_PATH
|
|
10
|
+
// Well-known location
|
|
11
|
+
const wellKnown = join(homedir(), '.bod-cloud/keys/bod-cloud_ed25519')
|
|
12
|
+
if (existsSync(wellKnown)) return wellKnown
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default defineCommand({
|
|
17
|
+
meta: { name: 'ssh', description: 'SSH into the remote Bodify server' },
|
|
18
|
+
args: {
|
|
19
|
+
user: { type: 'string', description: 'SSH user (default: root)' },
|
|
20
|
+
port: { type: 'string', description: 'SSH port (default: 22)' },
|
|
21
|
+
key: { type: 'string', description: 'SSH private key path' },
|
|
22
|
+
command: { type: 'string', alias: 'c', description: 'Command to execute on remote host' },
|
|
23
|
+
},
|
|
24
|
+
async run({ args, rawArgs }) {
|
|
25
|
+
const { url } = getResolvedInstance(loadConfig())
|
|
26
|
+
const host = new URL(url).hostname
|
|
27
|
+
|
|
28
|
+
const sshArgs: string[] = []
|
|
29
|
+
const keyPath = resolveKeyPath(args.key)
|
|
30
|
+
if (keyPath) sshArgs.push('-i', keyPath)
|
|
31
|
+
if (args.port) sshArgs.push('-p', args.port)
|
|
32
|
+
sshArgs.push(args.user ? `${args.user}@${host}` : `root@${host}`)
|
|
33
|
+
|
|
34
|
+
// Remote command via -c "cmd" or -- cmd arg1 arg2
|
|
35
|
+
const dashDashIdx = rawArgs?.indexOf('--') ?? -1
|
|
36
|
+
const remoteCmd = dashDashIdx >= 0
|
|
37
|
+
? rawArgs!.slice(dashDashIdx + 1).join(' ')
|
|
38
|
+
: args.command
|
|
39
|
+
if (remoteCmd) sshArgs.push(remoteCmd)
|
|
40
|
+
|
|
41
|
+
const proc = Bun.spawn(['ssh', ...sshArgs], {
|
|
42
|
+
stdout: 'inherit',
|
|
43
|
+
stderr: 'inherit',
|
|
44
|
+
stdin: 'inherit',
|
|
45
|
+
})
|
|
46
|
+
process.exitCode = await proc.exited
|
|
47
|
+
},
|
|
48
|
+
})
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { join, dirname } from 'path'
|
|
5
|
+
|
|
6
|
+
const InstanceSchema = z.object({
|
|
7
|
+
url: z.string().url(),
|
|
8
|
+
apiKey: z.string(),
|
|
9
|
+
capabilities: z.object({
|
|
10
|
+
registryEnabled: z.boolean().default(false),
|
|
11
|
+
baseDomain: z.string().nullable().default(null),
|
|
12
|
+
githubConfigured: z.boolean().default(false),
|
|
13
|
+
}).default({}),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const ConfigSchema = z.object({
|
|
17
|
+
instances: z.record(z.string(), InstanceSchema),
|
|
18
|
+
defaultInstance: z.string().nullable().default(null),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type Config = z.infer<typeof ConfigSchema>
|
|
22
|
+
export type Instance = z.infer<typeof InstanceSchema>
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CONFIG_PATH = join(homedir(), '.bod', 'config.json')
|
|
25
|
+
|
|
26
|
+
export function resolveToken(token: string): string {
|
|
27
|
+
let envKey: string | undefined
|
|
28
|
+
if (token.startsWith('$')) envKey = token.slice(1)
|
|
29
|
+
else if (token.startsWith('env:')) envKey = token.slice(4)
|
|
30
|
+
if (!envKey) return token
|
|
31
|
+
const value = process.env[envKey]
|
|
32
|
+
if (!value) throw new Error(`Environment variable "${envKey}" is not set (referenced in config)`)
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function configExists(configPath?: string): boolean {
|
|
37
|
+
return existsSync(configPath ?? DEFAULT_CONFIG_PATH)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadConfig(configPath?: string): Config {
|
|
41
|
+
const path = configPath ?? DEFAULT_CONFIG_PATH
|
|
42
|
+
if (!existsSync(path)) {
|
|
43
|
+
throw new Error(`Config not found: ${path}\nRun "bod login <url>" first.`)
|
|
44
|
+
}
|
|
45
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
46
|
+
return ConfigSchema.parse(raw)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeConfig(config: Config, configPath?: string): void {
|
|
50
|
+
const validated = ConfigSchema.parse(config)
|
|
51
|
+
const path = configPath ?? DEFAULT_CONFIG_PATH
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
53
|
+
writeFileSync(path, JSON.stringify(validated, null, 2), { mode: 0o600 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getDefaultInstance(config: Config): { name: string; instance: Instance } {
|
|
57
|
+
const name = config.defaultInstance
|
|
58
|
+
if (!name || !config.instances[name]) {
|
|
59
|
+
const first = Object.keys(config.instances)[0]
|
|
60
|
+
if (!first) throw new Error('No instances configured. Run "bod login <url>" first.')
|
|
61
|
+
return { name: first, instance: config.instances[first] }
|
|
62
|
+
}
|
|
63
|
+
return { name, instance: config.instances[name] }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Override instance from --instance flag or BOD_INSTANCE env var */
|
|
67
|
+
let instanceOverride: string | undefined
|
|
68
|
+
|
|
69
|
+
export function setInstanceOverride(name: string | undefined) {
|
|
70
|
+
instanceOverride = name
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getResolvedInstance(config: Config): { name: string; url: string; apiKey: string; instance: Instance } {
|
|
74
|
+
const override = instanceOverride ?? process.env.BOD_INSTANCE
|
|
75
|
+
if (override) {
|
|
76
|
+
const inst = config.instances[override]
|
|
77
|
+
if (!inst) throw new Error(`Instance "${override}" not found in config. Available: ${Object.keys(config.instances).join(', ')}`)
|
|
78
|
+
return { name: override, url: inst.url, apiKey: resolveToken(inst.apiKey), instance: inst }
|
|
79
|
+
}
|
|
80
|
+
const { name, instance } = getDefaultInstance(config)
|
|
81
|
+
return { name, url: instance.url, apiKey: resolveToken(instance.apiKey), instance }
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DEBUG = process.env.DEBUG ?? ''
|
|
2
|
+
|
|
3
|
+
function shouldLog(component: string): boolean {
|
|
4
|
+
if (!DEBUG) return false
|
|
5
|
+
if (DEBUG === '*') return true
|
|
6
|
+
return DEBUG.split(',').some(p => p.trim() === component)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createLogger(component: string) {
|
|
10
|
+
const prefix = `[${component}]`
|
|
11
|
+
return {
|
|
12
|
+
debug: (...args: unknown[]) => shouldLog(component) && console.debug(prefix, ...args),
|
|
13
|
+
verbose: (...args: unknown[]) => shouldLog(component) && console.log(prefix, ...args),
|
|
14
|
+
info: (...args: unknown[]) => console.log(prefix, ...args),
|
|
15
|
+
warn: (...args: unknown[]) => console.warn(prefix, ...args),
|
|
16
|
+
error: (...args: unknown[]) => console.error(prefix, ...args),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const logger = { forComponent: createLogger }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Table from 'cli-table3'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
export function printTable(rows: Record<string, unknown>[], columns?: string[]) {
|
|
5
|
+
if (rows.length === 0) { console.log(chalk.dim('(none)')); return }
|
|
6
|
+
const keys = columns ?? Object.keys(rows[0])
|
|
7
|
+
const table = new Table({
|
|
8
|
+
head: keys.map(k => chalk.bold(k)),
|
|
9
|
+
style: { head: [], border: [] },
|
|
10
|
+
})
|
|
11
|
+
for (const row of rows) {
|
|
12
|
+
table.push(keys.map(k => String(row[k] ?? '')))
|
|
13
|
+
}
|
|
14
|
+
console.log(table.toString())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function printJson(data: unknown) {
|
|
18
|
+
console.log(JSON.stringify(data, null, 2))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function printKv(entries: Record<string, unknown>) {
|
|
22
|
+
const maxKey = Math.max(...Object.keys(entries).map(k => k.length))
|
|
23
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
24
|
+
console.log(`${chalk.bold(k.padEnd(maxKey))} ${v}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { BodClient } from '../client'
|
|
4
|
+
|
|
5
|
+
export async function resolveAppId(client: BodClient, nameOrId: string): Promise<string> {
|
|
6
|
+
const apps = await client.get<any[]>('/apps')
|
|
7
|
+
const match = apps.find(a => a.name === nameOrId || a.id === nameOrId)
|
|
8
|
+
if (!match) {
|
|
9
|
+
console.error(chalk.red(`App not found: ${nameOrId}`))
|
|
10
|
+
process.exit(1)
|
|
11
|
+
}
|
|
12
|
+
return match.id
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readAppNameFromYaml(): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
const yaml = readFileSync('bodify.yaml', 'utf-8')
|
|
18
|
+
return yaml.match(/name:\s*(.+)/)?.[1]?.trim()
|
|
19
|
+
} catch { return undefined }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve app name from explicit arg or bodify.yaml fallback. Logs clearly when falling back. */
|
|
23
|
+
export function resolveAppName(arg: string | undefined): string {
|
|
24
|
+
if (arg) return arg
|
|
25
|
+
const name = readAppNameFromYaml()
|
|
26
|
+
if (!name) {
|
|
27
|
+
console.error(chalk.red('App name required. Pass <app> or run from a directory with bodify.yaml'))
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
console.log(chalk.dim(`Using app "${name}" from bodify.yaml`))
|
|
31
|
+
return name
|
|
32
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"baseUrl": ".",
|
|
12
|
+
"paths": {
|
|
13
|
+
"@/*": ["src/*"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"]
|
|
17
|
+
}
|