bod-cli 0.9.1 → 0.10.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 +10 -0
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +4 -1
- package/src/commands/env.ts +66 -34
- package/src/commands/host.ts +93 -0
|
@@ -124,6 +124,16 @@ Open an app in the default browser. Resolves the URL from the app's domain.
|
|
|
124
124
|
bod open my-api # opens https://my-api.bodify.example.com
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### `bod host <path> [--slug <s>] [--app <appId>]`
|
|
128
|
+
Publish a file or folder to a public CDN URL (`https://serve.bod.ee/s/<slug>/…`). Handles create → upload → finalize and prints the live URL. Ephemeral (48h) unless `--app` links it to an app (then persistent). See the **bod-serve** skill for the full flow.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
bod host ./dist # a static site → live URL
|
|
132
|
+
bod host ./clip.mp3 # a single file (video/audio/pdf/image)
|
|
133
|
+
bod host ./dist --slug my-site # reuse/update an existing slug
|
|
134
|
+
bod host ./assets --app my-api # persistent app assets
|
|
135
|
+
```
|
|
136
|
+
|
|
127
137
|
### `bod env list|set|unset <app>`
|
|
128
138
|
Manage environment variables.
|
|
129
139
|
|
package/CLAUDE.md
CHANGED
|
@@ -33,7 +33,7 @@ src/
|
|
|
33
33
|
├── rollback.ts # bod rollback [app]
|
|
34
34
|
├── apps.ts # bod apps list|status
|
|
35
35
|
├── logs.ts # bod logs <app> [-f]
|
|
36
|
-
├── env.ts # bod env list|set|unset (set supports -f .env
|
|
36
|
+
├── env.ts # bod env list|set|unset; scoped by <app>/--global/--group (+ --env); set supports -f .env
|
|
37
37
|
├── open.ts # bod open <app>
|
|
38
38
|
├── add.ts # bod add <pkg>
|
|
39
39
|
└── remove.ts # bod remove <pkg>
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import addCmd from './commands/add'
|
|
|
13
13
|
import removeCmd from './commands/remove'
|
|
14
14
|
import openCmd from './commands/open'
|
|
15
15
|
import serveCmd from './commands/serve'
|
|
16
|
+
import hostCmd from './commands/host'
|
|
16
17
|
import sshCmd from './commands/ssh'
|
|
17
18
|
import publishCmd from './commands/publish'
|
|
18
19
|
import dbCmd from './commands/db'
|
|
@@ -46,6 +47,7 @@ const subCommands = {
|
|
|
46
47
|
remove: removeCmd,
|
|
47
48
|
open: openCmd,
|
|
48
49
|
serve: serveCmd,
|
|
50
|
+
host: hostCmd,
|
|
49
51
|
ssh: sshCmd,
|
|
50
52
|
publish: publishCmd,
|
|
51
53
|
db: dbCmd,
|
|
@@ -108,7 +110,8 @@ const main = defineCommand({
|
|
|
108
110
|
// Commands with subcommands need a default, commands with required positionals need prompting
|
|
109
111
|
const interactiveArgs = await getInteractiveArgs(command)
|
|
110
112
|
if (interactiveArgs === null) continue // user cancelled
|
|
111
|
-
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- citty's CommandDef union isn't assignable to itself across a heterogeneous subCommands map
|
|
114
|
+
await runCommand(subCommands[command as keyof typeof subCommands] as any, { rawArgs: interactiveArgs })
|
|
112
115
|
} catch (e) {
|
|
113
116
|
if ((e as Error).name === 'ExitPromptError') continue
|
|
114
117
|
console.error(`Error: ${(e as Error).message}`)
|
package/src/commands/env.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { printTable } from '../utils/output'
|
|
|
7
7
|
import { resolveAppId, resolveAppName } from '../utils/resolve'
|
|
8
8
|
|
|
9
9
|
// --- Bodify global secrets store types (mirror the agent /secrets API contract) ---
|
|
10
|
-
interface Scope { app?: string; env?: string }
|
|
10
|
+
interface Scope { app?: string; env?: string; group?: string }
|
|
11
11
|
interface MaskedEntry { scope: Scope; masked: string; updatedAt: number; updatedBy?: string }
|
|
12
12
|
interface MaskedVar { key: string; description?: string; createdAt: number; updatedAt: number; entries: MaskedEntry[] }
|
|
13
13
|
interface ResolvedEnv { environment?: string; values: Record<string, string> }
|
|
@@ -33,22 +33,42 @@ function parseDotEnv(content: string): Record<string, string> {
|
|
|
33
33
|
return env
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** Build a secrets scope from the app arg + flags. `--global` drops the app axis
|
|
37
|
-
|
|
36
|
+
/** Build a secrets scope from the app arg + flags. `--global` drops the app axis;
|
|
37
|
+
* `--group` scopes to a named group (apps that inherit it) instead of one app. */
|
|
38
|
+
function buildScope(app: string | undefined, env: string | undefined, global: boolean, group?: string): Scope {
|
|
38
39
|
const scope: Scope = {}
|
|
39
|
-
if (
|
|
40
|
+
if (group) scope.group = group
|
|
41
|
+
else if (!global && app) scope.app = app
|
|
40
42
|
if (env) scope.env = env
|
|
41
43
|
return scope
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
/** Citty binds the FIRST positional to `app`, but under --global/--group there is no
|
|
47
|
+
* app — the leading positional is really the next one (KEY=VALUE / key). Shift it back
|
|
48
|
+
* so `app` is undefined and `rest` holds the real positionals. Returns the genuine app
|
|
49
|
+
* only when no scope flag was passed. */
|
|
50
|
+
function resolvePositionals(app: string | undefined, rest: (string | undefined)[], global: boolean, group?: string) {
|
|
51
|
+
if (global || group) return { app: undefined, rest: [app, ...rest].filter((p): p is string => p !== undefined) }
|
|
52
|
+
return { app, rest: rest.filter((p): p is string => p !== undefined) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Guard: <app>, --global and --group are mutually exclusive (they pick the scope's
|
|
56
|
+
* primary axis). --env is orthogonal and combines with any of them. */
|
|
57
|
+
function assertScopeFlags(app: string | undefined, global: boolean, group?: string) {
|
|
58
|
+
const picked = [app && '<app>', global && '--global', group && '--group'].filter(Boolean)
|
|
59
|
+
if (picked.length > 1) {
|
|
60
|
+
console.error(chalk.red(`Pass only one of ${picked.join(', ')} — they are mutually exclusive.`))
|
|
48
61
|
process.exit(1)
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
/** Human label for a scope's primary axis (+ optional env). */
|
|
66
|
+
function renderScope(scope: Scope): string {
|
|
67
|
+
const base = scope.app ? `app:${scope.app}` : scope.group ? `group:${scope.group}` : scope.env ? `env:${scope.env}` : 'all'
|
|
68
|
+
// env already shown as the base when it's the only axis; otherwise append it.
|
|
69
|
+
return (scope.app || scope.group) && scope.env ? `${base}/${scope.env}` : base
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
/** Quote a dotenv value if it contains characters that would break a bare assignment. */
|
|
53
73
|
function quoteDotEnvValue(value: string): string {
|
|
54
74
|
if (/[\s"'#=$`\\]/.test(value) || value === '') {
|
|
@@ -58,33 +78,32 @@ function quoteDotEnvValue(value: string): string {
|
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
const listCmd = defineCommand({
|
|
61
|
-
meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global:
|
|
81
|
+
meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group: variables in that scope (masked).' },
|
|
62
82
|
args: {
|
|
63
83
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
64
84
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
65
85
|
global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
|
|
86
|
+
group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
|
|
66
87
|
},
|
|
67
88
|
async run({ args }) {
|
|
68
89
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
69
90
|
const client = new BodClient(url, apiKey)
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
assertScopeFlags(args.app, !!args.global, args.group)
|
|
93
|
+
|
|
94
|
+
if (args.global || args.group) {
|
|
72
95
|
const vars = await client.get<MaskedVar[]>('/secrets/vars')
|
|
73
|
-
if (!vars.length) {
|
|
74
|
-
console.log(chalk.dim('No variables set.'))
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
96
|
const rows: Record<string, unknown>[] = []
|
|
78
97
|
for (const v of vars) {
|
|
79
98
|
for (const e of v.entries) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
: e.scope.env
|
|
83
|
-
? `env:${e.scope.env}`
|
|
84
|
-
: 'all'
|
|
85
|
-
rows.push({ key: v.key, scope, value: e.masked })
|
|
99
|
+
if (args.group && e.scope.group !== args.group) continue
|
|
100
|
+
rows.push({ key: v.key, scope: renderScope(e.scope), value: e.masked })
|
|
86
101
|
}
|
|
87
102
|
}
|
|
103
|
+
if (!rows.length) {
|
|
104
|
+
console.log(chalk.dim(args.group ? `No variables scoped to group:${args.group}.` : 'No variables set.'))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
88
107
|
printTable(rows, ['key', 'scope', 'value'])
|
|
89
108
|
return
|
|
90
109
|
}
|
|
@@ -103,22 +122,26 @@ const listCmd = defineCommand({
|
|
|
103
122
|
})
|
|
104
123
|
|
|
105
124
|
const setCmd = defineCommand({
|
|
106
|
-
meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, and/or --env.' },
|
|
125
|
+
meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, and/or --env.' },
|
|
107
126
|
args: {
|
|
108
127
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
|
|
109
128
|
pair: { type: 'positional', description: 'KEY=VALUE', required: false },
|
|
110
129
|
file: { type: 'string', alias: 'f', description: 'Path to .env file (bulk import)' },
|
|
111
130
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
112
131
|
global: { type: 'boolean', alias: 'g', description: 'Shared var (no app); broadens reach' },
|
|
132
|
+
group: { type: 'string', description: 'Scope to a group (apps that inherit it); excludes <app>/--global' },
|
|
113
133
|
},
|
|
114
134
|
async run({ args }) {
|
|
115
135
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
116
136
|
const client = new BodClient(url, apiKey)
|
|
117
137
|
|
|
118
|
-
// --global
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
// Under --global/--group the leading positional is the pair, not an app — shift it.
|
|
139
|
+
const { app, rest } = resolvePositionals(args.app, [args.pair], !!args.global, args.group)
|
|
140
|
+
// Real conflict: a scope flag AND a surplus leading positional (a true <app>) before the pair.
|
|
141
|
+
assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group)
|
|
142
|
+
const pair = (args.global || args.group) ? rest[0] : args.pair
|
|
143
|
+
const appName = args.global || args.group ? undefined : resolveAppName(app)
|
|
144
|
+
const scope = buildScope(appName, args.env, !!args.global, args.group)
|
|
122
145
|
|
|
123
146
|
if (args.file) {
|
|
124
147
|
const f = Bun.file(args.file)
|
|
@@ -139,13 +162,13 @@ const setCmd = defineCommand({
|
|
|
139
162
|
return
|
|
140
163
|
}
|
|
141
164
|
|
|
142
|
-
if (!
|
|
165
|
+
if (!pair || !pair.includes('=')) {
|
|
143
166
|
console.error(chalk.red('Format: bod env set <app> KEY=VALUE or bod env set <app> -f .env (use --global for a shared var)'))
|
|
144
167
|
process.exit(1)
|
|
145
168
|
}
|
|
146
|
-
const eqIdx =
|
|
147
|
-
const key =
|
|
148
|
-
const value =
|
|
169
|
+
const eqIdx = pair.indexOf('=')
|
|
170
|
+
const key = pair.slice(0, eqIdx)
|
|
171
|
+
const value = pair.slice(eqIdx + 1)
|
|
149
172
|
await client.put(`/secrets/vars/${encodeURIComponent(key)}`, { value, scope })
|
|
150
173
|
console.log(chalk.green(`✓ Set ${key}`))
|
|
151
174
|
},
|
|
@@ -155,22 +178,31 @@ const unsetCmd = defineCommand({
|
|
|
155
178
|
meta: { name: 'unset', description: 'Remove one scoped entry of a secret. Use `rm` to delete the whole variable.' },
|
|
156
179
|
args: {
|
|
157
180
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
|
|
158
|
-
key: { type: 'positional', description: 'Variable name', required:
|
|
181
|
+
key: { type: 'positional', description: 'Variable name', required: false },
|
|
159
182
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
160
183
|
global: { type: 'boolean', alias: 'g', description: 'Target the shared (no-app) entry' },
|
|
184
|
+
group: { type: 'string', description: 'Target a group-scoped entry; excludes <app>/--global' },
|
|
161
185
|
},
|
|
162
186
|
async run({ args }) {
|
|
163
187
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
164
188
|
const client = new BodClient(url, apiKey)
|
|
165
189
|
|
|
166
|
-
|
|
167
|
-
const
|
|
190
|
+
// Under --global/--group the leading positional is the key, not an app — shift it.
|
|
191
|
+
const { app, rest } = resolvePositionals(args.app, [args.key], !!args.global, args.group)
|
|
192
|
+
assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group)
|
|
193
|
+
const key = (args.global || args.group) ? rest[0] : args.key
|
|
194
|
+
if (!key) {
|
|
195
|
+
console.error(chalk.red('Variable name required: bod env unset <app> KEY (use --global/--group for shared entries)'))
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
const appName = args.global || args.group ? undefined : resolveAppName(app)
|
|
168
199
|
const qs: string[] = []
|
|
169
200
|
if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
|
|
201
|
+
if (args.group) qs.push(`group=${encodeURIComponent(args.group)}`)
|
|
170
202
|
if (args.env) qs.push(`env=${encodeURIComponent(args.env)}`)
|
|
171
203
|
const suffix = qs.length ? `?${qs.join('&')}` : ''
|
|
172
|
-
await client.del(`/secrets/vars/${encodeURIComponent(
|
|
173
|
-
console.log(chalk.green(`✓ Removed ${
|
|
204
|
+
await client.del(`/secrets/vars/${encodeURIComponent(key)}/entry${suffix}`)
|
|
205
|
+
console.log(chalk.green(`✓ Removed ${key}`))
|
|
174
206
|
},
|
|
175
207
|
})
|
|
176
208
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { statSync, readdirSync, readFileSync, existsSync } from 'fs'
|
|
4
|
+
import { join, relative, basename, extname } from 'path'
|
|
5
|
+
import { loadConfig, getResolvedInstance } from '../config'
|
|
6
|
+
|
|
7
|
+
const MIME: Record<string, string> = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
|
|
9
|
+
'.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json',
|
|
11
|
+
'.map': 'application/json', '.xml': 'application/xml', '.txt': 'text/plain; charset=utf-8',
|
|
12
|
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
13
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',
|
|
14
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
15
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
|
|
16
|
+
'.pdf': 'application/pdf', '.wasm': 'application/wasm',
|
|
17
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
|
|
18
|
+
}
|
|
19
|
+
function mimeFor(path: string): string { return MIME[extname(path).toLowerCase()] ?? 'application/octet-stream' }
|
|
20
|
+
|
|
21
|
+
// Collect { rel, abs } for a file or (recursively) a directory. Skips dotfiles + node_modules.
|
|
22
|
+
function collect(root: string): Array<{ rel: string; abs: string }> {
|
|
23
|
+
const st = statSync(root)
|
|
24
|
+
if (st.isFile()) return [{ rel: basename(root), abs: root }]
|
|
25
|
+
const out: Array<{ rel: string; abs: string }> = []
|
|
26
|
+
const walk = (dir: string) => {
|
|
27
|
+
for (const name of readdirSync(dir)) {
|
|
28
|
+
if (name.startsWith('.') || name === 'node_modules') continue
|
|
29
|
+
const abs = join(dir, name)
|
|
30
|
+
const s = statSync(abs)
|
|
31
|
+
if (s.isDirectory()) walk(abs)
|
|
32
|
+
else out.push({ rel: relative(root, abs).split('\\').join('/'), abs })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
walk(root)
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default defineCommand({
|
|
40
|
+
meta: { name: 'host', description: 'Publish a file or folder to the web (serve.bod.ee) and get a CDN URL' },
|
|
41
|
+
args: {
|
|
42
|
+
path: { type: 'positional', description: 'File or directory to publish', required: true },
|
|
43
|
+
slug: { type: 'string', description: 'Reuse/update an existing site slug' },
|
|
44
|
+
app: { type: 'string', description: 'Link to a Bodify app id → persistent (else ephemeral, 48h)' },
|
|
45
|
+
},
|
|
46
|
+
async run({ args }) {
|
|
47
|
+
const root = args.path
|
|
48
|
+
if (!existsSync(root)) { console.error(chalk.red(`Path not found: ${root}`)); process.exit(1) }
|
|
49
|
+
|
|
50
|
+
const files = collect(root)
|
|
51
|
+
if (!files.length) { console.error(chalk.red('No files to publish')); process.exit(1) }
|
|
52
|
+
|
|
53
|
+
const { url, apiKey, name: instanceName } = getResolvedInstance(loadConfig())
|
|
54
|
+
const auth: Record<string, string> = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
|
55
|
+
const totalBytes = files.reduce((n, f) => n + statSync(f.abs).size, 0)
|
|
56
|
+
console.log(chalk.dim(`Publishing ${files.length} file(s), ${(totalBytes / 1024).toFixed(1)} KB to ${instanceName}...`))
|
|
57
|
+
|
|
58
|
+
// 1. Create the site + presigned uploads.
|
|
59
|
+
const manifest = files.map(f => ({ path: f.rel, contentType: mimeFor(f.rel), size: statSync(f.abs).size }))
|
|
60
|
+
const createRes = await fetch(`${url}/api/v1/publish`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json', ...auth },
|
|
63
|
+
body: JSON.stringify({ files: manifest, slug: args.slug, appId: args.app }),
|
|
64
|
+
})
|
|
65
|
+
if (!createRes.ok) { console.error(chalk.red(`Publish failed: ${createRes.status} ${await createRes.text()}`)); process.exit(1) }
|
|
66
|
+
const site = await createRes.json() as {
|
|
67
|
+
slug: string; siteUrl: string; expiresAt?: number
|
|
68
|
+
upload: { uploads: Array<{ path: string; url: string | null; key: string }>; finalizeUrl: string }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Upload each file — presigned PUT (R2) or direct-to-agent PUT (local provider).
|
|
72
|
+
const byPath = new Map(files.map(f => [f.rel, f.abs]))
|
|
73
|
+
for (const u of site.upload.uploads) {
|
|
74
|
+
const abs = byPath.get(u.path)!
|
|
75
|
+
const data = new Uint8Array(readFileSync(abs))
|
|
76
|
+
const ct = mimeFor(u.path)
|
|
77
|
+
const target = u.url ?? `${url}/api/v1/upload/${site.slug}/${u.path.split('/').map(encodeURIComponent).join('/')}`
|
|
78
|
+
const putRes = await fetch(target, {
|
|
79
|
+
method: 'PUT',
|
|
80
|
+
headers: { 'Content-Type': ct, ...(u.url ? {} : auth) },
|
|
81
|
+
body: data,
|
|
82
|
+
})
|
|
83
|
+
if (!putRes.ok) { console.error(chalk.red(`Upload failed for ${u.path}: ${putRes.status} ${await putRes.text()}`)); process.exit(1) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Finalize.
|
|
87
|
+
const finRes = await fetch(`${url}${site.upload.finalizeUrl}`, { method: 'POST', headers: { ...auth } })
|
|
88
|
+
if (!finRes.ok) { console.error(chalk.red(`Finalize failed: ${finRes.status} ${await finRes.text()}`)); process.exit(1) }
|
|
89
|
+
|
|
90
|
+
console.log(chalk.green(`✓ Live: ${site.siteUrl}`))
|
|
91
|
+
console.log(chalk.dim(` slug: ${site.slug}${args.app ? ` (persistent, app ${args.app})` : site.expiresAt ? ` (expires ${new Date(site.expiresAt).toISOString()})` : ''}`))
|
|
92
|
+
},
|
|
93
|
+
})
|