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.
- package/package.json +1 -1
- package/src/commands/env.ts +178 -42
package/package.json
CHANGED
package/src/commands/env.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
59
|
-
const
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
84
|
-
const
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
217
|
+
await client.del(`/secrets/vars/${encodeURIComponent(args.key)}`)
|
|
218
|
+
console.log(chalk.green(`✓ Deleted ${args.key} (all scopes)`))
|
|
219
|
+
},
|
|
220
|
+
})
|
|
100
221
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
})
|