bod-cli 0.8.4 → 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.
- package/package.json +1 -1
- package/src/commands/env.ts +144 -40
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 }
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
59
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
84
|
-
const
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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
|
})
|