@optima-chat/dev-skills 0.7.36 → 0.7.38

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.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * optima-verify-health —— 服务上线健康探针(L1 DNS / L2 TLS / L3-L5 /health)。
4
+ *
5
+ * 逐层探、逐层报,逐层 pass 才算上线成功。零依赖(纯 Node 内置)。
6
+ * 配套静态合规审计 audit-probe.py 在 optima-terraform 仓 docs/cn-prod/probes/(读 terraform 文件,绑仓不搬)。
7
+ *
8
+ * L1 DNS : FQDN 能解析到 IP
9
+ * L2 TLS : 443 握手 + 证书链/有效期/SNI(SAN)匹配
10
+ * L3 /health : optima-core 风格 /health,顶层 status 健康
11
+ * L4 真部署 : 解析 gitCommit;传 --expect-commit <sha> 时比对(证明跑的是这次镜像)
12
+ * L5 依赖 : checks[*] 全绿(DB/Redis/上游由服务在 health handler 自注册)
13
+ *
14
+ * ⚠️ optima-core 的 JS 与 Py 两套 /health schema 不一致,都兼容:
15
+ * JS : gitCommit(camel) / status∈{healthy,unhealthy} / 不健康返 503 / 有 gitBranch
16
+ * Py : git_commit(snake) / status∈{healthy,degraded} / 永远 200 / check 可 timeout|error / 无 git_branch
17
+ *
18
+ * 用法:
19
+ * optima-verify-health user-auth # 默认 cn-prod
20
+ * optima-verify-health user-auth --env prod # 环境 stage|prod|cn|all
21
+ * optima-verify-health --all --env all # stage/prod/cn 三环境矩阵
22
+ * optima-verify-health gateway-core --expect-commit a1b2c3d
23
+ * optima-verify-health --url https://auth-cn.optima.chat/health
24
+ * optima-verify-health --all --json # 机器可读,接 CI
25
+ * optima-verify-health --all --strict # warn 也算不通过
26
+ * 退出码:fail → 非 0;warn 默认放行(0),--strict 让 warn 也非 0。
27
+ */
28
+ import { promises as dns } from 'node:dns';
29
+ import * as tls from 'node:tls';
30
+ import * as https from 'node:https';
31
+
32
+ type Env = 'stage' | 'prod' | 'cn';
33
+ interface SvcCfg { path: string; stage?: string; prod?: string; cn?: string; cn_path?: string; }
34
+
35
+ // 服务 × 环境 FQDN 表。某服务某环境没部署 → 该 env 键缺省,探时跳过。
36
+ // cn-prod 真实 subdomain 抄自 optima-terraform 的 stack main.tf(非文档表,如 agentic-chat 真名是 agentic-chat-cn 不是 ai-cn)。
37
+ const SERVICES: Record<string, SvcCfg> = {
38
+ 'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth-cn.optima.chat' },
39
+ 'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'agentic-chat-cn.optima.chat' },
40
+ 'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'api-cn.optima.chat', cn_path: '/health/live' },
41
+ 'mcp-host': { path: '/health', stage: 'mcp-stage.optima.onl', prod: 'mcp.optima.onl' },
42
+ 'gateway-core': { path: '/health', cn: 'gw-cn.optima.chat' },
43
+ 'optima-scout': { path: '/health', cn: 'scout-cn.optima.chat' },
44
+ 'optima-skills': { path: '/health', cn: 'skills-cn.optima.chat' },
45
+ };
46
+ const ENVS: Env[] = ['stage', 'prod', 'cn'];
47
+
48
+ const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[34m', N = '\x1b[0m';
49
+ const MARK: Record<string, string> = { ok: `${G}✅${N}`, fail: `${R}❌${N}`, warn: `${Y}⚠️ ${N}`, na: `${B}··${N}` };
50
+ type Result = 'ok' | 'warn' | 'fail';
51
+ interface Layer { layer: string; result: Result; detail: string; }
52
+
53
+ const verdict = (ls: Layer[]): 'pass' | 'warn' | 'fail' =>
54
+ ls.some((l) => l.result === 'fail') ? 'fail' : ls.some((l) => l.result === 'warn') ? 'warn' : 'pass';
55
+
56
+ const VTXT: Record<string, string> = {
57
+ pass: `${G}上线成功(L1-L5 全绿)${N}`,
58
+ warn: `${Y}存活但有告警(见上 ⚠️;非阻塞)${N}`,
59
+ fail: `${R}未通过 — 见上 ❌${N}`,
60
+ };
61
+
62
+ function tlsCheck(host: string, timeout = 10000): Promise<{ ok: boolean; detail: string; result: Result }> {
63
+ return new Promise((resolve) => {
64
+ const sock = tls.connect({ host, port: 443, servername: host, timeout }, () => {
65
+ const cert = sock.getPeerCertificate();
66
+ const days = Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86400000);
67
+ const sans = (cert.subjectaltname || '').split(',').map((s) => s.trim().replace(/^DNS:/, '')).slice(0, 3);
68
+ sock.end();
69
+ resolve({ ok: true, result: days > 7 ? 'ok' : 'warn',
70
+ detail: `证书 ${days}d 后过期, SAN=${sans.join(',')}` + (days > 7 ? '' : ` (剩 ${days}d!)`) });
71
+ });
72
+ sock.on('error', (e: Error) => resolve({ ok: false, result: 'fail', detail: `握手/证书失败: ${e.message}` }));
73
+ sock.on('timeout', () => { sock.destroy(); resolve({ ok: false, result: 'fail', detail: '握手超时' }); });
74
+ });
75
+ }
76
+
77
+ function httpGet(url: string, timeout = 10000): Promise<{ code: number; body: string; location?: string }> {
78
+ return new Promise((resolve, reject) => {
79
+ const req = https.get(url, { headers: { 'User-Agent': 'optima-verify-health' }, timeout }, (res) => {
80
+ let body = '';
81
+ res.on('data', (c) => (body += c));
82
+ res.on('end', () => resolve({ code: res.statusCode || 0, body, location: res.headers.location }));
83
+ });
84
+ req.on('error', reject);
85
+ req.on('timeout', () => { req.destroy(new Error('请求超时')); });
86
+ });
87
+ }
88
+
89
+ async function probe(host: string, path: string, expect?: string): Promise<Layer[]> {
90
+ const layers: Layer[] = [];
91
+
92
+ // L1 DNS
93
+ try {
94
+ const addrs = await dns.lookup(host, { all: true });
95
+ const ips = [...new Set(addrs.map((a) => a.address))];
96
+ layers.push({ layer: 'L1 DNS', result: 'ok', detail: `${host} → ${ips.join(', ')}` });
97
+ } catch (e: any) {
98
+ layers.push({ layer: 'L1 DNS', result: 'fail', detail: `${host} 无法解析: ${e.message}` });
99
+ return layers;
100
+ }
101
+
102
+ // L2 TLS
103
+ const t = await tlsCheck(host);
104
+ layers.push({ layer: 'L2 TLS', result: t.result, detail: t.detail });
105
+ if (!t.ok) return layers;
106
+
107
+ // L3-L5 /health
108
+ const url = `https://${host}${path}`;
109
+ let r: { code: number; body: string; location?: string };
110
+ try {
111
+ r = await httpGet(url);
112
+ } catch (e: any) {
113
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `${url} 无法连接: ${e.message}` });
114
+ return layers;
115
+ }
116
+ if (r.code >= 300 && r.code < 400) {
117
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 重定向到 ${r.location || '?'}(没路由到服务,疑似未部署/listener 缺失)` });
118
+ return layers;
119
+ }
120
+ let d: any;
121
+ try { d = JSON.parse(r.body || '{}'); } catch {
122
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 但非 JSON(可能不是 optima-core /health): ${JSON.stringify(r.body.slice(0, 80))}` });
123
+ return layers;
124
+ }
125
+
126
+ const status = d.status ?? '?';
127
+ if (status === 'healthy') layers.push({ layer: 'L3 /health', result: 'ok', detail: `HTTP ${r.code} status=healthy svc=${d.service ?? '?'} ver=${d.version ?? '?'}` });
128
+ else if (['ok', 'up', 'pass'].includes(status)) layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=${status}(存活,但非 optima-core 标准 health,L4/L5 无从验证)` });
129
+ else if (status === 'degraded') layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=degraded(Py:部分依赖挂但服务起着,见 L5)` });
130
+ else layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} status=${status}` });
131
+
132
+ // L4 真部署 / 非裸起
133
+ const commit: string = d.gitCommit || d.git_commit || '';
134
+ const branch: string = d.gitBranch || '';
135
+ const bt = `commit=${commit || '∅'}` + (branch ? ` branch=${branch}` : '');
136
+ if (!commit) layers.push({ layer: 'L4 部署', result: 'warn', detail: '无 gitCommit(没用 optima-core health,或 build-info 没烘进去)' });
137
+ else if (expect) {
138
+ const short = expect.slice(0, commit.length);
139
+ layers.push(commit === short
140
+ ? { layer: 'L4 部署', result: 'ok', detail: `${bt} == 期望 ${short} ✓ 跑的是这次镜像` }
141
+ : { layer: 'L4 部署', result: 'fail', detail: `${bt} != 期望 ${short} ✗ 旧镜像/部署没生效` });
142
+ } else layers.push({ layer: 'L4 部署', result: 'ok', detail: `${bt}(未传 --expect-commit,仅展示)` });
143
+
144
+ // L5 依赖 checks 全绿
145
+ const checks: Record<string, any> = d.checks || {};
146
+ const keys = Object.keys(checks);
147
+ if (keys.length === 0) layers.push({ layer: 'L5 依赖', result: 'warn', detail: 'health 未注册任何 checks(服务没在 handler 里挂 DB/Redis/上游)' });
148
+ else {
149
+ const bad = keys.filter((k) => checks[k].status !== 'healthy');
150
+ const summary = keys.map((k) => `${k}=${checks[k].status}(${checks[k].latencyMs ?? checks[k].latency_ms ?? '?'}ms)`).join(', ');
151
+ layers.push(bad.length
152
+ ? { layer: 'L5 依赖', result: 'fail', detail: `${bad.length} 个不健康: ${bad.map((k) => `${k}=${checks[k].status}`).join(',')} | 全部: ${summary}` }
153
+ : { layer: 'L5 依赖', result: 'ok', detail: `${keys.length} 个依赖全绿: ${summary}` });
154
+ }
155
+ return layers;
156
+ }
157
+
158
+ function resolve(svc: string, e: Env): [string, string, string] | null {
159
+ const cfg = SERVICES[svc];
160
+ const host = cfg[e];
161
+ if (!host) return null;
162
+ const path = (cfg as any)[`${e}_path`] || cfg.path;
163
+ return [`${svc} [${e}]`, host, path];
164
+ }
165
+
166
+ async function main() {
167
+ const argv = process.argv.slice(2);
168
+ const has = (f: string) => argv.includes(f);
169
+ const val = (f: string) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
170
+ const asJson = has('--json'), strict = has('--strict');
171
+ const expect = val('--expect-commit');
172
+ const env = (val('--env') || 'cn') as Env | 'all';
173
+ const envs: Env[] = env === 'all' ? ENVS : [env as Env];
174
+ const positional = argv.filter((x, i) => !x.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && ['--env', '--expect-commit', '--url'].includes(argv[i - 1])));
175
+
176
+ let targets: [string, string, string][] = [];
177
+ if (has('--url')) {
178
+ const u = new URL(val('--url')!);
179
+ targets = [[u.hostname, u.hostname, u.pathname || '/health']];
180
+ } else if (has('--all')) {
181
+ for (const e of envs) for (const s of Object.keys(SERVICES)) { const t = resolve(s, e); if (t) targets.push(t); }
182
+ } else if (positional[0] && SERVICES[positional[0]]) {
183
+ for (const e of envs) { const t = resolve(positional[0], e); if (t) targets.push(t); }
184
+ } else {
185
+ console.log((require('fs').readFileSync(__filename, 'utf-8').match(/\/\*\*[\s\S]*?\*\//)?.[0] || '').replace(/^\s*\*?/gm, ''));
186
+ process.exit(2);
187
+ }
188
+ if (targets.length === 0) { console.log(`(无目标:${env} 环境下该服务无部署)`); process.exit(2); }
189
+
190
+ const results: any[] = [];
191
+ let worstOk = true;
192
+ for (const [name, host, path] of targets) {
193
+ const layers = await probe(host, path, expect);
194
+ const v = verdict(layers);
195
+ if (v === 'fail' || (strict && v === 'warn')) worstOk = false;
196
+ if (asJson) results.push({ service: name, url: `https://${host}${path}`, verdict: v, layers });
197
+ else {
198
+ console.log(`\n${'='.repeat(60)}\n ${name} https://${host}${path}\n${'='.repeat(60)}`);
199
+ for (const l of layers) console.log(` ${MARK[l.result] || '?'} ${l.layer.padEnd(11)} ${l.detail}`);
200
+ console.log(` ${'—'.repeat(56)}\n ${'结论'.padEnd(10)} ${VTXT[v]}`);
201
+ }
202
+ }
203
+ if (asJson) console.log(JSON.stringify(results, null, 2));
204
+ // 用 exitCode 而非 process.exit():后者会在管道(--json | jq)未 flush 完就退出而截断输出
205
+ process.exitCode = worstOk ? 0 : 1;
206
+ }
207
+
208
+ main();
@@ -41,17 +41,25 @@ exports.getInfisicalToken = getInfisicalToken;
41
41
  exports.getInfisicalSecrets = getInfisicalSecrets;
42
42
  exports.parseDatabaseUrl = parseDatabaseUrl;
43
43
  exports.setupSSHTunnel = setupSSHTunnel;
44
+ exports.setupSSMTunnel = setupSSMTunnel;
45
+ exports.setupTunnel = setupTunnel;
44
46
  exports.queryDB = queryDB;
45
47
  exports.connectAuthDB = connectAuthDB;
46
48
  exports.connectBillingDB = connectBillingDB;
47
49
  exports.resolveUserId = resolveUserId;
48
50
  const child_process_1 = require("child_process");
49
51
  const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
50
53
  // ─── Constants ──────────────────────────────────────────────────────────────
51
54
  exports.RDS_HOSTS = {
52
55
  stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
53
56
  prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
54
57
  };
58
+ // Shared bastion (optima-bi-data EC2, shared-services stack). Reached via SSM —
59
+ // no public port 22 dependency, so it is immune to the MaxStartups throttling that
60
+ // the open-to-world sshd suffers under internet scan load. SSH path kept as fallback.
61
+ const AWS_REGION = 'ap-southeast-1';
62
+ const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
55
63
  const EC2_HOST = '3.0.210.113';
56
64
  // ─── SQL escaping ───────────────────────────────────────────────────────────
57
65
  /** Escape a string value for safe inclusion in SQL single-quoted literals. */
@@ -137,6 +145,82 @@ function setupSSHTunnel(dbHost, localPort) {
137
145
  `-o ExitOnForwardFailure=yes -o ConnectTimeout=10 ` +
138
146
  `-L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
139
147
  }
148
+ /** Block the current (sync) call for `ms` without spawning a subprocess. */
149
+ function sleepSync(ms) {
150
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
151
+ }
152
+ function assertSSMPrereqs() {
153
+ for (const [bin, hint] of [
154
+ ['session-manager-plugin', 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html'],
155
+ ['aws', 'AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'],
156
+ ]) {
157
+ try {
158
+ (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: 'ignore' });
159
+ }
160
+ catch {
161
+ throw new Error(`${bin} not found — required for the SSM DB tunnel. Install: ${hint}\n(or set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel)`);
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * Open a local→RDS tunnel through the shared bastion using SSM port forwarding.
167
+ * No public SSH port is touched. A healthy existing tunnel on the port is reused
168
+ * (the ~2s SSM cold start is paid once, then every query is just a round-trip).
169
+ */
170
+ function setupSSMTunnel(dbHost, localPort) {
171
+ let portInUse = false;
172
+ try {
173
+ (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
174
+ portInUse = true;
175
+ }
176
+ catch { /* free */ }
177
+ if (portInUse) {
178
+ if (isTunnelHealthy(localPort))
179
+ return; // warm reuse — no cold start
180
+ console.log(`! Tunnel on port ${localPort} not responding (zombie), replacing...`);
181
+ killOrphanTunnel(localPort);
182
+ }
183
+ assertSSMPrereqs();
184
+ console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
185
+ const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
186
+ const out = fs.openSync(logFile, 'w');
187
+ const child = (0, child_process_1.spawn)('aws', [
188
+ 'ssm', 'start-session',
189
+ '--region', AWS_REGION,
190
+ '--target', BASTION_INSTANCE_ID,
191
+ '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
192
+ '--parameters', `host=${dbHost},portNumber=5432,localPortNumber=${localPort}`,
193
+ ], { detached: true, stdio: ['ignore', out, out] });
194
+ child.unref();
195
+ // Wait until the remote leg actually forwards (pg_isready probes RDS through the
196
+ // tunnel). Local port binds instantly; the remote hop needs ~1-2s to come up.
197
+ const deadlineMs = Date.now() + 20000;
198
+ while (Date.now() < deadlineMs) {
199
+ if (isTunnelHealthy(localPort)) {
200
+ console.log(`✓ SSM tunnel ready on port ${localPort}`);
201
+ return;
202
+ }
203
+ sleepSync(500);
204
+ }
205
+ let tail = '(no log)';
206
+ try {
207
+ tail = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-8).join('\n');
208
+ }
209
+ catch { /* ignore */ }
210
+ throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
211
+ }
212
+ /**
213
+ * Open a DB tunnel through the shared bastion. Defaults to SSM (no public port 22);
214
+ * set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel.
215
+ */
216
+ function setupTunnel(dbHost, localPort) {
217
+ if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
218
+ setupSSHTunnel(dbHost, localPort);
219
+ }
220
+ else {
221
+ setupSSMTunnel(dbHost, localPort);
222
+ }
223
+ }
140
224
  // ─── psql ───────────────────────────────────────────────────────────────────
141
225
  function findPsqlPath() {
142
226
  try {
@@ -166,8 +250,7 @@ async function connectAuthDB(env, infisicalConfig, token) {
166
250
  const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
167
251
  const host = exports.RDS_HOSTS[env];
168
252
  const port = env === 'stage' ? 15432 : 15433;
169
- setupSSHTunnel(host, port);
170
- await new Promise(r => setTimeout(r, 1000));
253
+ setupTunnel(host, port);
171
254
  const conn = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
172
255
  return { query: (sql) => queryDB(conn, sql) };
173
256
  }
@@ -180,8 +263,7 @@ async function connectBillingDB(env, infisicalConfig, token) {
180
263
  throw new Error('DATABASE_URL not found for billing service');
181
264
  const parsed = parseDatabaseUrl(dbUrl);
182
265
  const port = env === 'stage' ? 15434 : 15435;
183
- setupSSHTunnel(parsed.host, port);
184
- await new Promise(r => setTimeout(r, 1000));
266
+ setupTunnel(parsed.host, port);
185
267
  const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
186
268
  return { query: (sql) => queryDB(conn, sql) };
187
269
  }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCreate = runCreate;
4
+ const billing_http_1 = require("../billing-http");
5
+ const confirm_prompt_1 = require("../confirm-prompt");
6
+ /** Normalize a date/datetime arg to a full ISO datetime string (billing requires z.iso.datetime). */
7
+ function toIso(v) {
8
+ const d = new Date(v);
9
+ if (isNaN(d.getTime()))
10
+ throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
11
+ return d.toISOString();
12
+ }
13
+ function parseArgs(argv) {
14
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
15
+ console.log(`Usage: optima-discount create --code <CODE> --percent <1-100> [options]
16
+
17
+ Required:
18
+ --code <CODE> Promo code (stored uppercased)
19
+ --percent <1-100> Percentage off
20
+
21
+ Optional:
22
+ --products <a,b,...> Limit to these productKeys (default: all)
23
+ --starts <date> Valid-from (YYYY-MM-DD or ISO; default: immediately)
24
+ --ends <date> Valid-until
25
+ --max <N> Max total redemptions (default: unlimited; 1 = single-use)
26
+ --campaign <label> Grouping label
27
+ --env stage|prod (default: stage)
28
+ --yes Skip prod confirmation`);
29
+ process.exit(0);
30
+ }
31
+ const out = { env: 'stage', yes: false };
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 '--code':
37
+ out.code = next;
38
+ i++;
39
+ break;
40
+ case '--percent':
41
+ out.percentOff = parseInt(next, 10);
42
+ i++;
43
+ break;
44
+ case '--products':
45
+ if (!next)
46
+ throw new Error('--products requires a value');
47
+ out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean);
48
+ i++;
49
+ break;
50
+ case '--starts':
51
+ out.startsAt = toIso(next);
52
+ i++;
53
+ break;
54
+ case '--ends':
55
+ out.endsAt = toIso(next);
56
+ i++;
57
+ break;
58
+ case '--max':
59
+ out.maxRedemptions = parseInt(next, 10);
60
+ i++;
61
+ break;
62
+ case '--campaign':
63
+ out.campaign = next;
64
+ i++;
65
+ break;
66
+ case '--env':
67
+ out.env = next;
68
+ i++;
69
+ break;
70
+ case '--yes':
71
+ out.yes = true;
72
+ break;
73
+ default: throw new Error(`Unknown arg: ${a}`);
74
+ }
75
+ }
76
+ if (!out.code)
77
+ throw new Error('--code required');
78
+ if (out.percentOff === undefined || isNaN(out.percentOff))
79
+ throw new Error('--percent required (1-100)');
80
+ return out;
81
+ }
82
+ async function runCreate(argv) {
83
+ const args = parseArgs(argv);
84
+ (0, billing_http_1.validateEnv)(args.env);
85
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
86
+ const body = { code: args.code, percentOff: args.percentOff };
87
+ if (args.productKeys)
88
+ body.productKeys = args.productKeys;
89
+ if (args.startsAt)
90
+ body.startsAt = args.startsAt;
91
+ if (args.endsAt)
92
+ body.endsAt = args.endsAt;
93
+ if (args.maxRedemptions !== undefined)
94
+ body.maxRedemptions = args.maxRedemptions;
95
+ if (args.campaign)
96
+ body.campaign = args.campaign;
97
+ console.log(`\n🎟️ Creating discount code on ${args.env.toUpperCase()}...`);
98
+ const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/discount-codes', body);
99
+ console.log(`✓ Created (HTTP ${res.status}):`);
100
+ console.log(JSON.stringify(res.body, null, 2));
101
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDisable = runDisable;
4
+ const billing_http_1 = require("../billing-http");
5
+ const confirm_prompt_1 = require("../confirm-prompt");
6
+ function parseArgs(argv) {
7
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
+ console.log(`Usage: optima-discount disable --code <CODE> [options]
9
+
10
+ Required:
11
+ --code <CODE>
12
+
13
+ Optional:
14
+ --env stage|prod (default: stage)
15
+ --yes Skip prod confirmation`);
16
+ process.exit(0);
17
+ }
18
+ const out = { env: 'stage', yes: false };
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const a = argv[i];
21
+ const next = argv[i + 1];
22
+ switch (a) {
23
+ case '--code':
24
+ out.code = next;
25
+ i++;
26
+ break;
27
+ case '--env':
28
+ out.env = next;
29
+ i++;
30
+ break;
31
+ case '--yes':
32
+ out.yes = true;
33
+ break;
34
+ default: throw new Error(`Unknown arg: ${a}`);
35
+ }
36
+ }
37
+ if (!out.code)
38
+ throw new Error('--code required');
39
+ return out;
40
+ }
41
+ async function runDisable(argv) {
42
+ const args = parseArgs(argv);
43
+ (0, billing_http_1.validateEnv)(args.env);
44
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
45
+ const res = await (0, billing_http_1.callBilling)(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
46
+ console.log(`✓ Disabled (HTTP ${res.status}):`);
47
+ console.log(JSON.stringify(res.body, null, 2));
48
+ }
@@ -0,0 +1,135 @@
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.runGenerate = runGenerate;
37
+ const fs = __importStar(require("fs"));
38
+ const billing_http_1 = require("../billing-http");
39
+ const confirm_prompt_1 = require("../confirm-prompt");
40
+ function toIso(v) {
41
+ const d = new Date(v);
42
+ if (isNaN(d.getTime()))
43
+ throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
44
+ return d.toISOString();
45
+ }
46
+ function parseArgs(argv) {
47
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
48
+ console.log(`Usage: optima-discount generate --count <N> --percent <1-100> --campaign <label> [options]
49
+
50
+ Generates N unique single-use codes (maxRedemptions=1). Codes are written to a
51
+ local file (NOT printed) to keep them copy-paste clean.
52
+
53
+ Required:
54
+ --count <N> How many codes (1-1000)
55
+ --percent <1-100> Percentage off
56
+ --campaign <label> Grouping label (also the code prefix)
57
+
58
+ Optional:
59
+ --products <a,b,...> Limit to these productKeys
60
+ --starts <date> Valid-from (YYYY-MM-DD or ISO)
61
+ --ends <date> Valid-until
62
+ --env stage|prod (default: stage)
63
+ --yes Skip prod confirmation`);
64
+ process.exit(0);
65
+ }
66
+ const out = { env: 'stage', yes: false };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ const next = argv[i + 1];
70
+ switch (a) {
71
+ case '--count':
72
+ out.count = parseInt(next, 10);
73
+ i++;
74
+ break;
75
+ case '--percent':
76
+ out.percentOff = parseInt(next, 10);
77
+ i++;
78
+ break;
79
+ case '--campaign':
80
+ out.campaign = next;
81
+ i++;
82
+ break;
83
+ case '--products':
84
+ if (!next)
85
+ throw new Error('--products requires a value');
86
+ out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean);
87
+ i++;
88
+ break;
89
+ case '--starts':
90
+ out.startsAt = toIso(next);
91
+ i++;
92
+ break;
93
+ case '--ends':
94
+ out.endsAt = toIso(next);
95
+ i++;
96
+ break;
97
+ case '--env':
98
+ out.env = next;
99
+ i++;
100
+ break;
101
+ case '--yes':
102
+ out.yes = true;
103
+ break;
104
+ default: throw new Error(`Unknown arg: ${a}`);
105
+ }
106
+ }
107
+ if (out.count === undefined || isNaN(out.count))
108
+ throw new Error('--count required (1-1000)');
109
+ if (out.percentOff === undefined || isNaN(out.percentOff))
110
+ throw new Error('--percent required (1-100)');
111
+ if (!out.campaign)
112
+ throw new Error('--campaign required');
113
+ return out;
114
+ }
115
+ async function runGenerate(argv) {
116
+ const args = parseArgs(argv);
117
+ (0, billing_http_1.validateEnv)(args.env);
118
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
119
+ const body = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
120
+ if (args.productKeys)
121
+ body.productKeys = args.productKeys;
122
+ if (args.startsAt)
123
+ body.startsAt = args.startsAt;
124
+ if (args.endsAt)
125
+ body.endsAt = args.endsAt;
126
+ console.log(`\n🎟️ Generating ${args.count} codes on ${args.env.toUpperCase()}...`);
127
+ const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/discount-codes/batch', body);
128
+ const codes = res.body.codes ?? [];
129
+ const safeCampaign = args.campaign.replace(/[^A-Za-z0-9_-]/g, '');
130
+ const file = `./discount-codes-${safeCampaign}-${Date.now()}.txt`;
131
+ // mode 0o600: single-use codes are sensitive-ish; written to the operator's CWD.
132
+ fs.writeFileSync(file, codes.join('\n') + '\n', { encoding: 'utf-8', mode: 0o600 });
133
+ console.log(`✓ Generated ${codes.length} codes (HTTP ${res.status}). Written to: ${file}`);
134
+ console.log(` (codes are in the file, not printed, to keep them copy-paste clean)`);
135
+ }