bod-cli 0.8.4 → 0.9.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/env.ts +178 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.8.4",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -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; group?: 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,115 @@ 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
+ * `--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 {
39
+ const scope: Scope = {}
40
+ if (group) scope.group = group
41
+ else if (!global && app) scope.app = app
42
+ if (env) scope.env = env
43
+ return scope
44
+ }
45
+
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.`))
61
+ process.exit(1)
62
+ }
63
+ }
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
+
72
+ /** Quote a dotenv value if it contains characters that would break a bare assignment. */
73
+ function quoteDotEnvValue(value: string): string {
74
+ if (/[\s"'#=$`\\]/.test(value) || value === '') {
75
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
76
+ }
77
+ return value
78
+ }
79
+
80
+ const listCmd = defineCommand({
81
+ meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global: all variables with scopes (masked).' },
49
82
  args: {
50
83
  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' },
84
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
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' },
53
87
  },
54
88
  async run({ args }) {
55
89
  const { url, apiKey } = getResolvedInstance(loadConfig())
56
90
  const client = new BodClient(url, apiKey)
91
+
92
+ assertScopeFlags(args.app, !!args.global, args.group)
93
+
94
+ if (args.global || args.group) {
95
+ const vars = await client.get<MaskedVar[]>('/secrets/vars')
96
+ const rows: Record<string, unknown>[] = []
97
+ for (const v of vars) {
98
+ for (const e of v.entries) {
99
+ if (args.group && e.scope.group !== args.group) continue
100
+ rows.push({ key: v.key, scope: renderScope(e.scope), value: e.masked })
101
+ }
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
+ }
107
+ printTable(rows, ['key', 'scope', 'value'])
108
+ return
109
+ }
110
+
111
+ // Per-app: show fully resolved values for the chosen environment.
57
112
  const appId = await resolveAppId(client, resolveAppName(args.app))
58
- const detail = await client.get<any>(`/apps/${appId}`)
59
- const existing = detail.env ?? {}
113
+ const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
114
+ const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
115
+ const values = res.values ?? {}
116
+ if (Object.keys(values).length === 0) {
117
+ console.log(chalk.dim('No environment variables set.'))
118
+ return
119
+ }
120
+ for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
121
+ },
122
+ })
123
+
124
+ const setCmd = defineCommand({
125
+ meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, and/or --env.' },
126
+ args: {
127
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
128
+ pair: { type: 'positional', description: 'KEY=VALUE', required: false },
129
+ file: { type: 'string', alias: 'f', description: 'Path to .env file (bulk import)' },
130
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
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' },
133
+ },
134
+ async run({ args }) {
135
+ const { url, apiKey } = getResolvedInstance(loadConfig())
136
+ const client = new BodClient(url, apiKey)
137
+
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)
60
145
 
61
146
  if (args.file) {
62
147
  const f = Bun.file(args.file)
@@ -64,49 +149,100 @@ const setCmd = defineCommand({
64
149
  console.error(chalk.red(`File not found: ${args.file}`))
65
150
  process.exit(1)
66
151
  }
67
- const content = await f.text()
68
- const parsed = parseDotEnv(content)
152
+ const parsed = parseDotEnv(await f.text())
69
153
  const keys = Object.keys(parsed)
70
154
  if (keys.length === 0) {
71
155
  console.error(chalk.red('No variables found in file.'))
72
156
  process.exit(1)
73
157
  }
74
- await client.put(`/apps/${appId}`, { env: { ...existing, ...parsed } })
158
+ for (const key of keys) {
159
+ await client.put(`/secrets/vars/${encodeURIComponent(key)}`, { value: parsed[key], scope })
160
+ }
75
161
  console.log(chalk.green(`✓ Set ${keys.length} var(s): ${keys.join(', ')}`))
76
162
  return
77
163
  }
78
164
 
79
- 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'))
165
+ if (!pair || !pair.includes('=')) {
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)'))
81
167
  process.exit(1)
82
168
  }
83
- const [key, ...vals] = args.pair.split('=')
84
- const value = vals.join('=')
85
- await client.put(`/apps/${appId}`, { env: { ...existing, [key]: value } })
169
+ const eqIdx = pair.indexOf('=')
170
+ const key = pair.slice(0, eqIdx)
171
+ const value = pair.slice(eqIdx + 1)
172
+ await client.put(`/secrets/vars/${encodeURIComponent(key)}`, { value, scope })
86
173
  console.log(chalk.green(`✓ Set ${key}`))
87
174
  },
88
175
  })
89
176
 
90
177
  const unsetCmd = defineCommand({
91
- meta: { name: 'unset', description: 'Remove env var' },
178
+ meta: { name: 'unset', description: 'Remove one scoped entry of a secret. Use `rm` to delete the whole variable.' },
179
+ args: {
180
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
181
+ key: { type: 'positional', description: 'Variable name', required: false },
182
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
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' },
185
+ },
186
+ async run({ args }) {
187
+ const { url, apiKey } = getResolvedInstance(loadConfig())
188
+ const client = new BodClient(url, apiKey)
189
+
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)
199
+ const qs: string[] = []
200
+ if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
201
+ if (args.group) qs.push(`group=${encodeURIComponent(args.group)}`)
202
+ if (args.env) qs.push(`env=${encodeURIComponent(args.env)}`)
203
+ const suffix = qs.length ? `?${qs.join('&')}` : ''
204
+ await client.del(`/secrets/vars/${encodeURIComponent(key)}/entry${suffix}`)
205
+ console.log(chalk.green(`✓ Removed ${key}`))
206
+ },
207
+ })
208
+
209
+ const rmCmd = defineCommand({
210
+ meta: { name: 'rm', description: 'Delete an entire variable across ALL scopes.' },
92
211
  args: {
93
- app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
94
212
  key: { type: 'positional', description: 'Variable name', required: true },
95
213
  },
96
214
  async run({ args }) {
97
215
  const { url, apiKey } = getResolvedInstance(loadConfig())
98
216
  const client = new BodClient(url, apiKey)
99
- const appId = await resolveAppId(client, resolveAppName(args.app))
217
+ await client.del(`/secrets/vars/${encodeURIComponent(args.key)}`)
218
+ console.log(chalk.green(`✓ Deleted ${args.key} (all scopes)`))
219
+ },
220
+ })
100
221
 
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 })
105
- console.log(chalk.green(`✓ Removed ${args.key}`))
222
+ const pullCmd = defineCommand({
223
+ meta: { name: 'pull', description: 'Write an app\'s resolved env to a dotenv file (default .env, mode 0600).' },
224
+ args: {
225
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
226
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
227
+ output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
228
+ },
229
+ async run({ args }) {
230
+ const { url, apiKey } = getResolvedInstance(loadConfig())
231
+ const client = new BodClient(url, apiKey)
232
+ const appId = await resolveAppId(client, resolveAppName(args.app))
233
+ const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
234
+ const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
235
+ const values = res.values ?? {}
236
+ const out = args.output ?? '.env'
237
+ const body = Object.entries(values)
238
+ .map(([k, v]) => `${k}=${quoteDotEnvValue(v)}`)
239
+ .join('\n')
240
+ writeFileSync(out, body ? body + '\n' : '', { mode: 0o600 })
241
+ console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
106
242
  },
107
243
  })
108
244
 
109
245
  export default defineCommand({
110
- meta: { name: 'env', description: 'Manage environment variables' },
111
- subCommands: { list: listCmd, set: setCmd, unset: unsetCmd },
246
+ meta: { name: 'env', description: 'Manage environment variables (Bodify global secrets store)' },
247
+ subCommands: { list: listCmd, set: setCmd, unset: unsetCmd, rm: rmCmd, pull: pullCmd },
112
248
  })