@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.
- package/AGENTS.md +2 -0
- package/bin/helpers/billing-http.ts +162 -0
- package/bin/helpers/confirm-prompt.ts +23 -0
- package/bin/helpers/entitlement/grant.ts +70 -0
- package/bin/helpers/entitlement/list.ts +77 -0
- package/bin/helpers/entitlement/revoke.ts +116 -0
- package/bin/helpers/entitlement.ts +32 -0
- package/bin/helpers/infisical-secrets.ts +41 -0
- package/bin/helpers/product/add-channel.ts +78 -0
- package/bin/helpers/product/create.ts +96 -0
- package/bin/helpers/product/show.ts +43 -0
- package/bin/helpers/product/toggle-channel.ts +56 -0
- package/bin/helpers/product/update.ts +72 -0
- package/bin/helpers/product.ts +46 -0
- package/dist/bin/helpers/billing-http.js +139 -0
- package/dist/bin/helpers/confirm-prompt.js +54 -0
- package/dist/bin/helpers/entitlement/grant.js +73 -0
- package/dist/bin/helpers/entitlement/list.js +62 -0
- package/dist/bin/helpers/entitlement/revoke.js +105 -0
- package/dist/bin/helpers/entitlement.js +39 -0
- package/dist/bin/helpers/infisical-secrets.js +33 -0
- package/dist/bin/helpers/product/add-channel.js +95 -0
- package/dist/bin/helpers/product/create.js +124 -0
- package/dist/bin/helpers/product/show.js +46 -0
- package/dist/bin/helpers/product/toggle-channel.js +61 -0
- package/dist/bin/helpers/product/update.js +92 -0
- package/dist/bin/helpers/product.js +53 -0
- package/docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md +1973 -0
- package/docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md +324 -0
- 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?
|