@wdyy/skills 0.1.3 → 0.1.5

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.
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "name": "wdyy-api-standard",
15
15
  "description": "根据“获取XX数据”“使用XX接口”等具体需求,定位已登记的接口 reference,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口的数据调用时。",
16
- "files": ["SKILL.md", "agents/openai.yaml", "reference/api-根据就诊号获取检验数据.md"]
16
+ "files": ["SKILL.md", "agents/openai.yaml", "reference/api-demo.md", "reference/api-sms.md"]
17
17
  },
18
18
  {
19
19
  "name": "wdyy-bug-record",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  {
24
24
  "name": "wdyy-logging-standard",
25
- "description": "为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、蓝绿实例隔离、traceId 传递、敏感信息脱敏与 2MB 文件轮转。Use when 编写日志、错误处理、前端异常上报或审查生产可观测性时。",
25
+ "description": "为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、traceId 传递、完整原始入参与 2MB 文件轮转。Use when 编写日志、错误处理、接口日志或审查生产可观测性时。",
26
26
  "files": ["SKILL.md", "agents/openai.yaml", "reference/logging-rules.md", "scripts/validate-log-entry.mjs", "scripts/validate-log-entry.test.mjs", "templates/frontend-error-report.template.ts", "templates/logger.template.ts"]
27
27
  }
28
28
  ]
@@ -16,16 +16,17 @@ description: 根据“获取XX数据”“使用XX接口”等具体需求,定
16
16
 
17
17
  ## 路由步骤
18
18
 
19
- 1. 根据需求中的数据名称、业务对象或接口名称,在 `reference/api-*.md` 中查找候选接口。
19
+ 1. 根据需求中的数据名称、业务对象或接口名称,在 `reference/api-*.md` 中查找候选业务接口,排除仅用于说明格式的 `api-demo.md`。
20
20
  2. 仅在唯一匹配时读取该 reference,使用其中的接口 URL、JSON 入参和 JSON 返回值编写调用方式。
21
21
  3. 按 reference 的“备注”实现特殊调用要求。
22
22
  4. 若没有匹配接口、存在多个候选接口,或 URL、入参、返回值不完整,先要求补充或确认接口信息;不得编造调用细节。
23
23
 
24
24
  ## 接口文档约定
25
25
 
26
+ - `reference/api-demo.md` 仅说明接口文档格式,不得作为业务调用依据;其中可使用 `XXX` 等占位内容。
26
27
  - 一个接口对应一个 `reference/api-接口名称.md` 文件。
27
28
  - 文件名去除 `.md` 后必须与“接口名称”字段一致。
28
- - 每个接口文档必须按以下顺序包含六个一级标题:
29
+ - 每个业务接口文档必须按以下顺序包含六个一级标题:
29
30
 
30
31
  ```md
31
32
  # 接口名称:api-xxx
@@ -1,8 +1,10 @@
1
- # 接口名称:api-根据就诊号获取检验数据
1
+ # 模板示例
2
2
 
3
- # 接口用途:根据就诊号获取检验数据
3
+ # 接口名称:api-demo
4
4
 
5
- # 接口URL:http://172.26.1.64:8996/api/execute/getLabInfoByVisitId
5
+ # 接口用途:XXX
6
+
7
+ # 接口URL:XXX
6
8
 
7
9
  # 入参格式:JSON
8
10
 
@@ -0,0 +1,67 @@
1
+ # 接口名称:api-sms
2
+
3
+ # 接口用途:向一个或多个手机号发送相同的短信内容
4
+
5
+ # 接口URL:http://172.26.1.80:8998/api/sms/send
6
+
7
+ `POST` 请求,`Content-Type` 为 `application/json; charset=utf-8`。当前地址为内网默认地址;生产或测试环境地址如有差异,以部署方提供的地址为准。
8
+
9
+ # 入参格式:JSON
10
+
11
+ ```json
12
+ {
13
+ "mobiles": [
14
+ "13800000001",
15
+ "13800000002"
16
+ ],
17
+ "content": "【系统通知】您的验证码是1234"
18
+ }
19
+ ```
20
+
21
+ - `mobiles`:必填,`array[string]`,接收短信的手机号列表;单次最多 200 个手机号。
22
+ - `content`:必填,`string`,短信正文内容。
23
+ - 接入方不传入 token、`userCode` 或 `token` 等短信网关凭证。
24
+
25
+ # 返回值:JSON
26
+
27
+ 成功提交至短信网关时返回 HTTP `200`:
28
+
29
+ ```json
30
+ {
31
+ "msgId": "2601291081805244415"
32
+ }
33
+ ```
34
+
35
+ `msgId` 为短信网关消息流水号,表示请求已提交至网关,不表示短信已最终送达。
36
+
37
+ 失败或被拦截时返回 `error` 字段。手机号列表为空或短信内容为空时返回 HTTP `400`:
38
+
39
+ ```json
40
+ {
41
+ "error": "Error: Mobile number array cannot be empty."
42
+ }
43
+ ```
44
+
45
+ ```json
46
+ {
47
+ "error": "Error: Content cannot be empty."
48
+ }
49
+ ```
50
+
51
+ 触发发送限流时返回 HTTP `429`:
52
+
53
+ ```json
54
+ {
55
+ "error": "Too many requests: 手机号138****0001获取短信过于频繁,请稍后再试"
56
+ }
57
+ ```
58
+
59
+ 短信网关拒绝或返回异常时,接口仍可能返回 HTTP `200`:
60
+
61
+ ```json
62
+ {
63
+ "error": "-41"
64
+ }
65
+ ```
66
+
67
+ # 备注:使用 `POST` 请求调用;响应体存在 `msgId` 才表示提交成功,存在 `error` 表示发送失败或被拦截,不能只根据 HTTP 状态码判断成功。调用方应避免在日志中完整输出手机号、短信内容及验证码。调用前确认短信签名与模板已报备,并遵守单次最多 200 个手机号、单 IP 每分钟最多 120 次、单手机号每分钟最多 3 次的限流规则。
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: wdyy-logging-standard
3
- description: 为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、蓝绿实例隔离、traceId 传递、敏感信息脱敏与 2MB 文件轮转。Use when 编写日志、错误处理、前端异常上报或审查生产可观测性时。
3
+ description: 为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、traceId 传递、完整原始入参与 2MB 文件轮转。Use when 编写日志、错误处理、接口日志或审查生产可观测性时。
4
4
  ---
5
5
 
6
6
  # 企业日志规范
7
7
 
8
8
  ## Overview
9
9
 
10
- 建立可追踪、脱敏、可轮转的结构化日志,以支撑运行时排障而不泄露敏感信息。
10
+ 建立可追踪、可轮转的结构化日志,以支撑运行时排障并保留完整原始上下文。
11
11
 
12
12
  ## When to Use
13
13
 
@@ -15,51 +15,47 @@ description: 为 NestJS 和 Vue 项目实现前端异常上报、后端统一结
15
15
 
16
16
  ## 协作边界
17
17
 
18
- 日志实现必须纳入可观测性、实现计划和测试验证。本 skill 独立规定企业字段与隐私边界,不能降低 RED 指标、追踪或告警要求。
18
+ 日志实现必须纳入可观测性、实现计划和测试验证,不能降低 RED 指标、追踪或告警要求。
19
19
 
20
20
  ## 输入与输出
21
21
 
22
- - 输入:服务名、实例标识、环境、请求上下文、敏感字段清单、`LOG_DIR`。
23
- - 输出:统一 logger、前端异常上报客户端、HTTP 日志中间件、脱敏规则和日志验证结果。
22
+ - 输入:服务名、实例标识、环境、请求上下文、`LOG_DIR`。
23
+ - 输出:统一 logger、前端异常上报客户端、HTTP 日志中间件、日志文件命名规则和日志验证结果。
24
24
  - 使用 [日志规则](reference/logging-rules.md) 和 [logger 模板](templates/logger.template.ts)。
25
25
 
26
26
  ## 执行步骤
27
27
 
28
- 1. 定义 `timestamp`、`level`、`service`、`instanceId`、`env`、`traceId`、`userId`、`method`、`path`、`query`、`body`、`statusCode`、`result`、`durationMs`、`errorCode`、`message`、`params` 的最小 JSON 契约。`timestamp` 必须为服务器本地时间 `YYYY-MM-DD HH:mm:ss`,精确到秒。
28
+ 1. 定义 `timestamp`、`level`、`service`、`instanceId`、`env`、`traceId`、`userId`、`method`、`path`、`query`、`body`、`response`、`statusCode`、`result`、`durationMs`、`errorCode`、`message`、`params` 的最小 JSON 契约。`timestamp` 必须为服务器本地时间 `YYYY-MM-DD HH:mm:ss`,精确到秒。
29
29
  2. 在入口生成或透传 traceId;在内部 REST 调用中继续传递。
30
30
  3. 统一封装 logger;业务代码不得直接使用 `console.log`。
31
- 4. HTTP 请求日志记录 `query`(查询参数)和 `body`(请求体);授权请求可记录实际用户、模式、表和权限等入参。`statusCode` 100–399 `result` `success`,为 400–599 时 `result` 为 `failure`。
32
- 5. password、token、secret、authorization、databaseUrl、idCard、bankCard 为敏感字段,必须连同键和值一起从日志中移除。数组仅保留前 500 项,超出部分追加 `[TRUNCATED N ITEMS]`;拒绝记录未裁剪的大对象。
33
- 6. Vue 捕获未处理异常和 Promise 拒绝,裁剪、脱敏后上报后端;浏览器不得尝试写服务器文件。
34
- 7. 后端按 `LOG_DIR/<service>/<instanceId>/yyyy-mm-dd_hh24-mm-ss.log` 写入,文件名使用服务器本地时间;历史日志不重命名。最新文件超过 2MB 时创建新文件,并测试轮转。
31
+ 4. HTTP 请求日志记录完整的原始 `query`(查询参数)和 `body`(请求体);授权请求可记录实际用户、模式、表和权限等入参。服务端返回成功时记录 `result: success`;返回失败时记录 `result: failure`,并在 `response` 中记录完整失败内容。HTTP `statusCode` 为 100–399 时为成功,400–599 时为失败。
32
+ 5. 日志不执行脱敏、数组截断或大对象裁剪;`body` 必须保留完整原始入参。
33
+ 6. Vue 捕获未处理异常和 Promise 拒绝并上报后端;浏览器不得尝试写服务器文件。
34
+ 7. 后端直接按 `LOG_DIR/yyyy-mm-dd_hh24-mm-ss.log` 写入,禁止创建子目录。若同一秒需要创建多个文件,依次使用 `yyyy-mm-dd_hh24-mm-ss_1.log`、`yyyy-mm-dd_hh24-mm-ss_2.log` 等递增后缀;既有日志文件不得重命名。最新文件超过 2MB 时创建新文件,并测试轮转。
35
35
 
36
36
  ## 禁止事项
37
37
 
38
- - 不得记录秘密、完整敏感身份信息或未裁剪的大请求/响应对象。
39
38
  - 不得将 `message` 仅限制为 `success` 或 `error`。
40
39
  - 不得用 console 输出替代业务日志。
41
40
 
42
41
  ## Red Flags
43
42
 
44
- - 业务代码直接调用 console,或日志包含未脱敏的凭证、身份信息。
43
+ - 业务代码直接调用 console,或 `body` 未保留完整原始入参。
45
44
  - 请求日志缺失 traceId、状态码或耗时。
46
- - 蓝绿容器共享同一日志文件,或前端异常未进入统一后端日志。
45
+ - 同秒创建的日志文件覆盖既有文件,或前端异常未进入统一后端日志。
47
46
 
48
47
  ## Verification
49
48
 
50
49
  - [ ] 正常、异常和 HTTP 请求日志均为可解析 JSON。
51
50
  - [ ] traceId 跨入口与内部调用可追踪。
52
- - [ ] 敏感字段移除、数组截断、HTTP 入参与状态结果映射均有测试覆盖,轮转阈值可验证。
53
- - [ ] 前端异常上报经过裁剪、脱敏和后端身份校验。
54
- - [ ] 蓝绿实例使用独立日志目录,容器替换后日志仍保留。
51
+ - [ ] 完整 HTTP 入参、服务端成功/失败结果映射、失败响应内容与同秒递增命名均有测试覆盖,轮转阈值可验证。
52
+ - [ ] 前端异常上报经过后端身份校验。
53
+ - [ ] 日志直接写入持久化挂载的 `LOG_DIR`,容器替换后日志仍保留。
55
54
  - [ ] `LOG_DIR` 缺失或不可写时服务以明确错误停止。
56
55
 
57
- 无法确定某字段是否敏感时按敏感处理并要求数据负责人确认;不得先原样记录再补救。
58
-
59
56
  ## Common Rationalizations
60
57
 
61
58
  | 合理化说法 | 事实 |
62
59
  |---|---|
63
- | “开发日志不用脱敏” | 开发日志同样会被共享和长期保留。 |
64
60
  | “console.log 足够定位问题” | 无统一字段、轮转和 traceId 的输出不能支撑运维。 |
65
- | “完整请求最方便排查” | 便利不构成暴露隐私和大对象的理由。 |
61
+ | “失败响应只记录状态码即可” | 必须记录完整失败内容,才能定位服务端返回的业务错误。 |
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Enterprise Logging Standard"
3
- short_description: "Implement instance-safe logs and frontend error reporting"
4
- default_prompt: "Use $wdyy-logging-standard to implement frontend error reporting and instance-isolated structured logs."
3
+ short_description: "Implement full-context structured logs and frontend error reporting"
4
+ default_prompt: "Use $wdyy-logging-standard to implement full-context structured logs, result recording, and frontend error reporting."
@@ -1,12 +1,12 @@
1
1
  # 日志规则
2
2
 
3
3
  - 日志为一行 JSON,最小字段见 `logger.template.ts`。
4
+ - `logger.template.ts` 必须可在未安装 `@types/node` 的项目中独立类型检查;读取环境变量时通过 `globalThis` 声明最小运行时形状,不直接依赖 Node.js 全局类型。
4
5
  - `timestamp` 使用服务器本地时间 `YYYY-MM-DD HH:mm:ss`,精确到秒;不得包含毫秒、`T` 或 `Z`。
5
- - 文件名使用服务器本地时间 `yyyy-mm-dd_hh24-mm-ss.log`;最新日志文件大于 2MB 时先创建新文件再写入。既有日志文件不得重命名。
6
- - 日志仅记录关键业务事件、请求摘要和异常;`params`、`query` 和 `body` 必须裁剪。授权请求可记录实际用户、模式、表和权限等入参。
7
- - HTTP 日志必须保留 `statusCode`,且 `result` 100–399 时为 `success`、在 400–599 时为 `failure`。
8
- - password、token、secret、authorization、databaseUrl、idCard、bankCard 必须从输出中完全移除,不得以占位符输出。数组保留前 500 项;超出时追加 `[TRUNCATED N ITEMS]`。
6
+ - 文件直接写入 `LOG_DIR`,不得创建服务、实例或日期子目录。文件名使用服务器本地时间 `yyyy-mm-dd_hh24-mm-ss.log`;同一秒内创建多个文件时,后续文件依次为 `yyyy-mm-dd_hh24-mm-ss_1.log`、`yyyy-mm-dd_hh24-mm-ss_2.log`。最新日志文件大于 2MB 时先创建新文件再写入,既有日志文件不得重命名或覆盖。
7
+ - 日志仅记录关键业务事件、请求和异常;`params`、`query`、`body` 和 `response` 均保留完整原始值,不执行脱敏、数组截断或大对象裁剪。授权请求可记录实际用户、模式、表和权限等入参。
8
+ - HTTP 日志必须保留 `statusCode`。服务端成功返回时 `result` `success`;失败返回时 `result` 为 `failure`,并在 `response` 中保留完整失败内容。100–399 为成功,400–599 为失败。
9
9
  - HTTP 入口和内部服务调用必须透传 traceId。
10
- - 每条日志必须包含 `instanceId`;生产蓝绿容器分别写入 `LOG_DIR/<service>/<instanceId>/`,不得并发写同一文件。
11
- - Vue 未处理异常和 Promise 拒绝必须裁剪、脱敏后上报后端;后端校验请求身份和大小后写入统一日志。
10
+ - 每条日志必须包含 `instanceId`;通过同秒递增后缀避免多个实例或进程覆盖同一日志文件。
11
+ - Vue 未处理异常和 Promise 拒绝必须上报后端;后端校验请求身份后写入统一日志。
12
12
  - 日志目录必须挂载到容器外部持久路径,替换容器不得删除历史日志。
@@ -9,25 +9,6 @@ if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(entry.timestamp)) {
9
9
  throw new Error('timestamp must use local YYYY-MM-DD HH:mm:ss format');
10
10
  }
11
11
 
12
- const sensitiveKeys = new Set([
13
- 'password', 'token', 'secret', 'authorization', 'databaseurl', 'idcard', 'bankcard',
14
- ]);
15
- const validateValue = (value) => {
16
- if (Array.isArray(value)) {
17
- if (value.length > 500 && (value.length !== 501 || !/^\[TRUNCATED \d+ ITEMS\]$/.test(value.at(-1)))) {
18
- throw new Error('Arrays over 500 items must end with a truncation marker');
19
- }
20
- value.forEach(validateValue);
21
- return;
22
- }
23
- if (!value || typeof value !== 'object') return;
24
- for (const [key, item] of Object.entries(value)) {
25
- if (sensitiveKeys.has(key.toLowerCase())) throw new Error('Sensitive fields must be omitted');
26
- validateValue(item);
27
- }
28
- };
29
- validateValue(entry);
30
-
31
12
  if ('statusCode' in entry) {
32
13
  const expected = entry.statusCode >= 100 && entry.statusCode <= 399 ? 'success'
33
14
  : entry.statusCode >= 400 && entry.statusCode <= 599 ? 'failure' : undefined;
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { afterEach, test } from 'node:test';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { createLogFileName } from '../templates/logger.template.ts';
7
8
 
8
9
  const validator = new URL('./validate-log-entry.mjs', import.meta.url);
9
10
  const temporaryDirectories = [];
@@ -24,6 +25,15 @@ async function validate(entry) {
24
25
  return spawnSync(process.execPath, [validator.pathname, input], { encoding: 'utf8' });
25
26
  }
26
27
 
28
+ test('同一秒日志文件使用递增后缀', () => {
29
+ const date = new Date(2026, 6, 28, 9, 5, 3);
30
+
31
+ assert.deepEqual(
32
+ [createLogFileName(date), createLogFileName(date, 1), createLogFileName(date, 2)],
33
+ ['2026-07-28_09-05-03.log', '2026-07-28_09-05-03_1.log', '2026-07-28_09-05-03_2.log'],
34
+ );
35
+ });
36
+
27
37
  test('蓝绿实例日志必须包含 instanceId', async () => {
28
38
  const result = await validate({
29
39
  timestamp: '2026-07-09 08:00:00',
@@ -66,24 +76,7 @@ test('HTTP 状态码与 result 不一致时明确失败', async () => {
66
76
  assert.match(result.stderr, /result/);
67
77
  });
68
78
 
69
- test('每个敏感字段出现在日志中时都明确失败', async () => {
70
- for (const key of ['password', 'token', 'secret', 'authorization', 'databaseUrl', 'idCard', 'bankCard']) {
71
- const result = await validate({
72
- timestamp: '2026-07-09 08:00:00',
73
- level: 'info',
74
- service: 'backend',
75
- instanceId: 'blue',
76
- env: 'production',
77
- message: 'request completed',
78
- body: { [key]: 'not-allowed' },
79
- });
80
-
81
- assert.notEqual(result.status, 0, key);
82
- assert.match(result.stderr, /Sensitive/, key);
83
- }
84
- });
85
-
86
- test('HTTP 成功结果与截断数组可通过验证', async () => {
79
+ test('完整原始入参和成功响应可通过验证', async () => {
87
80
  const result = await validate({
88
81
  timestamp: '2026-07-09 08:00:00',
89
82
  level: 'info',
@@ -93,9 +86,11 @@ test('HTTP 成功结果与截断数组可通过验证', async () => {
93
86
  message: 'request completed',
94
87
  query: { page: '1' },
95
88
  body: {
96
- user: 'operator',
97
- permissions: [...Array.from({ length: 500 }, (_, index) => index), '[TRUNCATED 1 ITEMS]'],
89
+ password: 'unredacted',
90
+ idCard: '110101199001011234',
91
+ permissions: Array.from({ length: 501 }, (_, index) => index),
98
92
  },
93
+ response: { code: 'OK', message: '处理成功' },
99
94
  statusCode: 201,
100
95
  result: 'success',
101
96
  });
@@ -103,19 +98,21 @@ test('HTTP 成功结果与截断数组可通过验证', async () => {
103
98
  assert.equal(result.status, 0, result.stderr);
104
99
  });
105
100
 
106
- test('超过 500 项且没有截断标记时明确失败', async () => {
101
+ test('失败响应内容和 failure 结果可通过验证', async () => {
107
102
  const result = await validate({
108
103
  timestamp: '2026-07-09 08:00:00',
109
104
  level: 'info',
110
105
  service: 'backend',
111
106
  instanceId: 'blue',
112
107
  env: 'production',
113
- message: 'request completed',
114
- body: { items: Array.from({ length: 501 }, (_, index) => index) },
108
+ message: 'request failed',
109
+ body: { data: 'original-request' },
110
+ response: { code: 'INVALID_DATA', message: '参数不合法' },
111
+ statusCode: 422,
112
+ result: 'failure',
115
113
  });
116
114
 
117
- assert.notEqual(result.status, 0);
118
- assert.match(result.stderr, /truncation/);
115
+ assert.equal(result.status, 0, result.stderr);
119
116
  });
120
117
 
121
118
  test('UTC ISO timestamp 时明确失败', async () => {
@@ -7,16 +7,11 @@ type FrontendErrorReport = {
7
7
  };
8
8
 
9
9
  export async function reportFrontendError(report: FrontendErrorReport): Promise<void> {
10
- const payload = {
11
- ...report,
12
- message: report.message.slice(0, 500),
13
- stack: report.stack?.slice(0, 2000),
14
- };
15
10
  const response = await fetch('/api/client-errors', {
16
11
  method: 'POST',
17
12
  headers: { 'Content-Type': 'application/json' },
18
13
  credentials: 'same-origin',
19
- body: JSON.stringify(payload),
14
+ body: JSON.stringify(report),
20
15
  });
21
16
  if (!response.ok) throw new Error(`Frontend error report failed: ${response.status}`);
22
17
  }
@@ -6,6 +6,7 @@ export type LogContext = {
6
6
  statusCode?: number;
7
7
  query?: Record<string, unknown>;
8
8
  body?: unknown;
9
+ response?: unknown;
9
10
  durationMs?: number;
10
11
  errorCode?: string;
11
12
  params?: Record<string, unknown>;
@@ -21,24 +22,16 @@ export type LogEntry = LogContext & {
21
22
  result?: 'success' | 'failure';
22
23
  };
23
24
 
24
- const sensitiveKeys = new Set([
25
- 'password', 'token', 'secret', 'authorization', 'databaseurl', 'idcard', 'bankcard',
26
- ]);
25
+ const pad = (value: number) => String(value).padStart(2, '0');
27
26
 
28
- export const redact = (value: unknown): unknown => {
29
- if (Array.isArray(value)) {
30
- const items = value.slice(0, 500).map(redact);
31
- return value.length > 500
32
- ? [...items, '[TRUNCATED ' + (value.length - 500) + ' ITEMS]']
33
- : items;
34
- }
35
- if (!value || typeof value !== 'object') return value;
36
- return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => (
37
- sensitiveKeys.has(key.toLowerCase()) ? [] : [[key, redact(item)]]
38
- )));
39
- };
27
+ type RuntimeEnvironment = Record<string, string | undefined>;
40
28
 
41
- const pad = (value: number) => String(value).padStart(2, '0');
29
+ const getRuntimeEnvironment = (): RuntimeEnvironment => {
30
+ const runtime = globalThis as typeof globalThis & {
31
+ process?: { env?: RuntimeEnvironment };
32
+ };
33
+ return runtime.process?.env ?? {};
34
+ };
42
35
 
43
36
  export const formatLocalTimestamp = (date = new Date()): string => (
44
37
  [date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join('-')
@@ -46,9 +39,10 @@ export const formatLocalTimestamp = (date = new Date()): string => (
46
39
  + [pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds())].join(':')
47
40
  );
48
41
 
49
- export const createLogFileName = (date = new Date()): string => (
50
- formatLocalTimestamp(date).replace(' ', '_').replaceAll(':', '-') + '.log'
51
- );
42
+ export const createLogFileName = (date = new Date(), collisionIndex = 0): string => {
43
+ const baseName = formatLocalTimestamp(date).replace(' ', '_').replaceAll(':', '-');
44
+ return `${baseName}${collisionIndex > 0 ? `_${collisionIndex}` : ''}.log`;
45
+ };
52
46
 
53
47
  const resultForStatusCode = (statusCode: number): LogEntry['result'] => {
54
48
  if (statusCode >= 100 && statusCode <= 399) return 'success';
@@ -57,18 +51,18 @@ const resultForStatusCode = (statusCode: number): LogEntry['result'] => {
57
51
  };
58
52
 
59
53
  export const toLogEntry = (level: LogEntry['level'], message: string, context: LogContext): LogEntry => {
60
- const sanitizedContext = redact(context) as LogContext;
61
- const result = sanitizedContext.statusCode === undefined
54
+ const environment = getRuntimeEnvironment();
55
+ const result = context.statusCode === undefined
62
56
  ? undefined
63
- : resultForStatusCode(sanitizedContext.statusCode);
57
+ : resultForStatusCode(context.statusCode);
64
58
  return {
65
59
  timestamp: formatLocalTimestamp(),
66
60
  level,
67
- service: process.env.SERVICE_NAME ?? 'backend',
68
- instanceId: process.env.INSTANCE_ID ?? 'local',
69
- env: process.env.NODE_ENV ?? 'development',
61
+ service: environment.SERVICE_NAME ?? 'backend',
62
+ instanceId: environment.INSTANCE_ID ?? 'local',
63
+ env: environment.NODE_ENV ?? 'development',
70
64
  message,
71
- ...sanitizedContext,
65
+ ...context,
72
66
  ...(result ? { result } : {}),
73
67
  };
74
68
  };
package/README.md CHANGED
@@ -29,7 +29,7 @@ npm install -g @wdyy/skills
29
29
  wdyy init
30
30
  ```
31
31
 
32
- `wdyy init` 自动执行 `openspec init`,将 5 个受管理 Skill 安装到 `.agents/skills/`,并在 `docs/bug_record.md` 创建或追加 BUG 记录模板。若目标项目已有 `AGENTS.md`,原文件保持不变,命令将生成 `AGENTS_new.md`;每次运行都会覆盖更新 `.agents/skills/wdyy-*`,但不修改同级自定义 Skill。
32
+ `wdyy init` 会在系统用户目录 `.agents/skills/` 安装或更新 5 个受管理 Skill,不在目标项目创建 `.agents/skills/`。它还会执行 `git init`,创建或补齐 `.gitignore` 的 `.env`、`node_modules/`、`venv/`、`.pnpm-store/`、`logs/`、`dist/`、`.DS_Store`、`*.tar.gz` 规则,创建 `src/frontend`、`src/backend`、`database`、`deploy`、`docs`、`scripts`、`tests` 和空 `.env.example`,并执行 `openspec init`、创建或追加 `docs/bug_record.md` 模板。若目标项目已有 `AGENTS.md`,原文件保持不变,命令将生成 `AGENTS_new.md`。
33
33
 
34
34
  ### 仅更新已安装的 Skill
35
35
 
@@ -41,9 +41,9 @@ wdyy update-skill wdyy-logging-standard
41
41
  wdyy update-skill --all
42
42
  ```
43
43
 
44
- `wdyy update-skill <skill-name>` 只接受 5 个受管理的 `wdyy-*` Skill,且仅替换指定目录;`--all` 替换全部 5 个受管理 Skill。两种方式都不会写入 `AGENTS.md` 或 `AGENTS_new.md`、追加 `docs/bug_record.md`,也不会执行 `openspec init`。首次初始化或需要重新生成项目规则时,使用 `wdyy init`。
44
+ `wdyy update-skill <skill-name>` 只接受 5 个受管理的 `wdyy-*` Skill,且仅替换系统用户目录 `.agents/skills/` 中的指定目录;`--all` 替换全部 5 个受管理 Skill。两种方式都不会写入调用项目的 `AGENTS.md`、`AGENTS_new.md`、`docs/bug_record.md` 或 `.agents/skills/`,也不会执行 `openspec init`。首次初始化或需要重新生成项目规则时,使用 `wdyy init`。
45
45
 
46
- 中大型变更先使用 `/opsx:explore` 或 `/opsx:propose <change-name>`,在方案确认后使用 `/opsx:apply`;完成验证后使用 `/opsx:archive`。涉及数据库、内部 API、日志、部署时,按 `AGENTS.md` 的路由加载相应企业 Skill。配置仅从环境变量读取,不得提交秘密或生产环境 `.env`。
46
+ 中大型变更先使用 `/opsx:explore` 或 `/opsx:propose <change-name>`,在方案确认后使用 `/opsx:apply`;完成验证后使用 `/opsx:archive`。涉及数据库、内部 API、日志、部署时,按 `AGENTS.md` 的路由加载相应企业 Skill。所有 URL、端口和 IP 地址从 `.env` 读取,不得硬编码;不得提交秘密或生产环境 `.env`。项目完成后创建 `README.md`,说明技术栈、目录结构和安装部署。
47
47
 
48
48
  ## 固定技术栈
49
49
 
package/lib/wdyy-cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { access, appendFile, cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -12,6 +13,29 @@ const managedSkillNames = [
12
13
  'wdyy-deployment-standard'
13
14
  ];
14
15
 
16
+ const defaultProjectDirectories = [
17
+ 'src/frontend',
18
+ 'src/backend',
19
+ 'database',
20
+ 'deploy',
21
+ 'docs',
22
+ 'scripts',
23
+ 'tests'
24
+ ];
25
+
26
+ const requiredGitIgnoreEntries = [
27
+ '.env',
28
+ 'node_modules/',
29
+ 'venv/',
30
+ '.pnpm-store/',
31
+ 'logs/',
32
+ 'dist/',
33
+ '.DS_Store',
34
+ '*.tar.gz'
35
+ ];
36
+
37
+ const defaultProjectLayout = [...defaultProjectDirectories, '.env.example'];
38
+
15
39
  const generatedAgentsContent = `# 企业开发规则
16
40
 
17
41
  本项目同时使用OpenSpec及企业 Skill统一开发规则及流程。
@@ -23,9 +47,11 @@ const generatedAgentsContent = `# 企业开发规则
23
47
  - 后端:NestJS。
24
48
  - 数据库:PostgreSQL 18。
25
49
 
26
- 默认项目目录为 \`src/frontend\`、\`src/backend\`、\`database\`、\`deploy\`、\`docs\`、\`scripts\`、\`tests\`、\`.env.example\`。
50
+ 默认项目目录为 ${defaultProjectLayout.map((path) => `\`${path}\``).join('、')}。
27
51
 
28
- 配置仅从环境变量读取;不得提交秘密或生产环境 \`.env\`。
52
+ 所有 URL、端口和 IP 地址必须从 \`.env\` 文件读取,禁止在代码中硬编码;不得提交秘密或生产环境 \`.env\`。
53
+
54
+ 项目开发完成后必须创建 \`README.md\`,内容包括项目技术栈、目录结构和安装部署说明。
29
55
 
30
56
  ## Skill 路由
31
57
 
@@ -54,8 +80,7 @@ function printHelp() {
54
80
  console.log('仅更新当前项目的一个或全部受管理 wdyy Skill,不执行项目初始化。');
55
81
  }
56
82
 
57
- async function installManagedSkills(targetRoot, writtenPaths, skillNames = managedSkillNames) {
58
- const skillsRoot = join(targetRoot, '.agents', 'skills');
83
+ async function installManagedSkills(skillsRoot, writtenPaths, skillNames = managedSkillNames) {
59
84
  await mkdir(skillsRoot, { recursive: true });
60
85
  const temporaryRoot = await mkdtemp(join(skillsRoot, '.wdyy-skills-'));
61
86
 
@@ -78,6 +103,72 @@ async function installManagedSkills(targetRoot, writtenPaths, skillNames = manag
78
103
  }
79
104
  }
80
105
 
106
+ function getGlobalSkillsRoot() {
107
+ return join(homedir(), '.agents', 'skills');
108
+ }
109
+
110
+ function printWrittenPaths(writtenPaths) {
111
+ if (writtenPaths.length > 0) console.error(`已写入路径:\n${writtenPaths.map((path) => `- ${path}`).join('\n')}`);
112
+ }
113
+
114
+ function initializeGit(targetRoot, writtenPaths) {
115
+ const result = spawnSync('git', ['init'], { cwd: targetRoot, stdio: 'inherit' });
116
+ if (result.error || result.status !== 0) {
117
+ const detail = result.error?.message ?? `退出码 ${result.status ?? 1}`;
118
+ console.error(`Git 初始化失败: ${detail}`);
119
+ printWrittenPaths(writtenPaths);
120
+ return result.status ?? 1;
121
+ }
122
+
123
+ writtenPaths.push(join(targetRoot, '.git'));
124
+ console.log('Git 初始化完成。');
125
+ return 0;
126
+ }
127
+
128
+ async function appendGitIgnoreEntries(targetRoot, writtenPaths) {
129
+ const gitIgnorePath = join(targetRoot, '.gitignore');
130
+ let existing = '';
131
+ try {
132
+ existing = await readFile(gitIgnorePath, 'utf8');
133
+ } catch (error) {
134
+ if (error.code !== 'ENOENT') throw error;
135
+ }
136
+
137
+ const existingEntries = new Set(existing.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean));
138
+ const missingEntries = requiredGitIgnoreEntries.filter((entry) => !existingEntries.has(entry));
139
+ if (missingEntries.length === 0 && existing.length > 0) return;
140
+
141
+ const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? '' : '\n';
142
+ await appendFile(gitIgnorePath, `${prefix}${missingEntries.join('\n')}\n`, 'utf8');
143
+ writtenPaths.push(gitIgnorePath);
144
+ console.log(`已更新 Git 忽略规则: ${gitIgnorePath}`);
145
+ }
146
+
147
+ async function createDefaultProjectLayout(targetRoot, writtenPaths) {
148
+ for (const directory of defaultProjectDirectories) {
149
+ const destination = join(targetRoot, directory);
150
+ let existed = true;
151
+ try {
152
+ await access(destination);
153
+ } catch (error) {
154
+ if (error.code !== 'ENOENT') throw error;
155
+ existed = false;
156
+ }
157
+ await mkdir(destination, { recursive: true });
158
+ if (!existed) writtenPaths.push(destination);
159
+ }
160
+
161
+ const envExamplePath = join(targetRoot, '.env.example');
162
+ try {
163
+ await access(envExamplePath);
164
+ } catch (error) {
165
+ if (error.code !== 'ENOENT') throw error;
166
+ await writeFile(envExamplePath, '', 'utf8');
167
+ writtenPaths.push(envExamplePath);
168
+ }
169
+ console.log(`已准备默认项目目录: ${defaultProjectDirectories.join('、')}`);
170
+ }
171
+
81
172
  async function writeAgentsFile(targetRoot, writtenPaths) {
82
173
  const agentsPath = join(targetRoot, 'AGENTS.md');
83
174
  let destination = agentsPath;
@@ -115,7 +206,7 @@ function initializeOpenSpec(targetRoot, writtenPaths) {
115
206
  if (result.error || result.status !== 0) {
116
207
  const detail = result.error?.message ?? `退出码 ${result.status ?? 1}`;
117
208
  console.error(`OpenSpec 初始化失败: ${detail}`);
118
- console.error(`已写入路径:\n${writtenPaths.map((path) => `- ${path}`).join('\n')}`);
209
+ printWrittenPaths(writtenPaths);
119
210
  return result.status ?? 1;
120
211
  }
121
212
 
@@ -127,8 +218,12 @@ export async function run(argumentsList) {
127
218
  if (argumentsList.length === 1 && argumentsList[0] === 'init') {
128
219
  const writtenPaths = [];
129
220
  try {
221
+ const gitExitCode = initializeGit(process.cwd(), writtenPaths);
222
+ if (gitExitCode !== 0) return gitExitCode;
130
223
  await writeAgentsFile(process.cwd(), writtenPaths);
131
- await installManagedSkills(process.cwd(), writtenPaths);
224
+ await installManagedSkills(getGlobalSkillsRoot(), writtenPaths);
225
+ await appendGitIgnoreEntries(process.cwd(), writtenPaths);
226
+ await createDefaultProjectLayout(process.cwd(), writtenPaths);
132
227
  await appendBugRecordTemplate(process.cwd(), writtenPaths);
133
228
  const openSpecExitCode = initializeOpenSpec(process.cwd(), writtenPaths);
134
229
  if (openSpecExitCode !== 0) return openSpecExitCode;
@@ -136,7 +231,7 @@ export async function run(argumentsList) {
136
231
  return 0;
137
232
  } catch (error) {
138
233
  console.error(`wdyy 初始化失败: ${error.message}`);
139
- if (writtenPaths.length > 0) console.error(`已写入路径:\n${writtenPaths.map((path) => `- ${path}`).join('\n')}`);
234
+ printWrittenPaths(writtenPaths);
140
235
  return 1;
141
236
  }
142
237
  }
@@ -156,11 +251,11 @@ export async function run(argumentsList) {
156
251
 
157
252
  const writtenPaths = [];
158
253
  try {
159
- await installManagedSkills(process.cwd(), writtenPaths, skillNames);
254
+ await installManagedSkills(getGlobalSkillsRoot(), writtenPaths, skillNames);
160
255
  return 0;
161
256
  } catch (error) {
162
257
  console.error(`Skill 更新失败: ${error.message}`);
163
- if (writtenPaths.length > 0) console.error(`已写入路径:\n${writtenPaths.map((path) => `- ${path}`).join('\n')}`);
258
+ printWrittenPaths(writtenPaths);
164
259
  return 1;
165
260
  }
166
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdyy/skills",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "企业内部开发 Skill 与项目初始化命令",
5
5
  "type": "module",
6
6
  "bin": {