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