bod-cli 0.9.1 → 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 +64 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -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
- function buildScope(app: string | undefined, env: string | undefined, global: boolean): Scope {
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 (!global && app) scope.app = app
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
- /** 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.'))
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 === '') {
@@ -63,28 +83,27 @@ const listCmd = defineCommand({
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
- if (args.global) {
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
- 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 })
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
  }
@@ -110,15 +129,19 @@ const setCmd = defineCommand({
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 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)
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 (!args.pair || !args.pair.includes('=')) {
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 = args.pair.indexOf('=')
147
- const key = args.pair.slice(0, eqIdx)
148
- const value = args.pair.slice(eqIdx + 1)
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: true },
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
- assertScopeFlags(args.app, !!args.global)
167
- const appName = args.global ? undefined : resolveAppName(args.app)
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(args.key)}/entry${suffix}`)
173
- console.log(chalk.green(`✓ Removed ${args.key}`))
204
+ await client.del(`/secrets/vars/${encodeURIComponent(key)}/entry${suffix}`)
205
+ console.log(chalk.green(`✓ Removed ${key}`))
174
206
  },
175
207
  })
176
208