@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
@@ -0,0 +1,96 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface CreateArgs {
4
+ key: string;
5
+ plugins: string[];
6
+ type: string;
7
+ name?: string;
8
+ description?: string;
9
+ refundWindowDays?: number;
10
+ refundProrateMaxDays?: number;
11
+ bundledPlanId?: string;
12
+ bundledDurationDays?: number;
13
+ revokeBundledOnRefund?: boolean;
14
+ metadata?: Record<string, unknown>;
15
+ env: string;
16
+ }
17
+
18
+ function parseArgs(argv: string[]): CreateArgs {
19
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
20
+ console.log(`Usage: optima-product create --key <productKey> --plugins <slug1,slug2,...> --type <ProductType> [options]
21
+
22
+ Required:
23
+ --key <productKey> Unique slug
24
+ --plugins <slug1,slug2,...> Comma-separated, >=1 plugin slug
25
+ --type <ProductType> Only ONE_SHOT_SKILL accepted by v1 CLI policy
26
+
27
+ Optional:
28
+ --name "..." Convenience flag, folded into metadata.name
29
+ --description "..." Convenience flag, folded into metadata.description
30
+ --refund-window-days N
31
+ --refund-prorate-max-days N
32
+ --bundled-plan-id <planId> (joint with --bundled-duration-days)
33
+ --bundled-duration-days N
34
+ --revoke-bundled-on-refund true|false
35
+ --metadata '<json>' JSON object stored in product.metadata
36
+ --env stage|prod (default: stage)`);
37
+ process.exit(0);
38
+ }
39
+
40
+ const out: Partial<CreateArgs> = { env: 'stage', revokeBundledOnRefund: undefined };
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ const next = argv[i + 1];
44
+ switch (a) {
45
+ case '--key': out.key = next; i++; break;
46
+ case '--plugins': out.plugins = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
47
+ case '--type': out.type = next; i++; break;
48
+ case '--name': out.name = next; i++; break;
49
+ case '--description': out.description = next; i++; break;
50
+ case '--refund-window-days': out.refundWindowDays = parseInt(next, 10); i++; break;
51
+ case '--refund-prorate-max-days': out.refundProrateMaxDays = parseInt(next, 10); i++; break;
52
+ case '--bundled-plan-id': out.bundledPlanId = next; i++; break;
53
+ case '--bundled-duration-days': out.bundledDurationDays = parseInt(next, 10); i++; break;
54
+ case '--revoke-bundled-on-refund': out.revokeBundledOnRefund = next === 'true'; i++; break;
55
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
56
+ case '--env': out.env = next; i++; break;
57
+ default: throw new Error(`Unknown arg: ${a}`);
58
+ }
59
+ }
60
+
61
+ if (!out.key) throw new Error('--key required');
62
+ if (!out.plugins || out.plugins.length === 0) throw new Error('--plugins required (>=1 slug)');
63
+ if (!out.type) throw new Error('--type required');
64
+ if (out.type !== 'ONE_SHOT_SKILL') {
65
+ throw new Error(`v1 CLI accepts only --type ONE_SHOT_SKILL (got ${out.type}). See spec §5.1.`);
66
+ }
67
+ if ((out.bundledPlanId == null) !== (out.bundledDurationDays == null)) {
68
+ throw new Error('--bundled-plan-id and --bundled-duration-days must be set together');
69
+ }
70
+ return out as CreateArgs;
71
+ }
72
+
73
+ export async function runCreate(argv: string[]): Promise<void> {
74
+ const args = parseArgs(argv);
75
+ validateEnv(args.env);
76
+
77
+ const body: Record<string, unknown> = {
78
+ productKey: args.key,
79
+ type: args.type,
80
+ pluginSlugs: args.plugins,
81
+ };
82
+ if (args.name !== undefined) body.name = args.name;
83
+ if (args.description !== undefined) body.description = args.description;
84
+ if (args.refundWindowDays !== undefined) body.refundWindowDays = args.refundWindowDays;
85
+ if (args.refundProrateMaxDays !== undefined) body.refundProrateMaxDays = args.refundProrateMaxDays;
86
+ if (args.bundledPlanId !== undefined) body.bundledPlanId = args.bundledPlanId;
87
+ if (args.bundledDurationDays !== undefined) body.bundledDurationDays = args.bundledDurationDays;
88
+ if (args.revokeBundledOnRefund !== undefined) body.revokeBundledOnRefund = args.revokeBundledOnRefund;
89
+ if (args.metadata !== undefined) body.metadata = args.metadata;
90
+
91
+ console.log(`\n🎁 Creating product ${args.key} (${args.plugins.length} plugin(s)) on ${args.env.toUpperCase()}...`);
92
+
93
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/products', body);
94
+ console.log(`✓ Created Product (HTTP ${res.status}):`);
95
+ console.log(JSON.stringify(res.body, null, 2));
96
+ }
@@ -0,0 +1,43 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface ShowArgs {
4
+ key: string;
5
+ env: string;
6
+ }
7
+
8
+ function parseArgs(argv: string[]): ShowArgs {
9
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
10
+ console.log(`Usage: optima-product show --key <productKey> [options]
11
+
12
+ Required:
13
+ --key <productKey>
14
+
15
+ Optional:
16
+ --env stage|prod (default: stage)
17
+
18
+ Note: returns the bare Product row only — productPlugins and channels arrays
19
+ are NOT included (billing's GET /api/internal/products/:key is a plain
20
+ findUnique with no include). To inspect channels/plugins today, query the DB
21
+ directly via optima-query-db. Tracked as follow-up in spec §7.`);
22
+ process.exit(0);
23
+ }
24
+ const out: Partial<ShowArgs> = { env: 'stage' };
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ const next = argv[i + 1];
28
+ switch (a) {
29
+ case '--key': out.key = next; i++; break;
30
+ case '--env': out.env = next; i++; break;
31
+ default: throw new Error(`Unknown arg: ${a}`);
32
+ }
33
+ }
34
+ if (!out.key) throw new Error('--key required');
35
+ return out as ShowArgs;
36
+ }
37
+
38
+ export async function runShow(argv: string[]): Promise<void> {
39
+ const args = parseArgs(argv);
40
+ validateEnv(args.env);
41
+ const res = await callBilling(args.env, 'GET', `/api/internal/products/${encodeURIComponent(args.key)}`);
42
+ console.log(JSON.stringify(res.body, null, 2));
43
+ }
@@ -0,0 +1,56 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface ToggleArgs {
4
+ key: string;
5
+ provider: string;
6
+ enabled: boolean;
7
+ env: string;
8
+ }
9
+
10
+ function parseArgs(argv: string[]): ToggleArgs {
11
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
12
+ console.log(`Usage: optima-product toggle-channel --key <productKey> --provider STRIPE --enabled true|false [options]
13
+
14
+ Required:
15
+ --key <productKey>
16
+ --provider STRIPE v1 CLI accepts only STRIPE
17
+ --enabled true|false
18
+
19
+ Optional:
20
+ --env stage|prod (default: stage)`);
21
+ process.exit(0);
22
+ }
23
+ const out: Partial<ToggleArgs> = { env: 'stage' };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ const next = argv[i + 1];
27
+ switch (a) {
28
+ case '--key': out.key = next; i++; break;
29
+ case '--provider': out.provider = next; i++; break;
30
+ case '--enabled':
31
+ if (next !== 'true' && next !== 'false') throw new Error('--enabled must be true or false');
32
+ out.enabled = next === 'true'; i++; break;
33
+ case '--env': out.env = next; i++; break;
34
+ default: throw new Error(`Unknown arg: ${a}`);
35
+ }
36
+ }
37
+ if (!out.key) throw new Error('--key required');
38
+ if (!out.provider) throw new Error('--provider required');
39
+ if (out.provider !== 'STRIPE') throw new Error(`v1 CLI accepts only --provider STRIPE (got ${out.provider})`);
40
+ if (out.enabled === undefined) throw new Error('--enabled required');
41
+ return out as ToggleArgs;
42
+ }
43
+
44
+ export async function runToggleChannel(argv: string[]): Promise<void> {
45
+ const args = parseArgs(argv);
46
+ validateEnv(args.env);
47
+ console.log(`\n🔁 Setting ${args.provider} channel enabled=${args.enabled} on ${args.key} (${args.env.toUpperCase()})...`);
48
+ const res = await callBilling(
49
+ args.env,
50
+ 'PATCH',
51
+ `/api/billing/admin/products/${encodeURIComponent(args.key)}/channels/${args.provider}`,
52
+ { enabled: args.enabled },
53
+ );
54
+ console.log(`✓ Channel updated (HTTP ${res.status}):`);
55
+ console.log(JSON.stringify(res.body, null, 2));
56
+ }
@@ -0,0 +1,72 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface UpdateArgs {
4
+ key: string;
5
+ refundWindowDays?: number | null;
6
+ refundProrateMaxDays?: number | null;
7
+ bundledPlanId?: string | null;
8
+ bundledDurationDays?: number | null;
9
+ revokeBundledOnRefund?: boolean;
10
+ metadata?: Record<string, unknown>;
11
+ env: string;
12
+ }
13
+
14
+ function parseArgs(argv: string[]): UpdateArgs {
15
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
16
+ console.log(`Usage: optima-product update --key <productKey> [options]
17
+
18
+ Required:
19
+ --key <productKey>
20
+
21
+ Optional (PATCH — only included fields are updated):
22
+ --refund-window-days N | null
23
+ --refund-prorate-max-days N | null
24
+ --bundled-plan-id <planId> | null (joint with --bundled-duration-days)
25
+ --bundled-duration-days N | null
26
+ --revoke-bundled-on-refund true|false
27
+ --metadata '<json>' FULL REPLACE — Prisma does not deep-merge JSON
28
+ --env stage|prod (default: stage)
29
+
30
+ Note: productKey, type, and pluginSlugs are immutable post-create. To change plugin
31
+ membership, create a new Product with a new key.`);
32
+ process.exit(0);
33
+ }
34
+ const out: Partial<UpdateArgs> = { env: 'stage' };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ const next = argv[i + 1];
38
+ const parseNullable = (v: string): number | null => v === 'null' ? null : parseInt(v, 10);
39
+ switch (a) {
40
+ case '--key': out.key = next; i++; break;
41
+ case '--refund-window-days': out.refundWindowDays = parseNullable(next); i++; break;
42
+ case '--refund-prorate-max-days': out.refundProrateMaxDays = parseNullable(next); i++; break;
43
+ case '--bundled-plan-id': out.bundledPlanId = next === 'null' ? null : next; i++; break;
44
+ case '--bundled-duration-days': out.bundledDurationDays = parseNullable(next); i++; break;
45
+ case '--revoke-bundled-on-refund': out.revokeBundledOnRefund = next === 'true'; i++; break;
46
+ case '--metadata': out.metadata = JSON.parse(next); i++; break;
47
+ case '--env': out.env = next; i++; break;
48
+ default: throw new Error(`Unknown arg: ${a}`);
49
+ }
50
+ }
51
+ if (!out.key) throw new Error('--key required');
52
+ return out as UpdateArgs;
53
+ }
54
+
55
+ export async function runUpdate(argv: string[]): Promise<void> {
56
+ const args = parseArgs(argv);
57
+ validateEnv(args.env);
58
+ const body: Record<string, unknown> = {};
59
+ if (args.refundWindowDays !== undefined) body.refundWindowDays = args.refundWindowDays;
60
+ if (args.refundProrateMaxDays !== undefined) body.refundProrateMaxDays = args.refundProrateMaxDays;
61
+ if (args.bundledPlanId !== undefined) body.bundledPlanId = args.bundledPlanId;
62
+ if (args.bundledDurationDays !== undefined) body.bundledDurationDays = args.bundledDurationDays;
63
+ if (args.revokeBundledOnRefund !== undefined) body.revokeBundledOnRefund = args.revokeBundledOnRefund;
64
+ if (args.metadata !== undefined) body.metadata = args.metadata;
65
+
66
+ if (Object.keys(body).length === 0) throw new Error('At least one updatable field must be passed');
67
+
68
+ console.log(`\n✏️ Updating product ${args.key} on ${args.env.toUpperCase()}...`);
69
+ const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/products/${encodeURIComponent(args.key)}`, body);
70
+ console.log(`✓ Updated Product (HTTP ${res.status}):`);
71
+ console.log(JSON.stringify(res.body, null, 2));
72
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCreate } from './product/create';
4
+ import { runUpdate } from './product/update';
5
+ import { runAddChannel } from './product/add-channel';
6
+ import { runToggleChannel } from './product/toggle-channel';
7
+ import { runShow } from './product/show';
8
+
9
+ const SUBCOMMANDS = ['create', 'update', 'add-channel', 'toggle-channel', 'show'] as const;
10
+
11
+ function printHelp() {
12
+ console.log(`Usage: optima-product <subcommand> [options]
13
+
14
+ Subcommands:
15
+ create Create a Product bundling 1+ plugin slugs
16
+ update Patch refund policy / metadata on an existing Product
17
+ add-channel Attach a payment channel (Stripe Price ID) to a Product
18
+ toggle-channel Enable/disable an existing channel
19
+ show Show a Product's bare row (note: does NOT include plugins/channels)
20
+
21
+ Run 'optima-product <subcommand> --help' for subcommand-specific options.`);
22
+ }
23
+
24
+ async function main() {
25
+ const [, , subcommand, ...rest] = process.argv;
26
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') {
27
+ printHelp();
28
+ process.exit(0);
29
+ }
30
+ switch (subcommand) {
31
+ case 'create': await runCreate(rest); break;
32
+ case 'update': await runUpdate(rest); break;
33
+ case 'add-channel': await runAddChannel(rest); break;
34
+ case 'toggle-channel': await runToggleChannel(rest); break;
35
+ case 'show': await runShow(rest); break;
36
+ default:
37
+ console.error(`Unknown subcommand: ${subcommand}`);
38
+ printHelp();
39
+ process.exit(1);
40
+ }
41
+ }
42
+
43
+ main().catch((err) => {
44
+ console.error(err.message);
45
+ process.exit(1);
46
+ });
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateEnv = validateEnv;
4
+ exports.getServiceToken = getServiceToken;
5
+ exports.callBilling = callBilling;
6
+ const child_process_1 = require("child_process");
7
+ const infisical_secrets_1 = require("./infisical-secrets");
8
+ const db_utils_1 = require("./db-utils");
9
+ const USER_AUTH_URLS = {
10
+ stage: 'https://auth.stage.optima.onl',
11
+ prod: 'https://auth.optima.onl',
12
+ };
13
+ /**
14
+ * Validate the --env flag value at command entry, before any I/O.
15
+ *
16
+ * Without this, a typo like `--env staging` flows downstream and surfaces as
17
+ * a confusing error: entitlement subcommands hit resolveUserId first (SSH
18
+ * tunnel to RDS_HOSTS[undefined] → cryptic ssh failure), product subcommands
19
+ * reach getServiceToken (USER_AUTH_URLS[undefined] → "Unknown env"). All
20
+ * fail-closed (no wrong-env write), but the UX diverges per subcommand.
21
+ * Every runX handler calls this first so the error is uniform and immediate.
22
+ */
23
+ function validateEnv(env) {
24
+ if (env !== 'stage' && env !== 'prod') {
25
+ throw new Error(`--env must be "stage" or "prod" (got: ${env})`);
26
+ }
27
+ return env;
28
+ }
29
+ // T1 discovered: client_id differs per env (stage=dev-skills-ubd3qz6n,
30
+ // prod=dev-skills-hinxa0rs). Both stored in Infisical alongside the
31
+ // secret at /shared-secrets/oauth-clients/.
32
+ const DEV_SKILLS_OAUTH_PATH = '/shared-secrets/oauth-clients';
33
+ const DEV_SKILLS_CLIENT_ID_KEY = 'DEV_SKILLS_OAUTH_CLIENT_ID';
34
+ const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
35
+ // ───── Cache (process-lifetime) ─────────────────────────────────────────────
36
+ // One CLI invocation does at most a handful of HTTP calls. We mint the M2M
37
+ // token once and reuse it. Cross-invocation re-mint is fine — JWT TTL is
38
+ // typically ≥1h, far longer than any single CLI run.
39
+ //
40
+ // NOT handled (acceptable for admin CLI):
41
+ // * Token expiry mid-invocation — a long-stalled revoke (list call + 5min
42
+ // pause + refund call) could in theory expire. Operator can retry.
43
+ // * Infisical 5xx retry — getInfisicalToken is sync execSync curl with no
44
+ // retry; any transient failure surfaces immediately. Re-run the CLI.
45
+ const tokenCache = {};
46
+ const billingUrlCache = {};
47
+ function getBillingUrl(env) {
48
+ if (billingUrlCache[env])
49
+ return billingUrlCache[env];
50
+ const url = (0, infisical_secrets_1.fetchInfisicalSecret)(env, '/shared-secrets/domain-urls', 'BILLING_URL');
51
+ billingUrlCache[env] = url;
52
+ return url;
53
+ }
54
+ function getServiceToken(env) {
55
+ if (tokenCache[env])
56
+ return tokenCache[env];
57
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
58
+ const tok = (0, db_utils_1.getInfisicalToken)(cfg);
59
+ // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
60
+ const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_ID_KEY, cfg, tok);
61
+ const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_SECRET_KEY, cfg, tok);
62
+ const authUrl = USER_AUTH_URLS[env];
63
+ if (!authUrl)
64
+ throw new Error(`Unknown env: ${env}`);
65
+ const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
66
+ const response = (0, child_process_1.execSync)(`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`, { encoding: 'utf-8' });
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(response);
70
+ }
71
+ catch {
72
+ throw new Error(`user-auth token endpoint returned non-JSON (${env}): ${response.slice(0, 200)}`);
73
+ }
74
+ if (!parsed.access_token) {
75
+ throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
76
+ }
77
+ tokenCache[env] = parsed.access_token;
78
+ return parsed.access_token;
79
+ }
80
+ function formatBillingError(status, statusText, body) {
81
+ let parsed = null;
82
+ try {
83
+ parsed = JSON.parse(body);
84
+ }
85
+ catch { /* non-JSON */ }
86
+ // Dominant Wave 1.5 envelope: flat { error: "CODE_STRING", message: "..." }
87
+ // emitted by billing's global error handler (app.ts:99-118) for ALL
88
+ // BillingError throws + validation errors + internal errors. Most inline
89
+ // route returns also use this shape (admin-products.ts:110,144,184-185,etc).
90
+ if (parsed && typeof parsed.error === 'string') {
91
+ return `❌ Error [${status}] ${parsed.error}: ${parsed.message ?? '(no message)'}`;
92
+ }
93
+ // Less-common nested envelope: { error: { code, message } } — used by a
94
+ // few inline 400/404 returns in admin-products.ts toggle-channel handler
95
+ // (lines 92-93, 100-101, 114-117). Possibly extends to other routes
96
+ // post-Wave-1.5 as standardization lands.
97
+ if (parsed && typeof parsed.error === 'object' && parsed.error !== null) {
98
+ const code = parsed.error.code ?? 'UNKNOWN';
99
+ const msg = parsed.error.message ?? '(no message)';
100
+ return `❌ Error [${status}] ${code}: ${msg}`;
101
+ }
102
+ // Non-envelope fallback (raw 502 from upstream LB, crashed handler before
103
+ // error middleware, plain-text body, etc.)
104
+ return `❌ Error [${status}] ${statusText}\n Response body (first 500 bytes): ${body.slice(0, 500)}`;
105
+ }
106
+ /**
107
+ * Make an authenticated call to optima-billing. Returns `{status, body}` on
108
+ * 2xx; throws Error with formatted message on non-2xx. Single retry on 5xx
109
+ * (one-shot — no exponential backoff; admin CLI doesn't justify it).
110
+ */
111
+ async function callBilling(env, method, path, body) {
112
+ const url = `${getBillingUrl(env)}${path}`;
113
+ const token = getServiceToken(env);
114
+ const doFetch = async () => fetch(url, {
115
+ method,
116
+ headers: {
117
+ Authorization: `Bearer ${token}`,
118
+ 'Content-Type': 'application/json',
119
+ },
120
+ body: body !== undefined ? JSON.stringify(body) : undefined,
121
+ });
122
+ let res = await doFetch();
123
+ if (res.status >= 500) {
124
+ // One retry on 5xx
125
+ res = await doFetch();
126
+ }
127
+ const text = await res.text();
128
+ if (!res.ok) {
129
+ throw new Error(formatBillingError(res.status, res.statusText, text));
130
+ }
131
+ let parsed;
132
+ try {
133
+ parsed = text ? JSON.parse(text) : undefined;
134
+ }
135
+ catch {
136
+ throw new Error(`Billing returned non-JSON 2xx body: ${text.slice(0, 200)}`);
137
+ }
138
+ return { status: res.status, body: parsed };
139
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.confirmIfProd = confirmIfProd;
37
+ const readline = __importStar(require("readline"));
38
+ /**
39
+ * On prod, print the resolved action and require typing "yes" to proceed.
40
+ * No-op on stage or when --yes was passed. Exits 1 if user declines.
41
+ */
42
+ async function confirmIfProd(env, actionDescription, skipFlag) {
43
+ if (env !== 'prod' || skipFlag)
44
+ return;
45
+ console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
46
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
47
+ const answer = await new Promise((resolve) => {
48
+ rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
49
+ });
50
+ if (answer !== 'yes') {
51
+ console.error('❌ Aborted by user.');
52
+ process.exit(1);
53
+ }
54
+ }
@@ -92,17 +92,50 @@ function parseDatabaseUrl(url) {
92
92
  return { user: decodeURIComponent(match[1]), password: decodeURIComponent(match[2]), host: match[3], port: parseInt(match[4], 10), database: match[5] };
93
93
  }
94
94
  // ─── SSH tunnel ─────────────────────────────────────────────────────────────
95
+ // 端口被占不等于 tunnel 还能转发:AWS bastion idle 断连后,本地 ssh 进程还活着、
96
+ // 端口还 LISTEN,但流量过不去,所有后续查询会 hang 到客户端 timeout。
97
+ // 用 pg_isready 真去 ping postgres,超时就当 zombie,杀掉重建。
98
+ function isTunnelHealthy(localPort) {
99
+ try {
100
+ (0, child_process_1.execSync)(`pg_isready -h localhost -p ${localPort} -t 3 -q`, { stdio: 'ignore' });
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ function killOrphanTunnel(localPort) {
108
+ try {
109
+ const pids = (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { encoding: 'utf-8' }).trim();
110
+ if (pids)
111
+ (0, child_process_1.execSync)(`kill -9 ${pids.split(/\s+/).join(' ')}`, { stdio: 'ignore' });
112
+ }
113
+ catch { /* nothing to kill */ }
114
+ }
95
115
  function setupSSHTunnel(dbHost, localPort) {
116
+ let portInUse = false;
96
117
  try {
97
118
  (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
98
- return;
119
+ portInUse = true;
120
+ }
121
+ catch { /* free */ }
122
+ if (portInUse) {
123
+ if (isTunnelHealthy(localPort))
124
+ return;
125
+ console.log(`! SSH tunnel on port ${localPort} not responding (zombie), replacing...`);
126
+ killOrphanTunnel(localPort);
99
127
  }
100
- catch { /* need tunnel */ }
101
128
  const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
102
129
  if (!fs.existsSync(sshKeyPath))
103
130
  throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
104
131
  console.log(`Creating SSH tunnel: localhost:${localPort} -> ${EC2_HOST} -> ${dbHost}:5432`);
105
- (0, child_process_1.execSync)(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
132
+ // ServerAliveInterval/CountMax: 服务端 30s 没回应就让 ssh 自己退出(不留 zombie)
133
+ // ExitOnForwardFailure: 端口绑定失败立刻退出(不会黑悄悄继续跑)
134
+ // ConnectTimeout: 10s 不通就放弃(业界 ssh 默认 120s 太宽)
135
+ (0, child_process_1.execSync)(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no ` +
136
+ `-o ServerAliveInterval=30 -o ServerAliveCountMax=3 ` +
137
+ `-o ExitOnForwardFailure=yes -o ConnectTimeout=10 ` +
138
+ `-L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
106
139
  }
107
140
  // ─── psql ───────────────────────────────────────────────────────────────────
108
141
  function findPsqlPath() {
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runGrant = runGrant;
4
+ const billing_http_1 = require("../billing-http");
5
+ const confirm_prompt_1 = require("../confirm-prompt");
6
+ const db_utils_1 = require("../db-utils");
7
+ function parseArgs(argv) {
8
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
9
+ console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
10
+
11
+ Required:
12
+ --email <user-email> Resolved to userId via user-auth DB
13
+ --product-key <productKey>
14
+ --justification "..." Required by billing (400 otherwise); stored on entitlement.justification
15
+
16
+ Optional:
17
+ --yes Skip prod confirmation prompt (no-op on stage)
18
+ --env stage|prod (default: stage)
19
+
20
+ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
21
+ process.exit(0);
22
+ }
23
+ const out = { env: 'stage', yes: false };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ const next = argv[i + 1];
27
+ switch (a) {
28
+ case '--email':
29
+ out.email = next;
30
+ i++;
31
+ break;
32
+ case '--product-key':
33
+ out.productKey = next;
34
+ i++;
35
+ break;
36
+ case '--justification':
37
+ out.justification = next;
38
+ i++;
39
+ break;
40
+ case '--yes':
41
+ out.yes = true;
42
+ break;
43
+ case '--env':
44
+ out.env = next;
45
+ i++;
46
+ break;
47
+ default: throw new Error(`Unknown arg: ${a}`);
48
+ }
49
+ }
50
+ if (!out.email)
51
+ throw new Error('--email required');
52
+ if (!out.productKey)
53
+ throw new Error('--product-key required');
54
+ if (!out.justification)
55
+ throw new Error('--justification required (billing returns 400 otherwise)');
56
+ return out;
57
+ }
58
+ async function runGrant(argv) {
59
+ const args = parseArgs(argv);
60
+ (0, billing_http_1.validateEnv)(args.env);
61
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
62
+ const token = (0, db_utils_1.getInfisicalToken)(cfg);
63
+ const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
64
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`, args.yes);
65
+ console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
66
+ const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
67
+ userId,
68
+ productKey: args.productKey,
69
+ justification: args.justification,
70
+ });
71
+ console.log(`✓ Granted entitlement (HTTP ${res.status}):`);
72
+ console.log(JSON.stringify(res.body, null, 2));
73
+ }