@optima-chat/dev-skills 0.7.35 → 0.7.37
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/.claude/skills/discount-codes/SKILL.md +61 -0
- package/AGENTS.md +1 -0
- package/README.md +2 -0
- package/bin/helpers/billing-http.ts +41 -12
- package/bin/helpers/discount/create.ts +79 -0
- package/bin/helpers/discount/disable.ts +44 -0
- package/bin/helpers/discount/generate.ts +84 -0
- package/bin/helpers/discount/list.ts +46 -0
- package/bin/helpers/discount.ts +41 -0
- package/bin/helpers/plugin/set-default.ts +65 -0
- package/bin/helpers/plugin/set-paid.ts +69 -0
- package/bin/helpers/plugin/show.ts +42 -0
- package/bin/helpers/plugin.ts +32 -0
- package/dist/bin/helpers/billing-http.js +23 -9
- package/dist/bin/helpers/discount/create.js +101 -0
- package/dist/bin/helpers/discount/disable.js +48 -0
- package/dist/bin/helpers/discount/generate.js +135 -0
- package/dist/bin/helpers/discount/list.js +59 -0
- package/dist/bin/helpers/discount.js +47 -0
- package/dist/bin/helpers/entitlement.js +0 -0
- package/dist/bin/helpers/generate-test-token.js +0 -0
- package/dist/bin/helpers/grant-balance.js +0 -0
- package/dist/bin/helpers/grant-subscription.js +0 -0
- package/dist/bin/helpers/plugin/set-default.js +61 -0
- package/dist/bin/helpers/plugin/set-paid.js +65 -0
- package/dist/bin/helpers/plugin/show.js +45 -0
- package/dist/bin/helpers/plugin.js +39 -0
- package/dist/bin/helpers/product.js +0 -0
- package/dist/bin/helpers/query-db.js +0 -0
- package/dist/bin/helpers/show-env.js +0 -0
- package/docs/2026-05-27-discount-codes-plan-B-dev-skills-cli.md +517 -0
- package/docs/superpowers/plans/2026-05-25-optima-plugin-cli-impl.md +700 -0
- package/docs/superpowers/specs/2026-05-25-optima-plugin-cli-design.md +156 -0
- package/package.json +3 -1
- package/dist/bin/helpers/grant-credits.js +0 -71
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "discount-codes"
|
|
3
|
+
description: "当用户请求创建优惠码、发优惠码、生成折扣码、promo code、discount code、批量生成优惠码、停用优惠码、查看优惠码时,使用此技能。支持 Stage、Prod 两个环境。"
|
|
4
|
+
allowed-tools: ["Bash"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 优惠码管理
|
|
8
|
+
|
|
9
|
+
为 billing 结账(技能包 / 会员)创建和管理百分比优惠码。
|
|
10
|
+
|
|
11
|
+
## 执行方式:使用 CLI 工具
|
|
12
|
+
|
|
13
|
+
无论用户用 `/discount-codes` 还是直接请求,都使用 `optima-discount` CLI(thin HTTP client,调 billing admin 端点):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
optima-discount <subcommand> [options]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 子命令
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 建共享码:LAUNCH20 = 8 折,限 scout,6/30 截止,最多核销 100 次
|
|
23
|
+
optima-discount create --code LAUNCH20 --percent 20 --products scout --ends 2026-06-30 --max 100 --env prod
|
|
24
|
+
|
|
25
|
+
# 批量唯一码(每码用一次)—— 码写入本地文件,不打屏
|
|
26
|
+
optima-discount generate --count 100 --percent 50 --campaign partner-q3 --products scout --env prod
|
|
27
|
+
# → ./discount-codes-partner-q3-<ts>.txt
|
|
28
|
+
|
|
29
|
+
# 查看(按 campaign / code 过滤)
|
|
30
|
+
optima-discount list --campaign partner-q3 --env prod
|
|
31
|
+
|
|
32
|
+
# 停用
|
|
33
|
+
optima-discount disable --code LAUNCH20 --env prod
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 参数
|
|
37
|
+
|
|
38
|
+
| 参数 | 说明 |
|
|
39
|
+
|------|------|
|
|
40
|
+
| `--code` | 优惠码(存为大写) |
|
|
41
|
+
| `--percent` | 折扣百分比 1-100 |
|
|
42
|
+
| `--products` | 逗号分隔的 productKey;省略 = 所有商品 |
|
|
43
|
+
| `--starts` / `--ends` | 有效期(`YYYY-MM-DD` 或 ISO datetime) |
|
|
44
|
+
| `--max` | 总核销上限(省略 = 不限;`1` = 一次性) |
|
|
45
|
+
| `--campaign` | 分组标签(generate 时也是码前缀) |
|
|
46
|
+
| `--count` | generate 生成数量 1-1000 |
|
|
47
|
+
| `--limit` | list 返回上限(默认 500,最大 1000) |
|
|
48
|
+
| `--env` | `stage` / `prod`(默认 `stage`) |
|
|
49
|
+
| `--yes` | 跳过 prod 二次确认 |
|
|
50
|
+
|
|
51
|
+
## 安全提醒
|
|
52
|
+
|
|
53
|
+
1. **Stage 优先**:默认 `stage`。
|
|
54
|
+
2. **Prod 谨慎**:`create` / `generate` / `disable` 在 prod 会要求输入 "yes" 确认(`--yes` 跳过)。
|
|
55
|
+
3. **唯一码写文件**:`generate` 的码写入当前目录文件(`mode 0600`),不打屏——避免复制时被终端 padding 破坏。
|
|
56
|
+
4. 依赖 billing 已部署对应环境(admin 端点存在)。越界值(如 `--percent 0`)由 billing 服务端校验返 400。
|
|
57
|
+
|
|
58
|
+
## 相关
|
|
59
|
+
|
|
60
|
+
- `optima-product` — 管理付费商品(优惠码作用于其结账)
|
|
61
|
+
- `optima-query-db` — 查 `discount_codes` / `discount_redemptions` 表核对
|
package/AGENTS.md
CHANGED
|
@@ -13,6 +13,7 @@ Prefer the installed CLI tools over reimplementing long shell workflows:
|
|
|
13
13
|
- `optima-grant-balance <email> --amount <usd> [options]`
|
|
14
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
15
|
- `optima-entitlement <grant|revoke|list> [options]` — admin-grant / revoke / list paid-plugin entitlements (refuses revoke of PAYMENT / PARTNER source)
|
|
16
|
+
- `optima-plugin <show|set-paid|set-default> [options]` — flip a plugin's skills-side paid/free state (isPaid) + defaultForUser (the user-facing gate; pairs with optima-product for the billing side)
|
|
16
17
|
|
|
17
18
|
For code-reading tasks across Optima repositories, use `gh` commands against `Optima-Chat/<repo>`.
|
|
18
19
|
|
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ Optima Dev Skills 让 Claude Code 能够直接在 **CI、Stage、Prod** 三个
|
|
|
42
42
|
- **generate-test-token** - 生成测试 Access Token 用于 API 测试
|
|
43
43
|
- **use-commerce-cli** - 使用 Commerce CLI 管理电商店铺
|
|
44
44
|
- **read-code** - 阅读 Optima-Chat 组织下任意仓库的代码
|
|
45
|
+
- **discount-codes** - 创建/生成/查看/停用 billing 优惠码(Stage/Prod)
|
|
45
46
|
|
|
46
47
|
## 👤 用户故事
|
|
47
48
|
|
|
@@ -103,6 +104,7 @@ Claude:
|
|
|
103
104
|
| `optima-query-db` | 数据库查询工具 | `optima-query-db user-auth "SELECT COUNT(*) FROM users" prod` |
|
|
104
105
|
| `optima-show-env` | 查看服务环境变量 | `optima-show-env commerce-backend stage --filter DATABASE` |
|
|
105
106
|
| `optima-generate-test-token` | 生成测试 token | `optima-generate-test-token --business-name "测试店铺"` |
|
|
107
|
+
| `optima-discount` | 优惠码管理 | `optima-discount create --code LAUNCH20 --percent 20 --env stage` |
|
|
106
108
|
|
|
107
109
|
**特点**:
|
|
108
110
|
- ✅ 支持 CI、Stage、Prod 三个环境(query-db)
|
|
@@ -43,6 +43,7 @@ const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
|
|
|
43
43
|
// retry; any transient failure surfaces immediately. Re-run the CLI.
|
|
44
44
|
const tokenCache: Record<string, string> = {};
|
|
45
45
|
const billingUrlCache: Record<string, string> = {};
|
|
46
|
+
const skillsUrlCache: Record<string, string> = {};
|
|
46
47
|
|
|
47
48
|
function getBillingUrl(env: string): string {
|
|
48
49
|
if (billingUrlCache[env]) return billingUrlCache[env];
|
|
@@ -51,6 +52,13 @@ function getBillingUrl(env: string): string {
|
|
|
51
52
|
return url;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
function getSkillsUrl(env: string): string {
|
|
56
|
+
if (skillsUrlCache[env]) return skillsUrlCache[env];
|
|
57
|
+
const url = fetchInfisicalSecret(env, '/shared-secrets/domain-urls', 'SKILLS_REGISTRY_URL');
|
|
58
|
+
skillsUrlCache[env] = url;
|
|
59
|
+
return url;
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
export function getServiceToken(env: string): string {
|
|
55
63
|
if (tokenCache[env]) return tokenCache[env];
|
|
56
64
|
|
|
@@ -89,7 +97,7 @@ interface BillingErrorEnvelope {
|
|
|
89
97
|
code?: string;
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
function
|
|
100
|
+
function formatServiceError(status: number, statusText: string, body: string): string {
|
|
93
101
|
let parsed: BillingErrorEnvelope | null = null;
|
|
94
102
|
try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
|
|
95
103
|
|
|
@@ -114,24 +122,25 @@ function formatBillingError(status: number, statusText: string, body: string): s
|
|
|
114
122
|
return `❌ Error [${status}] ${statusText}\n Response body (first 500 bytes): ${body.slice(0, 500)}`;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
// ───── Public: callBilling
|
|
118
|
-
export interface
|
|
125
|
+
// ───── Public: callService / callBilling / callSkills ───────────────────────
|
|
126
|
+
export interface ServiceResponse<T> {
|
|
119
127
|
status: number;
|
|
120
128
|
body: T;
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
132
|
+
* Authenticated call to an Optima service (billing or skills — same dev-skills
|
|
133
|
+
* M2M token works for both). Returns `{status, body}` on 2xx; throws Error with
|
|
134
|
+
* formatted message on non-2xx. Single retry on 5xx (no backoff — admin CLI).
|
|
127
135
|
*/
|
|
128
|
-
|
|
136
|
+
async function callService<T>(
|
|
137
|
+
baseUrl: string,
|
|
129
138
|
env: string,
|
|
130
139
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
131
140
|
path: string,
|
|
132
141
|
body?: object,
|
|
133
|
-
): Promise<
|
|
134
|
-
const url = `${
|
|
142
|
+
): Promise<ServiceResponse<T>> {
|
|
143
|
+
const url = `${baseUrl}${path}`;
|
|
135
144
|
const token = getServiceToken(env);
|
|
136
145
|
|
|
137
146
|
const doFetch = async () => fetch(url, {
|
|
@@ -145,18 +154,38 @@ export async function callBilling<T = unknown>(
|
|
|
145
154
|
|
|
146
155
|
let res = await doFetch();
|
|
147
156
|
if (res.status >= 500) {
|
|
148
|
-
// One retry on 5xx
|
|
149
157
|
res = await doFetch();
|
|
150
158
|
}
|
|
151
159
|
const text = await res.text();
|
|
152
160
|
if (!res.ok) {
|
|
153
|
-
throw new Error(
|
|
161
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
154
162
|
}
|
|
155
163
|
let parsed: T;
|
|
156
164
|
try {
|
|
157
165
|
parsed = text ? (JSON.parse(text) as T) : (undefined as unknown as T);
|
|
158
166
|
} catch {
|
|
159
|
-
throw new Error(`
|
|
167
|
+
throw new Error(`Service returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
160
168
|
}
|
|
161
169
|
return { status: res.status, body: parsed };
|
|
162
170
|
}
|
|
171
|
+
|
|
172
|
+
/** Kept as an alias for billing-side callers that reference the response type. */
|
|
173
|
+
export interface BillingResponse<T> extends ServiceResponse<T> {}
|
|
174
|
+
|
|
175
|
+
export async function callBilling<T = unknown>(
|
|
176
|
+
env: string,
|
|
177
|
+
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
178
|
+
path: string,
|
|
179
|
+
body?: object,
|
|
180
|
+
): Promise<ServiceResponse<T>> {
|
|
181
|
+
return callService<T>(getBillingUrl(env), env, method, path, body);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function callSkills<T = unknown>(
|
|
185
|
+
env: string,
|
|
186
|
+
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
187
|
+
path: string,
|
|
188
|
+
body?: object,
|
|
189
|
+
): Promise<ServiceResponse<T>> {
|
|
190
|
+
return callService<T>(getSkillsUrl(env), env, method, path, body);
|
|
191
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { callBilling, validateEnv } from '../billing-http';
|
|
2
|
+
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
|
|
4
|
+
interface CreateArgs {
|
|
5
|
+
code: string;
|
|
6
|
+
percentOff: number;
|
|
7
|
+
productKeys?: string[];
|
|
8
|
+
startsAt?: string;
|
|
9
|
+
endsAt?: string;
|
|
10
|
+
maxRedemptions?: number;
|
|
11
|
+
campaign?: string;
|
|
12
|
+
env: string;
|
|
13
|
+
yes: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Normalize a date/datetime arg to a full ISO datetime string (billing requires z.iso.datetime). */
|
|
17
|
+
function toIso(v: string): string {
|
|
18
|
+
const d = new Date(v);
|
|
19
|
+
if (isNaN(d.getTime())) throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
|
|
20
|
+
return d.toISOString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv: string[]): CreateArgs {
|
|
24
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
25
|
+
console.log(`Usage: optima-discount create --code <CODE> --percent <1-100> [options]
|
|
26
|
+
|
|
27
|
+
Required:
|
|
28
|
+
--code <CODE> Promo code (stored uppercased)
|
|
29
|
+
--percent <1-100> Percentage off
|
|
30
|
+
|
|
31
|
+
Optional:
|
|
32
|
+
--products <a,b,...> Limit to these productKeys (default: all)
|
|
33
|
+
--starts <date> Valid-from (YYYY-MM-DD or ISO; default: immediately)
|
|
34
|
+
--ends <date> Valid-until
|
|
35
|
+
--max <N> Max total redemptions (default: unlimited; 1 = single-use)
|
|
36
|
+
--campaign <label> Grouping label
|
|
37
|
+
--env stage|prod (default: stage)
|
|
38
|
+
--yes Skip prod confirmation`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
const out: Partial<CreateArgs> = { env: 'stage', yes: false };
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
const next = argv[i + 1];
|
|
45
|
+
switch (a) {
|
|
46
|
+
case '--code': out.code = next; i++; break;
|
|
47
|
+
case '--percent': out.percentOff = parseInt(next, 10); i++; break;
|
|
48
|
+
case '--products': if (!next) throw new Error('--products requires a value'); out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
|
|
49
|
+
case '--starts': out.startsAt = toIso(next); i++; break;
|
|
50
|
+
case '--ends': out.endsAt = toIso(next); i++; break;
|
|
51
|
+
case '--max': out.maxRedemptions = parseInt(next, 10); i++; break;
|
|
52
|
+
case '--campaign': out.campaign = next; i++; break;
|
|
53
|
+
case '--env': out.env = next; i++; break;
|
|
54
|
+
case '--yes': out.yes = true; break;
|
|
55
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!out.code) throw new Error('--code required');
|
|
59
|
+
if (out.percentOff === undefined || isNaN(out.percentOff)) throw new Error('--percent required (1-100)');
|
|
60
|
+
return out as CreateArgs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runCreate(argv: string[]): Promise<void> {
|
|
64
|
+
const args = parseArgs(argv);
|
|
65
|
+
validateEnv(args.env);
|
|
66
|
+
await confirmIfProd(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
|
|
67
|
+
|
|
68
|
+
const body: Record<string, unknown> = { code: args.code, percentOff: args.percentOff };
|
|
69
|
+
if (args.productKeys) body.productKeys = args.productKeys;
|
|
70
|
+
if (args.startsAt) body.startsAt = args.startsAt;
|
|
71
|
+
if (args.endsAt) body.endsAt = args.endsAt;
|
|
72
|
+
if (args.maxRedemptions !== undefined) body.maxRedemptions = args.maxRedemptions;
|
|
73
|
+
if (args.campaign) body.campaign = args.campaign;
|
|
74
|
+
|
|
75
|
+
console.log(`\n🎟️ Creating discount code on ${args.env.toUpperCase()}...`);
|
|
76
|
+
const res = await callBilling(args.env, 'POST', '/api/billing/admin/discount-codes', body);
|
|
77
|
+
console.log(`✓ Created (HTTP ${res.status}):`);
|
|
78
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { callBilling, validateEnv } from '../billing-http';
|
|
2
|
+
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
|
|
4
|
+
interface DisableArgs {
|
|
5
|
+
code: string;
|
|
6
|
+
env: string;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv: string[]): DisableArgs {
|
|
11
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
12
|
+
console.log(`Usage: optima-discount disable --code <CODE> [options]
|
|
13
|
+
|
|
14
|
+
Required:
|
|
15
|
+
--code <CODE>
|
|
16
|
+
|
|
17
|
+
Optional:
|
|
18
|
+
--env stage|prod (default: stage)
|
|
19
|
+
--yes Skip prod confirmation`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
const out: Partial<DisableArgs> = { env: 'stage', yes: false };
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
const next = argv[i + 1];
|
|
26
|
+
switch (a) {
|
|
27
|
+
case '--code': out.code = next; i++; break;
|
|
28
|
+
case '--env': out.env = next; i++; break;
|
|
29
|
+
case '--yes': out.yes = true; break;
|
|
30
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!out.code) throw new Error('--code required');
|
|
34
|
+
return out as DisableArgs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runDisable(argv: string[]): Promise<void> {
|
|
38
|
+
const args = parseArgs(argv);
|
|
39
|
+
validateEnv(args.env);
|
|
40
|
+
await confirmIfProd(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
|
|
41
|
+
const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
|
|
42
|
+
console.log(`✓ Disabled (HTTP ${res.status}):`);
|
|
43
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
44
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { callBilling, validateEnv } from '../billing-http';
|
|
3
|
+
import { confirmIfProd } from '../confirm-prompt';
|
|
4
|
+
|
|
5
|
+
interface GenArgs {
|
|
6
|
+
count: number;
|
|
7
|
+
percentOff: number;
|
|
8
|
+
campaign: string;
|
|
9
|
+
productKeys?: string[];
|
|
10
|
+
startsAt?: string;
|
|
11
|
+
endsAt?: string;
|
|
12
|
+
env: string;
|
|
13
|
+
yes: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toIso(v: string): string {
|
|
17
|
+
const d = new Date(v);
|
|
18
|
+
if (isNaN(d.getTime())) throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
|
|
19
|
+
return d.toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv: string[]): GenArgs {
|
|
23
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
24
|
+
console.log(`Usage: optima-discount generate --count <N> --percent <1-100> --campaign <label> [options]
|
|
25
|
+
|
|
26
|
+
Generates N unique single-use codes (maxRedemptions=1). Codes are written to a
|
|
27
|
+
local file (NOT printed) to keep them copy-paste clean.
|
|
28
|
+
|
|
29
|
+
Required:
|
|
30
|
+
--count <N> How many codes (1-1000)
|
|
31
|
+
--percent <1-100> Percentage off
|
|
32
|
+
--campaign <label> Grouping label (also the code prefix)
|
|
33
|
+
|
|
34
|
+
Optional:
|
|
35
|
+
--products <a,b,...> Limit to these productKeys
|
|
36
|
+
--starts <date> Valid-from (YYYY-MM-DD or ISO)
|
|
37
|
+
--ends <date> Valid-until
|
|
38
|
+
--env stage|prod (default: stage)
|
|
39
|
+
--yes Skip prod confirmation`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const out: Partial<GenArgs> = { env: 'stage', yes: false };
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
const next = argv[i + 1];
|
|
46
|
+
switch (a) {
|
|
47
|
+
case '--count': out.count = parseInt(next, 10); i++; break;
|
|
48
|
+
case '--percent': out.percentOff = parseInt(next, 10); i++; break;
|
|
49
|
+
case '--campaign': out.campaign = next; i++; break;
|
|
50
|
+
case '--products': if (!next) throw new Error('--products requires a value'); out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
|
|
51
|
+
case '--starts': out.startsAt = toIso(next); i++; break;
|
|
52
|
+
case '--ends': out.endsAt = toIso(next); i++; break;
|
|
53
|
+
case '--env': out.env = next; i++; break;
|
|
54
|
+
case '--yes': out.yes = true; break;
|
|
55
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (out.count === undefined || isNaN(out.count)) throw new Error('--count required (1-1000)');
|
|
59
|
+
if (out.percentOff === undefined || isNaN(out.percentOff)) throw new Error('--percent required (1-100)');
|
|
60
|
+
if (!out.campaign) throw new Error('--campaign required');
|
|
61
|
+
return out as GenArgs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runGenerate(argv: string[]): Promise<void> {
|
|
65
|
+
const args = parseArgs(argv);
|
|
66
|
+
validateEnv(args.env);
|
|
67
|
+
await confirmIfProd(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
|
|
68
|
+
|
|
69
|
+
const body: Record<string, unknown> = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
|
|
70
|
+
if (args.productKeys) body.productKeys = args.productKeys;
|
|
71
|
+
if (args.startsAt) body.startsAt = args.startsAt;
|
|
72
|
+
if (args.endsAt) body.endsAt = args.endsAt;
|
|
73
|
+
|
|
74
|
+
console.log(`\n🎟️ Generating ${args.count} codes on ${args.env.toUpperCase()}...`);
|
|
75
|
+
const res = await callBilling<{ codes: string[] }>(args.env, 'POST', '/api/billing/admin/discount-codes/batch', body);
|
|
76
|
+
const codes = res.body.codes ?? [];
|
|
77
|
+
|
|
78
|
+
const safeCampaign = args.campaign.replace(/[^A-Za-z0-9_-]/g, '');
|
|
79
|
+
const file = `./discount-codes-${safeCampaign}-${Date.now()}.txt`;
|
|
80
|
+
// mode 0o600: single-use codes are sensitive-ish; written to the operator's CWD.
|
|
81
|
+
fs.writeFileSync(file, codes.join('\n') + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
82
|
+
console.log(`✓ Generated ${codes.length} codes (HTTP ${res.status}). Written to: ${file}`);
|
|
83
|
+
console.log(` (codes are in the file, not printed, to keep them copy-paste clean)`);
|
|
84
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { callBilling, validateEnv } from '../billing-http';
|
|
2
|
+
|
|
3
|
+
interface ListArgs {
|
|
4
|
+
campaign?: string;
|
|
5
|
+
code?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
env: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv: string[]): ListArgs {
|
|
11
|
+
if (argv[0] === '-h' || argv[0] === '--help') {
|
|
12
|
+
console.log(`Usage: optima-discount list [options]
|
|
13
|
+
|
|
14
|
+
Optional:
|
|
15
|
+
--campaign <label> Filter by campaign
|
|
16
|
+
--code <CODE> Filter by exact code
|
|
17
|
+
--limit <N> Max rows (default 500, max 1000)
|
|
18
|
+
--env stage|prod (default: stage)`);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const out: Partial<ListArgs> = { env: 'stage' };
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
const next = argv[i + 1];
|
|
25
|
+
switch (a) {
|
|
26
|
+
case '--campaign': out.campaign = next; i++; break;
|
|
27
|
+
case '--code': out.code = next; i++; break;
|
|
28
|
+
case '--limit': { const n = parseInt(next, 10); if (isNaN(n)) throw new Error('--limit requires a number'); out.limit = n; i++; break; }
|
|
29
|
+
case '--env': out.env = next; i++; break;
|
|
30
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out as ListArgs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runList(argv: string[]): Promise<void> {
|
|
37
|
+
const args = parseArgs(argv);
|
|
38
|
+
validateEnv(args.env);
|
|
39
|
+
const qs = new URLSearchParams();
|
|
40
|
+
if (args.campaign) qs.set('campaign', args.campaign);
|
|
41
|
+
if (args.code) qs.set('code', args.code);
|
|
42
|
+
if (args.limit !== undefined) qs.set('limit', String(args.limit));
|
|
43
|
+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
|
|
44
|
+
const res = await callBilling(args.env, 'GET', `/api/billing/admin/discount-codes${suffix}`);
|
|
45
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runCreate } from './discount/create';
|
|
4
|
+
import { runGenerate } from './discount/generate';
|
|
5
|
+
import { runList } from './discount/list';
|
|
6
|
+
import { runDisable } from './discount/disable';
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`Usage: optima-discount <subcommand> [options]
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
create Create one discount code (shared or single-use)
|
|
13
|
+
generate Generate N unique single-use codes (written to a file)
|
|
14
|
+
list List discount codes (filter by campaign/code)
|
|
15
|
+
disable Disable a discount code
|
|
16
|
+
|
|
17
|
+
Run 'optima-discount <subcommand> --help' for subcommand-specific options.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
const [, , subcommand, ...rest] = process.argv;
|
|
22
|
+
if (!subcommand || subcommand === '-h' || subcommand === '--help') {
|
|
23
|
+
printHelp();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
switch (subcommand) {
|
|
27
|
+
case 'create': await runCreate(rest); break;
|
|
28
|
+
case 'generate': await runGenerate(rest); break;
|
|
29
|
+
case 'list': await runList(rest); break;
|
|
30
|
+
case 'disable': await runDisable(rest); break;
|
|
31
|
+
default:
|
|
32
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
33
|
+
printHelp();
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch((err) => {
|
|
39
|
+
console.error(err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { callSkills, validateEnv } from '../billing-http';
|
|
2
|
+
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
|
|
4
|
+
interface SetDefaultArgs {
|
|
5
|
+
slug: string;
|
|
6
|
+
default: boolean;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
env: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv: string[]): SetDefaultArgs {
|
|
12
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
13
|
+
console.log(`Usage: optima-plugin set-default --slug <slug> --default true|false [options]
|
|
14
|
+
|
|
15
|
+
Required:
|
|
16
|
+
--slug <slug>
|
|
17
|
+
--default true|false Sets Plugin.defaultForUser
|
|
18
|
+
|
|
19
|
+
Optional:
|
|
20
|
+
--yes Skip prod confirmation prompt (no-op on stage)
|
|
21
|
+
--env stage|prod (default: stage)
|
|
22
|
+
|
|
23
|
+
Note: no skill-sync broadcast — changes what NEW user syncs receive; does not
|
|
24
|
+
retroactively add/remove the plugin for existing users until their next sync.`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
const out: Partial<SetDefaultArgs> = { env: 'stage', yes: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
switch (a) {
|
|
32
|
+
case '--slug': out.slug = next; i++; break;
|
|
33
|
+
case '--default':
|
|
34
|
+
if (next !== 'true' && next !== 'false') throw new Error('--default must be true or false');
|
|
35
|
+
out.default = next === 'true'; i++; break;
|
|
36
|
+
case '--yes': out.yes = true; break;
|
|
37
|
+
case '--env': out.env = next; i++; break;
|
|
38
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!out.slug) throw new Error('--slug required');
|
|
42
|
+
if (out.default === undefined) throw new Error('--default required (true|false)');
|
|
43
|
+
return out as SetDefaultArgs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runSetDefault(argv: string[]): Promise<void> {
|
|
47
|
+
const args = parseArgs(argv);
|
|
48
|
+
validateEnv(args.env);
|
|
49
|
+
|
|
50
|
+
await confirmIfProd(
|
|
51
|
+
args.env,
|
|
52
|
+
`Action: set defaultForUser=${args.default} on plugin '${args.slug}' (${args.env.toUpperCase()})`,
|
|
53
|
+
args.yes,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
console.log(`\n🔧 Setting defaultForUser=${args.default} on ${args.slug} (${args.env.toUpperCase()})...`);
|
|
57
|
+
const res = await callSkills(
|
|
58
|
+
args.env,
|
|
59
|
+
'PATCH',
|
|
60
|
+
`/api/admin/plugins/${encodeURIComponent(args.slug)}`,
|
|
61
|
+
{ defaultForUser: args.default },
|
|
62
|
+
);
|
|
63
|
+
console.log(`✓ Updated plugin (HTTP ${res.status}):`);
|
|
64
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { callSkills, validateEnv } from '../billing-http';
|
|
2
|
+
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
|
|
4
|
+
interface SetPaidArgs {
|
|
5
|
+
slug: string;
|
|
6
|
+
paid: boolean;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
env: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv: string[]): SetPaidArgs {
|
|
12
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
13
|
+
console.log(`Usage: optima-plugin set-paid --slug <slug> --paid true|false [options]
|
|
14
|
+
|
|
15
|
+
Required:
|
|
16
|
+
--slug <slug>
|
|
17
|
+
--paid true|false Sets Plugin.isPaid (the user-facing paid/free gate)
|
|
18
|
+
|
|
19
|
+
Optional:
|
|
20
|
+
--yes Skip prod confirmation prompt (no-op on stage)
|
|
21
|
+
--env stage|prod (default: stage)
|
|
22
|
+
|
|
23
|
+
Note: salesUrl is NOT settable here (skills PATCH is strict; salesUrl is
|
|
24
|
+
publish-time-only via plugin.json metadata). When isPaid=true and salesUrl is
|
|
25
|
+
null, the 402 falls back to sales.optima.onl.`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
const out: Partial<SetPaidArgs> = { env: 'stage', yes: false };
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const a = argv[i];
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
switch (a) {
|
|
33
|
+
case '--slug': out.slug = next; i++; break;
|
|
34
|
+
case '--paid':
|
|
35
|
+
if (next !== 'true' && next !== 'false') throw new Error('--paid must be true or false');
|
|
36
|
+
out.paid = next === 'true'; 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.slug) throw new Error('--slug required');
|
|
43
|
+
if (out.paid === undefined) throw new Error('--paid required (true|false)');
|
|
44
|
+
return out as SetPaidArgs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runSetPaid(argv: string[]): Promise<void> {
|
|
48
|
+
const args = parseArgs(argv);
|
|
49
|
+
validateEnv(args.env);
|
|
50
|
+
|
|
51
|
+
await confirmIfProd(
|
|
52
|
+
args.env,
|
|
53
|
+
`Action: set isPaid=${args.paid} on plugin '${args.slug}' (${args.env.toUpperCase()})`,
|
|
54
|
+
args.yes,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
console.log(`\n💰 Setting isPaid=${args.paid} on ${args.slug} (${args.env.toUpperCase()})...`);
|
|
58
|
+
const res = await callSkills(
|
|
59
|
+
args.env,
|
|
60
|
+
'PATCH',
|
|
61
|
+
`/api/admin/plugins/${encodeURIComponent(args.slug)}`,
|
|
62
|
+
{ isPaid: args.paid },
|
|
63
|
+
);
|
|
64
|
+
console.log(`✓ Updated plugin (HTTP ${res.status}):`);
|
|
65
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
66
|
+
if (args.paid) {
|
|
67
|
+
console.log(`\nℹ️ Reminder: ensure a billing Product + channel exists for '${args.slug}' (optima-product) or users will 402 with no purchase path. salesUrl is publish-time-only (currently shown above).`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { callSkills, validateEnv } from '../billing-http';
|
|
2
|
+
|
|
3
|
+
interface ShowArgs {
|
|
4
|
+
slug: 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-plugin show --slug <slug> [options]
|
|
11
|
+
|
|
12
|
+
Required:
|
|
13
|
+
--slug <slug>
|
|
14
|
+
|
|
15
|
+
Optional:
|
|
16
|
+
--env stage|prod (default: stage)
|
|
17
|
+
|
|
18
|
+
Note: reads the public GET /api/plugins/:slug — shows isPaid, salesUrl, and
|
|
19
|
+
descriptive fields, but NOT defaultForUser / status / trustLevel (public
|
|
20
|
+
endpoint omits them). Returns 404 for non-ACTIVE plugins.`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
const out: Partial<ShowArgs> = { 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 '--slug': out.slug = next; i++; break;
|
|
29
|
+
case '--env': out.env = next; i++; break;
|
|
30
|
+
default: throw new Error(`Unknown arg: ${a}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!out.slug) throw new Error('--slug required');
|
|
34
|
+
return out as ShowArgs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runShow(argv: string[]): Promise<void> {
|
|
38
|
+
const args = parseArgs(argv);
|
|
39
|
+
validateEnv(args.env);
|
|
40
|
+
const res = await callSkills(args.env, 'GET', `/api/plugins/${encodeURIComponent(args.slug)}`);
|
|
41
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
42
|
+
}
|