bod-cli 0.8.1 → 0.9.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.
@@ -27,9 +27,6 @@ jobs:
27
27
  - name: Install dependencies
28
28
  run: bun install --frozen-lockfile
29
29
 
30
- - name: Build
31
- run: bun run build
32
-
33
30
  - name: Configure Git
34
31
  run: |
35
32
  git config user.name "GitHub Actions Bot"
package/package.json CHANGED
@@ -1,12 +1,9 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "bin": {
6
- "bod": "./dist/cli.js"
7
- },
8
- "scripts": {
9
- "build": "bun build src/cli.ts --outdir dist --target node --format esm"
6
+ "bod": "./src/cli.ts"
10
7
  },
11
8
  "dependencies": {
12
9
  "@inquirer/prompts": "^7.0.0",
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import serveCmd from './commands/serve'
16
16
  import sshCmd from './commands/ssh'
17
17
  import publishCmd from './commands/publish'
18
18
  import dbCmd from './commands/db'
19
+ import pkg from '../package.json' with { type: 'json' }
19
20
 
20
21
  // Parse --instance / --local / -l early so it's set before citty dispatches subcommands.
21
22
  // Supports both `--instance=NAME` and `--instance NAME` forms.
@@ -53,7 +54,7 @@ const subCommands = {
53
54
  const main = defineCommand({
54
55
  meta: {
55
56
  name: 'bod',
56
- version: '0.1.0',
57
+ version: pkg.version,
57
58
  description: 'Unified CLI for the Bod product family',
58
59
  },
59
60
  args: {
@@ -1,27 +1,16 @@
1
1
  import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
+ import { writeFileSync } from 'fs'
3
4
  import { loadConfig, getResolvedInstance } from '../config'
4
5
  import { BodClient } from '../client'
6
+ import { printTable } from '../utils/output'
5
7
  import { resolveAppId, resolveAppName } from '../utils/resolve'
6
8
 
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
- })
9
+ // --- Bodify global secrets store types (mirror the agent /secrets API contract) ---
10
+ interface Scope { app?: string; env?: string }
11
+ interface MaskedEntry { scope: Scope; masked: string; updatedAt: number; updatedBy?: string }
12
+ interface MaskedVar { key: string; description?: string; createdAt: number; updatedAt: number; entries: MaskedEntry[] }
13
+ interface ResolvedEnv { environment?: string; values: Record<string, string> }
25
14
 
26
15
  function parseDotEnv(content: string): Record<string, string> {
27
16
  const env: Record<string, string> = {}
@@ -44,19 +33,92 @@ function parseDotEnv(content: string): Record<string, string> {
44
33
  return env
45
34
  }
46
35
 
47
- const setCmd = defineCommand({
48
- meta: { name: 'set', description: 'Set env var (KEY=VALUE or --file .env)' },
36
+ /** Build a secrets scope from the app arg + flags. `--global` drops the app axis. */
37
+ function buildScope(app: string | undefined, env: string | undefined, global: boolean): Scope {
38
+ const scope: Scope = {}
39
+ if (!global && app) scope.app = app
40
+ if (env) scope.env = env
41
+ return scope
42
+ }
43
+
44
+ /** Guard: --global and a positional <app> are mutually exclusive. */
45
+ function assertScopeFlags(app: string | undefined, global: boolean) {
46
+ if (global && app) {
47
+ console.error(chalk.red('Pass either <app> or --global, not both.'))
48
+ process.exit(1)
49
+ }
50
+ }
51
+
52
+ /** Quote a dotenv value if it contains characters that would break a bare assignment. */
53
+ function quoteDotEnvValue(value: string): string {
54
+ if (/[\s"'#=$`\\]/.test(value) || value === '') {
55
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
56
+ }
57
+ return value
58
+ }
59
+
60
+ const listCmd = defineCommand({
61
+ meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global: all variables with scopes (masked).' },
49
62
  args: {
50
63
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
51
- pair: { type: 'positional', description: 'KEY=VALUE', required: false },
52
- file: { type: 'string', alias: 'f', description: 'Path to .env file' },
64
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
65
+ global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
53
66
  },
54
67
  async run({ args }) {
55
68
  const { url, apiKey } = getResolvedInstance(loadConfig())
56
69
  const client = new BodClient(url, apiKey)
70
+
71
+ if (args.global) {
72
+ const vars = await client.get<MaskedVar[]>('/secrets/vars')
73
+ if (!vars.length) {
74
+ console.log(chalk.dim('No variables set.'))
75
+ return
76
+ }
77
+ const rows: Record<string, unknown>[] = []
78
+ for (const v of vars) {
79
+ for (const e of v.entries) {
80
+ const scope = e.scope.app
81
+ ? `app:${e.scope.app}${e.scope.env ? `/${e.scope.env}` : ''}`
82
+ : e.scope.env
83
+ ? `env:${e.scope.env}`
84
+ : 'all'
85
+ rows.push({ key: v.key, scope, value: e.masked })
86
+ }
87
+ }
88
+ printTable(rows, ['key', 'scope', 'value'])
89
+ return
90
+ }
91
+
92
+ // Per-app: show fully resolved values for the chosen environment.
57
93
  const appId = await resolveAppId(client, resolveAppName(args.app))
58
- const detail = await client.get<any>(`/apps/${appId}`)
59
- const existing = detail.env ?? {}
94
+ const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
95
+ const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
96
+ const values = res.values ?? {}
97
+ if (Object.keys(values).length === 0) {
98
+ console.log(chalk.dim('No environment variables set.'))
99
+ return
100
+ }
101
+ for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
102
+ },
103
+ })
104
+
105
+ const setCmd = defineCommand({
106
+ meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, and/or --env.' },
107
+ args: {
108
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
109
+ pair: { type: 'positional', description: 'KEY=VALUE', required: false },
110
+ file: { type: 'string', alias: 'f', description: 'Path to .env file (bulk import)' },
111
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
112
+ global: { type: 'boolean', alias: 'g', description: 'Shared var (no app); broadens reach' },
113
+ },
114
+ async run({ args }) {
115
+ const { url, apiKey } = getResolvedInstance(loadConfig())
116
+ const client = new BodClient(url, apiKey)
117
+
118
+ // --global has no app; otherwise resolve the app NAME (server resolves name→id).
119
+ assertScopeFlags(args.app, !!args.global)
120
+ const appName = args.global ? undefined : resolveAppName(args.app)
121
+ const scope = buildScope(appName, args.env, !!args.global)
60
122
 
61
123
  if (args.file) {
62
124
  const f = Bun.file(args.file)
@@ -64,49 +126,91 @@ const setCmd = defineCommand({
64
126
  console.error(chalk.red(`File not found: ${args.file}`))
65
127
  process.exit(1)
66
128
  }
67
- const content = await f.text()
68
- const parsed = parseDotEnv(content)
129
+ const parsed = parseDotEnv(await f.text())
69
130
  const keys = Object.keys(parsed)
70
131
  if (keys.length === 0) {
71
132
  console.error(chalk.red('No variables found in file.'))
72
133
  process.exit(1)
73
134
  }
74
- await client.put(`/apps/${appId}`, { env: { ...existing, ...parsed } })
135
+ for (const key of keys) {
136
+ await client.put(`/secrets/vars/${encodeURIComponent(key)}`, { value: parsed[key], scope })
137
+ }
75
138
  console.log(chalk.green(`✓ Set ${keys.length} var(s): ${keys.join(', ')}`))
76
139
  return
77
140
  }
78
141
 
79
142
  if (!args.pair || !args.pair.includes('=')) {
80
- console.error(chalk.red('Format: bod env set <app> KEY=VALUE or bod env set <app> -f .env'))
143
+ console.error(chalk.red('Format: bod env set <app> KEY=VALUE or bod env set <app> -f .env (use --global for a shared var)'))
81
144
  process.exit(1)
82
145
  }
83
- const [key, ...vals] = args.pair.split('=')
84
- const value = vals.join('=')
85
- await client.put(`/apps/${appId}`, { env: { ...existing, [key]: value } })
146
+ const eqIdx = args.pair.indexOf('=')
147
+ const key = args.pair.slice(0, eqIdx)
148
+ const value = args.pair.slice(eqIdx + 1)
149
+ await client.put(`/secrets/vars/${encodeURIComponent(key)}`, { value, scope })
86
150
  console.log(chalk.green(`✓ Set ${key}`))
87
151
  },
88
152
  })
89
153
 
90
154
  const unsetCmd = defineCommand({
91
- meta: { name: 'unset', description: 'Remove env var' },
155
+ meta: { name: 'unset', description: 'Remove one scoped entry of a secret. Use `rm` to delete the whole variable.' },
92
156
  args: {
93
- app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
157
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
94
158
  key: { type: 'positional', description: 'Variable name', required: true },
159
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
160
+ global: { type: 'boolean', alias: 'g', description: 'Target the shared (no-app) entry' },
95
161
  },
96
162
  async run({ args }) {
97
163
  const { url, apiKey } = getResolvedInstance(loadConfig())
98
164
  const client = new BodClient(url, apiKey)
99
- const appId = await resolveAppId(client, resolveAppName(args.app))
100
165
 
101
- const detail = await client.get<any>(`/apps/${appId}`)
102
- const env = { ...(detail.env ?? {}) }
103
- delete env[args.key]
104
- await client.put(`/apps/${appId}`, { env })
166
+ assertScopeFlags(args.app, !!args.global)
167
+ const appName = args.global ? undefined : resolveAppName(args.app)
168
+ const qs: string[] = []
169
+ if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
170
+ if (args.env) qs.push(`env=${encodeURIComponent(args.env)}`)
171
+ const suffix = qs.length ? `?${qs.join('&')}` : ''
172
+ await client.del(`/secrets/vars/${encodeURIComponent(args.key)}/entry${suffix}`)
105
173
  console.log(chalk.green(`✓ Removed ${args.key}`))
106
174
  },
107
175
  })
108
176
 
177
+ const rmCmd = defineCommand({
178
+ meta: { name: 'rm', description: 'Delete an entire variable across ALL scopes.' },
179
+ args: {
180
+ key: { type: 'positional', description: 'Variable name', required: true },
181
+ },
182
+ async run({ args }) {
183
+ const { url, apiKey } = getResolvedInstance(loadConfig())
184
+ const client = new BodClient(url, apiKey)
185
+ await client.del(`/secrets/vars/${encodeURIComponent(args.key)}`)
186
+ console.log(chalk.green(`✓ Deleted ${args.key} (all scopes)`))
187
+ },
188
+ })
189
+
190
+ const pullCmd = defineCommand({
191
+ meta: { name: 'pull', description: 'Write an app\'s resolved env to a dotenv file (default .env, mode 0600).' },
192
+ args: {
193
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
194
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
195
+ output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
196
+ },
197
+ async run({ args }) {
198
+ const { url, apiKey } = getResolvedInstance(loadConfig())
199
+ const client = new BodClient(url, apiKey)
200
+ const appId = await resolveAppId(client, resolveAppName(args.app))
201
+ const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
202
+ const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
203
+ const values = res.values ?? {}
204
+ const out = args.output ?? '.env'
205
+ const body = Object.entries(values)
206
+ .map(([k, v]) => `${k}=${quoteDotEnvValue(v)}`)
207
+ .join('\n')
208
+ writeFileSync(out, body ? body + '\n' : '', { mode: 0o600 })
209
+ console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
210
+ },
211
+ })
212
+
109
213
  export default defineCommand({
110
- meta: { name: 'env', description: 'Manage environment variables' },
111
- subCommands: { list: listCmd, set: setCmd, unset: unsetCmd },
214
+ meta: { name: 'env', description: 'Manage environment variables (Bodify global secrets store)' },
215
+ subCommands: { list: listCmd, set: setCmd, unset: unsetCmd, rm: rmCmd, pull: pullCmd },
112
216
  })