@optima-chat/dev-skills 0.7.32 → 0.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AGENTS.md +2 -0
  2. package/bin/helpers/billing-http.ts +162 -0
  3. package/bin/helpers/confirm-prompt.ts +23 -0
  4. package/bin/helpers/db-utils.ts +38 -2
  5. package/bin/helpers/entitlement/grant.ts +70 -0
  6. package/bin/helpers/entitlement/list.ts +77 -0
  7. package/bin/helpers/entitlement/revoke.ts +116 -0
  8. package/bin/helpers/entitlement.ts +32 -0
  9. package/bin/helpers/infisical-secrets.ts +41 -0
  10. package/bin/helpers/product/add-channel.ts +78 -0
  11. package/bin/helpers/product/create.ts +96 -0
  12. package/bin/helpers/product/show.ts +43 -0
  13. package/bin/helpers/product/toggle-channel.ts +56 -0
  14. package/bin/helpers/product/update.ts +72 -0
  15. package/bin/helpers/product.ts +46 -0
  16. package/dist/bin/helpers/billing-http.js +139 -0
  17. package/dist/bin/helpers/confirm-prompt.js +54 -0
  18. package/dist/bin/helpers/db-utils.js +36 -3
  19. package/dist/bin/helpers/entitlement/grant.js +73 -0
  20. package/dist/bin/helpers/entitlement/list.js +62 -0
  21. package/dist/bin/helpers/entitlement/revoke.js +105 -0
  22. package/dist/bin/helpers/entitlement.js +39 -0
  23. package/dist/bin/helpers/generate-test-token.js +0 -0
  24. package/dist/bin/helpers/grant-balance.js +0 -0
  25. package/dist/bin/helpers/grant-credits.js +71 -0
  26. package/dist/bin/helpers/grant-subscription.js +0 -0
  27. package/dist/bin/helpers/infisical-secrets.js +33 -0
  28. package/dist/bin/helpers/product/add-channel.js +95 -0
  29. package/dist/bin/helpers/product/create.js +124 -0
  30. package/dist/bin/helpers/product/show.js +46 -0
  31. package/dist/bin/helpers/product/toggle-channel.js +61 -0
  32. package/dist/bin/helpers/product/update.js +92 -0
  33. package/dist/bin/helpers/product.js +53 -0
  34. package/dist/bin/helpers/query-db.js +0 -0
  35. package/dist/bin/helpers/show-env.js +0 -0
  36. package/docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md +1973 -0
  37. package/docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md +324 -0
  38. package/package.json +7 -5
package/AGENTS.md CHANGED
@@ -11,6 +11,8 @@ Prefer the installed CLI tools over reimplementing long shell workflows:
11
11
  - `optima-generate-test-token [options]`
12
12
  - `optima-grant-subscription <email> [options]`
13
13
  - `optima-grant-balance <email> --amount <usd> [options]`
14
+ - `optima-product <create|update|add-channel|toggle-channel|show> [options]` — manage paid-plugin marketplace Products + Stripe channels (Wave 1.5 admin endpoints; stage default)
15
+ - `optima-entitlement <grant|revoke|list> [options]` — admin-grant / revoke / list paid-plugin entitlements (refuses revoke of PAYMENT / PARTNER source)
14
16
 
15
17
  For code-reading tasks across Optima repositories, use `gh` commands against `Optima-Chat/<repo>`.
16
18
 
@@ -0,0 +1,162 @@
1
+ import { execSync } from 'child_process';
2
+ import { fetchInfisicalSecret } from './infisical-secrets';
3
+ import { getInfisicalConfig, getInfisicalToken } from './db-utils';
4
+
5
+ const USER_AUTH_URLS: Record<string, string> = {
6
+ stage: 'https://auth.stage.optima.onl',
7
+ prod: 'https://auth.optima.onl',
8
+ };
9
+
10
+ /**
11
+ * Validate the --env flag value at command entry, before any I/O.
12
+ *
13
+ * Without this, a typo like `--env staging` flows downstream and surfaces as
14
+ * a confusing error: entitlement subcommands hit resolveUserId first (SSH
15
+ * tunnel to RDS_HOSTS[undefined] → cryptic ssh failure), product subcommands
16
+ * reach getServiceToken (USER_AUTH_URLS[undefined] → "Unknown env"). All
17
+ * fail-closed (no wrong-env write), but the UX diverges per subcommand.
18
+ * Every runX handler calls this first so the error is uniform and immediate.
19
+ */
20
+ export function validateEnv(env: string): 'stage' | 'prod' {
21
+ if (env !== 'stage' && env !== 'prod') {
22
+ throw new Error(`--env must be "stage" or "prod" (got: ${env})`);
23
+ }
24
+ return env;
25
+ }
26
+
27
+ // T1 discovered: client_id differs per env (stage=dev-skills-ubd3qz6n,
28
+ // prod=dev-skills-hinxa0rs). Both stored in Infisical alongside the
29
+ // secret at /shared-secrets/oauth-clients/.
30
+ const DEV_SKILLS_OAUTH_PATH = '/shared-secrets/oauth-clients';
31
+ const DEV_SKILLS_CLIENT_ID_KEY = 'DEV_SKILLS_OAUTH_CLIENT_ID';
32
+ const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
33
+
34
+ // ───── Cache (process-lifetime) ─────────────────────────────────────────────
35
+ // One CLI invocation does at most a handful of HTTP calls. We mint the M2M
36
+ // token once and reuse it. Cross-invocation re-mint is fine — JWT TTL is
37
+ // typically ≥1h, far longer than any single CLI run.
38
+ //
39
+ // NOT handled (acceptable for admin CLI):
40
+ // * Token expiry mid-invocation — a long-stalled revoke (list call + 5min
41
+ // pause + refund call) could in theory expire. Operator can retry.
42
+ // * Infisical 5xx retry — getInfisicalToken is sync execSync curl with no
43
+ // retry; any transient failure surfaces immediately. Re-run the CLI.
44
+ const tokenCache: Record<string, string> = {};
45
+ const billingUrlCache: Record<string, string> = {};
46
+
47
+ function getBillingUrl(env: string): string {
48
+ if (billingUrlCache[env]) return billingUrlCache[env];
49
+ const url = fetchInfisicalSecret(env, '/shared-secrets/domain-urls', 'BILLING_URL');
50
+ billingUrlCache[env] = url;
51
+ return url;
52
+ }
53
+
54
+ export function getServiceToken(env: string): string {
55
+ if (tokenCache[env]) return tokenCache[env];
56
+
57
+ const cfg = getInfisicalConfig();
58
+ const tok = getInfisicalToken(cfg);
59
+ // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
60
+ const clientId = fetchInfisicalSecret(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_ID_KEY, cfg, tok);
61
+ const clientSecret = fetchInfisicalSecret(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_SECRET_KEY, cfg, tok);
62
+
63
+ const authUrl = USER_AUTH_URLS[env];
64
+ if (!authUrl) throw new Error(`Unknown env: ${env}`);
65
+
66
+ const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
67
+ const response = execSync(
68
+ `curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`,
69
+ { encoding: 'utf-8' },
70
+ );
71
+
72
+ let parsed: { access_token?: string; error?: string };
73
+ try {
74
+ parsed = JSON.parse(response);
75
+ } catch {
76
+ throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${response.slice(0, 200)}`);
77
+ }
78
+ if (!parsed.access_token) {
79
+ throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
80
+ }
81
+ tokenCache[env] = parsed.access_token;
82
+ return parsed.access_token;
83
+ }
84
+
85
+ // ───── Error envelope ───────────────────────────────────────────────────────
86
+ interface BillingErrorEnvelope {
87
+ error?: { code?: string; message?: string } | string;
88
+ message?: string;
89
+ code?: string;
90
+ }
91
+
92
+ function formatBillingError(status: number, statusText: string, body: string): string {
93
+ let parsed: BillingErrorEnvelope | null = null;
94
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
95
+
96
+ // Dominant Wave 1.5 envelope: flat { error: "CODE_STRING", message: "..." }
97
+ // emitted by billing's global error handler (app.ts:99-118) for ALL
98
+ // BillingError throws + validation errors + internal errors. Most inline
99
+ // route returns also use this shape (admin-products.ts:110,144,184-185,etc).
100
+ if (parsed && typeof parsed.error === 'string') {
101
+ return `❌ Error [${status}] ${parsed.error}: ${parsed.message ?? '(no message)'}`;
102
+ }
103
+ // Less-common nested envelope: { error: { code, message } } — used by a
104
+ // few inline 400/404 returns in admin-products.ts toggle-channel handler
105
+ // (lines 92-93, 100-101, 114-117). Possibly extends to other routes
106
+ // post-Wave-1.5 as standardization lands.
107
+ if (parsed && typeof parsed.error === 'object' && parsed.error !== null) {
108
+ const code = (parsed.error as { code?: string }).code ?? 'UNKNOWN';
109
+ const msg = (parsed.error as { message?: string }).message ?? '(no message)';
110
+ return `❌ Error [${status}] ${code}: ${msg}`;
111
+ }
112
+ // Non-envelope fallback (raw 502 from upstream LB, crashed handler before
113
+ // error middleware, plain-text body, etc.)
114
+ return `❌ Error [${status}] ${statusText}\n Response body (first 500 bytes): ${body.slice(0, 500)}`;
115
+ }
116
+
117
+ // ───── Public: callBilling ──────────────────────────────────────────────────
118
+ export interface BillingResponse<T> {
119
+ status: number;
120
+ body: T;
121
+ }
122
+
123
+ /**
124
+ * Make an authenticated call to optima-billing. Returns `{status, body}` on
125
+ * 2xx; throws Error with formatted message on non-2xx. Single retry on 5xx
126
+ * (one-shot — no exponential backoff; admin CLI doesn't justify it).
127
+ */
128
+ export async function callBilling<T = unknown>(
129
+ env: string,
130
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
131
+ path: string,
132
+ body?: object,
133
+ ): Promise<BillingResponse<T>> {
134
+ const url = `${getBillingUrl(env)}${path}`;
135
+ const token = getServiceToken(env);
136
+
137
+ const doFetch = async () => fetch(url, {
138
+ method,
139
+ headers: {
140
+ Authorization: `Bearer ${token}`,
141
+ 'Content-Type': 'application/json',
142
+ },
143
+ body: body !== undefined ? JSON.stringify(body) : undefined,
144
+ });
145
+
146
+ let res = await doFetch();
147
+ if (res.status >= 500) {
148
+ // One retry on 5xx
149
+ res = await doFetch();
150
+ }
151
+ const text = await res.text();
152
+ if (!res.ok) {
153
+ throw new Error(formatBillingError(res.status, res.statusText, text));
154
+ }
155
+ let parsed: T;
156
+ try {
157
+ parsed = text ? (JSON.parse(text) as T) : (undefined as unknown as T);
158
+ } catch {
159
+ throw new Error(`Billing returned non-JSON 2xx body: ${text.slice(0, 200)}`);
160
+ }
161
+ return { status: res.status, body: parsed };
162
+ }
@@ -0,0 +1,23 @@
1
+ import * as readline from 'readline';
2
+
3
+ /**
4
+ * On prod, print the resolved action and require typing "yes" to proceed.
5
+ * No-op on stage or when --yes was passed. Exits 1 if user declines.
6
+ */
7
+ export async function confirmIfProd(
8
+ env: string,
9
+ actionDescription: string,
10
+ skipFlag: boolean,
11
+ ): Promise<void> {
12
+ if (env !== 'prod' || skipFlag) return;
13
+
14
+ console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ const answer = await new Promise<string>((resolve) => {
17
+ rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
18
+ });
19
+ if (answer !== 'yes') {
20
+ console.error('❌ Aborted by user.');
21
+ process.exit(1);
22
+ }
23
+ }
@@ -70,12 +70,48 @@ export function parseDatabaseUrl(url: string): { user: string; password: string;
70
70
  }
71
71
 
72
72
  // ─── SSH tunnel ─────────────────────────────────────────────────────────────
73
+ // 端口被占不等于 tunnel 还能转发:AWS bastion idle 断连后,本地 ssh 进程还活着、
74
+ // 端口还 LISTEN,但流量过不去,所有后续查询会 hang 到客户端 timeout。
75
+ // 用 pg_isready 真去 ping postgres,超时就当 zombie,杀掉重建。
76
+ function isTunnelHealthy(localPort: number): boolean {
77
+ try {
78
+ execSync(`pg_isready -h localhost -p ${localPort} -t 3 -q`, { stdio: 'ignore' });
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function killOrphanTunnel(localPort: number): void {
86
+ try {
87
+ const pids = execSync(`lsof -ti:${localPort}`, { encoding: 'utf-8' }).trim();
88
+ if (pids) execSync(`kill -9 ${pids.split(/\s+/).join(' ')}`, { stdio: 'ignore' });
89
+ } catch { /* nothing to kill */ }
90
+ }
91
+
73
92
  export function setupSSHTunnel(dbHost: string, localPort: number): void {
74
- try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); return; } catch { /* need tunnel */ }
93
+ let portInUse = false;
94
+ try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); portInUse = true; } catch { /* free */ }
95
+
96
+ if (portInUse) {
97
+ if (isTunnelHealthy(localPort)) return;
98
+ console.log(`! SSH tunnel on port ${localPort} not responding (zombie), replacing...`);
99
+ killOrphanTunnel(localPort);
100
+ }
101
+
75
102
  const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
76
103
  if (!fs.existsSync(sshKeyPath)) throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
77
104
  console.log(`Creating SSH tunnel: localhost:${localPort} -> ${EC2_HOST} -> ${dbHost}:5432`);
78
- execSync(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
105
+ // ServerAliveInterval/CountMax: 服务端 30s 没回应就让 ssh 自己退出(不留 zombie)
106
+ // ExitOnForwardFailure: 端口绑定失败立刻退出(不会黑悄悄继续跑)
107
+ // ConnectTimeout: 10s 不通就放弃(业界 ssh 默认 120s 太宽)
108
+ execSync(
109
+ `ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no ` +
110
+ `-o ServerAliveInterval=30 -o ServerAliveCountMax=3 ` +
111
+ `-o ExitOnForwardFailure=yes -o ConnectTimeout=10 ` +
112
+ `-L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`,
113
+ { stdio: 'inherit' }
114
+ );
79
115
  }
80
116
 
81
117
  // ─── psql ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,70 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+ import { confirmIfProd } from '../confirm-prompt';
3
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
+
5
+ interface GrantArgs {
6
+ email: string;
7
+ productKey: string;
8
+ justification: string;
9
+ yes: boolean;
10
+ env: string;
11
+ }
12
+
13
+ function parseArgs(argv: string[]): GrantArgs {
14
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
15
+ console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
16
+
17
+ Required:
18
+ --email <user-email> Resolved to userId via user-auth DB
19
+ --product-key <productKey>
20
+ --justification "..." Required by billing (400 otherwise); stored on entitlement.justification
21
+
22
+ Optional:
23
+ --yes Skip prod confirmation prompt (no-op on stage)
24
+ --env stage|prod (default: stage)
25
+
26
+ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
27
+ process.exit(0);
28
+ }
29
+ const out: Partial<GrantArgs> = { env: 'stage', yes: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ const next = argv[i + 1];
33
+ switch (a) {
34
+ case '--email': out.email = next; i++; break;
35
+ case '--product-key': out.productKey = next; i++; break;
36
+ case '--justification': out.justification = next; i++; break;
37
+ case '--yes': out.yes = true; break;
38
+ case '--env': out.env = next; i++; break;
39
+ default: throw new Error(`Unknown arg: ${a}`);
40
+ }
41
+ }
42
+ if (!out.email) throw new Error('--email required');
43
+ if (!out.productKey) throw new Error('--product-key required');
44
+ if (!out.justification) throw new Error('--justification required (billing returns 400 otherwise)');
45
+ return out as GrantArgs;
46
+ }
47
+
48
+ export async function runGrant(argv: string[]): Promise<void> {
49
+ const args = parseArgs(argv);
50
+ validateEnv(args.env);
51
+
52
+ const cfg = getInfisicalConfig();
53
+ const token = getInfisicalToken(cfg);
54
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
55
+
56
+ await confirmIfProd(
57
+ args.env,
58
+ `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
59
+ args.yes,
60
+ );
61
+
62
+ console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
63
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
64
+ userId,
65
+ productKey: args.productKey,
66
+ justification: args.justification,
67
+ });
68
+ console.log(`✓ Granted entitlement (HTTP ${res.status}):`);
69
+ console.log(JSON.stringify(res.body, null, 2));
70
+ }
@@ -0,0 +1,77 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
3
+
4
+ interface ListArgs {
5
+ email: string;
6
+ env: string;
7
+ }
8
+
9
+ function parseArgs(argv: string[]): ListArgs {
10
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
11
+ console.log(`Usage: optima-entitlement list --email <user-email> [options]
12
+
13
+ Required:
14
+ --email <user-email> Resolved to userId via user-auth DB
15
+
16
+ Optional:
17
+ --env stage|prod (default: stage)`);
18
+ process.exit(0);
19
+ }
20
+ const out: Partial<ListArgs> = { env: 'stage' };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ const next = argv[i + 1];
24
+ switch (a) {
25
+ case '--email': out.email = next; i++; break;
26
+ case '--env': out.env = next; i++; break;
27
+ default: throw new Error(`Unknown arg: ${a}`);
28
+ }
29
+ }
30
+ if (!out.email) throw new Error('--email required');
31
+ return out as ListArgs;
32
+ }
33
+
34
+ interface EntitlementRow {
35
+ id: string;
36
+ productKey: string;
37
+ status: string;
38
+ source: string;
39
+ purchasedAt: string;
40
+ refundedAt: string | null;
41
+ }
42
+
43
+ export async function runList(argv: string[]): Promise<void> {
44
+ const args = parseArgs(argv);
45
+ validateEnv(args.env);
46
+
47
+ const cfg = getInfisicalConfig();
48
+ const token = getInfisicalToken(cfg);
49
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
50
+
51
+ const res = await callBilling<{ entitlements: EntitlementRow[] }>(
52
+ args.env,
53
+ 'GET',
54
+ `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`,
55
+ );
56
+
57
+ const rows = res.body.entitlements ?? [];
58
+ if (rows.length === 0) {
59
+ console.log(`(no entitlements for ${args.email} on ${args.env})`);
60
+ return;
61
+ }
62
+ // Newest first per spec
63
+ rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
64
+ console.log(`${rows.length} entitlement(s) for ${args.email}:\n`);
65
+ console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
66
+ console.log('-'.repeat(140));
67
+ for (const r of rows) {
68
+ console.log(
69
+ r.id.padEnd(38) + ' | ' +
70
+ r.productKey.padEnd(32) + ' | ' +
71
+ r.status.padEnd(9) + ' | ' +
72
+ r.source.padEnd(12) + ' | ' +
73
+ r.purchasedAt.padEnd(24) + ' | ' +
74
+ (r.refundedAt ?? ''),
75
+ );
76
+ }
77
+ }
@@ -0,0 +1,116 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+ import { confirmIfProd } from '../confirm-prompt';
3
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
+
5
+ interface RevokeArgs {
6
+ email: string;
7
+ productKey: string;
8
+ reason: string;
9
+ yes: boolean;
10
+ env: string;
11
+ }
12
+
13
+ interface EntitlementRow {
14
+ id: string;
15
+ productKey: string;
16
+ status: string;
17
+ source: string;
18
+ }
19
+
20
+ function parseArgs(argv: string[]): RevokeArgs {
21
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
22
+ console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
23
+
24
+ Required:
25
+ --email <user-email> Resolved to userId via user-auth DB
26
+ --product-key <productKey>
27
+ --reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
28
+
29
+ Optional:
30
+ --yes Skip prod confirmation prompt (no-op on stage)
31
+ --env stage|prod (default: stage)
32
+
33
+ Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
34
+ error pointing to the right reversal flow.`);
35
+ process.exit(0);
36
+ }
37
+ const out: Partial<RevokeArgs> = { env: 'stage', yes: false };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ const next = argv[i + 1];
41
+ switch (a) {
42
+ case '--email': out.email = next; i++; break;
43
+ case '--product-key': out.productKey = next; i++; break;
44
+ case '--reason': out.reason = next; i++; break;
45
+ case '--yes': out.yes = true; break;
46
+ case '--env': out.env = next; i++; break;
47
+ default: throw new Error(`Unknown arg: ${a}`);
48
+ }
49
+ }
50
+ if (!out.email) throw new Error('--email required');
51
+ if (!out.productKey) throw new Error('--product-key required');
52
+ if (!out.reason) throw new Error('--reason required (billing returns 400 otherwise)');
53
+ return out as RevokeArgs;
54
+ }
55
+
56
+ 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.`;
57
+
58
+ 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.`;
59
+
60
+ export async function runRevoke(argv: string[]): Promise<void> {
61
+ const args = parseArgs(argv);
62
+ validateEnv(args.env);
63
+
64
+ const cfg = getInfisicalConfig();
65
+ const token = getInfisicalToken(cfg);
66
+ const userId = await resolveUserId(args.email, args.env, cfg, token);
67
+
68
+ // Step 1: Fetch user's entitlements
69
+ const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
70
+ args.env,
71
+ 'GET',
72
+ `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`,
73
+ );
74
+ const all = listRes.body.entitlements ?? [];
75
+
76
+ // Step 2: Filter for ACTIVE + matching productKey
77
+ const matches = all.filter((e) => e.status === 'ACTIVE' && e.productKey === args.productKey);
78
+
79
+ // Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
80
+ if (matches.length === 0) {
81
+ throw new Error(`no active entitlement for (user=${args.email}, product=${args.productKey}) on ${args.env}`);
82
+ }
83
+ if (matches.length > 1) {
84
+ 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}`);
85
+ }
86
+ const target = matches[0];
87
+
88
+ // Step 4: Validate source
89
+ if (target.source === 'PAYMENT') throw new Error(PAYMENT_REFUSAL);
90
+ if (target.source === 'PARTNER') throw new Error(PARTNER_REFUSAL);
91
+ if (target.source !== 'ADMIN_GRANT') throw new Error(`unknown entitlement source: ${target.source}`);
92
+
93
+ await confirmIfProd(
94
+ args.env,
95
+ `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
96
+ args.yes,
97
+ );
98
+
99
+ // Step 5: Refund
100
+ // refundAmountCents=0 is always correct for ADMIN_GRANT (priceCents=0,
101
+ // no upstream charge). Pass explicitly so billing's auto-compute
102
+ // (which requires refundWindowDays on the Product) doesn't 400 with
103
+ // MANUAL_REFUND_AMOUNT_REQUIRED for products that lack a refund policy.
104
+ // PAYMENT/PARTNER sources are already refused in step 4 above, so
105
+ // this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
106
+ // refund path in billing (admin-products.ts:296-307) is gated on
107
+ // source=PAYMENT and won't trigger here.
108
+ console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.email}...`);
109
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
110
+ entitlementId: target.id,
111
+ refundReason: args.reason,
112
+ refundAmountCents: 0,
113
+ });
114
+ console.log(`✓ Revoked entitlement (HTTP ${res.status}):`);
115
+ console.log(JSON.stringify(res.body, null, 2));
116
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runGrant } from './entitlement/grant';
4
+ import { runList } from './entitlement/list';
5
+ import { runRevoke } from './entitlement/revoke';
6
+
7
+ function printHelp() {
8
+ console.log(`Usage: optima-entitlement <subcommand> [options]
9
+
10
+ Subcommands:
11
+ grant Admin-grant a product entitlement to a user
12
+ revoke Revoke an admin-granted entitlement (refuses PAYMENT / PARTNER sources)
13
+ list List a user's entitlements, newest first
14
+
15
+ Run 'optima-entitlement <subcommand> --help' for subcommand-specific options.`);
16
+ }
17
+
18
+ async function main() {
19
+ const [, , subcommand, ...rest] = process.argv;
20
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') { printHelp(); process.exit(0); }
21
+ switch (subcommand) {
22
+ case 'list': await runList(rest); break;
23
+ case 'grant': await runGrant(rest); break;
24
+ case 'revoke': await runRevoke(rest); break;
25
+ default:
26
+ console.error(`Unknown subcommand: ${subcommand}`);
27
+ printHelp();
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ main().catch((err) => { console.error(err.message); process.exit(1); });
@@ -0,0 +1,41 @@
1
+ import { execSync } from 'child_process';
2
+ import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
3
+
4
+ /**
5
+ * Fetch a single secret value from Infisical given env + path + name.
6
+ *
7
+ * env mapping: 'stage' → Infisical env slug 'staging'; 'prod' → 'prod'
8
+ * (matches dev-skills convention documented at
9
+ * ~/.claude/projects/-mnt-d-work-projects-optima/memory/optima_infisical_env_naming.md).
10
+ *
11
+ * Returns the raw secretValue string. Throws if the secret is missing.
12
+ */
13
+ export function fetchInfisicalSecret(
14
+ env: string,
15
+ secretPath: string,
16
+ secretName: string,
17
+ config?: InfisicalConfig,
18
+ token?: string,
19
+ ): string {
20
+ const cfg = config ?? getInfisicalConfig();
21
+ const tok = token ?? getInfisicalToken(cfg);
22
+ const envSlug = env === 'stage' ? 'staging' : env;
23
+ const encodedPath = encodeURIComponent(secretPath);
24
+ const encodedName = encodeURIComponent(secretName);
25
+
26
+ const response = execSync(
27
+ `curl -s "${cfg.url}/api/v3/secrets/raw/${encodedName}?workspaceId=${cfg.projectId}&environment=${envSlug}&secretPath=${encodedPath}" -H "Authorization: Bearer ${tok}"`,
28
+ { encoding: 'utf-8' },
29
+ );
30
+
31
+ let parsed: { secret?: { secretValue?: string }; message?: string };
32
+ try {
33
+ parsed = JSON.parse(response);
34
+ } catch {
35
+ throw new Error(`Infisical raw secret fetch returned non-JSON for ${secretPath}/${secretName} (${envSlug}): ${response.slice(0, 200)}`);
36
+ }
37
+ if (!parsed.secret?.secretValue) {
38
+ throw new Error(`Infisical secret not found: env=${envSlug} path=${secretPath} name=${secretName} (response: ${response.slice(0, 200)})`);
39
+ }
40
+ return parsed.secret.secretValue;
41
+ }
@@ -0,0 +1,78 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface AddChannelArgs {
4
+ key: string;
5
+ provider: string;
6
+ stripePriceId: string;
7
+ priceCents: number;
8
+ currency: string;
9
+ enabled?: boolean;
10
+ metadata?: Record<string, unknown>;
11
+ env: string;
12
+ }
13
+
14
+ function parseArgs(argv: string[]): AddChannelArgs {
15
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
16
+ console.log(`Usage: optima-product add-channel --key <productKey> --provider STRIPE --stripe-price-id <price_xxx> --price-cents N --currency USD [options]
17
+
18
+ Required:
19
+ --key <productKey>
20
+ --provider STRIPE v1 CLI accepts only STRIPE (schema supports more)
21
+ --stripe-price-id <price_xxx> Pre-created in Stripe Dashboard; wire-mapped to externalProductId
22
+ --price-cents N MUST be > 0; should match Stripe Price's unit_amount (NOT verified)
23
+ --currency USD Should match Stripe Price's currency (NOT verified)
24
+
25
+ Optional:
26
+ --enabled true|false default: true
27
+ --metadata '<json>'
28
+ --env stage|prod (default: stage)`);
29
+ process.exit(0);
30
+ }
31
+ const out: Partial<AddChannelArgs> = { env: 'stage' };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ const next = argv[i + 1];
35
+ switch (a) {
36
+ case '--key': out.key = next; i++; break;
37
+ case '--provider': out.provider = next; i++; break;
38
+ case '--stripe-price-id': out.stripePriceId = next; i++; break;
39
+ case '--price-cents': out.priceCents = parseInt(next, 10); i++; break;
40
+ case '--currency': out.currency = next; i++; break;
41
+ case '--enabled': out.enabled = next === 'true'; i++; break;
42
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
43
+ case '--env': out.env = next; i++; break;
44
+ default: throw new Error(`Unknown arg: ${a}`);
45
+ }
46
+ }
47
+ if (!out.key) throw new Error('--key required');
48
+ if (!out.provider) throw new Error('--provider required');
49
+ if (out.provider !== 'STRIPE') throw new Error(`v1 CLI accepts only --provider STRIPE (got ${out.provider})`);
50
+ if (!out.stripePriceId) throw new Error('--stripe-price-id required');
51
+ if (out.priceCents === undefined || !Number.isFinite(out.priceCents)) throw new Error('--price-cents required (integer)');
52
+ if (out.priceCents <= 0) throw new Error('--price-cents must be > 0');
53
+ if (!out.currency) throw new Error('--currency required');
54
+ return out as AddChannelArgs;
55
+ }
56
+
57
+ export async function runAddChannel(argv: string[]): Promise<void> {
58
+ const args = parseArgs(argv);
59
+ validateEnv(args.env);
60
+ const body: Record<string, unknown> = {
61
+ provider: args.provider,
62
+ externalProductId: args.stripePriceId,
63
+ priceCents: args.priceCents,
64
+ currency: args.currency,
65
+ };
66
+ if (args.enabled !== undefined) body.enabled = args.enabled;
67
+ if (args.metadata !== undefined) body.metadata = args.metadata;
68
+
69
+ console.log(`\n💳 Adding ${args.provider} channel to ${args.key} on ${args.env.toUpperCase()}...`);
70
+ const res = await callBilling(
71
+ args.env,
72
+ 'POST',
73
+ `/api/billing/admin/products/${encodeURIComponent(args.key)}/channels`,
74
+ body,
75
+ );
76
+ console.log(`✓ Created Channel (HTTP ${res.status}):`);
77
+ console.log(JSON.stringify(res.body, null, 2));
78
+ }