bod-cli 0.10.2 → 0.10.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -6,6 +6,21 @@ import { BodClient } from '../client'
6
6
  import { printTable } from '../utils/output'
7
7
  import { resolveAppId, resolveAppName } from '../utils/resolve'
8
8
 
9
+ // --- Subs PLATFORM PROVIDER scope (mirror bodify subs.secrets.ts) ---
10
+ // Provider credentials (Stripe secret key, Apple shared secret, Play SA) are money
11
+ // credentials. They must NOT land in any app's process env, so they live under a
12
+ // PSEUDO app id that no real app equals and no deploy can resolve. The server's
13
+ // resolveScope passes any `__subs__~`-prefixed app string through VERBATIM (no
14
+ // name→id resolution) — that verbatim pass-through is the whole point, so the CLI
15
+ // must send the literal string as scope.app.
16
+ const SUBS_SCOPE_PREFIX = '__subs__~'
17
+ /** Platform-wide provider scope — one credential shared by every app. Note the DOUBLE ~. */
18
+ const SUBS_GLOBAL_SCOPE_APP = `${SUBS_SCOPE_PREFIX}~global`
19
+ /** Per-app provider override — the suffix MUST be the app UUID, never its name. */
20
+ function subsAppScope(appId: string): string {
21
+ return `${SUBS_SCOPE_PREFIX}${appId}`
22
+ }
23
+
9
24
  // --- Bodify global secrets store types (mirror the agent /secrets API contract) ---
10
25
  interface Scope { app?: string; env?: string; group?: string }
11
26
  interface MaskedEntry { scope: Scope; masked: string; updatedAt: number; updatedBy?: string }
@@ -43,23 +58,44 @@ function buildScope(app: string | undefined, env: string | undefined, global: bo
43
58
  return scope
44
59
  }
45
60
 
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
61
+ /** Subs flags collapse the app positional the same way --global/--group do (there is
62
+ * no <app> positional — the pseudo scope IS the app axis). */
63
+ interface SubsFlags { subs?: boolean; subsApp?: string }
64
+ function isSubsMode(s: SubsFlags): boolean {
65
+ return !!s.subs || !!s.subsApp
66
+ }
67
+
68
+ /** Citty binds the FIRST positional to `app`, but under --global/--group/--subs there is
69
+ * no app — the leading positional is really the next one (KEY=VALUE / key). Shift it back
48
70
  * so `app` is undefined and `rest` holds the real positionals. Returns the genuine app
49
71
  * 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) }
72
+ function resolvePositionals(app: string | undefined, rest: (string | undefined)[], global: boolean, group?: string, subs?: SubsFlags) {
73
+ if (global || group || (subs && isSubsMode(subs))) return { app: undefined, rest: [app, ...rest].filter((p): p is string => p !== undefined) }
52
74
  return { app, rest: rest.filter((p): p is string => p !== undefined) }
53
75
  }
54
76
 
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)
77
+ /** Guard: <app>, --global, --group, --subs and --subs-app are mutually exclusive (they
78
+ * pick the scope's primary axis). --env is orthogonal to app/group/global but the subs
79
+ * pseudo scope is app-only (its reader matches scope.app by strict equality and ignores
80
+ * env), so --env with a subs flag would write an entry nothing can ever read: forbid it. */
81
+ function assertScopeFlags(app: string | undefined, global: boolean, group: string | undefined, subs: SubsFlags, env?: string) {
82
+ const picked = [app && '<app>', global && '--global', group && '--group', subs.subs && '--subs', subs.subsApp && '--subs-app'].filter(Boolean)
59
83
  if (picked.length > 1) {
60
84
  console.error(chalk.red(`Pass only one of ${picked.join(', ')} — they are mutually exclusive.`))
61
85
  process.exit(1)
62
86
  }
87
+ if (isSubsMode(subs) && env) {
88
+ console.error(chalk.red('--env cannot combine with --subs/--subs-app: the provider scope is app-only and an env-scoped entry would be unreadable.'))
89
+ process.exit(1)
90
+ }
91
+ }
92
+
93
+ /** Build the literal `__subs__~…` scope.app for a subs write/read.
94
+ * Global → `__subs__~~global` sent verbatim. Per-app → resolve NAME→id client-side
95
+ * (the server won't resolve a `__subs__~` string), THEN prefix. */
96
+ async function buildSubsScopeApp(client: BodClient, subs: SubsFlags): Promise<string> {
97
+ if (subs.subsApp) return subsAppScope(await resolveAppId(client, subs.subsApp))
98
+ return SUBS_GLOBAL_SCOPE_APP
63
99
  }
64
100
 
65
101
  /** Human label for a scope's primary axis (+ optional env). */
@@ -78,18 +114,39 @@ function quoteDotEnvValue(value: string): string {
78
114
  }
79
115
 
80
116
  const listCmd = defineCommand({
81
- meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group: variables in that scope (masked).' },
117
+ meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
82
118
  args: {
83
119
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
84
120
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
85
121
  global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
86
122
  group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
123
+ subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
124
+ 'subs-app': { type: 'string', description: 'List masked per-app subs provider secrets for this app name/id (__subs__~<appId>)' },
87
125
  },
88
126
  async run({ args }) {
89
127
  const { url, apiKey } = getResolvedInstance(loadConfig())
90
128
  const client = new BodClient(url, apiKey)
91
129
 
92
- assertScopeFlags(args.app, !!args.global, args.group)
130
+ const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
131
+ assertScopeFlags(args.app, !!args.global, args.group, subs, args.env)
132
+
133
+ if (isSubsMode(subs)) {
134
+ const scopeApp = await buildSubsScopeApp(client, subs)
135
+ const vars = await client.get<MaskedVar[]>('/secrets/vars')
136
+ const rows: Record<string, unknown>[] = []
137
+ for (const v of vars) {
138
+ for (const e of v.entries) {
139
+ if (e.scope.app !== scopeApp) continue
140
+ rows.push({ key: v.key, scope: renderScope(e.scope), value: e.masked })
141
+ }
142
+ }
143
+ if (!rows.length) {
144
+ console.log(chalk.dim(`No subs provider secrets set for scope app:${scopeApp}.`))
145
+ return
146
+ }
147
+ printTable(rows, ['key', 'scope', 'value'])
148
+ return
149
+ }
93
150
 
94
151
  if (args.global || args.group) {
95
152
  const vars = await client.get<MaskedVar[]>('/secrets/vars')
@@ -122,7 +179,7 @@ const listCmd = defineCommand({
122
179
  })
123
180
 
124
181
  const setCmd = defineCommand({
125
- meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, and/or --env.' },
182
+ meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, --subs/--subs-app, and/or --env.' },
126
183
  args: {
127
184
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
128
185
  pair: { type: 'positional', description: 'KEY=VALUE', required: false },
@@ -130,18 +187,24 @@ const setCmd = defineCommand({
130
187
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
131
188
  global: { type: 'boolean', alias: 'g', description: 'Shared var (no app); broadens reach' },
132
189
  group: { type: 'string', description: 'Scope to a group (apps that inherit it); excludes <app>/--global' },
190
+ subs: { type: 'boolean', description: 'Platform-GLOBAL subs provider secret (__subs__~~global). For money creds (Stripe/Apple/Play) — kept OUT of every app env; NOT --global.' },
191
+ 'subs-app': { type: 'string', description: 'Per-app subs provider override for this app name/id (resolved to __subs__~<appId>)' },
133
192
  },
134
193
  async run({ args }) {
135
194
  const { url, apiKey } = getResolvedInstance(loadConfig())
136
195
  const client = new BodClient(url, apiKey)
137
196
 
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)
197
+ const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
198
+ const subsMode = isSubsMode(subs)
199
+ // Under --global/--group/--subs the leading positional is the pair, not an app — shift it.
200
+ const { app, rest } = resolvePositionals(args.app, [args.pair], !!args.global, args.group, subs)
140
201
  // 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)
202
+ assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group, subs, args.env)
203
+ const pair = (args.global || args.group || subsMode) ? rest[0] : args.pair
204
+ const appName = args.global || args.group || subsMode ? undefined : resolveAppName(app)
205
+ const scope = subsMode
206
+ ? { app: await buildSubsScopeApp(client, subs) }
207
+ : buildScope(appName, args.env, !!args.global, args.group)
145
208
 
146
209
  if (args.file) {
147
210
  const f = Bun.file(args.file)
@@ -182,22 +245,28 @@ const unsetCmd = defineCommand({
182
245
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
183
246
  global: { type: 'boolean', alias: 'g', description: 'Target the shared (no-app) entry' },
184
247
  group: { type: 'string', description: 'Target a group-scoped entry; excludes <app>/--global' },
248
+ subs: { type: 'boolean', description: 'Target the platform-GLOBAL subs provider entry (__subs__~~global)' },
249
+ 'subs-app': { type: 'string', description: 'Target the per-app subs provider entry for this app name/id (__subs__~<appId>)' },
185
250
  },
186
251
  async run({ args }) {
187
252
  const { url, apiKey } = getResolvedInstance(loadConfig())
188
253
  const client = new BodClient(url, apiKey)
189
254
 
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
255
+ const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
256
+ const subsMode = isSubsMode(subs)
257
+ // Under --global/--group/--subs the leading positional is the key, not an app — shift it.
258
+ const { app, rest } = resolvePositionals(args.app, [args.key], !!args.global, args.group, subs)
259
+ assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group, subs, args.env)
260
+ const key = (args.global || args.group || subsMode) ? rest[0] : args.key
194
261
  if (!key) {
195
- console.error(chalk.red('Variable name required: bod env unset <app> KEY (use --global/--group for shared entries)'))
262
+ console.error(chalk.red('Variable name required: bod env unset <app> KEY (use --global/--group/--subs for shared entries)'))
196
263
  process.exit(1)
197
264
  }
198
- const appName = args.global || args.group ? undefined : resolveAppName(app)
265
+ const appName = args.global || args.group || subsMode ? undefined : resolveAppName(app)
199
266
  const qs: string[] = []
200
- if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
267
+ // Subs sends the literal __subs__~… as the app axis (server passes it through verbatim).
268
+ if (subsMode) qs.push(`app=${encodeURIComponent(await buildSubsScopeApp(client, subs))}`)
269
+ else if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
201
270
  if (args.group) qs.push(`group=${encodeURIComponent(args.group)}`)
202
271
  if (args.env) qs.push(`env=${encodeURIComponent(args.env)}`)
203
272
  const suffix = qs.length ? `?${qs.join('&')}` : ''
@@ -0,0 +1,122 @@
1
+ // Verifies `bod env set/list/unset` with the subs PLATFORM PROVIDER flags emit the
2
+ // exact `__subs__~…` scope.app the bodify vault requires — at the real CLI surface
3
+ // (full citty parse + positional shift + name→id resolution), against a mock agent.
4
+ import { test, expect, beforeAll, afterAll } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+
9
+ const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
10
+ const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
11
+ const APP_NAME = 'blank'
12
+
13
+ interface Captured { method: string; path: string; body: any }
14
+ let captured: Captured[] = []
15
+ let server: ReturnType<typeof Bun.serve>
16
+ let home: string
17
+
18
+ beforeAll(() => {
19
+ server = Bun.serve({
20
+ port: 0,
21
+ async fetch(req) {
22
+ const url = new URL(req.url)
23
+ const body = req.method === 'PUT' || req.method === 'POST' ? await req.json().catch(() => null) : null
24
+ captured.push({ method: req.method, path: url.pathname + url.search, body })
25
+ if (url.pathname === '/api/apps') {
26
+ return Response.json([{ id: APP_ID, name: APP_NAME }])
27
+ }
28
+ if (url.pathname === '/api/secrets/vars' && req.method === 'GET') {
29
+ return Response.json([
30
+ { key: 'STRIPE_SECRET_KEY', createdAt: 0, updatedAt: 0, entries: [
31
+ { scope: { app: '__subs__~~global' }, masked: 'sk_l***', updatedAt: 0 },
32
+ { scope: { app: `__subs__~${APP_ID}` }, masked: 'sk_a***', updatedAt: 0 },
33
+ { scope: {}, masked: 'other***', updatedAt: 0 },
34
+ ] },
35
+ ])
36
+ }
37
+ return new Response('ok')
38
+ },
39
+ })
40
+ home = mkdtempSync(join(tmpdir(), 'bod-subs-test-'))
41
+ mkdirSync(join(home, '.bod'), { recursive: true })
42
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
43
+ defaultInstance: 'test',
44
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
45
+ }))
46
+ })
47
+
48
+ afterAll(() => {
49
+ server?.stop(true)
50
+ rmSync(home, { recursive: true, force: true })
51
+ })
52
+
53
+ async function runCli(args: string[]) {
54
+ captured = []
55
+ const proc = Bun.spawn(['bun', CLI, ...args], {
56
+ env: { ...process.env, HOME: home, _BOD_INSTANCE_OVERRIDE: '', BOD_INSTANCE: '' },
57
+ stdout: 'pipe', stderr: 'pipe',
58
+ })
59
+ const [stdout, stderr, code] = await Promise.all([
60
+ new Response(proc.stdout).text(),
61
+ new Response(proc.stderr).text(),
62
+ proc.exited,
63
+ ])
64
+ return { stdout, stderr, code }
65
+ }
66
+
67
+ test('env set --subs → scope.app is the literal __subs__~~global (verbatim, no resolution)', async () => {
68
+ const { code, stderr } = await runCli(['env', 'set', '--subs', 'STRIPE_SECRET_KEY=sk_live_platform'])
69
+ expect(code).toBe(0)
70
+ const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/STRIPE_SECRET_KEY'))
71
+ expect(put).toBeTruthy()
72
+ expect(put!.body).toEqual({ value: 'sk_live_platform', scope: { app: '__subs__~~global' } })
73
+ // Global form must NOT resolve any app name→id.
74
+ expect(captured.some(c => c.path === '/api/apps')).toBe(false)
75
+ expect(stderr).toBe('')
76
+ })
77
+
78
+ test('env set --subs-app <name> → resolves name→id, then prefixes __subs__~<id>', async () => {
79
+ const { code } = await runCli(['env', 'set', '--subs-app', APP_NAME, 'STRIPE_SECRET_KEY=sk_live_app'])
80
+ expect(code).toBe(0)
81
+ // Name was resolved to id via /api/apps.
82
+ expect(captured.some(c => c.path === '/api/apps')).toBe(true)
83
+ const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/STRIPE_SECRET_KEY'))
84
+ expect(put!.body).toEqual({ value: 'sk_live_app', scope: { app: `__subs__~${APP_ID}` } })
85
+ })
86
+
87
+ test('env set --subs-app accepts an app id directly', async () => {
88
+ const { code } = await runCli(['env', 'set', '--subs-app', APP_ID, 'APPLE_SHARED_SECRET=abc'])
89
+ expect(code).toBe(0)
90
+ const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/APPLE_SHARED_SECRET'))
91
+ expect(put!.body).toEqual({ value: 'abc', scope: { app: `__subs__~${APP_ID}` } })
92
+ })
93
+
94
+ test('env set --subs --env is rejected (subs scope is app-only)', async () => {
95
+ const { code, stderr } = await runCli(['env', 'set', '--subs', '--env', 'prod', 'K=v'])
96
+ expect(code).toBe(1)
97
+ expect(stderr).toContain('app-only')
98
+ })
99
+
100
+ test('env set --subs --global is rejected (mutually exclusive)', async () => {
101
+ const { code, stderr } = await runCli(['env', 'set', '--subs', '--global', 'K=v'])
102
+ expect(code).toBe(1)
103
+ expect(stderr).toContain('mutually exclusive')
104
+ })
105
+
106
+ test('env list --subs shows only the platform-global provider entry (masked)', async () => {
107
+ const { code, stdout } = await runCli(['env', 'list', '--subs'])
108
+ expect(code).toBe(0)
109
+ expect(stdout).toContain('STRIPE_SECRET_KEY')
110
+ expect(stdout).toContain('sk_l***')
111
+ expect(stdout).not.toContain('sk_a***') // per-app entry excluded
112
+ expect(stdout).not.toContain('other***') // default-scoped entry excluded
113
+ })
114
+
115
+ test('env unset --subs deletes the __subs__~~global entry via app= query param', async () => {
116
+ const { code } = await runCli(['env', 'unset', '--subs', 'STRIPE_SECRET_KEY'])
117
+ expect(code).toBe(0)
118
+ const del = captured.find(c => c.method === 'DELETE')
119
+ expect(del).toBeTruthy()
120
+ expect(del!.path).toContain('/api/secrets/vars/STRIPE_SECRET_KEY/entry')
121
+ expect(del!.path).toContain(`app=${encodeURIComponent('__subs__~~global')}`)
122
+ })