@optima-chat/dev-skills 0.8.0 → 0.8.2

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.
@@ -50,7 +50,8 @@
50
50
  - `ci` - CI 持续集成环境(开发环境,默认)
51
51
  - `stage` - Stage 预发布环境(ECS Fargate)
52
52
  - `prod` - 生产环境(ECS Fargate)
53
- - `cn-prod`(别名 `cn`)- 阿里云 cn-prod 环境(SAE,cn-beijing,经 buildbox 取日志)
53
+ - `cn-prod`(别名 `cn`)- 阿里云 cn-prod 环境(SAE,cn-beijing,`optima-logs` 直连 SLS)
54
+ - `cn-stage` - 阿里云 cn-stage 预发环境(SAE,cn-beijing,`optima-logs` 直连 SLS)
54
55
 
55
56
  ## 示例
56
57
 
@@ -72,7 +73,7 @@
72
73
  - `ci` 或未指定 → 使用 SSH + Docker Compose(第 0 节,默认)
73
74
  - `stage` → 使用 AWS CloudWatch Logs - ECS(第 1 节)
74
75
  - `prod` → 使用 AWS CloudWatch Logs - ECS(第 2 节)
75
- - `cn-prod` / `cn` → 阿里云 SAE,经 buildbox `DescribeInstanceLog`(第 3 节)
76
+ - `cn-prod` / `cn` / `cn-stage` → 阿里云 SLS,`optima-logs` 直连(第 3 节,免 buildbox)
76
77
 
77
78
  ### 0. CI 环境(environment = "ci" 或默认)
78
79
 
@@ -211,59 +212,38 @@ aws logs get-log-events --log-group-name /ecs/commerce-backend-prod --log-stream
211
212
 
212
213
  **注意**: `optima-store` 仅在 Stage 环境部署
213
214
 
214
- ### 3. cn-prod 环境(environment = "cn-prod" 或 "cn")
215
+ ### 3. cn-prod / cn-stage 环境(environment = "cn-prod"/"cn" 或 "cn-stage")
215
216
 
216
- **部署方式**: 阿里云 **SAE**(Serverless App Engine,cn-beijing),不是 ECS/CloudWatch。
217
- **访问方式**: buildbox(`root@47.94.105.163`,唯一装了 `aliyun-optima` profile + VPC 可达的机器)调 SAE OpenAPI
217
+ **部署方式**: 阿里云 **SAE**(cn-beijing),不是 ECS/CloudWatch。
218
+ **访问方式**: **用 `optima-logs` CLI 直连阿里云 SLS**,不再经 buildbox
218
219
 
219
- > ⚠️ SAE **未配 SLS**,stdout 日志只能用 `DescribeInstanceLog` 取实例**当前缓冲**(非历史检索;重启/滚动后旧日志丢)。要历史检索需给 SAE 配 SLS。
220
+ > 现状更新(2026-06-16):cn-prod / cn-stage 全部服务**已接 SLS**(含 ECI `agent-runtime`)。
221
+ > SLS `GetLogs` 是公网控制面 API,本机 `aliyun-optima` profile 直连即可,支持**历史检索 + 时间窗 + 关键词**——
222
+ > 旧的「SSH buildbox → SAE `DescribeInstanceLog` 取当前缓冲」流程已弃用(缓冲式、重启即丢、不能检索)。
220
223
 
221
- **日志链**: service → AppId → GroupId → InstanceId → `DescribeInstanceLog`
222
-
223
- **关键坑**(实测):
224
- - API RPC-style `aliyun sae DescribeInstanceLog`,ROA 路径是 `/pop/v1/sam/**instance**/describeInstanceLog`(不是 `/sam/app/...`,写错报 "can not find api by path")。
225
- - buildbox 密码在 1P `op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password`(标题带括号必须用 item ID)。
226
-
227
- **service AppId 映射**(cn-prod,2026-06-01;新增/变动用 `aliyun sae ListApplications` 重查):
224
+ **推荐用法(首选)**:
225
+ ```bash
226
+ optima-logs gateway-core # 默认 cn-prod,最近 1h,100 行
227
+ optima-logs gateway-core --env cn-stage
228
+ optima-logs agent-runtime --env cn-prod --since 2h # ECI 容器也直接可见
229
+ optima-logs user-auth --grep error -n 200
230
+ optima-logs gateway-core --since 30m --json | jq . # 机器可读
231
+ ```
228
232
 
229
- | service | AppId |
230
- |---|---|
231
- | `gateway-core` | `a08ce23f-3d3e-4d89-a2cf-53c8adba614e` |
232
- | `agentic-chat` | `6e290c73-a646-43ef-9da5-ad0b2e7eff73` |
233
- | `gw-admin` | `c6bc5a78-b27f-46e2-825a-eaa338c23645` |
234
- | `user-auth` | `d6fbf9de-fed6-4165-978f-7b3a0a456acc` |
235
- | `user-auth-admin` | `54093f99-37bf-4354-abd7-f6e76abdbcb6` |
236
- | `optima-scout` | `f5bb7e82-e57c-4bd4-83ae-2c25f8d38647` |
237
- | `optima-skills` | `55a63ee7-fb75-46e4-99f3-20854a699237` |
238
- | `infisical` | `52b33fc6-b26d-495a-8885-225d38b3d42f` |
233
+ 参数:`--env stage|prod|cn-prod|cn-stage`、`--since 30m|2h|1d`、`--grep <kw>`、`--lines/-n <N>`、`--json`。
234
+ `service` == SLS logstore 名(同名)。列全部 logstore:
235
+ `aliyun sls ListLogStores --project optima-cn-prod-1911493506120573 --region cn-beijing --profile aliyun-optima`
239
236
 
240
- **完整脚本**(本地跑、经 buildbox 中转;改 `SERVICE`/`LINES`):
237
+ **底层(仅参考,正常用 `optima-logs` 即可)**:
238
+ - SLS project:`optima-cn-prod-1911493506120573` / `optima-cn-stage-1911493506120573`(`optima-<env>-<accountId>`)。
239
+ - logstore = service 名;日志正文在 `content` 字段。
241
240
  ```bash
242
- SERVICE=gateway-core; LINES=80
243
- BBPW=$(op-win read "op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password")
244
- declare -A APPID=(
245
- [gateway-core]=a08ce23f-3d3e-4d89-a2cf-53c8adba614e
246
- [agentic-chat]=6e290c73-a646-43ef-9da5-ad0b2e7eff73
247
- [gw-admin]=c6bc5a78-b27f-46e2-825a-eaa338c23645
248
- [user-auth]=d6fbf9de-fed6-4165-978f-7b3a0a456acc
249
- [user-auth-admin]=54093f99-37bf-4354-abd7-f6e76abdbcb6
250
- [optima-scout]=f5bb7e82-e57c-4bd4-83ae-2c25f8d38647
251
- [optima-skills]=55a63ee7-fb75-46e4-99f3-20854a699237
252
- [infisical]=52b33fc6-b26d-495a-8885-225d38b3d42f )
253
- AID=${APPID[$SERVICE]}
254
- sshpass -p "$BBPW" ssh -o StrictHostKeyChecking=no root@47.94.105.163 "bash -s" <<REMOTE | tail -n $LINES
255
- P="--region cn-beijing --profile aliyun-optima"
256
- GID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationGroups --AppId $AID \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)['Data'][0]['GroupId'])")
257
- IID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationInstances --AppId $AID --GroupId \$GID \$P 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin)['Data'];print((d if isinstance(d,list) else d['Instances'])[0]['InstanceId'])")
258
- aliyun sae DescribeInstanceLog --InstanceId "\$IID" \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('Data',''))"
259
- REMOTE
241
+ NOW=$(date +%s); FROM=$((NOW-3600))
242
+ aliyun sls GetLogs --project optima-cn-prod-1911493506120573 --logstore gateway-core \
243
+ --from $FROM --to $NOW --line 100 --reverse true --query error \
244
+ --region cn-beijing --profile aliyun-optima
260
245
  ```
261
246
 
262
- **局限 / 提示**:
263
- - 只取第 1 个 InstanceId;多副本要遍历 `Data` 里全部 instance。
264
- - `agent-runtime` 不在 SAE(是 ECI 动态容器)——它的日志看 `gateway-core` 里 `eci-bridge` 的转发/错误行,或单独查对应 ECI。
265
- - 过滤:把上面输出 `| grep -iE 'error|eci|fail'` 即可(例:排 agentic-chat 链路 ECI 失败就 grep `gateway-core` 日志的 `eci-bridge`)。
266
-
267
247
  ## 完整示例脚本
268
248
 
269
249
  ### Stage 环境
package/bin/cli.js CHANGED
@@ -36,7 +36,7 @@ switch (command) {
36
36
  log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
37
37
  log(' optima-grant-balance <email> --amount <usd> [--env] Grant USD wallet balance', 'cyan');
38
38
  log(' optima-grant-subscription <email|phone|userId> --plan <p> [--env] Grant subscription', 'cyan');
39
- log(' /logs <service> [lines] [env] View service logs (skill)', 'cyan');
39
+ log(' optima-logs <service> [--env] [--since] [--grep] [-n] View logs (cn=SLS 直连/aws=CloudWatch)', 'cyan');
40
40
  log(' /restart-ecs <service> [env] Restart ECS service (skill)', 'cyan');
41
41
 
42
42
  log('\nSupported Services:', 'yellow');
@@ -1,6 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { fetchInfisicalSecret } from './infisical-secrets';
3
- import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets } from './db-utils';
3
+ import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets, resolveUserId } from './db-utils';
4
4
 
5
5
  const USER_AUTH_URLS: Record<string, string> = {
6
6
  stage: 'https://auth.stage.optima.onl',
@@ -292,6 +292,25 @@ export async function resolveUserIdByEmail(env: string, email: string): Promise<
292
292
  return parsed.user_id;
293
293
  }
294
294
 
295
+ /**
296
+ * Resolve email → userId across all envs, picking the path that exists for
297
+ * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
298
+ * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
299
+ * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
300
+ * (resolveUserIdByEmail) — the cn dev-skills token carries the
301
+ * internal:users:write scope that endpoint requires. Lets the entitlement
302
+ * subcommands support cn the same way grant-subscription already does, without
303
+ * each call site re-implementing the branch.
304
+ */
305
+ export async function resolveUserIdByEmailAnyEnv(env: string, email: string): Promise<string> {
306
+ if (env === 'cn-prod' || env === 'cn-stage') {
307
+ return resolveUserIdByEmail(env, email);
308
+ }
309
+ const cfg = getInfisicalConfig();
310
+ const token = getInfisicalToken(cfg);
311
+ return resolveUserId(email, env, cfg, token);
312
+ }
313
+
295
314
  /**
296
315
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
297
316
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -1,17 +1,21 @@
1
1
  import * as readline from 'readline';
2
2
 
3
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.
4
+ * On a production env, print the resolved action and require typing "yes" to
5
+ * proceed. Both AWS `prod` and Aliyun `cn-prod` are production cn-prod must
6
+ * gate identically or the cn rollout silently loses the prod safety prompt.
7
+ * No-op on stage / cn-stage or when --yes was passed. Exits 1 if user declines.
6
8
  */
9
+ const PROD_ENVS = new Set(['prod', 'cn-prod']);
10
+
7
11
  export async function confirmIfProd(
8
12
  env: string,
9
13
  actionDescription: string,
10
14
  skipFlag: boolean,
11
15
  ): Promise<void> {
12
- if (env !== 'prod' || skipFlag) return;
16
+ if (!PROD_ENVS.has(env) || skipFlag) return;
13
17
 
14
- console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
18
+ console.log(`\n⚠️ About to perform on ${env.toUpperCase()}:\n${actionDescription}\n`);
15
19
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
20
  const answer = await new Promise<string>((resolve) => {
17
21
  rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
 
4
4
  interface CreateArgs {
@@ -34,7 +34,7 @@ Optional:
34
34
  --ends <date> Valid-until
35
35
  --max <N> Max total redemptions (default: unlimited; 1 = single-use)
36
36
  --campaign <label> Grouping label
37
- --env stage|prod (default: stage)
37
+ --env stage|prod|cn-prod|cn-stage (default: stage)
38
38
  --yes Skip prod confirmation`);
39
39
  process.exit(0);
40
40
  }
@@ -62,7 +62,7 @@ Optional:
62
62
 
63
63
  export async function runCreate(argv: string[]): Promise<void> {
64
64
  const args = parseArgs(argv);
65
- validateEnv(args.env);
65
+ validateEnvCnProd(args.env);
66
66
  await confirmIfProd(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
67
67
 
68
68
  const body: Record<string, unknown> = { code: args.code, percentOff: args.percentOff };
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
 
4
4
  interface DisableArgs {
@@ -15,7 +15,7 @@ Required:
15
15
  --code <CODE>
16
16
 
17
17
  Optional:
18
- --env stage|prod (default: stage)
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
19
  --yes Skip prod confirmation`);
20
20
  process.exit(0);
21
21
  }
@@ -36,7 +36,7 @@ Optional:
36
36
 
37
37
  export async function runDisable(argv: string[]): Promise<void> {
38
38
  const args = parseArgs(argv);
39
- validateEnv(args.env);
39
+ validateEnvCnProd(args.env);
40
40
  await confirmIfProd(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
41
41
  const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
42
42
  console.log(`✓ Disabled (HTTP ${res.status}):`);
@@ -1,5 +1,5 @@
1
1
  import * as fs from 'fs';
2
- import { callBilling, validateEnv } from '../billing-http';
2
+ import { callBilling, validateEnvCnProd } from '../billing-http';
3
3
  import { confirmIfProd } from '../confirm-prompt';
4
4
 
5
5
  interface GenArgs {
@@ -35,7 +35,7 @@ Optional:
35
35
  --products <a,b,...> Limit to these productKeys
36
36
  --starts <date> Valid-from (YYYY-MM-DD or ISO)
37
37
  --ends <date> Valid-until
38
- --env stage|prod (default: stage)
38
+ --env stage|prod|cn-prod|cn-stage (default: stage)
39
39
  --yes Skip prod confirmation`);
40
40
  process.exit(0);
41
41
  }
@@ -63,7 +63,7 @@ Optional:
63
63
 
64
64
  export async function runGenerate(argv: string[]): Promise<void> {
65
65
  const args = parseArgs(argv);
66
- validateEnv(args.env);
66
+ validateEnvCnProd(args.env);
67
67
  await confirmIfProd(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
68
68
 
69
69
  const body: Record<string, unknown> = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
 
3
3
  interface ListArgs {
4
4
  campaign?: string;
@@ -15,7 +15,7 @@ Optional:
15
15
  --campaign <label> Filter by campaign
16
16
  --code <CODE> Filter by exact code
17
17
  --limit <N> Max rows (default 500, max 1000)
18
- --env stage|prod (default: stage)`);
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
19
19
  process.exit(0);
20
20
  }
21
21
  const out: Partial<ListArgs> = { env: 'stage' };
@@ -35,7 +35,7 @@ Optional:
35
35
 
36
36
  export async function runList(argv: string[]): Promise<void> {
37
37
  const args = parseArgs(argv);
38
- validateEnv(args.env);
38
+ validateEnvCnProd(args.env);
39
39
  const qs = new URLSearchParams();
40
40
  if (args.campaign) qs.set('campaign', args.campaign);
41
41
  if (args.code) qs.set('code', args.code);
@@ -1,6 +1,5 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
3
 
5
4
  interface GrantArgs {
6
5
  email: string;
@@ -21,7 +20,7 @@ Required:
21
20
 
22
21
  Optional:
23
22
  --yes Skip prod confirmation prompt (no-op on stage)
24
- --env stage|prod (default: stage)
23
+ --env stage|prod|cn-prod|cn-stage (default: stage)
25
24
 
26
25
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
27
26
  process.exit(0);
@@ -47,11 +46,9 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
47
46
 
48
47
  export async function runGrant(argv: string[]): Promise<void> {
49
48
  const args = parseArgs(argv);
50
- validateEnv(args.env);
49
+ validateEnvCnProd(args.env);
51
50
 
52
- const cfg = getInfisicalConfig();
53
- const token = getInfisicalToken(cfg);
54
- const userId = await resolveUserId(args.email, args.env, cfg, token);
51
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
55
52
 
56
53
  await confirmIfProd(
57
54
  args.env,
@@ -1,5 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
2
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
3
2
 
4
3
  interface ListArgs {
5
4
  email: string;
@@ -14,7 +13,7 @@ Required:
14
13
  --email <user-email> Resolved to userId via user-auth DB
15
14
 
16
15
  Optional:
17
- --env stage|prod (default: stage)`);
16
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
18
17
  process.exit(0);
19
18
  }
20
19
  const out: Partial<ListArgs> = { env: 'stage' };
@@ -42,11 +41,9 @@ interface EntitlementRow {
42
41
 
43
42
  export async function runList(argv: string[]): Promise<void> {
44
43
  const args = parseArgs(argv);
45
- validateEnv(args.env);
44
+ validateEnvCnProd(args.env);
46
45
 
47
- const cfg = getInfisicalConfig();
48
- const token = getInfisicalToken(cfg);
49
- const userId = await resolveUserId(args.email, args.env, cfg, token);
46
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
50
47
 
51
48
  const res = await callBilling<{ entitlements: EntitlementRow[] }>(
52
49
  args.env,
@@ -1,6 +1,5 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
3
 
5
4
  interface RevokeArgs {
6
5
  email: string;
@@ -28,7 +27,7 @@ Required:
28
27
 
29
28
  Optional:
30
29
  --yes Skip prod confirmation prompt (no-op on stage)
31
- --env stage|prod (default: stage)
30
+ --env stage|prod|cn-prod|cn-stage (default: stage)
32
31
 
33
32
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
34
33
  error pointing to the right reversal flow.`);
@@ -59,11 +58,9 @@ const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI
59
58
 
60
59
  export async function runRevoke(argv: string[]): Promise<void> {
61
60
  const args = parseArgs(argv);
62
- validateEnv(args.env);
61
+ validateEnvCnProd(args.env);
63
62
 
64
- const cfg = getInfisicalConfig();
65
- const token = getInfisicalToken(cfg);
66
- const userId = await resolveUserId(args.email, args.env, cfg, token);
63
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
67
64
 
68
65
  // Step 1: Fetch user's entitlements
69
66
  const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * optima-logs —— 一条命令直取服务日志,四环境统一,用 --env 区分。
4
+ *
5
+ * stage / prod : AWS CloudWatch(`aws logs tail /ecs/<svc>-<env>`)
6
+ * cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls GetLogs`)
7
+ *
8
+ * cn 的关键改进:旧流程要 SSH 进 buildbox 再调 SAE `DescribeInstanceLog`,
9
+ * 只能看实例**当前缓冲**(重启即丢、不能检索)。现在 cn-prod/cn-stage 全部
10
+ * 服务已接 SLS,GetLogs 是公网控制面 API,本机 `aliyun-optima` profile 直连即可:
11
+ * - 免 buildbox 跳板
12
+ * - 支持时间窗(--since)+ 关键词检索(--grep)+ 历史(重启不丢)
13
+ *
14
+ * 前置:
15
+ * AWS → 已配 aws CLI 凭证(ap-southeast-1)
16
+ * cn → 已配 aliyun CLI profile `aliyun-optima`(cn-beijing)
17
+ *
18
+ * 用法:
19
+ * optima-logs gateway-core # 默认 cn-prod,最近 1h,100 行
20
+ * optima-logs gateway-core --env cn-stage
21
+ * optima-logs user-auth --env prod --since 2h
22
+ * optima-logs commerce-backend --grep error -n 200
23
+ * optima-logs gateway-core --since 30m --json # 机器可读
24
+ *
25
+ * service == SLS logstore == AWS 日志组 `/ecs/<svc>-<env>` 的 <svc>(同名)。
26
+ */
27
+ import { execFileSync } from 'child_process';
28
+
29
+ const ALIYUN_ACCOUNT = '1911493506120573';
30
+ const ALIYUN_PROFILE = 'aliyun-optima';
31
+ const ALIYUN_REGION = 'cn-beijing';
32
+ const AWS_REGION = 'ap-southeast-1';
33
+
34
+ const AWS_ENVS = ['stage', 'prod'] as const;
35
+ const CN_ENVS = ['cn-prod', 'cn-stage'] as const;
36
+ type Env = (typeof AWS_ENVS)[number] | (typeof CN_ENVS)[number];
37
+ const ALL_ENVS: Env[] = [...AWS_ENVS, ...CN_ENVS];
38
+
39
+ const C = { g: '\x1b[32m', y: '\x1b[33m', b: '\x1b[34m', d: '\x1b[2m', n: '\x1b[0m' };
40
+
41
+ interface Args {
42
+ service: string;
43
+ env: Env;
44
+ lines: number;
45
+ since: string; // 原样字符串,如 1h / 30m / 2d
46
+ grep?: string;
47
+ json: boolean;
48
+ }
49
+
50
+ const HELP = `Usage: optima-logs <service> [options]
51
+
52
+ 四环境统一查日志,cn 直连阿里云 SLS(免 buildbox),AWS 走 CloudWatch。
53
+
54
+ Required:
55
+ <service> 服务名(== SLS logstore == /ecs/<svc>-<env> 的 <svc>)
56
+
57
+ Optional:
58
+ --env <e> stage | prod | cn-prod | cn-stage (default: cn-prod)
59
+ --lines, -n <N> 返回行数 (default: 100)
60
+ --since <dur> 时间窗,如 30m / 2h / 1d / 3600(秒) (default: 1h)
61
+ --grep <kw> 关键词检索(cn=SLS query / aws=filter-pattern)
62
+ --json 原始 JSON 输出(接管道)
63
+ --help, -h 显示本帮助
64
+
65
+ Examples:
66
+ optima-logs gateway-core
67
+ optima-logs user-auth --env prod --since 2h
68
+ optima-logs commerce-backend --env cn-prod --grep error -n 200`;
69
+
70
+ /** 把 30m / 2h / 1d / 纯秒 解析成秒数;同时回填给 aws 用的带单位字符串。 */
71
+ function parseSince(raw: string): { sec: number; awsStr: string } {
72
+ const m = raw.match(/^(\d+)([smhdw]?)$/);
73
+ if (!m) throw new Error(`--since 格式非法: ${raw}(用 30m / 2h / 1d / 纯秒)`);
74
+ const n = parseInt(m[1], 10);
75
+ const unit = m[2] || 's';
76
+ const mult: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
77
+ return { sec: n * mult[unit], awsStr: `${n}${unit}` };
78
+ }
79
+
80
+ function parseArgs(argv: string[]): Args {
81
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
82
+ console.log(HELP);
83
+ process.exit(0);
84
+ }
85
+ const out: Partial<Args> = { env: 'cn-prod', lines: 100, since: '1h', json: false };
86
+ const positional: string[] = [];
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const a = argv[i];
89
+ const next = argv[i + 1];
90
+ switch (a) {
91
+ case '--env': out.env = next as Env; i++; break;
92
+ case '--lines': case '-n': {
93
+ const v = parseInt(next, 10);
94
+ if (isNaN(v) || v <= 0) throw new Error('--lines 需要正整数');
95
+ out.lines = v; i++; break;
96
+ }
97
+ case '--since': out.since = next; i++; break;
98
+ case '--grep': out.grep = next; i++; break;
99
+ case '--json': out.json = true; break;
100
+ default:
101
+ if (a.startsWith('-')) throw new Error(`未知参数: ${a}`);
102
+ positional.push(a);
103
+ }
104
+ }
105
+ if (positional.length === 0) throw new Error('缺少 <service>(见 --help)');
106
+ if (positional.length > 1) throw new Error(`多余参数: ${positional.slice(1).join(' ')}`);
107
+ out.service = positional[0];
108
+ if (!ALL_ENVS.includes(out.env as Env)) {
109
+ throw new Error(`--env 非法: ${out.env}(可选 ${ALL_ENVS.join(' | ')})`);
110
+ }
111
+ return out as Args;
112
+ }
113
+
114
+ function run(cmd: string, args: string[]): string {
115
+ return execFileSync(cmd, args, { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024 });
116
+ }
117
+
118
+ /** AWS CloudWatch:aws logs tail /ecs/<svc>-<env>。 */
119
+ function fetchAws(args: Args): void {
120
+ const group = `/ecs/${args.service}-${args.env}`;
121
+ const { awsStr } = parseSince(args.since);
122
+ const cmd = ['logs', 'tail', group, '--since', awsStr, '--region', AWS_REGION, '--format', args.json ? 'json' : 'short'];
123
+ if (args.grep) cmd.push('--filter-pattern', args.grep);
124
+ process.stderr.write(`${C.d}# AWS CloudWatch ${group} (since ${awsStr}${args.grep ? `, grep "${args.grep}"` : ''})${C.n}\n`);
125
+ try {
126
+ process.stdout.write(run('aws', cmd));
127
+ } catch (e: any) {
128
+ if (/ResourceNotFoundException/.test(e.stderr || e.message || '')) {
129
+ throw new Error(`日志组不存在: ${group}\n 确认服务名/环境对,或该服务未部署在 ${args.env}。`);
130
+ }
131
+ throw new Error(e.stderr || e.message);
132
+ }
133
+ }
134
+
135
+ /** 阿里云 SLS:aliyun sls GetLogs,project=optima-<env>-<account>,logstore=service。 */
136
+ function fetchCn(args: Args): void {
137
+ const project = `optima-${args.env}-${ALIYUN_ACCOUNT}`;
138
+ const { sec } = parseSince(args.since);
139
+ const now = Math.floor(Date.now() / 1000);
140
+ const from = now - sec;
141
+ const cmd = [
142
+ 'sls', 'GetLogs',
143
+ '--project', project,
144
+ '--logstore', args.service,
145
+ '--from', String(from),
146
+ '--to', String(now),
147
+ '--line', String(args.lines),
148
+ '--reverse', 'true', // 先拿最新 N 条
149
+ '--region', ALIYUN_REGION,
150
+ '--profile', ALIYUN_PROFILE,
151
+ ];
152
+ if (args.grep) { cmd.push('--query', args.grep); }
153
+ process.stderr.write(`${C.d}# 阿里云 SLS ${project}/${args.service} (since ${args.since}${args.grep ? `, query "${args.grep}"` : ''})${C.n}\n`);
154
+
155
+ let raw: string;
156
+ try {
157
+ raw = run('aliyun', cmd);
158
+ } catch (e: any) {
159
+ const msg = e.stderr || e.message || '';
160
+ if (/LogStoreNotExist|ProjectNotExist/.test(msg)) {
161
+ throw new Error(`SLS logstore 不存在: ${project}/${args.service}\n 确认服务名对,或该服务未接 SLS。列全部:\n aliyun sls ListLogStores --project ${project} --region ${ALIYUN_REGION} --profile ${ALIYUN_PROFILE}`);
162
+ }
163
+ throw new Error(msg);
164
+ }
165
+
166
+ const rows: Array<Record<string, string>> = JSON.parse(raw || '[]');
167
+ // reverse=true 取到的是新→旧,翻回旧→新便于阅读
168
+ rows.reverse();
169
+ if (args.json) {
170
+ console.log(JSON.stringify(rows, null, 2));
171
+ return;
172
+ }
173
+ if (rows.length === 0) {
174
+ process.stderr.write(`${C.y}(无日志:该时间窗内 ${args.service} 无输出,或 --grep 没命中)${C.n}\n`);
175
+ return;
176
+ }
177
+ for (const r of rows) {
178
+ // SLS 存的 content 已含容器时间戳 + stream(stdout/stderr)前缀,直接打印即可
179
+ process.stdout.write((r.content ?? JSON.stringify(r)) + '\n');
180
+ }
181
+ }
182
+
183
+ function main(): void {
184
+ let args: Args;
185
+ try {
186
+ args = parseArgs(process.argv.slice(2));
187
+ } catch (e: any) {
188
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
189
+ process.exit(1);
190
+ }
191
+ try {
192
+ if (CN_ENVS.includes(args.env as any)) fetchCn(args);
193
+ else fetchAws(args);
194
+ } catch (e: any) {
195
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ main();
@@ -6,6 +6,7 @@ exports.getServiceToken = getServiceToken;
6
6
  exports.callBilling = callBilling;
7
7
  exports.callSkills = callSkills;
8
8
  exports.resolveUserIdByEmail = resolveUserIdByEmail;
9
+ exports.resolveUserIdByEmailAnyEnv = resolveUserIdByEmailAnyEnv;
9
10
  exports.resolveUserIdByPhone = resolveUserIdByPhone;
10
11
  exports.getUserById = getUserById;
11
12
  const child_process_1 = require("child_process");
@@ -256,6 +257,24 @@ async function resolveUserIdByEmail(env, email) {
256
257
  console.log(`✓ Found user: ${parsed.user_id}`);
257
258
  return parsed.user_id;
258
259
  }
260
+ /**
261
+ * Resolve email → userId across all envs, picking the path that exists for
262
+ * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
263
+ * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
264
+ * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
265
+ * (resolveUserIdByEmail) — the cn dev-skills token carries the
266
+ * internal:users:write scope that endpoint requires. Lets the entitlement
267
+ * subcommands support cn the same way grant-subscription already does, without
268
+ * each call site re-implementing the branch.
269
+ */
270
+ async function resolveUserIdByEmailAnyEnv(env, email) {
271
+ if (env === 'cn-prod' || env === 'cn-stage') {
272
+ return resolveUserIdByEmail(env, email);
273
+ }
274
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
275
+ const token = (0, db_utils_1.getInfisicalToken)(cfg);
276
+ return (0, db_utils_1.resolveUserId)(email, env, cfg, token);
277
+ }
259
278
  /**
260
279
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
261
280
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -36,13 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.confirmIfProd = confirmIfProd;
37
37
  const readline = __importStar(require("readline"));
38
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.
39
+ * On a production env, print the resolved action and require typing "yes" to
40
+ * proceed. Both AWS `prod` and Aliyun `cn-prod` are production cn-prod must
41
+ * gate identically or the cn rollout silently loses the prod safety prompt.
42
+ * No-op on stage / cn-stage or when --yes was passed. Exits 1 if user declines.
41
43
  */
44
+ const PROD_ENVS = new Set(['prod', 'cn-prod']);
42
45
  async function confirmIfProd(env, actionDescription, skipFlag) {
43
- if (env !== 'prod' || skipFlag)
46
+ if (!PROD_ENVS.has(env) || skipFlag)
44
47
  return;
45
- console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
48
+ console.log(`\n⚠️ About to perform on ${env.toUpperCase()}:\n${actionDescription}\n`);
46
49
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
47
50
  const answer = await new Promise((resolve) => {
48
51
  rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
@@ -24,7 +24,7 @@ Optional:
24
24
  --ends <date> Valid-until
25
25
  --max <N> Max total redemptions (default: unlimited; 1 = single-use)
26
26
  --campaign <label> Grouping label
27
- --env stage|prod (default: stage)
27
+ --env stage|prod|cn-prod|cn-stage (default: stage)
28
28
  --yes Skip prod confirmation`);
29
29
  process.exit(0);
30
30
  }
@@ -81,7 +81,7 @@ Optional:
81
81
  }
82
82
  async function runCreate(argv) {
83
83
  const args = parseArgs(argv);
84
- (0, billing_http_1.validateEnv)(args.env);
84
+ (0, billing_http_1.validateEnvCnProd)(args.env);
85
85
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
86
86
  const body = { code: args.code, percentOff: args.percentOff };
87
87
  if (args.productKeys)
@@ -11,7 +11,7 @@ Required:
11
11
  --code <CODE>
12
12
 
13
13
  Optional:
14
- --env stage|prod (default: stage)
14
+ --env stage|prod|cn-prod|cn-stage (default: stage)
15
15
  --yes Skip prod confirmation`);
16
16
  process.exit(0);
17
17
  }
@@ -40,7 +40,7 @@ Optional:
40
40
  }
41
41
  async function runDisable(argv) {
42
42
  const args = parseArgs(argv);
43
- (0, billing_http_1.validateEnv)(args.env);
43
+ (0, billing_http_1.validateEnvCnProd)(args.env);
44
44
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
45
45
  const res = await (0, billing_http_1.callBilling)(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
46
46
  console.log(`✓ Disabled (HTTP ${res.status}):`);
@@ -59,7 +59,7 @@ Optional:
59
59
  --products <a,b,...> Limit to these productKeys
60
60
  --starts <date> Valid-from (YYYY-MM-DD or ISO)
61
61
  --ends <date> Valid-until
62
- --env stage|prod (default: stage)
62
+ --env stage|prod|cn-prod|cn-stage (default: stage)
63
63
  --yes Skip prod confirmation`);
64
64
  process.exit(0);
65
65
  }
@@ -114,7 +114,7 @@ Optional:
114
114
  }
115
115
  async function runGenerate(argv) {
116
116
  const args = parseArgs(argv);
117
- (0, billing_http_1.validateEnv)(args.env);
117
+ (0, billing_http_1.validateEnvCnProd)(args.env);
118
118
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
119
119
  const body = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
120
120
  if (args.productKeys)
@@ -10,7 +10,7 @@ Optional:
10
10
  --campaign <label> Filter by campaign
11
11
  --code <CODE> Filter by exact code
12
12
  --limit <N> Max rows (default 500, max 1000)
13
- --env stage|prod (default: stage)`);
13
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
14
14
  process.exit(0);
15
15
  }
16
16
  const out = { env: 'stage' };
@@ -45,7 +45,7 @@ Optional:
45
45
  }
46
46
  async function runList(argv) {
47
47
  const args = parseArgs(argv);
48
- (0, billing_http_1.validateEnv)(args.env);
48
+ (0, billing_http_1.validateEnvCnProd)(args.env);
49
49
  const qs = new URLSearchParams();
50
50
  if (args.campaign)
51
51
  qs.set('campaign', args.campaign);
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runGrant = runGrant;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
- const db_utils_1 = require("../db-utils");
7
6
  function parseArgs(argv) {
8
7
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
9
8
  console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
@@ -15,7 +14,7 @@ Required:
15
14
 
16
15
  Optional:
17
16
  --yes Skip prod confirmation prompt (no-op on stage)
18
- --env stage|prod (default: stage)
17
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
18
 
20
19
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
21
20
  process.exit(0);
@@ -57,10 +56,8 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
57
56
  }
58
57
  async function runGrant(argv) {
59
58
  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);
59
+ (0, billing_http_1.validateEnvCnProd)(args.env);
60
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
64
61
  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
62
  console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
66
63
  const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runList = runList;
4
4
  const billing_http_1 = require("../billing-http");
5
- const db_utils_1 = require("../db-utils");
6
5
  function parseArgs(argv) {
7
6
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
7
  console.log(`Usage: optima-entitlement list --email <user-email> [options]
@@ -11,7 +10,7 @@ Required:
11
10
  --email <user-email> Resolved to userId via user-auth DB
12
11
 
13
12
  Optional:
14
- --env stage|prod (default: stage)`);
13
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
15
14
  process.exit(0);
16
15
  }
17
16
  const out = { env: 'stage' };
@@ -36,10 +35,8 @@ Optional:
36
35
  }
37
36
  async function runList(argv) {
38
37
  const args = parseArgs(argv);
39
- (0, billing_http_1.validateEnv)(args.env);
40
- const cfg = (0, db_utils_1.getInfisicalConfig)();
41
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
42
- const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
38
+ (0, billing_http_1.validateEnvCnProd)(args.env);
39
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
43
40
  const res = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
44
41
  const rows = res.body.entitlements ?? [];
45
42
  if (rows.length === 0) {
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runRevoke = runRevoke;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
- const db_utils_1 = require("../db-utils");
7
6
  function parseArgs(argv) {
8
7
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
9
8
  console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
@@ -15,7 +14,7 @@ Required:
15
14
 
16
15
  Optional:
17
16
  --yes Skip prod confirmation prompt (no-op on stage)
18
- --env stage|prod (default: stage)
17
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
18
 
20
19
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
21
20
  error pointing to the right reversal flow.`);
@@ -60,10 +59,8 @@ const PAYMENT_REFUSAL = `refusing to revoke a PAYMENT-source entitlement via CLI
60
59
  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.`;
61
60
  async function runRevoke(argv) {
62
61
  const args = parseArgs(argv);
63
- (0, billing_http_1.validateEnv)(args.env);
64
- const cfg = (0, db_utils_1.getInfisicalConfig)();
65
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
66
- const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
62
+ (0, billing_http_1.validateEnvCnProd)(args.env);
63
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
67
64
  // Step 1: Fetch user's entitlements
68
65
  const listRes = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
69
66
  const all = listRes.body.entitlements ?? [];
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ /**
5
+ * optima-logs —— 一条命令直取服务日志,四环境统一,用 --env 区分。
6
+ *
7
+ * stage / prod : AWS CloudWatch(`aws logs tail /ecs/<svc>-<env>`)
8
+ * cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls GetLogs`)
9
+ *
10
+ * cn 的关键改进:旧流程要 SSH 进 buildbox 再调 SAE `DescribeInstanceLog`,
11
+ * 只能看实例**当前缓冲**(重启即丢、不能检索)。现在 cn-prod/cn-stage 全部
12
+ * 服务已接 SLS,GetLogs 是公网控制面 API,本机 `aliyun-optima` profile 直连即可:
13
+ * - 免 buildbox 跳板
14
+ * - 支持时间窗(--since)+ 关键词检索(--grep)+ 历史(重启不丢)
15
+ *
16
+ * 前置:
17
+ * AWS → 已配 aws CLI 凭证(ap-southeast-1)
18
+ * cn → 已配 aliyun CLI profile `aliyun-optima`(cn-beijing)
19
+ *
20
+ * 用法:
21
+ * optima-logs gateway-core # 默认 cn-prod,最近 1h,100 行
22
+ * optima-logs gateway-core --env cn-stage
23
+ * optima-logs user-auth --env prod --since 2h
24
+ * optima-logs commerce-backend --grep error -n 200
25
+ * optima-logs gateway-core --since 30m --json # 机器可读
26
+ *
27
+ * service == SLS logstore == AWS 日志组 `/ecs/<svc>-<env>` 的 <svc>(同名)。
28
+ */
29
+ const child_process_1 = require("child_process");
30
+ const ALIYUN_ACCOUNT = '1911493506120573';
31
+ const ALIYUN_PROFILE = 'aliyun-optima';
32
+ const ALIYUN_REGION = 'cn-beijing';
33
+ const AWS_REGION = 'ap-southeast-1';
34
+ const AWS_ENVS = ['stage', 'prod'];
35
+ const CN_ENVS = ['cn-prod', 'cn-stage'];
36
+ const ALL_ENVS = [...AWS_ENVS, ...CN_ENVS];
37
+ const C = { g: '\x1b[32m', y: '\x1b[33m', b: '\x1b[34m', d: '\x1b[2m', n: '\x1b[0m' };
38
+ const HELP = `Usage: optima-logs <service> [options]
39
+
40
+ 四环境统一查日志,cn 直连阿里云 SLS(免 buildbox),AWS 走 CloudWatch。
41
+
42
+ Required:
43
+ <service> 服务名(== SLS logstore == /ecs/<svc>-<env> 的 <svc>)
44
+
45
+ Optional:
46
+ --env <e> stage | prod | cn-prod | cn-stage (default: cn-prod)
47
+ --lines, -n <N> 返回行数 (default: 100)
48
+ --since <dur> 时间窗,如 30m / 2h / 1d / 3600(秒) (default: 1h)
49
+ --grep <kw> 关键词检索(cn=SLS query / aws=filter-pattern)
50
+ --json 原始 JSON 输出(接管道)
51
+ --help, -h 显示本帮助
52
+
53
+ Examples:
54
+ optima-logs gateway-core
55
+ optima-logs user-auth --env prod --since 2h
56
+ optima-logs commerce-backend --env cn-prod --grep error -n 200`;
57
+ /** 把 30m / 2h / 1d / 纯秒 解析成秒数;同时回填给 aws 用的带单位字符串。 */
58
+ function parseSince(raw) {
59
+ const m = raw.match(/^(\d+)([smhdw]?)$/);
60
+ if (!m)
61
+ throw new Error(`--since 格式非法: ${raw}(用 30m / 2h / 1d / 纯秒)`);
62
+ const n = parseInt(m[1], 10);
63
+ const unit = m[2] || 's';
64
+ const mult = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
65
+ return { sec: n * mult[unit], awsStr: `${n}${unit}` };
66
+ }
67
+ function parseArgs(argv) {
68
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
69
+ console.log(HELP);
70
+ process.exit(0);
71
+ }
72
+ const out = { env: 'cn-prod', lines: 100, since: '1h', json: false };
73
+ const positional = [];
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const a = argv[i];
76
+ const next = argv[i + 1];
77
+ switch (a) {
78
+ case '--env':
79
+ out.env = next;
80
+ i++;
81
+ break;
82
+ case '--lines':
83
+ case '-n': {
84
+ const v = parseInt(next, 10);
85
+ if (isNaN(v) || v <= 0)
86
+ throw new Error('--lines 需要正整数');
87
+ out.lines = v;
88
+ i++;
89
+ break;
90
+ }
91
+ case '--since':
92
+ out.since = next;
93
+ i++;
94
+ break;
95
+ case '--grep':
96
+ out.grep = next;
97
+ i++;
98
+ break;
99
+ case '--json':
100
+ out.json = true;
101
+ break;
102
+ default:
103
+ if (a.startsWith('-'))
104
+ throw new Error(`未知参数: ${a}`);
105
+ positional.push(a);
106
+ }
107
+ }
108
+ if (positional.length === 0)
109
+ throw new Error('缺少 <service>(见 --help)');
110
+ if (positional.length > 1)
111
+ throw new Error(`多余参数: ${positional.slice(1).join(' ')}`);
112
+ out.service = positional[0];
113
+ if (!ALL_ENVS.includes(out.env)) {
114
+ throw new Error(`--env 非法: ${out.env}(可选 ${ALL_ENVS.join(' | ')})`);
115
+ }
116
+ return out;
117
+ }
118
+ function run(cmd, args) {
119
+ return (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024 });
120
+ }
121
+ /** AWS CloudWatch:aws logs tail /ecs/<svc>-<env>。 */
122
+ function fetchAws(args) {
123
+ const group = `/ecs/${args.service}-${args.env}`;
124
+ const { awsStr } = parseSince(args.since);
125
+ const cmd = ['logs', 'tail', group, '--since', awsStr, '--region', AWS_REGION, '--format', args.json ? 'json' : 'short'];
126
+ if (args.grep)
127
+ cmd.push('--filter-pattern', args.grep);
128
+ process.stderr.write(`${C.d}# AWS CloudWatch ${group} (since ${awsStr}${args.grep ? `, grep "${args.grep}"` : ''})${C.n}\n`);
129
+ try {
130
+ process.stdout.write(run('aws', cmd));
131
+ }
132
+ catch (e) {
133
+ if (/ResourceNotFoundException/.test(e.stderr || e.message || '')) {
134
+ throw new Error(`日志组不存在: ${group}\n 确认服务名/环境对,或该服务未部署在 ${args.env}。`);
135
+ }
136
+ throw new Error(e.stderr || e.message);
137
+ }
138
+ }
139
+ /** 阿里云 SLS:aliyun sls GetLogs,project=optima-<env>-<account>,logstore=service。 */
140
+ function fetchCn(args) {
141
+ const project = `optima-${args.env}-${ALIYUN_ACCOUNT}`;
142
+ const { sec } = parseSince(args.since);
143
+ const now = Math.floor(Date.now() / 1000);
144
+ const from = now - sec;
145
+ const cmd = [
146
+ 'sls', 'GetLogs',
147
+ '--project', project,
148
+ '--logstore', args.service,
149
+ '--from', String(from),
150
+ '--to', String(now),
151
+ '--line', String(args.lines),
152
+ '--reverse', 'true', // 先拿最新 N 条
153
+ '--region', ALIYUN_REGION,
154
+ '--profile', ALIYUN_PROFILE,
155
+ ];
156
+ if (args.grep) {
157
+ cmd.push('--query', args.grep);
158
+ }
159
+ process.stderr.write(`${C.d}# 阿里云 SLS ${project}/${args.service} (since ${args.since}${args.grep ? `, query "${args.grep}"` : ''})${C.n}\n`);
160
+ let raw;
161
+ try {
162
+ raw = run('aliyun', cmd);
163
+ }
164
+ catch (e) {
165
+ const msg = e.stderr || e.message || '';
166
+ if (/LogStoreNotExist|ProjectNotExist/.test(msg)) {
167
+ throw new Error(`SLS logstore 不存在: ${project}/${args.service}\n 确认服务名对,或该服务未接 SLS。列全部:\n aliyun sls ListLogStores --project ${project} --region ${ALIYUN_REGION} --profile ${ALIYUN_PROFILE}`);
168
+ }
169
+ throw new Error(msg);
170
+ }
171
+ const rows = JSON.parse(raw || '[]');
172
+ // reverse=true 取到的是新→旧,翻回旧→新便于阅读
173
+ rows.reverse();
174
+ if (args.json) {
175
+ console.log(JSON.stringify(rows, null, 2));
176
+ return;
177
+ }
178
+ if (rows.length === 0) {
179
+ process.stderr.write(`${C.y}(无日志:该时间窗内 ${args.service} 无输出,或 --grep 没命中)${C.n}\n`);
180
+ return;
181
+ }
182
+ for (const r of rows) {
183
+ // SLS 存的 content 已含容器时间戳 + stream(stdout/stderr)前缀,直接打印即可
184
+ process.stdout.write((r.content ?? JSON.stringify(r)) + '\n');
185
+ }
186
+ }
187
+ function main() {
188
+ let args;
189
+ try {
190
+ args = parseArgs(process.argv.slice(2));
191
+ }
192
+ catch (e) {
193
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
194
+ process.exit(1);
195
+ }
196
+ try {
197
+ if (CN_ENVS.includes(args.env))
198
+ fetchCn(args);
199
+ else
200
+ fetchAws(args);
201
+ }
202
+ catch (e) {
203
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
204
+ process.exit(1);
205
+ }
206
+ }
207
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "optima-generate-test-token": "dist/bin/helpers/generate-test-token.js",
10
10
  "optima-grant-balance": "dist/bin/helpers/grant-balance.js",
11
11
  "optima-grant-subscription": "dist/bin/helpers/grant-subscription.js",
12
+ "optima-logs": "dist/bin/helpers/logs.js",
12
13
  "optima-plugin": "dist/bin/helpers/plugin.js",
13
14
  "optima-discount": "dist/bin/helpers/discount.js",
14
15
  "optima-product": "dist/bin/helpers/product.js",