@optima-chat/dev-skills 0.7.33 → 0.7.35

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 (30) hide show
  1. package/AGENTS.md +2 -0
  2. package/bin/helpers/billing-http.ts +162 -0
  3. package/bin/helpers/confirm-prompt.ts +23 -0
  4. package/bin/helpers/entitlement/grant.ts +70 -0
  5. package/bin/helpers/entitlement/list.ts +77 -0
  6. package/bin/helpers/entitlement/revoke.ts +116 -0
  7. package/bin/helpers/entitlement.ts +32 -0
  8. package/bin/helpers/infisical-secrets.ts +41 -0
  9. package/bin/helpers/product/add-channel.ts +78 -0
  10. package/bin/helpers/product/create.ts +96 -0
  11. package/bin/helpers/product/show.ts +43 -0
  12. package/bin/helpers/product/toggle-channel.ts +56 -0
  13. package/bin/helpers/product/update.ts +72 -0
  14. package/bin/helpers/product.ts +46 -0
  15. package/dist/bin/helpers/billing-http.js +139 -0
  16. package/dist/bin/helpers/confirm-prompt.js +54 -0
  17. package/dist/bin/helpers/entitlement/grant.js +73 -0
  18. package/dist/bin/helpers/entitlement/list.js +62 -0
  19. package/dist/bin/helpers/entitlement/revoke.js +105 -0
  20. package/dist/bin/helpers/entitlement.js +39 -0
  21. package/dist/bin/helpers/infisical-secrets.js +33 -0
  22. package/dist/bin/helpers/product/add-channel.js +95 -0
  23. package/dist/bin/helpers/product/create.js +124 -0
  24. package/dist/bin/helpers/product/show.js +46 -0
  25. package/dist/bin/helpers/product/toggle-channel.js +61 -0
  26. package/dist/bin/helpers/product/update.js +92 -0
  27. package/dist/bin/helpers/product.js +53 -0
  28. package/docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md +1973 -0
  29. package/docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md +324 -0
  30. package/package.json +7 -5
@@ -0,0 +1,1973 @@
1
+ # Marketplace Admin CLI — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add two new CLI bin entries (`optima-product` and `optima-entitlement`) to `@optima-chat/dev-skills` so the Optima team can manage paid-plugin marketplace state (Products, Channels, Entitlements) from the shell before any admin UI exists.
6
+
7
+ **Architecture:** Thin HTTP-client CLI. Each subcommand: parse args → resolve env config from Infisical → mint M2M service token from user-auth → POST/GET/PATCH the corresponding billing admin endpoint → print response or error envelope. No business logic in CLI — billing's `product.service.ts` / `entitlement.service.ts` own validation, outbox emission, set-difference, Stripe refund cascade. Matches existing dev-skills helper conventions (flat `bin/helpers/<command>.ts`, sync Infisical Universal-Auth via `getInfisicalToken`, exits 1 on any error).
8
+
9
+ **Tech Stack:** TypeScript, Node 18+ native `fetch`, existing `db-utils.ts` helpers (`getInfisicalConfig`, `getInfisicalToken`, `getInfisicalSecrets`, `resolveUserId`), no new npm dependencies.
10
+
11
+ **Spec:** [`docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md`](../specs/2026-05-24-marketplace-admin-cli-design.md)
12
+
13
+ **Testing convention:** Per spec §8 — **no unit tests** for HTTP wrapper / arg parsing (matches `grant-subscription` precedent, all existing dev-skills helpers are smoke-tested only). Each subcommand task includes a `--help` sanity-check step and a stage smoke step against a real billing endpoint. End-to-end Wave 1.5 smoke (spec §8.1-8.8) is the final task.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ ### New files
20
+
21
+ | Path | Responsibility |
22
+ |---|---|
23
+ | `bin/helpers/infisical-secrets.ts` | Fetch a single secret from Infisical at an arbitrary `secretPath` (generalization of `getInfisicalSecrets` for the dev-skills client_secret + BILLING_URL lookups). |
24
+ | `bin/helpers/billing-http.ts` | Combined module: `getServiceToken(env)` (M2M via user-auth `client_credentials`, memoized per process), `getBillingUrl(env)` (cached Infisical lookup), and `callBilling(env, method, path, body?)` (auth header + envelope unwrap + non-envelope fallback + one 5xx retry). One file because all three pieces are billing-specific and share the env-config cache. |
25
+ | `bin/helpers/confirm-prompt.ts` | `confirmIfProd(env, action, skipFlag)` — prints resolved action and reads `yes` from stdin when `env=prod && !--yes`. No-op on stage. |
26
+ | `bin/helpers/product.ts` | Top-level dispatcher for `optima-product <subcommand>` — parses subcommand, dispatches to `bin/helpers/product/<subcommand>.ts`, surfaces `--help`. |
27
+ | `bin/helpers/product/create.ts` | `optima-product create` subcommand handler. |
28
+ | `bin/helpers/product/update.ts` | `optima-product update` subcommand handler. |
29
+ | `bin/helpers/product/add-channel.ts` | `optima-product add-channel` subcommand handler. |
30
+ | `bin/helpers/product/toggle-channel.ts` | `optima-product toggle-channel` subcommand handler. |
31
+ | `bin/helpers/product/show.ts` | `optima-product show` subcommand handler. |
32
+ | `bin/helpers/entitlement.ts` | Top-level dispatcher for `optima-entitlement <subcommand>`. |
33
+ | `bin/helpers/entitlement/list.ts` | `optima-entitlement list` subcommand handler. |
34
+ | `bin/helpers/entitlement/grant.ts` | `optima-entitlement grant` subcommand handler. |
35
+ | `bin/helpers/entitlement/revoke.ts` | `optima-entitlement revoke` subcommand handler (internally calls list logic). |
36
+
37
+ ### Modified files
38
+
39
+ | Path | Change |
40
+ |---|---|
41
+ | `package.json` | Add 2 bin entries (`optima-product`, `optima-entitlement`); bump `engines.node` from `>=14.0.0` to `>=18.0.0`. |
42
+ | `AGENTS.md` | Add the 2 new CLI entries to "Primary Entry Points" + brief usage. |
43
+
44
+ ---
45
+
46
+ ## Tasks
47
+
48
+ ### Task 1: Infrastructure verification (no code)
49
+
50
+ **Goal:** Resolve the 3 blocking open questions from spec §10 before writing any code. Document findings inline in this plan (edit the task body) so the rest of the plan can pin concrete values.
51
+
52
+ **Preconditions** (operator's environment must have these before starting T1):
53
+ - `gh` CLI authenticated to `Optima-Chat` org
54
+ - `jq`, `curl`, `base64` on PATH
55
+ - Infisical Universal-Auth credentials configured as GitHub Variables on `Optima-Chat/optima-dev-skills` (already true if `optima-show-env` works locally)
56
+ - **Stripe test mode access** — required later by T8 step 5 and T15 step 10 (operator must be able to create test Products+Prices on Stripe Dashboard sandbox). If you don't have access, request it before starting impl.
57
+
58
+ **Files:**
59
+ - Modify: this plan (`docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md`) — fill in resolved values
60
+
61
+ - [ ] **Step 1: Locate `dev-skills-ubd3qz6n` client_secret in Infisical**
62
+
63
+ Try these paths in order via `optima-show-env` (works for any service whose secrets live at `/services/<name>`):
64
+
65
+ ```bash
66
+ optima-show-env dev-skills stage 2>&1 | grep -iE "CLIENT_SECRET|CLIENT_ID"
67
+ optima-show-env dev-skills prod 2>&1 | grep -iE "CLIENT_SECRET|CLIENT_ID"
68
+ ```
69
+
70
+ If `dev-skills` is not a known service:
71
+
72
+ ```bash
73
+ # Fall back to checking shared-secrets/oauth/ via direct curl
74
+ INFISICAL_TOKEN=$(gh variable get INFISICAL_CLIENT_ID -R Optima-Chat/optima-dev-skills | xargs -I{} curl -s -X POST "$(gh variable get INFISICAL_URL -R Optima-Chat/optima-dev-skills)/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d "{\"clientId\": \"{}\", \"clientSecret\": \"$(gh variable get INFISICAL_CLIENT_SECRET -R Optima-Chat/optima-dev-skills)\"}" | jq -r .accessToken)
75
+ PROJECT_ID=$(gh variable get INFISICAL_PROJECT_ID -R Optima-Chat/optima-dev-skills)
76
+ INFISICAL_URL=$(gh variable get INFISICAL_URL -R Optima-Chat/optima-dev-skills)
77
+ curl -s "$INFISICAL_URL/api/v3/secrets/raw?workspaceId=$PROJECT_ID&environment=staging&secretPath=/shared-secrets/oauth/dev-skills" -H "Authorization: Bearer $INFISICAL_TOKEN" | jq .
78
+ ```
79
+
80
+ **RESOLVED 2026-05-25** — Infisical layout discovered:
81
+
82
+ ```
83
+ Path: /shared-secrets/oauth-clients/
84
+ Secrets (per env):
85
+ DEV_SKILLS_OAUTH_CLIENT_ID
86
+ DEV_SKILLS_OAUTH_CLIENT_SECRET
87
+
88
+ Stage CLIENT_ID: dev-skills-ubd3qz6n
89
+ Prod CLIENT_ID: dev-skills-hinxa0rs
90
+
91
+ ⚠️ IMPORTANT DISCOVERY: client_id differs per environment (not just secret).
92
+ Plan T4 must fetch BOTH client_id and client_secret from Infisical per env —
93
+ do NOT hardcode DEV_SKILLS_CLIENT_ID. See updated T4 code.
94
+ ```
95
+
96
+ - [ ] **Step 2: Verify BILLING_URL exists on stage AND prod**
97
+
98
+ First, set up Infisical shell vars (always run — step 1's optima-show-env path doesn't export these):
99
+
100
+ ```bash
101
+ INFISICAL_URL=$(gh variable get INFISICAL_URL -R Optima-Chat/optima-dev-skills)
102
+ PROJECT_ID=$(gh variable get INFISICAL_PROJECT_ID -R Optima-Chat/optima-dev-skills)
103
+ CLIENT_ID=$(gh variable get INFISICAL_CLIENT_ID -R Optima-Chat/optima-dev-skills)
104
+ CLIENT_SECRET=$(gh variable get INFISICAL_CLIENT_SECRET -R Optima-Chat/optima-dev-skills)
105
+ INFISICAL_TOKEN=$(curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \
106
+ -H "Content-Type: application/json" \
107
+ -d "{\"clientId\": \"$CLIENT_ID\", \"clientSecret\": \"$CLIENT_SECRET\"}" | jq -r .accessToken)
108
+ [ -z "$INFISICAL_TOKEN" ] || [ "$INFISICAL_TOKEN" = "null" ] && { echo "Failed to mint Infisical token"; return 1; }
109
+ ```
110
+
111
+ Then fetch:
112
+
113
+ ```bash
114
+ # Stage
115
+ curl -s "$INFISICAL_URL/api/v3/secrets/raw/BILLING_URL?workspaceId=$PROJECT_ID&environment=staging&secretPath=%2Fshared-secrets%2Fdomain-urls" -H "Authorization: Bearer $INFISICAL_TOKEN" | jq '.secret.secretValue'
116
+
117
+ # Prod (re-token if prod uses different Infisical creds; otherwise reuse $INFISICAL_TOKEN)
118
+ curl -s "$INFISICAL_URL/api/v3/secrets/raw/BILLING_URL?workspaceId=$PROJECT_ID&environment=prod&secretPath=%2Fshared-secrets%2Fdomain-urls" -H "Authorization: Bearer $INFISICAL_TOKEN" | jq '.secret.secretValue'
119
+ ```
120
+
121
+ Expected: stage returns `"https://billing.stage.optima.onl"` (or similar); prod returns `"https://billing.optima.onl"` (or similar).
122
+
123
+ **RESOLVED 2026-05-25**:
124
+
125
+ ```
126
+ Stage BILLING_URL: https://billing-api.stage.optima.onl
127
+ Prod BILLING_URL: https://billing-api.optima.onl
128
+
129
+ Note hostname is `billing-api.*`, not `billing.*` (matches the ECS service
130
+ DNS pattern). Path: /shared-secrets/domain-urls/BILLING_URL on both envs.
131
+ ```
132
+
133
+ - [ ] **Step 3: Verify user-auth `client_credentials` grant returns `type=service` JWT**
134
+
135
+ ```bash
136
+ # Stage smoke (uses the secret found in Step 1)
137
+ DEV_SKILLS_SECRET='<paste-from-step-1>'
138
+ TOKEN=$(curl -s -X POST 'https://auth.stage.optima.onl/api/v1/oauth/token' \
139
+ -H 'Content-Type: application/x-www-form-urlencoded' \
140
+ -d "grant_type=client_credentials&client_id=dev-skills-ubd3qz6n&client_secret=$DEV_SKILLS_SECRET" | jq -r .access_token)
141
+ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
142
+ ```
143
+
144
+ Expected output includes `"type": "service"` and `"client_id": "dev-skills-ubd3qz6n"`.
145
+
146
+ **RESOLVED 2026-05-25** — stage smoke confirmed JWT has `type: "service"`:
147
+
148
+ ```
149
+ Sample payload (stage, dev-skills-ubd3qz6n):
150
+ {
151
+ "sub": "client:dev-skills-ubd3qz6n",
152
+ "aud": ["api"],
153
+ "scope": "api:internal",
154
+ "client_id": "dev-skills-ubd3qz6n",
155
+ "type": "service", ← required by billing extractAnyServiceAuth
156
+ "exp": <30min from iat>,
157
+ "iat": <now>,
158
+ "iss": "https://auth.stage.optima.onl",
159
+ "jti": "<random>"
160
+ }
161
+
162
+ Token TTL: 1800s (30 min) — comfortably exceeds any single CLI invocation.
163
+ Scope "api:internal" present but billing's requireAdminService does NOT
164
+ check scopes (verified during spec round 3), only clientId allowlist.
165
+ ```
166
+
167
+ - [ ] **Step 4: Commit the resolved values**
168
+
169
+ ```bash
170
+ git add docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md
171
+ git commit -m "plan T1: resolve infisical secret paths + verify M2M token shape"
172
+ ```
173
+
174
+ **Stop the plan if any step fails** — do not proceed to T2 with unresolved blockers.
175
+
176
+ ---
177
+
178
+ ### Task 2: Branch + bump engines.node to >=18
179
+
180
+ Branch hygiene per spec-plan-impl workflow: spec rounds and plan commits sit on `spec/marketplace-admin-cli`. Impl gets its own branch so the spec-review history stays bisectable.
181
+
182
+ **Files:**
183
+ - Modify: `package.json`
184
+
185
+ - [ ] **Step 1: Check out impl branch off the plan commit**
186
+
187
+ ```bash
188
+ cd /mnt/d/work/projects/optima/optima-dev-skills
189
+ git status # must be clean
190
+ git checkout -b impl/marketplace-admin-cli # off the current HEAD (last plan commit on spec branch)
191
+ ```
192
+
193
+ If T1 was committed (it edits this plan file), that commit is included automatically — the new branch carries everything.
194
+
195
+ - [ ] **Step 2: Edit package.json**
196
+
197
+ Change:
198
+ ```json
199
+ "engines": {
200
+ "node": ">=14.0.0"
201
+ },
202
+ ```
203
+ to:
204
+ ```json
205
+ "engines": {
206
+ "node": ">=18.0.0"
207
+ },
208
+ ```
209
+
210
+ - [ ] **Step 3: Verify build still passes**
211
+
212
+ ```bash
213
+ npm run build
214
+ ```
215
+
216
+ Expected: no errors.
217
+
218
+ - [ ] **Step 4: Commit**
219
+
220
+ ```bash
221
+ git add package.json
222
+ git commit -m "chore: bump engines.node to >=18 (native fetch required by new CLI bins)"
223
+ ```
224
+
225
+ ---
226
+
227
+ ### Task 3: Add `bin/helpers/infisical-secrets.ts`
228
+
229
+ **Files:**
230
+ - Create: `bin/helpers/infisical-secrets.ts`
231
+
232
+ - [ ] **Step 1: Write the helper**
233
+
234
+ Create `bin/helpers/infisical-secrets.ts`:
235
+
236
+ ```typescript
237
+ import { execSync } from 'child_process';
238
+ import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
239
+
240
+ /**
241
+ * Fetch a single secret value from Infisical given env + path + name.
242
+ *
243
+ * env mapping: 'stage' → Infisical env slug 'staging'; 'prod' → 'prod'
244
+ * (matches dev-skills convention documented at
245
+ * ~/.claude/projects/-mnt-d-work-projects-optima/memory/optima_infisical_env_naming.md).
246
+ *
247
+ * Returns the raw secretValue string. Throws if the secret is missing.
248
+ */
249
+ export function fetchInfisicalSecret(
250
+ env: string,
251
+ secretPath: string,
252
+ secretName: string,
253
+ config?: InfisicalConfig,
254
+ token?: string,
255
+ ): string {
256
+ const cfg = config ?? getInfisicalConfig();
257
+ const tok = token ?? getInfisicalToken(cfg);
258
+ const envSlug = env === 'stage' ? 'staging' : env;
259
+ const encodedPath = encodeURIComponent(secretPath);
260
+
261
+ const response = execSync(
262
+ `curl -s "${cfg.url}/api/v3/secrets/raw/${secretName}?workspaceId=${cfg.projectId}&environment=${envSlug}&secretPath=${encodedPath}" -H "Authorization: Bearer ${tok}"`,
263
+ { encoding: 'utf-8' },
264
+ );
265
+
266
+ let parsed: { secret?: { secretValue?: string }; message?: string };
267
+ try {
268
+ parsed = JSON.parse(response);
269
+ } catch {
270
+ throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${response.slice(0, 200)}`);
271
+ }
272
+ if (!parsed.secret?.secretValue) {
273
+ throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${response.slice(0, 200)})`);
274
+ }
275
+ return parsed.secret.secretValue;
276
+ }
277
+ ```
278
+
279
+ - [ ] **Step 2: Build to catch type errors**
280
+
281
+ ```bash
282
+ npm run build
283
+ ```
284
+
285
+ Expected: no errors.
286
+
287
+ - [ ] **Step 3: Sanity-check by fetching BILLING_URL**
288
+
289
+ ```bash
290
+ node -e "const { fetchInfisicalSecret } = require('./dist/bin/helpers/infisical-secrets'); console.log(fetchInfisicalSecret('stage', '/shared-secrets/domain-urls', 'BILLING_URL'));"
291
+ ```
292
+
293
+ Expected: prints the BILLING_URL value resolved in T1.2.
294
+
295
+ - [ ] **Step 4: Commit**
296
+
297
+ ```bash
298
+ git add bin/helpers/infisical-secrets.ts
299
+ git commit -m "feat(helpers): add fetchInfisicalSecret for single-secret lookups by path"
300
+ ```
301
+
302
+ ---
303
+
304
+ ### Task 4: Add `bin/helpers/billing-http.ts`
305
+
306
+ **Files:**
307
+ - Create: `bin/helpers/billing-http.ts`
308
+
309
+ - [ ] **Step 1: Write the module**
310
+
311
+ Create `bin/helpers/billing-http.ts`:
312
+
313
+ ```typescript
314
+ import { execSync } from 'child_process';
315
+ import { fetchInfisicalSecret } from './infisical-secrets';
316
+ import { getInfisicalConfig, getInfisicalToken } from './db-utils';
317
+
318
+ const USER_AUTH_URLS: Record<string, string> = {
319
+ stage: 'https://auth.stage.optima.onl',
320
+ prod: 'https://auth.optima.onl',
321
+ };
322
+
323
+ // T1 discovered: client_id differs per env (stage=dev-skills-ubd3qz6n,
324
+ // prod=dev-skills-hinxa0rs). Both stored in Infisical alongside the
325
+ // secret at /shared-secrets/oauth-clients/.
326
+ const DEV_SKILLS_OAUTH_PATH = '/shared-secrets/oauth-clients';
327
+ const DEV_SKILLS_CLIENT_ID_KEY = 'DEV_SKILLS_OAUTH_CLIENT_ID';
328
+ const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
329
+
330
+ // ───── Cache (process-lifetime) ─────────────────────────────────────────────
331
+ // One CLI invocation does at most a handful of HTTP calls. We mint the M2M
332
+ // token once and reuse it. Cross-invocation re-mint is fine — JWT TTL is
333
+ // typically ≥1h, far longer than any single CLI run.
334
+ //
335
+ // NOT handled (acceptable for admin CLI):
336
+ // * Token expiry mid-invocation — a long-stalled revoke (list call + 5min
337
+ // pause + refund call) could in theory expire. Operator can retry.
338
+ // * Infisical 5xx retry — getInfisicalToken is sync execSync curl with no
339
+ // retry; any transient failure surfaces immediately. Re-run the CLI.
340
+ const tokenCache: Record<string, string> = {};
341
+ const billingUrlCache: Record<string, string> = {};
342
+
343
+ function getBillingUrl(env: string): string {
344
+ if (billingUrlCache[env]) return billingUrlCache[env];
345
+ const url = fetchInfisicalSecret(env, '/shared-secrets/domain-urls', 'BILLING_URL');
346
+ billingUrlCache[env] = url;
347
+ return url;
348
+ }
349
+
350
+ export function getServiceToken(env: string): string {
351
+ if (tokenCache[env]) return tokenCache[env];
352
+
353
+ const cfg = getInfisicalConfig();
354
+ const tok = getInfisicalToken(cfg);
355
+ // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
356
+ const clientId = fetchInfisicalSecret(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_ID_KEY, cfg, tok);
357
+ const clientSecret = fetchInfisicalSecret(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_SECRET_KEY, cfg, tok);
358
+
359
+ const authUrl = USER_AUTH_URLS[env];
360
+ if (!authUrl) throw new Error(`Unknown env: ${env}`);
361
+
362
+ const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
363
+ const response = execSync(
364
+ `curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`,
365
+ { encoding: 'utf-8' },
366
+ );
367
+
368
+ let parsed: { access_token?: string; error?: string };
369
+ try {
370
+ parsed = JSON.parse(response);
371
+ } catch {
372
+ throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${response.slice(0, 200)}`);
373
+ }
374
+ if (!parsed.access_token) {
375
+ throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
376
+ }
377
+ tokenCache[env] = parsed.access_token;
378
+ return parsed.access_token;
379
+ }
380
+
381
+ // ───── Error envelope ───────────────────────────────────────────────────────
382
+ interface BillingErrorEnvelope {
383
+ error?: { code?: string; message?: string } | string;
384
+ message?: string;
385
+ code?: string;
386
+ }
387
+
388
+ function formatBillingError(status: number, statusText: string, body: string): string {
389
+ let parsed: BillingErrorEnvelope | null = null;
390
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
391
+
392
+ // Dominant Wave 1.5 envelope: flat { error: "CODE_STRING", message: "..." }
393
+ // emitted by billing's global error handler (app.ts:99-118) for ALL
394
+ // BillingError throws + validation errors + internal errors. Most inline
395
+ // route returns also use this shape (admin-products.ts:110,144,184-185,etc).
396
+ if (parsed && typeof parsed.error === 'string') {
397
+ return `❌ Error [${status}] ${parsed.error}: ${parsed.message ?? '(no message)'}`;
398
+ }
399
+ // Less-common nested envelope: { error: { code, message } } — used by a
400
+ // few inline 400/404 returns in admin-products.ts toggle-channel handler
401
+ // (lines 92-93, 100-101, 114-117). Possibly extends to other routes
402
+ // post-Wave-1.5 as standardization lands.
403
+ if (parsed && typeof parsed.error === 'object' && parsed.error !== null) {
404
+ const code = (parsed.error as { code?: string }).code ?? 'UNKNOWN';
405
+ const msg = (parsed.error as { message?: string }).message ?? '(no message)';
406
+ return `❌ Error [${status}] ${code}: ${msg}`;
407
+ }
408
+ // Non-envelope fallback (raw 502 from upstream LB, crashed handler before
409
+ // error middleware, plain-text body, etc.)
410
+ return `❌ Error [${status}] ${statusText}\n Response body (first 500 bytes): ${body.slice(0, 500)}`;
411
+ }
412
+
413
+ // ───── Public: callBilling ──────────────────────────────────────────────────
414
+ export interface BillingResponse<T> {
415
+ status: number;
416
+ body: T;
417
+ }
418
+
419
+ /**
420
+ * Make an authenticated call to optima-billing. Returns `{status, body}` on
421
+ * 2xx; throws Error with formatted message on non-2xx. Single retry on 5xx
422
+ * (one-shot — no exponential backoff; admin CLI doesn't justify it).
423
+ */
424
+ export async function callBilling<T = unknown>(
425
+ env: string,
426
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
427
+ path: string,
428
+ body?: object,
429
+ ): Promise<BillingResponse<T>> {
430
+ const url = `${getBillingUrl(env)}${path}`;
431
+ const token = getServiceToken(env);
432
+
433
+ const doFetch = async () => fetch(url, {
434
+ method,
435
+ headers: {
436
+ Authorization: `Bearer ${token}`,
437
+ 'Content-Type': 'application/json',
438
+ },
439
+ body: body !== undefined ? JSON.stringify(body) : undefined,
440
+ });
441
+
442
+ let res = await doFetch();
443
+ if (res.status >= 500) {
444
+ // One retry on 5xx
445
+ res = await doFetch();
446
+ }
447
+ const text = await res.text();
448
+ if (!res.ok) {
449
+ throw new Error(formatBillingError(res.status, res.statusText, text));
450
+ }
451
+ let parsed: T;
452
+ try {
453
+ parsed = text ? (JSON.parse(text) as T) : (undefined as unknown as T);
454
+ } catch {
455
+ throw new Error(`Billing returned non-JSON 2xx body: ${text.slice(0, 200)}`);
456
+ }
457
+ return { status: res.status, body: parsed };
458
+ }
459
+ ```
460
+
461
+ - [ ] **Step 2: (resolved at plan-edit time — T1 values now baked into the code above)**
462
+
463
+ T1 resolved that:
464
+ - Path: `/shared-secrets/oauth-clients`
465
+ - Keys: `DEV_SKILLS_OAUTH_CLIENT_ID`, `DEV_SKILLS_OAUTH_CLIENT_SECRET`
466
+ - client_id varies per env (stage=dev-skills-ubd3qz6n, prod=dev-skills-hinxa0rs)
467
+
468
+ The T4 code above fetches both via Infisical per env. No placeholders to replace.
469
+
470
+ - [ ] **Step 3: Build**
471
+
472
+ ```bash
473
+ npm run build
474
+ ```
475
+
476
+ Expected: no errors.
477
+
478
+ - [ ] **Step 4: Sanity smoke (mints token + lists entitlements for a known user)**
479
+
480
+ ```bash
481
+ node -e "
482
+ const { callBilling } = require('./dist/bin/helpers/billing-http');
483
+ callBilling('stage', 'GET', '/api/billing/admin/entitlements?userId=00000000-0000-0000-0000-000000000000')
484
+ .then(r => console.log('STATUS', r.status, 'BODY', JSON.stringify(r.body)))
485
+ .catch(e => { console.error(e.message); process.exit(1); });
486
+ "
487
+ ```
488
+
489
+ Expected: STATUS 200, BODY `{"entitlements":[]}` (empty array — userId doesn't exist, but the endpoint accepts any valid UUID and returns 200 with empty list, proving auth + endpoint reachability).
490
+
491
+ - [ ] **Step 5: Commit**
492
+
493
+ ```bash
494
+ git add bin/helpers/billing-http.ts
495
+ git commit -m "feat(helpers): add billing-http module (M2M token + callBilling wrapper)"
496
+ ```
497
+
498
+ ---
499
+
500
+ ### Task 5: Add `bin/helpers/confirm-prompt.ts`
501
+
502
+ **Files:**
503
+ - Create: `bin/helpers/confirm-prompt.ts`
504
+
505
+ - [ ] **Step 1: Write the helper**
506
+
507
+ Create `bin/helpers/confirm-prompt.ts`:
508
+
509
+ ```typescript
510
+ import * as readline from 'readline';
511
+
512
+ /**
513
+ * On prod, print the resolved action and require typing "yes" to proceed.
514
+ * No-op on stage or when --yes was passed. Exits 1 if user declines.
515
+ */
516
+ export async function confirmIfProd(
517
+ env: string,
518
+ actionDescription: string,
519
+ skipFlag: boolean,
520
+ ): Promise<void> {
521
+ if (env !== 'prod' || skipFlag) return;
522
+
523
+ console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
524
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
525
+ const answer = await new Promise<string>((resolve) => {
526
+ rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
527
+ });
528
+ if (answer !== 'yes') {
529
+ console.error('❌ Aborted by user.');
530
+ process.exit(1);
531
+ }
532
+ }
533
+ ```
534
+
535
+ - [ ] **Step 2: Build**
536
+
537
+ ```bash
538
+ npm run build
539
+ ```
540
+
541
+ Expected: no errors.
542
+
543
+ - [ ] **Step 3: Commit**
544
+
545
+ ```bash
546
+ git add bin/helpers/confirm-prompt.ts
547
+ git commit -m "feat(helpers): add confirmIfProd prod-only stdin confirmation"
548
+ ```
549
+
550
+ ---
551
+
552
+ ### Task 6: Add `optima-product` dispatcher + `create` subcommand
553
+
554
+ **Files:**
555
+ - Create: `bin/helpers/product.ts`
556
+ - Create: `bin/helpers/product/create.ts`
557
+
558
+ - [ ] **Step 1: Write the dispatcher**
559
+
560
+ Create `bin/helpers/product.ts`:
561
+
562
+ ```typescript
563
+ #!/usr/bin/env node
564
+
565
+ import { runCreate } from './product/create';
566
+
567
+ const SUBCOMMANDS = ['create', 'update', 'add-channel', 'toggle-channel', 'show'] as const;
568
+
569
+ function printHelp() {
570
+ console.log(`Usage: optima-product <subcommand> [options]
571
+
572
+ Subcommands:
573
+ create Create a Product bundling 1+ plugin slugs
574
+ update Patch refund policy / metadata on an existing Product
575
+ add-channel Attach a payment channel (Stripe Price ID) to a Product
576
+ toggle-channel Enable/disable an existing channel
577
+ show Show a Product's bare row (note: does NOT include plugins/channels)
578
+
579
+ Run 'optima-product <subcommand> --help' for subcommand-specific options.`);
580
+ }
581
+
582
+ async function main() {
583
+ const [, , subcommand, ...rest] = process.argv;
584
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') {
585
+ printHelp();
586
+ process.exit(0);
587
+ }
588
+ switch (subcommand) {
589
+ case 'create':
590
+ await runCreate(rest);
591
+ break;
592
+ case 'update':
593
+ case 'add-channel':
594
+ case 'toggle-channel':
595
+ case 'show':
596
+ console.error(`Subcommand '${subcommand}' not yet implemented (added in a later task).`);
597
+ process.exit(1);
598
+ default:
599
+ console.error(`Unknown subcommand: ${subcommand}`);
600
+ printHelp();
601
+ process.exit(1);
602
+ }
603
+ }
604
+
605
+ main().catch((err) => {
606
+ console.error(err.message);
607
+ process.exit(1);
608
+ });
609
+ ```
610
+
611
+ - [ ] **Step 2: Write the `create` subcommand handler**
612
+
613
+ Create `bin/helpers/product/create.ts`:
614
+
615
+ ```typescript
616
+ import { callBilling } from '../billing-http';
617
+
618
+ interface CreateArgs {
619
+ key: string;
620
+ plugins: string[];
621
+ type: string;
622
+ name?: string;
623
+ description?: string;
624
+ refundWindowDays?: number;
625
+ refundProrateMaxDays?: number;
626
+ bundledPlanId?: string;
627
+ bundledDurationDays?: number;
628
+ revokeBundledOnRefund?: boolean;
629
+ metadata?: Record<string, unknown>;
630
+ env: string;
631
+ }
632
+
633
+ function parseArgs(argv: string[]): CreateArgs {
634
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
635
+ console.log(`Usage: optima-product create --key <productKey> --plugins <slug1,slug2,...> --type <ProductType> [options]
636
+
637
+ Required:
638
+ --key <productKey> Unique slug
639
+ --plugins <slug1,slug2,...> Comma-separated, >=1 plugin slug
640
+ --type <ProductType> Only ONE_SHOT_SKILL accepted by v1 CLI policy
641
+
642
+ Optional:
643
+ --name "..." Convenience flag, folded into metadata.name
644
+ --description "..." Convenience flag, folded into metadata.description
645
+ --refund-window-days N
646
+ --refund-prorate-max-days N
647
+ --bundled-plan-id <planId> (joint with --bundled-duration-days)
648
+ --bundled-duration-days N
649
+ --revoke-bundled-on-refund true|false
650
+ --metadata '<json>' JSON object stored in product.metadata
651
+ --env stage|prod (default: stage)`);
652
+ process.exit(0);
653
+ }
654
+
655
+ const out: Partial<CreateArgs> = { env: 'stage', revokeBundledOnRefund: undefined };
656
+ for (let i = 0; i < argv.length; i++) {
657
+ const a = argv[i];
658
+ const next = argv[i + 1];
659
+ switch (a) {
660
+ case '--key': out.key = next; i++; break;
661
+ case '--plugins': out.plugins = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
662
+ case '--type': out.type = next; i++; break;
663
+ case '--name': out.name = next; i++; break;
664
+ case '--description': out.description = next; i++; break;
665
+ case '--refund-window-days': out.refundWindowDays = parseInt(next, 10); i++; break;
666
+ case '--refund-prorate-max-days': out.refundProrateMaxDays = parseInt(next, 10); i++; break;
667
+ case '--bundled-plan-id': out.bundledPlanId = next; i++; break;
668
+ case '--bundled-duration-days': out.bundledDurationDays = parseInt(next, 10); i++; break;
669
+ case '--revoke-bundled-on-refund': out.revokeBundledOnRefund = next === 'true'; i++; break;
670
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
671
+ case '--env': out.env = next; i++; break;
672
+ default: throw new Error(`Unknown arg: ${a}`);
673
+ }
674
+ }
675
+
676
+ if (!out.key) throw new Error('--key required');
677
+ if (!out.plugins || out.plugins.length === 0) throw new Error('--plugins required (>=1 slug)');
678
+ if (!out.type) throw new Error('--type required');
679
+ if (out.type !== 'ONE_SHOT_SKILL') {
680
+ throw new Error(`v1 CLI accepts only --type ONE_SHOT_SKILL (got ${out.type}). See spec §5.1.`);
681
+ }
682
+ if ((out.bundledPlanId == null) !== (out.bundledDurationDays == null)) {
683
+ throw new Error('--bundled-plan-id and --bundled-duration-days must be set together');
684
+ }
685
+ return out as CreateArgs;
686
+ }
687
+
688
+ export async function runCreate(argv: string[]): Promise<void> {
689
+ const args = parseArgs(argv);
690
+
691
+ const body: Record<string, unknown> = {
692
+ productKey: args.key,
693
+ type: args.type,
694
+ pluginSlugs: args.plugins,
695
+ };
696
+ if (args.name !== undefined) body.name = args.name;
697
+ if (args.description !== undefined) body.description = args.description;
698
+ if (args.refundWindowDays !== undefined) body.refundWindowDays = args.refundWindowDays;
699
+ if (args.refundProrateMaxDays !== undefined) body.refundProrateMaxDays = args.refundProrateMaxDays;
700
+ if (args.bundledPlanId !== undefined) body.bundledPlanId = args.bundledPlanId;
701
+ if (args.bundledDurationDays !== undefined) body.bundledDurationDays = args.bundledDurationDays;
702
+ if (args.revokeBundledOnRefund !== undefined) body.revokeBundledOnRefund = args.revokeBundledOnRefund;
703
+ if (args.metadata !== undefined) body.metadata = args.metadata;
704
+
705
+ console.log(`\n🎁 Creating product ${args.key} (${args.plugins.length} plugin(s)) on ${args.env.toUpperCase()}...`);
706
+
707
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/products', body);
708
+ console.log(`✓ Created Product (HTTP ${res.status}):`);
709
+ console.log(JSON.stringify(res.body, null, 2));
710
+ }
711
+ ```
712
+
713
+ - [ ] **Step 3: Build**
714
+
715
+ ```bash
716
+ npm run build
717
+ ```
718
+
719
+ Expected: no errors.
720
+
721
+ - [ ] **Step 4: Sanity-check --help**
722
+
723
+ ```bash
724
+ node dist/bin/helpers/product.js --help
725
+ node dist/bin/helpers/product.js create --help
726
+ ```
727
+
728
+ Expected: both print usage and exit 0.
729
+
730
+ - [ ] **Step 5: Stage smoke — create a probe product**
731
+
732
+ ```bash
733
+ node dist/bin/helpers/product.js create \
734
+ --key plan-t6-probe-$(date +%s) \
735
+ --plugins skillify \
736
+ --type ONE_SHOT_SKILL \
737
+ --name "T6 probe product" \
738
+ --env stage
739
+ ```
740
+
741
+ Expected: HTTP 201, JSON body with productKey, type=ONE_SHOT_SKILL, metadata.name="T6 probe product".
742
+
743
+ - [ ] **Step 6: Commit**
744
+
745
+ ```bash
746
+ git add bin/helpers/product.ts bin/helpers/product/create.ts
747
+ git commit -m "feat(product): add optima-product dispatcher + create subcommand"
748
+ ```
749
+
750
+ ---
751
+
752
+ ### Task 7: `optima-product update` subcommand
753
+
754
+ **Files:**
755
+ - Create: `bin/helpers/product/update.ts`
756
+ - Modify: `bin/helpers/product.ts` (wire in the subcommand)
757
+
758
+ - [ ] **Step 1: Write the update handler**
759
+
760
+ Create `bin/helpers/product/update.ts`:
761
+
762
+ ```typescript
763
+ import { callBilling } from '../billing-http';
764
+
765
+ interface UpdateArgs {
766
+ key: string;
767
+ refundWindowDays?: number | null;
768
+ refundProrateMaxDays?: number | null;
769
+ bundledPlanId?: string | null;
770
+ bundledDurationDays?: number | null;
771
+ revokeBundledOnRefund?: boolean;
772
+ metadata?: Record<string, unknown>;
773
+ env: string;
774
+ }
775
+
776
+ function parseArgs(argv: string[]): UpdateArgs {
777
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
778
+ console.log(`Usage: optima-product update --key <productKey> [options]
779
+
780
+ Required:
781
+ --key <productKey>
782
+
783
+ Optional (PATCH — only included fields are updated):
784
+ --refund-window-days N | null
785
+ --refund-prorate-max-days N | null
786
+ --bundled-plan-id <planId> | null (joint with --bundled-duration-days)
787
+ --bundled-duration-days N | null
788
+ --revoke-bundled-on-refund true|false
789
+ --metadata '<json>' FULL REPLACE — Prisma does not deep-merge JSON
790
+ --env stage|prod (default: stage)
791
+
792
+ Note: productKey, type, and pluginSlugs are immutable post-create. To change plugin
793
+ membership, create a new Product with a new key.`);
794
+ process.exit(0);
795
+ }
796
+ const out: Partial<UpdateArgs> = { env: 'stage' };
797
+ for (let i = 0; i < argv.length; i++) {
798
+ const a = argv[i];
799
+ const next = argv[i + 1];
800
+ const parseNullable = (v: string): number | null => v === 'null' ? null : parseInt(v, 10);
801
+ switch (a) {
802
+ case '--key': out.key = next; i++; break;
803
+ case '--refund-window-days': out.refundWindowDays = parseNullable(next); i++; break;
804
+ case '--refund-prorate-max-days': out.refundProrateMaxDays = parseNullable(next); i++; break;
805
+ case '--bundled-plan-id': out.bundledPlanId = next === 'null' ? null : next; i++; break;
806
+ case '--bundled-duration-days': out.bundledDurationDays = parseNullable(next); i++; break;
807
+ case '--revoke-bundled-on-refund': out.revokeBundledOnRefund = next === 'true'; i++; break;
808
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
809
+ case '--env': out.env = next; i++; break;
810
+ default: throw new Error(`Unknown arg: ${a}`);
811
+ }
812
+ }
813
+ if (!out.key) throw new Error('--key required');
814
+ return out as UpdateArgs;
815
+ }
816
+
817
+ export async function runUpdate(argv: string[]): Promise<void> {
818
+ const args = parseArgs(argv);
819
+ const body: Record<string, unknown> = {};
820
+ if (args.refundWindowDays !== undefined) body.refundWindowDays = args.refundWindowDays;
821
+ if (args.refundProrateMaxDays !== undefined) body.refundProrateMaxDays = args.refundProrateMaxDays;
822
+ if (args.bundledPlanId !== undefined) body.bundledPlanId = args.bundledPlanId;
823
+ if (args.bundledDurationDays !== undefined) body.bundledDurationDays = args.bundledDurationDays;
824
+ if (args.revokeBundledOnRefund !== undefined) body.revokeBundledOnRefund = args.revokeBundledOnRefund;
825
+ if (args.metadata !== undefined) body.metadata = args.metadata;
826
+
827
+ if (Object.keys(body).length === 0) throw new Error('At least one updatable field must be passed');
828
+
829
+ console.log(`\n✏️ Updating product ${args.key} on ${args.env.toUpperCase()}...`);
830
+ const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/products/${encodeURIComponent(args.key)}`, body);
831
+ console.log(`✓ Updated Product (HTTP ${res.status}):`);
832
+ console.log(JSON.stringify(res.body, null, 2));
833
+ }
834
+ ```
835
+
836
+ - [ ] **Step 2: Wire into dispatcher**
837
+
838
+ In `bin/helpers/product.ts`, add the import + case:
839
+
840
+ ```typescript
841
+ import { runUpdate } from './product/update';
842
+ ```
843
+
844
+ Replace the `case 'update':` line (currently in the not-implemented switch) with:
845
+
846
+ ```typescript
847
+ case 'update':
848
+ await runUpdate(rest);
849
+ break;
850
+ ```
851
+
852
+ - [ ] **Step 3: Build + smoke**
853
+
854
+ ```bash
855
+ npm run build
856
+ node dist/bin/helpers/product.js update --help
857
+ # Use the product key created in T6 step 5:
858
+ PROBE_KEY='<key-from-T6-step-5>'
859
+ node dist/bin/helpers/product.js update --key "$PROBE_KEY" --refund-window-days 14 --env stage
860
+ ```
861
+
862
+ Expected: HTTP 200, JSON body with refundWindowDays=14.
863
+
864
+ - [ ] **Step 4: Commit**
865
+
866
+ ```bash
867
+ git add bin/helpers/product.ts bin/helpers/product/update.ts
868
+ git commit -m "feat(product): add update subcommand (PATCH /api/billing/admin/products/:key)"
869
+ ```
870
+
871
+ ---
872
+
873
+ ### Task 8: `optima-product add-channel` subcommand
874
+
875
+ **Files:**
876
+ - Create: `bin/helpers/product/add-channel.ts`
877
+ - Modify: `bin/helpers/product.ts`
878
+
879
+ - [ ] **Step 1: Write the handler**
880
+
881
+ Create `bin/helpers/product/add-channel.ts`:
882
+
883
+ ```typescript
884
+ import { callBilling } from '../billing-http';
885
+
886
+ interface AddChannelArgs {
887
+ key: string;
888
+ provider: string;
889
+ stripePriceId: string;
890
+ priceCents: number;
891
+ currency: string;
892
+ enabled?: boolean;
893
+ metadata?: Record<string, unknown>;
894
+ env: string;
895
+ }
896
+
897
+ function parseArgs(argv: string[]): AddChannelArgs {
898
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
899
+ console.log(`Usage: optima-product add-channel --key <productKey> --provider STRIPE --stripe-price-id <price_xxx> --price-cents N --currency USD [options]
900
+
901
+ Required:
902
+ --key <productKey>
903
+ --provider STRIPE v1 CLI accepts only STRIPE (schema supports more)
904
+ --stripe-price-id <price_xxx> Pre-created in Stripe Dashboard; wire-mapped to externalProductId
905
+ --price-cents N MUST be > 0; should match Stripe Price's unit_amount (NOT verified)
906
+ --currency USD Should match Stripe Price's currency (NOT verified)
907
+
908
+ Optional:
909
+ --enabled true|false default: true
910
+ --metadata '<json>'
911
+ --env stage|prod (default: stage)`);
912
+ process.exit(0);
913
+ }
914
+ const out: Partial<AddChannelArgs> = { env: 'stage' };
915
+ for (let i = 0; i < argv.length; i++) {
916
+ const a = argv[i];
917
+ const next = argv[i + 1];
918
+ switch (a) {
919
+ case '--key': out.key = next; i++; break;
920
+ case '--provider': out.provider = next; i++; break;
921
+ case '--stripe-price-id': out.stripePriceId = next; i++; break;
922
+ case '--price-cents': out.priceCents = parseInt(next, 10); i++; break;
923
+ case '--currency': out.currency = next; i++; break;
924
+ case '--enabled': out.enabled = next === 'true'; i++; break;
925
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
926
+ case '--env': out.env = next; i++; break;
927
+ default: throw new Error(`Unknown arg: ${a}`);
928
+ }
929
+ }
930
+ if (!out.key) throw new Error('--key required');
931
+ if (!out.provider) throw new Error('--provider required');
932
+ if (out.provider !== 'STRIPE') throw new Error(`v1 CLI accepts only --provider STRIPE (got ${out.provider})`);
933
+ if (!out.stripePriceId) throw new Error('--stripe-price-id required');
934
+ if (out.priceCents === undefined || !Number.isFinite(out.priceCents)) throw new Error('--price-cents required (integer)');
935
+ if (out.priceCents <= 0) throw new Error('--price-cents must be > 0');
936
+ if (!out.currency) throw new Error('--currency required');
937
+ return out as AddChannelArgs;
938
+ }
939
+
940
+ export async function runAddChannel(argv: string[]): Promise<void> {
941
+ const args = parseArgs(argv);
942
+ const body: Record<string, unknown> = {
943
+ provider: args.provider,
944
+ externalProductId: args.stripePriceId,
945
+ priceCents: args.priceCents,
946
+ currency: args.currency,
947
+ };
948
+ if (args.enabled !== undefined) body.enabled = args.enabled;
949
+ if (args.metadata !== undefined) body.metadata = args.metadata;
950
+
951
+ console.log(`\n💳 Adding ${args.provider} channel to ${args.key} on ${args.env.toUpperCase()}...`);
952
+ const res = await callBilling(
953
+ args.env,
954
+ 'POST',
955
+ `/api/billing/admin/products/${encodeURIComponent(args.key)}/channels`,
956
+ body,
957
+ );
958
+ console.log(`✓ Created Channel (HTTP ${res.status}):`);
959
+ console.log(JSON.stringify(res.body, null, 2));
960
+ }
961
+ ```
962
+
963
+ - [ ] **Step 2: Wire into dispatcher**
964
+
965
+ In `bin/helpers/product.ts`, add:
966
+
967
+ ```typescript
968
+ import { runAddChannel } from './product/add-channel';
969
+ ```
970
+
971
+ Replace the `case 'add-channel':` with:
972
+
973
+ ```typescript
974
+ case 'add-channel':
975
+ await runAddChannel(rest);
976
+ break;
977
+ ```
978
+
979
+ - [ ] **Step 3: Build + smoke**
980
+
981
+ ```bash
982
+ npm run build
983
+ node dist/bin/helpers/product.js add-channel --help
984
+ # Pre-step: create a real Stripe test Price in the Stripe sandbox dashboard
985
+ # (https://dashboard.stripe.com/test/products → New product → name "T8 smoke" → price $1.00 → Save)
986
+ # Copy the price_xxx ID into the variable below.
987
+ STRIPE_PRICE_ID='<price_xxx-from-stripe-dashboard>'
988
+ PROBE_KEY='<key-from-T6-step-5>'
989
+ node dist/bin/helpers/product.js add-channel \
990
+ --key "$PROBE_KEY" \
991
+ --provider STRIPE \
992
+ --stripe-price-id "$STRIPE_PRICE_ID" \
993
+ --price-cents 100 \
994
+ --currency USD \
995
+ --env stage
996
+ ```
997
+
998
+ Expected: HTTP 201, JSON body with provider=STRIPE, externalProductId, priceCents=100.
999
+
1000
+ - [ ] **Step 4: Commit**
1001
+
1002
+ ```bash
1003
+ git add bin/helpers/product.ts bin/helpers/product/add-channel.ts
1004
+ git commit -m "feat(product): add add-channel subcommand"
1005
+ ```
1006
+
1007
+ ---
1008
+
1009
+ ### Task 9: `optima-product toggle-channel` subcommand
1010
+
1011
+ **Files:**
1012
+ - Create: `bin/helpers/product/toggle-channel.ts`
1013
+ - Modify: `bin/helpers/product.ts`
1014
+
1015
+ - [ ] **Step 1: Write the handler**
1016
+
1017
+ Create `bin/helpers/product/toggle-channel.ts`:
1018
+
1019
+ ```typescript
1020
+ import { callBilling } from '../billing-http';
1021
+
1022
+ interface ToggleArgs {
1023
+ key: string;
1024
+ provider: string;
1025
+ enabled: boolean;
1026
+ env: string;
1027
+ }
1028
+
1029
+ function parseArgs(argv: string[]): ToggleArgs {
1030
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
1031
+ console.log(`Usage: optima-product toggle-channel --key <productKey> --provider STRIPE --enabled true|false [options]
1032
+
1033
+ Required:
1034
+ --key <productKey>
1035
+ --provider STRIPE v1 CLI accepts only STRIPE
1036
+ --enabled true|false
1037
+
1038
+ Optional:
1039
+ --env stage|prod (default: stage)`);
1040
+ process.exit(0);
1041
+ }
1042
+ const out: Partial<ToggleArgs> = { env: 'stage' };
1043
+ for (let i = 0; i < argv.length; i++) {
1044
+ const a = argv[i];
1045
+ const next = argv[i + 1];
1046
+ switch (a) {
1047
+ case '--key': out.key = next; i++; break;
1048
+ case '--provider': out.provider = next; i++; break;
1049
+ case '--enabled':
1050
+ if (next !== 'true' && next !== 'false') throw new Error('--enabled must be true or false');
1051
+ out.enabled = next === 'true'; i++; break;
1052
+ case '--env': out.env = next; i++; break;
1053
+ default: throw new Error(`Unknown arg: ${a}`);
1054
+ }
1055
+ }
1056
+ if (!out.key) throw new Error('--key required');
1057
+ if (!out.provider) throw new Error('--provider required');
1058
+ if (out.provider !== 'STRIPE') throw new Error(`v1 CLI accepts only --provider STRIPE (got ${out.provider})`);
1059
+ if (out.enabled === undefined) throw new Error('--enabled required');
1060
+ return out as ToggleArgs;
1061
+ }
1062
+
1063
+ export async function runToggleChannel(argv: string[]): Promise<void> {
1064
+ const args = parseArgs(argv);
1065
+ console.log(`\n🔁 Setting ${args.provider} channel enabled=${args.enabled} on ${args.key} (${args.env.toUpperCase()})...`);
1066
+ const res = await callBilling(
1067
+ args.env,
1068
+ 'PATCH',
1069
+ `/api/billing/admin/products/${encodeURIComponent(args.key)}/channels/${args.provider}`,
1070
+ { enabled: args.enabled },
1071
+ );
1072
+ console.log(`✓ Channel updated (HTTP ${res.status}):`);
1073
+ console.log(JSON.stringify(res.body, null, 2));
1074
+ }
1075
+ ```
1076
+
1077
+ - [ ] **Step 2: Wire into dispatcher**
1078
+
1079
+ In `bin/helpers/product.ts`:
1080
+
1081
+ ```typescript
1082
+ import { runToggleChannel } from './product/toggle-channel';
1083
+ ```
1084
+
1085
+ Replace `case 'toggle-channel':` with:
1086
+
1087
+ ```typescript
1088
+ case 'toggle-channel':
1089
+ await runToggleChannel(rest);
1090
+ break;
1091
+ ```
1092
+
1093
+ - [ ] **Step 3: Build + smoke (disable then re-enable the T8 channel)**
1094
+
1095
+ ```bash
1096
+ npm run build
1097
+ PROBE_KEY='<key-from-T6-step-5>'
1098
+ node dist/bin/helpers/product.js toggle-channel --key "$PROBE_KEY" --provider STRIPE --enabled false --env stage
1099
+ node dist/bin/helpers/product.js toggle-channel --key "$PROBE_KEY" --provider STRIPE --enabled true --env stage
1100
+ ```
1101
+
1102
+ Expected: both calls return HTTP 200, second response shows enabled=true.
1103
+
1104
+ - [ ] **Step 4: Commit**
1105
+
1106
+ ```bash
1107
+ git add bin/helpers/product.ts bin/helpers/product/toggle-channel.ts
1108
+ git commit -m "feat(product): add toggle-channel subcommand"
1109
+ ```
1110
+
1111
+ ---
1112
+
1113
+ ### Task 10: `optima-product show` subcommand
1114
+
1115
+ **Files:**
1116
+ - Create: `bin/helpers/product/show.ts`
1117
+ - Modify: `bin/helpers/product.ts`
1118
+
1119
+ - [ ] **Step 1: Write the handler**
1120
+
1121
+ Create `bin/helpers/product/show.ts`:
1122
+
1123
+ ```typescript
1124
+ import { callBilling } from '../billing-http';
1125
+
1126
+ interface ShowArgs {
1127
+ key: string;
1128
+ env: string;
1129
+ }
1130
+
1131
+ function parseArgs(argv: string[]): ShowArgs {
1132
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
1133
+ console.log(`Usage: optima-product show --key <productKey> [options]
1134
+
1135
+ Required:
1136
+ --key <productKey>
1137
+
1138
+ Optional:
1139
+ --env stage|prod (default: stage)
1140
+
1141
+ Note: returns the bare Product row only — productPlugins and channels arrays
1142
+ are NOT included (billing's GET /api/internal/products/:key is a plain
1143
+ findUnique with no include). To inspect channels/plugins today, query the DB
1144
+ directly via optima-query-db. Tracked as follow-up in spec §7.`);
1145
+ process.exit(0);
1146
+ }
1147
+ const out: Partial<ShowArgs> = { env: 'stage' };
1148
+ for (let i = 0; i < argv.length; i++) {
1149
+ const a = argv[i];
1150
+ const next = argv[i + 1];
1151
+ switch (a) {
1152
+ case '--key': out.key = next; i++; break;
1153
+ case '--env': out.env = next; i++; break;
1154
+ default: throw new Error(`Unknown arg: ${a}`);
1155
+ }
1156
+ }
1157
+ if (!out.key) throw new Error('--key required');
1158
+ return out as ShowArgs;
1159
+ }
1160
+
1161
+ export async function runShow(argv: string[]): Promise<void> {
1162
+ const args = parseArgs(argv);
1163
+ const res = await callBilling(args.env, 'GET', `/api/internal/products/${encodeURIComponent(args.key)}`);
1164
+ console.log(JSON.stringify(res.body, null, 2));
1165
+ }
1166
+ ```
1167
+
1168
+ - [ ] **Step 2: Wire into dispatcher**
1169
+
1170
+ In `bin/helpers/product.ts`:
1171
+
1172
+ ```typescript
1173
+ import { runShow } from './product/show';
1174
+ ```
1175
+
1176
+ Replace `case 'show':` with:
1177
+
1178
+ ```typescript
1179
+ case 'show':
1180
+ await runShow(rest);
1181
+ break;
1182
+ ```
1183
+
1184
+ At this point the dispatcher's not-implemented branch should be empty — remove it. Final dispatcher switch should look like:
1185
+
1186
+ ```typescript
1187
+ switch (subcommand) {
1188
+ case 'create': await runCreate(rest); break;
1189
+ case 'update': await runUpdate(rest); break;
1190
+ case 'add-channel': await runAddChannel(rest); break;
1191
+ case 'toggle-channel': await runToggleChannel(rest); break;
1192
+ case 'show': await runShow(rest); break;
1193
+ default:
1194
+ console.error(`Unknown subcommand: ${subcommand}`);
1195
+ printHelp();
1196
+ process.exit(1);
1197
+ }
1198
+ ```
1199
+
1200
+ - [ ] **Step 3: Build + smoke**
1201
+
1202
+ ```bash
1203
+ npm run build
1204
+ PROBE_KEY='<key-from-T6-step-5>'
1205
+ node dist/bin/helpers/product.js show --key "$PROBE_KEY" --env stage
1206
+ ```
1207
+
1208
+ Expected: prints the bare Product JSON (productKey, type, refundWindowDays=14 from T7, metadata.name from T6, timestamps). No `productPlugins` or `channels` arrays.
1209
+
1210
+ - [ ] **Step 4: Commit**
1211
+
1212
+ ```bash
1213
+ git add bin/helpers/product.ts bin/helpers/product/show.ts
1214
+ git commit -m "feat(product): add show subcommand (note: bare Product, no plugins/channels)"
1215
+ ```
1216
+
1217
+ ---
1218
+
1219
+ ### Task 11: Add `optima-entitlement` dispatcher + `list` subcommand
1220
+
1221
+ `list` is implemented first because `revoke` depends on it internally.
1222
+
1223
+ **Files:**
1224
+ - Create: `bin/helpers/entitlement.ts`
1225
+ - Create: `bin/helpers/entitlement/list.ts`
1226
+
1227
+ - [ ] **Step 1: Write the dispatcher**
1228
+
1229
+ Create `bin/helpers/entitlement.ts`:
1230
+
1231
+ ```typescript
1232
+ #!/usr/bin/env node
1233
+
1234
+ import { runList } from './entitlement/list';
1235
+
1236
+ function printHelp() {
1237
+ console.log(`Usage: optima-entitlement <subcommand> [options]
1238
+
1239
+ Subcommands:
1240
+ grant Admin-grant a product entitlement to a user
1241
+ revoke Revoke an admin-granted entitlement (refuses PAYMENT / PARTNER sources)
1242
+ list List a user's entitlements, newest first
1243
+
1244
+ Run 'optima-entitlement <subcommand> --help' for subcommand-specific options.`);
1245
+ }
1246
+
1247
+ async function main() {
1248
+ const [, , subcommand, ...rest] = process.argv;
1249
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') { printHelp(); process.exit(0); }
1250
+ switch (subcommand) {
1251
+ case 'list': await runList(rest); break;
1252
+ case 'grant':
1253
+ case 'revoke':
1254
+ console.error(`Subcommand '${subcommand}' not yet implemented (added in a later task).`);
1255
+ process.exit(1);
1256
+ default:
1257
+ console.error(`Unknown subcommand: ${subcommand}`);
1258
+ printHelp();
1259
+ process.exit(1);
1260
+ }
1261
+ }
1262
+
1263
+ main().catch((err) => { console.error(err.message); process.exit(1); });
1264
+ ```
1265
+
1266
+ - [ ] **Step 2: Write the list handler**
1267
+
1268
+ Create `bin/helpers/entitlement/list.ts`:
1269
+
1270
+ ```typescript
1271
+ import { callBilling } from '../billing-http';
1272
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
1273
+
1274
+ interface ListArgs {
1275
+ email: string;
1276
+ env: string;
1277
+ }
1278
+
1279
+ function parseArgs(argv: string[]): ListArgs {
1280
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
1281
+ console.log(`Usage: optima-entitlement list --email <user-email> [options]
1282
+
1283
+ Required:
1284
+ --email <user-email> Resolved to userId via user-auth DB
1285
+
1286
+ Optional:
1287
+ --env stage|prod (default: stage)`);
1288
+ process.exit(0);
1289
+ }
1290
+ const out: Partial<ListArgs> = { env: 'stage' };
1291
+ for (let i = 0; i < argv.length; i++) {
1292
+ const a = argv[i];
1293
+ const next = argv[i + 1];
1294
+ switch (a) {
1295
+ case '--email': out.email = next; i++; break;
1296
+ case '--env': out.env = next; i++; break;
1297
+ default: throw new Error(`Unknown arg: ${a}`);
1298
+ }
1299
+ }
1300
+ if (!out.email) throw new Error('--email required');
1301
+ return out as ListArgs;
1302
+ }
1303
+
1304
+ interface EntitlementRow {
1305
+ id: string;
1306
+ productKey: string;
1307
+ status: string;
1308
+ source: string;
1309
+ purchasedAt: string;
1310
+ refundedAt: string | null;
1311
+ }
1312
+
1313
+ export async function runList(argv: string[]): Promise<void> {
1314
+ const args = parseArgs(argv);
1315
+
1316
+ const cfg = getInfisicalConfig();
1317
+ const token = getInfisicalToken(cfg);
1318
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
1319
+
1320
+ const res = await callBilling<{ entitlements: EntitlementRow[] }>(
1321
+ args.env,
1322
+ 'GET',
1323
+ `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`,
1324
+ );
1325
+
1326
+ const rows = res.body.entitlements ?? [];
1327
+ if (rows.length === 0) {
1328
+ console.log(`(no entitlements for ${args.email} on ${args.env})`);
1329
+ return;
1330
+ }
1331
+ // Newest first per spec
1332
+ rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
1333
+ console.log(`${rows.length} entitlement(s) for ${args.email}:\n`);
1334
+ console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
1335
+ console.log('-'.repeat(140));
1336
+ for (const r of rows) {
1337
+ console.log(
1338
+ r.id.padEnd(38) + ' | ' +
1339
+ r.productKey.padEnd(32) + ' | ' +
1340
+ r.status.padEnd(9) + ' | ' +
1341
+ r.source.padEnd(12) + ' | ' +
1342
+ r.purchasedAt.padEnd(24) + ' | ' +
1343
+ (r.refundedAt ?? ''),
1344
+ );
1345
+ }
1346
+ }
1347
+ ```
1348
+
1349
+ - [ ] **Step 3: Build + smoke**
1350
+
1351
+ ```bash
1352
+ npm run build
1353
+ node dist/bin/helpers/entitlement.js list --help
1354
+ node dist/bin/helpers/entitlement.js list --email pro.xu.optima@gmail.com --env stage
1355
+ ```
1356
+
1357
+ Expected: prints `(no entitlements ...)` OR a table of existing entitlements. Either is success — proves the endpoint chain works.
1358
+
1359
+ - [ ] **Step 4: Commit**
1360
+
1361
+ ```bash
1362
+ git add bin/helpers/entitlement.ts bin/helpers/entitlement/list.ts
1363
+ git commit -m "feat(entitlement): add optima-entitlement dispatcher + list subcommand"
1364
+ ```
1365
+
1366
+ ---
1367
+
1368
+ ### Task 12: `optima-entitlement grant` subcommand
1369
+
1370
+ **Files:**
1371
+ - Create: `bin/helpers/entitlement/grant.ts`
1372
+ - Modify: `bin/helpers/entitlement.ts`
1373
+
1374
+ - [ ] **Step 1: Write the handler**
1375
+
1376
+ Create `bin/helpers/entitlement/grant.ts`:
1377
+
1378
+ ```typescript
1379
+ import { callBilling } from '../billing-http';
1380
+ import { confirmIfProd } from '../confirm-prompt';
1381
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
1382
+
1383
+ interface GrantArgs {
1384
+ email: string;
1385
+ productKey: string;
1386
+ justification: string;
1387
+ yes: boolean;
1388
+ env: string;
1389
+ }
1390
+
1391
+ function parseArgs(argv: string[]): GrantArgs {
1392
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
1393
+ console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
1394
+
1395
+ Required:
1396
+ --email <user-email> Resolved to userId via user-auth DB
1397
+ --product-key <productKey>
1398
+ --justification "..." Required by billing (400 otherwise); stored on entitlement.justification
1399
+
1400
+ Optional:
1401
+ --yes Skip prod confirmation prompt (no-op on stage)
1402
+ --env stage|prod (default: stage)
1403
+
1404
+ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
1405
+ process.exit(0);
1406
+ }
1407
+ const out: Partial<GrantArgs> = { env: 'stage', yes: false };
1408
+ for (let i = 0; i < argv.length; i++) {
1409
+ const a = argv[i];
1410
+ const next = argv[i + 1];
1411
+ switch (a) {
1412
+ case '--email': out.email = next; i++; break;
1413
+ case '--product-key': out.productKey = next; i++; break;
1414
+ case '--justification': out.justification = next; i++; break;
1415
+ case '--yes': out.yes = true; break;
1416
+ case '--env': out.env = next; i++; break;
1417
+ default: throw new Error(`Unknown arg: ${a}`);
1418
+ }
1419
+ }
1420
+ if (!out.email) throw new Error('--email required');
1421
+ if (!out.productKey) throw new Error('--product-key required');
1422
+ if (!out.justification) throw new Error('--justification required (billing returns 400 otherwise)');
1423
+ return out as GrantArgs;
1424
+ }
1425
+
1426
+ export async function runGrant(argv: string[]): Promise<void> {
1427
+ const args = parseArgs(argv);
1428
+
1429
+ const cfg = getInfisicalConfig();
1430
+ const token = getInfisicalToken(cfg);
1431
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
1432
+
1433
+ await confirmIfProd(
1434
+ args.env,
1435
+ `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
1436
+ args.yes,
1437
+ );
1438
+
1439
+ console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
1440
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
1441
+ userId,
1442
+ productKey: args.productKey,
1443
+ justification: args.justification,
1444
+ });
1445
+ console.log(`✓ Granted entitlement (HTTP ${res.status}):`);
1446
+ console.log(JSON.stringify(res.body, null, 2));
1447
+ }
1448
+ ```
1449
+
1450
+ - [ ] **Step 2: Wire into dispatcher**
1451
+
1452
+ In `bin/helpers/entitlement.ts`:
1453
+
1454
+ ```typescript
1455
+ import { runGrant } from './entitlement/grant';
1456
+ ```
1457
+
1458
+ Replace `case 'grant':` with:
1459
+
1460
+ ```typescript
1461
+ case 'grant': await runGrant(rest); break;
1462
+ ```
1463
+
1464
+ - [ ] **Step 3: Build + smoke**
1465
+
1466
+ ```bash
1467
+ npm run build
1468
+ node dist/bin/helpers/entitlement.js grant --help
1469
+ PROBE_KEY='<key-from-T6-step-5>'
1470
+ node dist/bin/helpers/entitlement.js grant \
1471
+ --email pro.xu.optima@gmail.com \
1472
+ --product-key "$PROBE_KEY" \
1473
+ --justification "T12 plan smoke" \
1474
+ --env stage
1475
+ ```
1476
+
1477
+ Expected: HTTP 201, JSON body with userId, productKey, status=ACTIVE, source=ADMIN_GRANT, priceCents=0, grantedBy=<clientId>.
1478
+
1479
+ Verify with the list subcommand:
1480
+
1481
+ ```bash
1482
+ node dist/bin/helpers/entitlement.js list --email pro.xu.optima@gmail.com --env stage
1483
+ ```
1484
+
1485
+ Expected: table includes the new entitlement row.
1486
+
1487
+ - [ ] **Step 4: Commit**
1488
+
1489
+ ```bash
1490
+ git add bin/helpers/entitlement.ts bin/helpers/entitlement/grant.ts
1491
+ git commit -m "feat(entitlement): add grant subcommand with prod confirmation prompt"
1492
+ ```
1493
+
1494
+ ---
1495
+
1496
+ ### Task 13: `optima-entitlement revoke` subcommand
1497
+
1498
+ **Files:**
1499
+ - Create: `bin/helpers/entitlement/revoke.ts`
1500
+ - Modify: `bin/helpers/entitlement.ts`
1501
+
1502
+ - [ ] **Step 1: Write the handler**
1503
+
1504
+ Create `bin/helpers/entitlement/revoke.ts`:
1505
+
1506
+ ```typescript
1507
+ import { callBilling } from '../billing-http';
1508
+ import { confirmIfProd } from '../confirm-prompt';
1509
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
1510
+
1511
+ interface RevokeArgs {
1512
+ email: string;
1513
+ productKey: string;
1514
+ reason: string;
1515
+ yes: boolean;
1516
+ env: string;
1517
+ }
1518
+
1519
+ interface EntitlementRow {
1520
+ id: string;
1521
+ productKey: string;
1522
+ status: string;
1523
+ source: string;
1524
+ }
1525
+
1526
+ function parseArgs(argv: string[]): RevokeArgs {
1527
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
1528
+ console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
1529
+
1530
+ Required:
1531
+ --email <user-email> Resolved to userId via user-auth DB
1532
+ --product-key <productKey>
1533
+ --reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
1534
+
1535
+ Optional:
1536
+ --yes Skip prod confirmation prompt (no-op on stage)
1537
+ --env stage|prod (default: stage)
1538
+
1539
+ Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
1540
+ error pointing to the right reversal flow.`);
1541
+ process.exit(0);
1542
+ }
1543
+ const out: Partial<RevokeArgs> = { env: 'stage', yes: false };
1544
+ for (let i = 0; i < argv.length; i++) {
1545
+ const a = argv[i];
1546
+ const next = argv[i + 1];
1547
+ switch (a) {
1548
+ case '--email': out.email = next; i++; break;
1549
+ case '--product-key': out.productKey = next; i++; break;
1550
+ case '--reason': out.reason = next; i++; break;
1551
+ case '--yes': out.yes = true; break;
1552
+ case '--env': out.env = next; i++; break;
1553
+ default: throw new Error(`Unknown arg: ${a}`);
1554
+ }
1555
+ }
1556
+ if (!out.email) throw new Error('--email required');
1557
+ if (!out.productKey) throw new Error('--product-key required');
1558
+ if (!out.reason) throw new Error('--reason required (billing returns 400 otherwise)');
1559
+ return out as RevokeArgs;
1560
+ }
1561
+
1562
+ const PAYMENT_REFUSAL = `refusing to revoke a PAYMENT-source entitlement via CLI; this would leave the customer charged but unentitled. Use the Stripe refund flow which calls Stripe refund API + records refundedAmountCents + emits webhook. Manual psql is the escape hatch if absolutely necessary.`;
1563
+
1564
+ const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI; PARTNER grants are issued out-of-band and must be reversed via the partner contract / process that issued them. Manual psql is the escape hatch if absolutely necessary.`;
1565
+
1566
+ export async function runRevoke(argv: string[]): Promise<void> {
1567
+ const args = parseArgs(argv);
1568
+
1569
+ const cfg = getInfisicalConfig();
1570
+ const token = getInfisicalToken(cfg);
1571
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
1572
+
1573
+ // Step 1: Fetch user's entitlements
1574
+ const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
1575
+ args.env,
1576
+ 'GET',
1577
+ `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`,
1578
+ );
1579
+ const all = listRes.body.entitlements ?? [];
1580
+
1581
+ // Step 2: Filter for ACTIVE + matching productKey
1582
+ const matches = all.filter((e) => e.status === 'ACTIVE' && e.productKey === args.productKey);
1583
+
1584
+ // Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
1585
+ if (matches.length === 0) {
1586
+ throw new Error(`no active entitlement for (user=${args.email}, product=${args.productKey}) on ${args.env}`);
1587
+ }
1588
+ if (matches.length > 1) {
1589
+ throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list --email ${args.email}`);
1590
+ }
1591
+ const target = matches[0];
1592
+
1593
+ // Step 4: Validate source
1594
+ if (target.source === 'PAYMENT') throw new Error(PAYMENT_REFUSAL);
1595
+ if (target.source === 'PARTNER') throw new Error(PARTNER_REFUSAL);
1596
+ if (target.source !== 'ADMIN_GRANT') throw new Error(`unknown entitlement source: ${target.source}`);
1597
+
1598
+ await confirmIfProd(
1599
+ args.env,
1600
+ `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
1601
+ args.yes,
1602
+ );
1603
+
1604
+ // Step 5: Refund
1605
+ console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.email}...`);
1606
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
1607
+ entitlementId: target.id,
1608
+ refundReason: args.reason,
1609
+ });
1610
+ console.log(`✓ Revoked entitlement (HTTP ${res.status}):`);
1611
+ console.log(JSON.stringify(res.body, null, 2));
1612
+ }
1613
+ ```
1614
+
1615
+ - [ ] **Step 2: Wire into dispatcher**
1616
+
1617
+ In `bin/helpers/entitlement.ts`:
1618
+
1619
+ ```typescript
1620
+ import { runRevoke } from './entitlement/revoke';
1621
+ ```
1622
+
1623
+ Replace `case 'revoke':` with:
1624
+
1625
+ ```typescript
1626
+ case 'revoke': await runRevoke(rest); break;
1627
+ ```
1628
+
1629
+ Remove the now-empty not-implemented branch.
1630
+
1631
+ - [ ] **Step 3: Build + smoke**
1632
+
1633
+ ```bash
1634
+ npm run build
1635
+ node dist/bin/helpers/entitlement.js revoke --help
1636
+ PROBE_KEY='<key-from-T6-step-5>'
1637
+ node dist/bin/helpers/entitlement.js revoke \
1638
+ --email pro.xu.optima@gmail.com \
1639
+ --product-key "$PROBE_KEY" \
1640
+ --reason "T13 plan smoke cleanup" \
1641
+ --env stage
1642
+ ```
1643
+
1644
+ Expected: HTTP 200, JSON body with status=REFUNDED, refundedAt set.
1645
+
1646
+ Verify:
1647
+
1648
+ ```bash
1649
+ node dist/bin/helpers/entitlement.js list --email pro.xu.optima@gmail.com --env stage
1650
+ ```
1651
+
1652
+ Expected: the revoked row shows status=REFUNDED.
1653
+
1654
+ Also verify the refusal path by trying to revoke again immediately:
1655
+
1656
+ ```bash
1657
+ node dist/bin/helpers/entitlement.js revoke \
1658
+ --email pro.xu.optima@gmail.com \
1659
+ --product-key "$PROBE_KEY" \
1660
+ --reason "should fail — already revoked" \
1661
+ --env stage
1662
+ ```
1663
+
1664
+ Expected: exit 1 with `no active entitlement for (user, product) on stage`.
1665
+
1666
+ - [ ] **Step 4: Commit**
1667
+
1668
+ ```bash
1669
+ git add bin/helpers/entitlement.ts bin/helpers/entitlement/revoke.ts
1670
+ git commit -m "feat(entitlement): add revoke subcommand (refuses PAYMENT/PARTNER sources)"
1671
+ ```
1672
+
1673
+ ---
1674
+
1675
+ ### Task 14: Register bin entries + update AGENTS.md
1676
+
1677
+ **Files:**
1678
+ - Modify: `package.json`
1679
+ - Modify: `AGENTS.md`
1680
+
1681
+ - [ ] **Step 1: Add bin entries to package.json**
1682
+
1683
+ In the `bin` object, add two entries (keep alphabetical-ish order if it exists):
1684
+
1685
+ ```json
1686
+ "bin": {
1687
+ "optima-dev-skills": "bin/cli.js",
1688
+ "optima-entitlement": "dist/bin/helpers/entitlement.js",
1689
+ "optima-generate-test-token": "dist/bin/helpers/generate-test-token.js",
1690
+ "optima-grant-balance": "dist/bin/helpers/grant-balance.js",
1691
+ "optima-grant-subscription": "dist/bin/helpers/grant-subscription.js",
1692
+ "optima-product": "dist/bin/helpers/product.js",
1693
+ "optima-query-db": "dist/bin/helpers/query-db.js",
1694
+ "optima-show-env": "dist/bin/helpers/show-env.js"
1695
+ },
1696
+ ```
1697
+
1698
+ - [ ] **Step 2: Bump package version**
1699
+
1700
+ ```bash
1701
+ npm version patch --no-git-tag-version
1702
+ ```
1703
+
1704
+ This bumps `0.7.33` → `0.7.34`.
1705
+
1706
+ - [ ] **Step 3: Build + global install (replaces existing global)**
1707
+
1708
+ ```bash
1709
+ npm run build
1710
+ npm install -g .
1711
+ ```
1712
+
1713
+ - [ ] **Step 4: Verify bin entries are on PATH**
1714
+
1715
+ ```bash
1716
+ which optima-product && optima-product --help
1717
+ which optima-entitlement && optima-entitlement --help
1718
+ ```
1719
+
1720
+ Expected: both resolve to a node_modules path and print their usage.
1721
+
1722
+ - [ ] **Step 5: Update AGENTS.md**
1723
+
1724
+ In `AGENTS.md`, find the "Primary Entry Points" list and add (alphabetical):
1725
+
1726
+ ```markdown
1727
+ - `optima-entitlement <subcommand> [options]` — admin-grant / revoke / list paid-plugin entitlements
1728
+ - `optima-product <subcommand> [options]` — manage paid-plugin marketplace Products + Stripe channels
1729
+ ```
1730
+
1731
+ In the "Installed Codex Skills" list, no change needed (skills are different artifacts from bin CLIs — these new CLIs don't have associated `.claude/skills/` entries yet; that's a future-scope item per spec §7).
1732
+
1733
+ - [ ] **Step 6: Commit**
1734
+
1735
+ ```bash
1736
+ git add package.json AGENTS.md
1737
+ git commit -m "chore: register optima-product / optima-entitlement bin entries + AGENTS.md"
1738
+ ```
1739
+
1740
+ ---
1741
+
1742
+ ### Task 15: End-to-end stage smoke (spec §8)
1743
+
1744
+ **Files:**
1745
+ - None — this task runs and records results.
1746
+
1747
+ - [ ] **Step 1: Pick fresh smoke key**
1748
+
1749
+ ```bash
1750
+ SMOKE_KEY="plan-final-smoke-$(date +%s)"
1751
+ echo "Smoke key: $SMOKE_KEY"
1752
+ ```
1753
+
1754
+ - [ ] **Step 2: Spec §8 step 1 — create product**
1755
+
1756
+ ```bash
1757
+ optima-product create --key "$SMOKE_KEY" --plugins skillify --type ONE_SHOT_SKILL --env stage
1758
+ ```
1759
+
1760
+ Expected: HTTP 201.
1761
+
1762
+ - [ ] **Step 3: Spec §8 step 2 — show product**
1763
+
1764
+ ```bash
1765
+ optima-product show --key "$SMOKE_KEY" --env stage
1766
+ ```
1767
+
1768
+ Expected: returns Product row. Documented gap: no plugins/channels in response.
1769
+
1770
+ - [ ] **Step 4: Spec §8 step 3 — grant**
1771
+
1772
+ ```bash
1773
+ optima-entitlement grant --email pro.xu.optima@gmail.com --product-key "$SMOKE_KEY" --justification "final stage smoke" --env stage
1774
+ ```
1775
+
1776
+ Expected: HTTP 201, ACTIVE / ADMIN_GRANT.
1777
+
1778
+ - [ ] **Step 5: Spec §8 step 4 — list entitlements**
1779
+
1780
+ ```bash
1781
+ optima-entitlement list --email pro.xu.optima@gmail.com --env stage
1782
+ ```
1783
+
1784
+ Expected: table includes the new entitlement with status=ACTIVE, source=ADMIN_GRANT.
1785
+
1786
+ - [ ] **Step 6: Spec §8 step 5a — verify outbox**
1787
+
1788
+ ```bash
1789
+ optima-query-db billing "SELECT id, event_type, status, payload FROM outbox_events WHERE event_type='entitlement.granted' ORDER BY created_at DESC LIMIT 1" stage
1790
+ ```
1791
+
1792
+ Expected: status=DELIVERED. Inspect payload to confirm `pluginSlugs: ["skillify"]` (or equivalent — pin exact field path here once observed).
1793
+
1794
+ - [ ] **Step 7: Spec §8 step 5b — verify skills UserPlugin**
1795
+
1796
+ ```bash
1797
+ USER_ID=$(optima-query-db user-auth "SELECT id FROM users WHERE email='pro.xu.optima@gmail.com'" stage | tr -d ' ')
1798
+ optima-query-db skills "SELECT up.\"userId\", p.slug FROM \"UserPlugin\" up JOIN \"Plugin\" p ON p.id = up.\"pluginId\" WHERE up.\"userId\"='$USER_ID' AND p.slug='skillify'" stage
1799
+ ```
1800
+
1801
+ Expected: exactly 1 row with the user's id and `skillify` slug.
1802
+
1803
+ - [ ] **Step 8: Spec §8 step 6 — revoke**
1804
+
1805
+ ```bash
1806
+ optima-entitlement revoke --email pro.xu.optima@gmail.com --product-key "$SMOKE_KEY" --reason "final smoke cleanup" --env stage
1807
+ ```
1808
+
1809
+ Expected: HTTP 200, REFUNDED.
1810
+
1811
+ - [ ] **Step 9: Spec §8 step 7 — confirm revocation propagated**
1812
+
1813
+ ```bash
1814
+ optima-entitlement list --email pro.xu.optima@gmail.com --env stage
1815
+ # Re-run the step 7 SQL — expect 0 rows now
1816
+ optima-query-db skills "SELECT up.\"userId\", p.slug FROM \"UserPlugin\" up JOIN \"Plugin\" p ON p.id = up.\"pluginId\" WHERE up.\"userId\"='$USER_ID' AND p.slug='skillify'" stage
1817
+ ```
1818
+
1819
+ Expected: list shows the row as REFUNDED with refundedAt set; UserPlugin query returns 0 rows.
1820
+
1821
+ - [ ] **Step 10: Spec §8 step 8 — add + toggle channel (optional)**
1822
+
1823
+ Pre-step in Stripe test dashboard: create a $1 test Price. Paste the Price ID:
1824
+
1825
+ ```bash
1826
+ STRIPE_PRICE_ID='<price_xxx>'
1827
+ optima-product add-channel --key "$SMOKE_KEY" --provider STRIPE --stripe-price-id "$STRIPE_PRICE_ID" --price-cents 100 --currency USD --env stage
1828
+ optima-product show --key "$SMOKE_KEY" --env stage # still no channels in response — known gap
1829
+ optima-query-db billing "SELECT product_key, provider, external_product_id, price_cents, enabled FROM product_channels WHERE product_key='$SMOKE_KEY'" stage
1830
+ optima-product toggle-channel --key "$SMOKE_KEY" --provider STRIPE --enabled false --env stage
1831
+ ```
1832
+
1833
+ Expected: add returns HTTP 201, query shows the row with `enabled=true`, toggle returns HTTP 200, follow-up query shows `enabled=false`.
1834
+
1835
+ - [ ] **Step 11: Record smoke divergences (only if any)**
1836
+
1837
+ If any of these specifically differed from the plan/spec, append `~/.claude/projects/-mnt-d-work-projects-optima/memory/marketplace_admin_cli_smoke_notes.md`:
1838
+ - outbox `payload` column shape (e.g. wrapper around `pluginSlugs` not at top level)
1839
+ - error envelope shape (flat vs nested) on any failure surfaced during smoke
1840
+ - skills `UserPlugin` schema mismatch (column case, missing JOIN to `Plugin`)
1841
+ - M2M token rejection / 403 from billing despite allowlist
1842
+ - any other unexpected response shape worth recalling in a future cross-service smoke
1843
+
1844
+ If the smoke ran clean against the documented contracts, skip this step entirely — silent green is the expected default.
1845
+
1846
+ - [ ] **Step 12: Commit any plan-file edits made during smoke (e.g. pinned outbox payload field path)**
1847
+
1848
+ ```bash
1849
+ git status
1850
+ git add docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md # only if edited
1851
+ git commit -m "plan T15: pin outbox payload field path from final smoke observation" # only if applicable
1852
+ ```
1853
+
1854
+ ---
1855
+
1856
+ ### Task 16: Open PR
1857
+
1858
+ **Files:**
1859
+ - None.
1860
+
1861
+ - [ ] **Step 1: Push branch**
1862
+
1863
+ ```bash
1864
+ git push -u origin impl/marketplace-admin-cli
1865
+ ```
1866
+
1867
+ Branch was created in T2 step 1 (clean spec-plan-impl separation per CLAUDE.md). The `spec/marketplace-admin-cli` branch stays as the spec review record; PR base is `main`.
1868
+
1869
+ - [ ] **Step 2: Create PR**
1870
+
1871
+ ```bash
1872
+ gh pr create --title "feat: optima-product + optima-entitlement CLIs (marketplace admin)" --body "$(cat <<'EOF'
1873
+ ## Summary
1874
+
1875
+ Adds two new CLI bins to `@optima-chat/dev-skills` for managing the Wave 1.5 paid-plugin marketplace before an admin UI exists:
1876
+
1877
+ - `optima-product` — create / update / add-channel / toggle-channel / show
1878
+ - `optima-entitlement` — grant / revoke / list
1879
+
1880
+ Thin HTTP client to billing's admin endpoints; no business logic duplicated.
1881
+
1882
+ ## Spec + plan
1883
+
1884
+ - Spec: `docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md` (4 review rounds — `aedbe2b`)
1885
+ - Plan: `docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md`
1886
+
1887
+ ## Smoke
1888
+
1889
+ End-to-end on stage per spec §8 — see Task 15 results in this branch's history.
1890
+
1891
+ ## Follow-ups (out of v1)
1892
+
1893
+ - `optima-product list` — needs [optima-billing#58](https://github.com/Optima-Chat/optima-billing/issues/58)
1894
+ - `optima-product show --with-db-verify` — until billing `?include=plugins,channels` ships
1895
+ - `optima-entitlement show <id>` — needs billing `GET /admin/entitlements/:id`
1896
+ - Stripe SDK auto-create of Product+Price
1897
+ - Non-STRIPE channel providers (ALIPAY / WECHAT_PAY / AIRWALLEX)
1898
+ - prod usage: blocked on Wave 1.5 landing on billing main ([optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43))
1899
+
1900
+ ## Test plan
1901
+
1902
+ - [x] `optima-product create / update / add-channel / toggle-channel / show` against stage
1903
+ - [x] `optima-entitlement grant / revoke / list` against stage
1904
+ - [x] End-to-end §8 smoke green (outbox DELIVERED + UserPlugin upsert + delete)
1905
+ - [x] Revoke refuses PAYMENT / PARTNER sources with source-specific message
1906
+
1907
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1908
+ EOF
1909
+ )"
1910
+ ```
1911
+
1912
+ - [ ] **Step 3: Verify CI passes**
1913
+
1914
+ ```bash
1915
+ gh pr checks --watch
1916
+ ```
1917
+
1918
+ Expected: green.
1919
+
1920
+ ---
1921
+
1922
+ ## Self-review
1923
+
1924
+ Read the spec section-by-section. Check coverage:
1925
+
1926
+ | Spec section | Covered by |
1927
+ |---|---|
1928
+ | §1.1 create product bundling plugins | T6 |
1929
+ | §1.2 attach payment channel | T8 |
1930
+ | §1.3 toggle channel | T9 |
1931
+ | §1.4 show product | T10 |
1932
+ | §1.5 grant entitlement | T12 |
1933
+ | §1.6 revoke (admin-grant only) | T13 |
1934
+ | §1.7 list entitlements | T11 |
1935
+ | §3 non-goals (no Stripe SDK, stage-only, no PAYMENT revoke, no list-products) | Honored throughout; PAYMENT/PARTNER refusal in T13 |
1936
+ | §4 architecture (CLI → Infisical → user-auth → billing) | T3-T4 modules |
1937
+ | §5.1 all product subcommands w/ flags | T6-T10 implement exact flag surface |
1938
+ | §5.2 all entitlement subcommands w/ flags | T11-T13 |
1939
+ | §6.1 BILLING_URL Infisical lookup | T1.2 verify + T4 module |
1940
+ | §6.2 resolveUserId reuse | T11/T12/T13 use existing helper as 4-arg call |
1941
+ | §6.3 M2M token w/ type=service | T1.3 verify + T4 module memoizes |
1942
+ | §6.4 error envelope + non-envelope fallback + 5xx retry | T4 module |
1943
+ | §6.5 default output (human-readable, JSON pretty-print for object responses, table for list) | All subcommands (create/update/grant/revoke: pretty JSON; show: pretty JSON; list: table) |
1944
+ | §6.6 safety rails (env=stage default, no --force, prod confirm prompt) | T5 helper + T12/T13 usage |
1945
+ | §7 follow-ups | Listed in PR body T16 |
1946
+ | §8 end-to-end smoke | T15 (mirrors all 8 steps) |
1947
+ | §9 risks | Captured implicitly (allowlist verified, prod safety prompt in T12/T13) |
1948
+ | §10 open questions | T1 resolves all 3 |
1949
+
1950
+ **Type / signature consistency check:**
1951
+ - `callBilling<T>(env, method, path, body?)` — used identically in product/create, product/update, product/add-channel, product/toggle-channel, product/show, entitlement/list, entitlement/grant, entitlement/revoke. ✓
1952
+ - `resolveUserId(email, env, cfg, token)` — 4-arg called in entitlement/list, entitlement/grant, entitlement/revoke. ✓
1953
+ - `confirmIfProd(env, actionDescription, skipFlag)` — same signature in entitlement/grant + entitlement/revoke. ✓
1954
+ - `fetchInfisicalSecret(env, secretPath, secretName, config?, token?)` — used by billing-http.getBillingUrl + billing-http.getServiceToken with consistent call shape. ✓
1955
+
1956
+ **Placeholder scan:**
1957
+ - T4 step 1 has `<T1.1-resolved-path>` / `<T1.1-resolved-name>` placeholders that T4 step 2 explicitly resolves. ✓ (intentional, not abandoned)
1958
+ - T6/T7/T8/T9/T10/T13/T15 reference `<key-from-T6-step-5>` etc — operator copies from previous step output. ✓ (intentional)
1959
+ - T8 / T15 step 10 reference `<price_xxx-from-stripe-dashboard>` — operator does the Stripe Dashboard step manually. ✓ (intentional, matches spec §3 non-goal "no Stripe automation")
1960
+
1961
+ No `TBD` / `TODO` / "implement later" / hand-wavy "add error handling" left.
1962
+
1963
+ ---
1964
+
1965
+ ## Execution handoff
1966
+
1967
+ Plan complete and saved to `docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md`. Two execution options:
1968
+
1969
+ **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, two-stage review between tasks (spec adherence + code quality), fast iteration.
1970
+
1971
+ **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
1972
+
1973
+ Which approach?