@miphamai/cli 0.66.0 → 0.68.0
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/bin/mipham.ts +11 -1
- package/package.json +1 -1
- package/src/core/credential-masker/search.ts +20 -3
- package/src/core/project-scaffold.ts +459 -7
- package/src/index.tsx +1 -1
- package/src/security/fd.ts +52 -0
- package/src/shared/package-info.ts +1 -1
- package/src/tools/file/edit.ts +46 -15
- package/src/tools/file/grep.ts +16 -2
- package/src/tools/file/read.ts +35 -15
- package/src/tools/file/write.ts +21 -2
package/bin/mipham.ts
CHANGED
|
@@ -1070,7 +1070,14 @@ async function runInitCLI(): Promise<boolean> {
|
|
|
1070
1070
|
const targetDir = rawTarget ? resolve(rawTarget.replace(/^~/, homedir())) : process.cwd()
|
|
1071
1071
|
|
|
1072
1072
|
const gitInit = !args.includes('--no-git')
|
|
1073
|
-
const
|
|
1073
|
+
const full = args.includes('--full')
|
|
1074
|
+
const licenseArg = args.find((a) => a.startsWith('--license='))
|
|
1075
|
+
const licenseVal = licenseArg?.split('=')[1]
|
|
1076
|
+
const license =
|
|
1077
|
+
licenseVal === 'mit' || licenseVal === 'apache' || licenseVal === 'proprietary'
|
|
1078
|
+
? licenseVal
|
|
1079
|
+
: undefined
|
|
1080
|
+
const result = scaffoldProject(targetDir, { gitInit, full, license })
|
|
1074
1081
|
|
|
1075
1082
|
const lines: string[] = ['', `✅ Mipham Code 项目已初始化:${targetDir}`, '']
|
|
1076
1083
|
if (result.created.length > 0) {
|
|
@@ -1160,6 +1167,9 @@ async function main() {
|
|
|
1160
1167
|
Usage:
|
|
1161
1168
|
mipham Launch interactive CLI
|
|
1162
1169
|
mipham init [dir] Scaffold a project (CLAUDE.md/MIPHAM.md/README.md)
|
|
1170
|
+
--full Full scaffold: 8-section CLAUDE.md + 7 .md + .github templates
|
|
1171
|
+
--license=<type> mit | apache | proprietary (default: proprietary)
|
|
1172
|
+
--no-git Skip git init
|
|
1163
1173
|
mipham update Update to the latest version
|
|
1164
1174
|
mipham upgrade Same as 'mipham update'
|
|
1165
1175
|
mipham daemon <cmd> Daemon lifecycle (start, stop, status, restart)
|
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
1
2
|
import type { CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
3
|
import { matchCredentialFile } from './matcher'
|
|
3
4
|
import { CREDENTIAL_SENTINEL } from './types'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a path through symlinks before deny-rule matching, so a symlink whose
|
|
8
|
+
* name is innocuous but whose target is a sensitive file is still masked —
|
|
9
|
+
* mirroring Read, which matches on the canonical path. Falls back to the raw
|
|
10
|
+
* path when the target doesn't exist (e.g. synthetic paths in tests).
|
|
11
|
+
*/
|
|
12
|
+
function resolveForMatch(p: string): string {
|
|
13
|
+
try {
|
|
14
|
+
return realpathSync(p)
|
|
15
|
+
} catch {
|
|
16
|
+
return p
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
/**
|
|
6
21
|
* Mask grep/search output: any match line that belongs to a credential file
|
|
7
22
|
* (e.g. `.env`, `.aws/credentials`) has its content replaced with the
|
|
@@ -31,7 +46,9 @@ export function maskSearchOutput(
|
|
|
31
46
|
const m = line.match(/^(.+?):(\d+):(.*)$/)
|
|
32
47
|
if (m) {
|
|
33
48
|
out.push(
|
|
34
|
-
matchCredentialFile(m[1]
|
|
49
|
+
matchCredentialFile(resolveForMatch(m[1]!), config)
|
|
50
|
+
? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}`
|
|
51
|
+
: line,
|
|
35
52
|
)
|
|
36
53
|
} else {
|
|
37
54
|
out.push(line)
|
|
@@ -49,7 +66,7 @@ export function maskSearchOutput(
|
|
|
49
66
|
out.push(line) // blank separator — pass through, keep current file
|
|
50
67
|
continue
|
|
51
68
|
}
|
|
52
|
-
currentMasked = !!matchCredentialFile(line, config)
|
|
69
|
+
currentMasked = !!matchCredentialFile(resolveForMatch(line), config)
|
|
53
70
|
out.push(line)
|
|
54
71
|
}
|
|
55
72
|
|
|
@@ -68,6 +85,6 @@ export function maskGlobOutput(paths: string, config?: CredentialMaskingConfig):
|
|
|
68
85
|
if (!config?.enabled) return paths
|
|
69
86
|
return paths
|
|
70
87
|
.split('\n')
|
|
71
|
-
.map((p) => (matchCredentialFile(p, config) ? CREDENTIAL_SENTINEL : p))
|
|
88
|
+
.map((p) => (matchCredentialFile(resolveForMatch(p), config) ? CREDENTIAL_SENTINEL : p))
|
|
72
89
|
.join('\n')
|
|
73
90
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mipham Code — Project Scaffold
|
|
3
3
|
*
|
|
4
|
-
* `mipham init`
|
|
5
|
-
*
|
|
4
|
+
* `mipham init` 生成规范的项目文档,避免用户「冷建立文件」。
|
|
5
|
+
*
|
|
6
|
+
* 分级:
|
|
7
|
+
* - 基础(默认):CLAUDE.md(简版)+ MIPHAM.md + README.md
|
|
8
|
+
* - 完整(--full):八章 CLAUDE.md + SECURITY/CONTRIBUTING/CODE_OF_CONDUCT/
|
|
9
|
+
* DEVELOPMENT/TRADEMARKS/CHANGELOG/LICENSE + .github 四件套
|
|
10
|
+
*
|
|
11
|
+
* 模板遵循 One Mipham Corporation 规范。
|
|
6
12
|
*/
|
|
7
13
|
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'
|
|
8
14
|
import { join, basename } from 'node:path'
|
|
@@ -14,6 +20,23 @@ export interface ScaffoldResult {
|
|
|
14
20
|
gitInitialized: boolean
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
export type LicenseType = 'proprietary' | 'mit' | 'apache'
|
|
24
|
+
|
|
25
|
+
export interface ScaffoldOptions {
|
|
26
|
+
/** 是否执行 git init(默认 false,由 CLI 传入 true) */
|
|
27
|
+
gitInit?: boolean
|
|
28
|
+
/** 完整脚手架(生成完整 .md 体系 + .github 模板) */
|
|
29
|
+
full?: boolean
|
|
30
|
+
/** 许可证类型,默认 proprietary */
|
|
31
|
+
license?: LicenseType
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COPYRIGHT = '© 2026 One Mipham Corporation(北京华安麦逄科技有限公司)— All Rights Reserved.'
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// 基础模板(mipham init)
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
17
40
|
export function renderMiphamMd(projectName: string): string {
|
|
18
41
|
return `---
|
|
19
42
|
model: mipham-code
|
|
@@ -111,6 +134,405 @@ export function renderReadmeMd(projectName: string): string {
|
|
|
111
134
|
`
|
|
112
135
|
}
|
|
113
136
|
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// 完整模板(mipham init --full)
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export function renderFullClaudeMd(projectName: string): string {
|
|
142
|
+
return `# CLAUDE.md
|
|
143
|
+
|
|
144
|
+
> **项目**: ${projectName}
|
|
145
|
+
> **版本**: 0.1.0
|
|
146
|
+
> **初始化**: \`mipham init --full\`
|
|
147
|
+
> **公司**: One Mipham Corporation | 品牌: MiphamAI
|
|
148
|
+
> **维护人**: One Mipham Corporation 技术委员会
|
|
149
|
+
> **审核周期**: 每季度复审一次
|
|
150
|
+
|
|
151
|
+
This file provides guidance to Claude Code when working with code in this repository.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 一、项目概述
|
|
156
|
+
|
|
157
|
+
[简要描述项目目的和定位]
|
|
158
|
+
|
|
159
|
+
| 模块 | 目录 | 框架 | 语言 |
|
|
160
|
+
|------|------|------|------|
|
|
161
|
+
| [模块名] | \`[目录]\` | [框架] | [语言] |
|
|
162
|
+
|
|
163
|
+
## 二、AI 编码协作原则
|
|
164
|
+
|
|
165
|
+
**原则 1: 编码前先思考 — Think Before Coding**
|
|
166
|
+
- 不确定时必须提问,不得自行假设;存在多种解读时呈现所有选项
|
|
167
|
+
- 有更简单方案时必须指出;有理由时应推回不合理需求
|
|
168
|
+
|
|
169
|
+
**原则 2: 简洁优先 — Simplicity First**
|
|
170
|
+
- 只写解决问题所需的最小代码,不写未要求的功能
|
|
171
|
+
- 不添加未被要求的"灵活性"或"可配置性"
|
|
172
|
+
|
|
173
|
+
**原则 3: 精准修改 — Surgical Changes**
|
|
174
|
+
- 只改被要求修改的内容;匹配现有代码风格
|
|
175
|
+
- 发现无关 dead code 时只提出来,不自行删除
|
|
176
|
+
|
|
177
|
+
**原则 4: 目标驱动执行 — Goal-Driven Execution**
|
|
178
|
+
- 将任务转化为可验证的成功标准;多步骤任务先陈述简要计划
|
|
179
|
+
|
|
180
|
+
## 三、合规与安全(强制性)
|
|
181
|
+
|
|
182
|
+
- 用户数据:传输层 TLS 1.3,存储层 AES-256-GCM(母公司底线)
|
|
183
|
+
- 禁止在代码、日志、配置文件或提交历史中硬编码凭据、API 密钥、令牌
|
|
184
|
+
- PII 脱敏后进入开发/测试环境
|
|
185
|
+
- 面向用户的 AI 功能上线前通过 prompt injection 与对抗攻击测试
|
|
186
|
+
|
|
187
|
+
## 四、代码质量标准
|
|
188
|
+
|
|
189
|
+
- 配置自动化 lint + 格式化工具,通过 CI 强制执行
|
|
190
|
+
- 新代码通过测试后合并;至少一名资深工程师 review
|
|
191
|
+
- 禁止直接提交主分支:feature branch + PR 流程
|
|
192
|
+
- 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/)
|
|
193
|
+
|
|
194
|
+
## 五、技术架构原则
|
|
195
|
+
|
|
196
|
+
- 服务间通信通过定义良好的 API,禁止跨服务直接数据库访问
|
|
197
|
+
- 关键业务路径可观测性:结构化日志 + 指标 + 追踪
|
|
198
|
+
- 基础设施即代码(IaC)
|
|
199
|
+
|
|
200
|
+
## 六、项目结构约定
|
|
201
|
+
|
|
202
|
+
\`\`\`
|
|
203
|
+
${projectName}/
|
|
204
|
+
├── CLAUDE.md # AI 编码规范
|
|
205
|
+
├── SECURITY.md # 安全策略
|
|
206
|
+
├── README.md # 项目说明
|
|
207
|
+
├── LICENSE # 许可证
|
|
208
|
+
├── CHANGELOG.md # 版本变更日志
|
|
209
|
+
├── .github/ # PR/Issue 模板 + CODEOWNERS
|
|
210
|
+
└── src/ # [源代码目录]
|
|
211
|
+
\`\`\`
|
|
212
|
+
|
|
213
|
+
## 七、技术决策框架
|
|
214
|
+
|
|
215
|
+
按优先级评估技术选型:成熟度与社区支持 → 安全合规 → 团队能力匹配 → 技术栈互操作性 → 许可证与供应商风险。
|
|
216
|
+
|
|
217
|
+
## 八、代码评审检查清单
|
|
218
|
+
|
|
219
|
+
- [ ] 无硬编码凭据、密钥或 Token
|
|
220
|
+
- [ ] 敏感数据已加密/脱敏
|
|
221
|
+
- [ ] 新功能有对应测试
|
|
222
|
+
- [ ] 提交信息符合 Conventional Commits
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### 修订历史
|
|
227
|
+
|
|
228
|
+
| 版本 | 日期 | 变更内容 | 维护人 |
|
|
229
|
+
|------|------|---------|--------|
|
|
230
|
+
| 0.1.0 | 2026-08-30 | \`mipham init --full\` 初始化 | 技术委员会 |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
> **版权**: ${COPYRIGHT}
|
|
235
|
+
`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function renderSecurityMd(projectName: string): string {
|
|
239
|
+
return `# Security Policy — ${projectName}
|
|
240
|
+
|
|
241
|
+
## 加密要求
|
|
242
|
+
|
|
243
|
+
- 传输层 TLS 1.3,存储层 AES-256-GCM(母公司底线)
|
|
244
|
+
- 禁止在代码、日志、配置或提交历史中硬编码凭据、API 密钥、令牌
|
|
245
|
+
|
|
246
|
+
## 依赖安全
|
|
247
|
+
|
|
248
|
+
- 第三方依赖引入前通过许可证合规检查(严禁 copyleft/GPL)
|
|
249
|
+
- 定期升级依赖,响应 Dependabot 告警
|
|
250
|
+
|
|
251
|
+
## AI 安全
|
|
252
|
+
|
|
253
|
+
- 面向用户的 AI 功能上线前通过 prompt injection 测试、对抗攻击红队评估
|
|
254
|
+
- LLM System Prompt 版本管理,变更记录原因和审批人
|
|
255
|
+
|
|
256
|
+
## 报告漏洞
|
|
257
|
+
|
|
258
|
+
安全漏洞请勿公开提交 issue,私密报告至维护人(见 README)。
|
|
259
|
+
`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function renderContributingMd(projectName: string, license: LicenseType): string {
|
|
263
|
+
const isOpen = license !== 'proprietary'
|
|
264
|
+
const header = isOpen
|
|
265
|
+
? `# Contributing to ${projectName}
|
|
266
|
+
|
|
267
|
+
感谢你对 **${projectName}** 的关注。本项目采用 **${license === 'apache' ? 'Apache 2.0' : 'MIT'}** 开源许可。
|
|
268
|
+
`
|
|
269
|
+
: `# Contributing to ${projectName}
|
|
270
|
+
|
|
271
|
+
**${projectName} 是专有闭源商业软件**(见 LICENSE)。本文档仅面向**内部团队成员和授权贡献者**,不接受外部 PR。
|
|
272
|
+
`
|
|
273
|
+
|
|
274
|
+
return `${header}
|
|
275
|
+
## 分支与提交
|
|
276
|
+
|
|
277
|
+
- 分支命名:\`feat/xxx\`、\`fix/xxx\`、\`docs/xxx\`、\`chore/xxx\`
|
|
278
|
+
- 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/)
|
|
279
|
+
- \`main\` 受保护,所有变更走 PR + 至少一名资深工程师 review
|
|
280
|
+
|
|
281
|
+
## Pull Request 指南
|
|
282
|
+
|
|
283
|
+
请说明:
|
|
284
|
+
- 改了什么、为什么
|
|
285
|
+
- 如何测试
|
|
286
|
+
- UI 变更附截图
|
|
287
|
+
- 涉及环境变量或数据库迁移时,说明兼容性影响
|
|
288
|
+
|
|
289
|
+
## 安全
|
|
290
|
+
|
|
291
|
+
- 禁止硬编码凭据、密钥或 Token
|
|
292
|
+
- 安全漏洞按 SECURITY.md 渠道私密报告
|
|
293
|
+
|
|
294
|
+
## 许可证
|
|
295
|
+
|
|
296
|
+
${isOpen ? `贡献将被纳入项目许可证之下(见 LICENSE)。` : '未经书面授权,禁止复制、修改、分发或使用(见 LICENSE)。'}
|
|
297
|
+
`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function renderCodeOfConductMd(projectName: string): string {
|
|
301
|
+
return `# Code of Conduct — ${projectName}
|
|
302
|
+
|
|
303
|
+
## 期望行为
|
|
304
|
+
|
|
305
|
+
- 清晰、尊重地沟通
|
|
306
|
+
- 聚焦想法、代码与设计,而非个人
|
|
307
|
+
- 除非确证,否则假设他人善意
|
|
308
|
+
- 保持讨论相关且有建设性
|
|
309
|
+
|
|
310
|
+
## 不可接受的行为
|
|
311
|
+
|
|
312
|
+
- 任何形式的骚扰、歧视或恐吓
|
|
313
|
+
- 人身攻击、侮辱或贬损性言论
|
|
314
|
+
- 未经许可发布他人隐私信息
|
|
315
|
+
|
|
316
|
+
## 举报
|
|
317
|
+
|
|
318
|
+
如遇违反本准则的行为,通过 README.md 中的渠道联系维护者。所有举报严肃、保密处理。
|
|
319
|
+
`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function renderDevelopmentMd(projectName: string): string {
|
|
323
|
+
return `# ${projectName} — 开发指南
|
|
324
|
+
|
|
325
|
+
## 前置条件
|
|
326
|
+
|
|
327
|
+
| 工具 | 版本 | 说明 |
|
|
328
|
+
|------|------|------|
|
|
329
|
+
| [工具] | [版本] | [说明] |
|
|
330
|
+
|
|
331
|
+
## 快速开始
|
|
332
|
+
|
|
333
|
+
\`\`\`bash
|
|
334
|
+
# 1. 安装依赖
|
|
335
|
+
# 2. 配置环境
|
|
336
|
+
# 3. 运行
|
|
337
|
+
\`\`\`
|
|
338
|
+
|
|
339
|
+
## 常用命令
|
|
340
|
+
|
|
341
|
+
\`\`\`bash
|
|
342
|
+
# 构建 / 测试 / lint
|
|
343
|
+
\`\`\`
|
|
344
|
+
|
|
345
|
+
## 环境变量
|
|
346
|
+
|
|
347
|
+
| 变量 | 必填 | 说明 |
|
|
348
|
+
|------|------|------|
|
|
349
|
+
| [变量名] | [是/否] | [说明] |
|
|
350
|
+
|
|
351
|
+
## 测试
|
|
352
|
+
|
|
353
|
+
\`\`\`bash
|
|
354
|
+
# 运行测试
|
|
355
|
+
\`\`\`
|
|
356
|
+
`
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function renderTrademarksMd(projectName: string): string {
|
|
360
|
+
return `# ${projectName} 商标与品牌政策
|
|
361
|
+
|
|
362
|
+
本文件规范 **${projectName}** 商标与品牌资产的使用(名称、Logo、视觉识别)。独立于代码许可证。
|
|
363
|
+
|
|
364
|
+
> 说明:本政策仅为清晰起见,不构成法律意见。
|
|
365
|
+
|
|
366
|
+
## 允许的使用
|
|
367
|
+
|
|
368
|
+
- 讨论官方未修改项目时如实提及"${projectName}"
|
|
369
|
+
- 用名称链接到本仓库
|
|
370
|
+
|
|
371
|
+
## 禁止的使用(未经书面许可)
|
|
372
|
+
|
|
373
|
+
- 以暗示背书、赞助或官方身份的方式使用商标
|
|
374
|
+
- 修改、重绘、裁剪 Logo 或衍生品牌资产
|
|
375
|
+
- 注册或使用易混淆的名称 / 域名 / 社交账号
|
|
376
|
+
|
|
377
|
+
## 商业许可与授权
|
|
378
|
+
|
|
379
|
+
如需白标、OEM、企业部署,联系:\`legal@onemipham.com\`
|
|
380
|
+
`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function renderChangelogMd(projectName: string): string {
|
|
384
|
+
return `# Changelog
|
|
385
|
+
|
|
386
|
+
All notable changes to ${projectName} will be documented in this file.
|
|
387
|
+
|
|
388
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
389
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
390
|
+
|
|
391
|
+
## [0.1.0] — 2026-08-30
|
|
392
|
+
|
|
393
|
+
### Added
|
|
394
|
+
- 初始版本(\`mipham init --full\` 脚手架)
|
|
395
|
+
`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function renderLicenseMd(license: LicenseType): string {
|
|
399
|
+
if (license === 'mit') {
|
|
400
|
+
return `MIT License
|
|
401
|
+
|
|
402
|
+
Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
|
|
403
|
+
|
|
404
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
405
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
406
|
+
in the Software without restriction, including without limitation the rights
|
|
407
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
408
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
409
|
+
furnished to do so, subject to the following conditions:
|
|
410
|
+
|
|
411
|
+
The above copyright notice and this permission notice shall be included in all
|
|
412
|
+
copies or substantial portions of the Software.
|
|
413
|
+
|
|
414
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
415
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
416
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
417
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
418
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
419
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
420
|
+
SOFTWARE.
|
|
421
|
+
`
|
|
422
|
+
}
|
|
423
|
+
if (license === 'apache') {
|
|
424
|
+
return `Apache License 2.0
|
|
425
|
+
|
|
426
|
+
Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
|
|
427
|
+
|
|
428
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
429
|
+
you may not use this file except in compliance with the License.
|
|
430
|
+
You may obtain a copy of the License at:
|
|
431
|
+
|
|
432
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
433
|
+
|
|
434
|
+
Unless required by applicable law or agreed to in writing, software
|
|
435
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
436
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
437
|
+
See the License for the specific language governing permissions and
|
|
438
|
+
limitations under the License.
|
|
439
|
+
`
|
|
440
|
+
}
|
|
441
|
+
return `${projectLicenseProprietary()}
|
|
442
|
+
`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function projectLicenseProprietary(): string {
|
|
446
|
+
return `Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
|
|
447
|
+
All rights reserved.
|
|
448
|
+
|
|
449
|
+
Proprietary Software — Closed Source Commercial Software
|
|
450
|
+
|
|
451
|
+
This software and its source code are the proprietary property of
|
|
452
|
+
One Mipham Corporation. Reproduction, modification, distribution,
|
|
453
|
+
or use of this software in any form is prohibited without explicit
|
|
454
|
+
written authorization.
|
|
455
|
+
`
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function renderPullRequestTemplate(): string {
|
|
459
|
+
return `## 变更说明
|
|
460
|
+
<!-- 改了什么、为什么 -->
|
|
461
|
+
|
|
462
|
+
## 变更类型
|
|
463
|
+
- [ ] feat 新功能
|
|
464
|
+
- [ ] fix 修复
|
|
465
|
+
- [ ] docs 文档
|
|
466
|
+
- [ ] chore 维护
|
|
467
|
+
|
|
468
|
+
## 如何测试
|
|
469
|
+
<!-- 验证步骤与命令 -->
|
|
470
|
+
|
|
471
|
+
## 截图
|
|
472
|
+
<!-- UI 变更附截图 -->
|
|
473
|
+
|
|
474
|
+
## 兼容性影响
|
|
475
|
+
<!-- 环境变量、数据库迁移或破坏性变更 -->
|
|
476
|
+
`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function renderBugReportTemplate(): string {
|
|
480
|
+
return `---
|
|
481
|
+
name: Bug 报告
|
|
482
|
+
about: 报告一个 bug
|
|
483
|
+
title: "[Bug] "
|
|
484
|
+
labels: bug
|
|
485
|
+
assignees: ""
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## 描述
|
|
489
|
+
<!-- 清晰描述 bug -->
|
|
490
|
+
|
|
491
|
+
## 复现步骤
|
|
492
|
+
1.
|
|
493
|
+
2.
|
|
494
|
+
3.
|
|
495
|
+
|
|
496
|
+
## 期望行为
|
|
497
|
+
|
|
498
|
+
## 实际行为
|
|
499
|
+
|
|
500
|
+
## 环境
|
|
501
|
+
- 平台 / 系统:
|
|
502
|
+
- 版本:
|
|
503
|
+
- 日志 / 截图:
|
|
504
|
+
`
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function renderFeatureRequestTemplate(): string {
|
|
508
|
+
return `---
|
|
509
|
+
name: 功能请求
|
|
510
|
+
about: 建议一个新功能
|
|
511
|
+
title: "[Feature] "
|
|
512
|
+
labels: enhancement
|
|
513
|
+
assignees: ""
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## 问题背景
|
|
517
|
+
<!-- 这个功能解决什么问题 -->
|
|
518
|
+
|
|
519
|
+
## 期望方案
|
|
520
|
+
<!-- 描述你期望的功能 -->
|
|
521
|
+
|
|
522
|
+
## 备选方案
|
|
523
|
+
<!-- 考虑过的其他方案 -->
|
|
524
|
+
`
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function renderCodeowners(owner: string): string {
|
|
528
|
+
return `* @${owner.replace(/^@/, '')}
|
|
529
|
+
`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
// 脚手架
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
114
536
|
/** 检测目录是否为「愣建文件夹」——无任何条目(忽略 .DS_Store)。目录不存在则视为非空。 */
|
|
115
537
|
export function isEmptyProject(dir: string): boolean {
|
|
116
538
|
try {
|
|
@@ -121,16 +543,45 @@ export function isEmptyProject(dir: string): boolean {
|
|
|
121
543
|
}
|
|
122
544
|
}
|
|
123
545
|
|
|
124
|
-
|
|
125
|
-
|
|
546
|
+
interface FileSpec {
|
|
547
|
+
path: string
|
|
548
|
+
content: string
|
|
549
|
+
}
|
|
126
550
|
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
551
|
+
function buildFileSpecs(name: string, opts?: ScaffoldOptions): FileSpec[] {
|
|
552
|
+
const full = opts?.full === true
|
|
553
|
+
const license: LicenseType = opts?.license ?? 'proprietary'
|
|
554
|
+
|
|
555
|
+
const specs: FileSpec[] = [
|
|
556
|
+
{ path: 'CLAUDE.md', content: full ? renderFullClaudeMd(name) : renderClaudeMd(name) },
|
|
130
557
|
{ path: 'MIPHAM.md', content: renderMiphamMd(name) },
|
|
131
558
|
{ path: 'README.md', content: renderReadmeMd(name) },
|
|
132
559
|
]
|
|
133
560
|
|
|
561
|
+
if (!full) return specs
|
|
562
|
+
|
|
563
|
+
return [
|
|
564
|
+
...specs,
|
|
565
|
+
{ path: 'SECURITY.md', content: renderSecurityMd(name) },
|
|
566
|
+
{ path: 'CONTRIBUTING.md', content: renderContributingMd(name, license) },
|
|
567
|
+
{ path: 'CODE_OF_CONDUCT.md', content: renderCodeOfConductMd(name) },
|
|
568
|
+
{ path: 'DEVELOPMENT.md', content: renderDevelopmentMd(name) },
|
|
569
|
+
{ path: 'TRADEMARKS.md', content: renderTrademarksMd(name) },
|
|
570
|
+
{ path: 'CHANGELOG.md', content: renderChangelogMd(name) },
|
|
571
|
+
{ path: 'LICENSE', content: renderLicenseMd(license) },
|
|
572
|
+
{ path: '.github/pull_request_template.md', content: renderPullRequestTemplate() },
|
|
573
|
+
{ path: '.github/ISSUE_TEMPLATE/bug_report.md', content: renderBugReportTemplate() },
|
|
574
|
+
{ path: '.github/ISSUE_TEMPLATE/feature_request.md', content: renderFeatureRequestTemplate() },
|
|
575
|
+
{ path: '.github/CODEOWNERS', content: renderCodeowners('sarvadaya') },
|
|
576
|
+
]
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function scaffoldProject(targetDir: string, opts?: ScaffoldOptions): ScaffoldResult {
|
|
580
|
+
mkdirSync(targetDir, { recursive: true })
|
|
581
|
+
|
|
582
|
+
const name = basename(targetDir) || 'my-project'
|
|
583
|
+
const files = buildFileSpecs(name, opts)
|
|
584
|
+
|
|
134
585
|
const created: string[] = []
|
|
135
586
|
const skipped: string[] = []
|
|
136
587
|
|
|
@@ -139,6 +590,7 @@ export function scaffoldProject(targetDir: string, opts?: { gitInit?: boolean })
|
|
|
139
590
|
if (existsSync(full)) {
|
|
140
591
|
skipped.push(f.path)
|
|
141
592
|
} else {
|
|
593
|
+
mkdirSync(join(full, '..'), { recursive: true })
|
|
142
594
|
writeFileSync(full, f.content, 'utf-8')
|
|
143
595
|
created.push(f.path)
|
|
144
596
|
}
|
package/src/index.tsx
CHANGED
|
@@ -314,7 +314,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
314
314
|
const { isEmptyProject } = await import('./core/project-scaffold')
|
|
315
315
|
if (isEmptyProject(process.cwd())) {
|
|
316
316
|
console.log(
|
|
317
|
-
'\n💡 空目录。运行 `mipham init`
|
|
317
|
+
'\n💡 空目录。运行 `mipham init` 生成基础文档,或 `mipham init --full` 生成完整规范(八章 CLAUDE.md + 7 个 .md + .github 模板),或直接开始对话。\n',
|
|
318
318
|
)
|
|
319
319
|
}
|
|
320
320
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { openSync, writeFileSync, closeSync, constants } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* O_NOFOLLOW is POSIX-only; on Windows it is undefined. Fall back to 0 (a
|
|
5
|
+
* no-op) — Windows symlinks are governed by different semantics, and
|
|
6
|
+
* `resolveSafe` still rejects UNC/device-namespace paths and workspace
|
|
7
|
+
* escapes before any file is opened.
|
|
8
|
+
*/
|
|
9
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Open a file with O_NOFOLLOW so a symlink swapped in after path resolution
|
|
13
|
+
* (a TOCTOU race) fails with ELOOP instead of being silently followed.
|
|
14
|
+
*
|
|
15
|
+
* `resolveSafe` resolves symlinks at check time, but the returned canonical
|
|
16
|
+
* path is a *string*; re-opening it by path re-resolves the filesystem. If a
|
|
17
|
+
* concurrent process replaces the final component with a symlink between the
|
|
18
|
+
* check and the open, O_NOFOLLOW makes the open fail closed.
|
|
19
|
+
*
|
|
20
|
+
* Callers combine with their own flags (O_RDONLY / O_WRONLY / O_CREAT /
|
|
21
|
+
* O_TRUNC). Throws ELOOP when the path is a symlink.
|
|
22
|
+
*/
|
|
23
|
+
export function openNoFollow(path: string, flags: number, mode?: number): number {
|
|
24
|
+
return openSync(path, flags | O_NOFOLLOW, mode)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True if `err` is ELOOP — a symlink was encountered where O_NOFOLLOW forbade following it. */
|
|
28
|
+
export function isSymlinkLoop(err: unknown): boolean {
|
|
29
|
+
return (err as NodeJS.ErrnoException)?.code === 'ELOOP'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open a file O_WRONLY|O_NOFOLLOW, write `content`, and close. Used by Write
|
|
34
|
+
* and Edit so a symlink swapped in after `resolveSafe` is rejected rather than
|
|
35
|
+
* followed (writing through it would clobber an out-of-workspace target).
|
|
36
|
+
*
|
|
37
|
+
* `flags` should include O_WRONLY|O_TRUNC (and O_CREAT for Write). Throws
|
|
38
|
+
* ELOOP when the path is a symlink.
|
|
39
|
+
*/
|
|
40
|
+
export function writeFileNoFollow(
|
|
41
|
+
path: string,
|
|
42
|
+
content: string,
|
|
43
|
+
flags: number,
|
|
44
|
+
mode = 0o666,
|
|
45
|
+
): void {
|
|
46
|
+
const fd = openSync(path, flags | O_NOFOLLOW, mode)
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(fd, content, 'utf-8')
|
|
49
|
+
} finally {
|
|
50
|
+
closeSync(fd)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.68.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, closeSync, constants } from 'node:fs'
|
|
2
2
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
3
3
|
import { resolveSafe } from '../../security/path'
|
|
4
|
+
import { openNoFollow, writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Characters considered part of an identifier in source code.
|
|
@@ -89,7 +90,22 @@ export const editTool: ToolDefinition = {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
// O_NOFOLLOW: fail closed on a symlink swapped in after resolveSafe (TOCTOU).
|
|
94
|
+
let rfd: number
|
|
95
|
+
try {
|
|
96
|
+
rfd = openNoFollow(filePath, constants.O_RDONLY)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (isSymlinkLoop(err)) {
|
|
99
|
+
return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
|
|
100
|
+
}
|
|
101
|
+
throw err
|
|
102
|
+
}
|
|
103
|
+
let content: string
|
|
104
|
+
try {
|
|
105
|
+
content = readFileSync(rfd, 'utf-8')
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(rfd)
|
|
108
|
+
}
|
|
93
109
|
|
|
94
110
|
// ── Read tracking: mark as read before editing ──
|
|
95
111
|
ctx.readFiles?.add(filePath)
|
|
@@ -114,28 +130,43 @@ export const editTool: ToolDefinition = {
|
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
132
|
|
|
133
|
+
let result: string
|
|
134
|
+
let count: number
|
|
117
135
|
if (replaceAll) {
|
|
118
136
|
// Build result by splicing at each match (in reverse so indices stay valid)
|
|
119
|
-
|
|
137
|
+
result = content
|
|
120
138
|
for (let i = matches.length - 1; i >= 0; i--) {
|
|
121
139
|
const idx = matches[i]!
|
|
122
140
|
result = result.slice(0, idx) + newStr + result.slice(idx + oldStr.length)
|
|
123
141
|
}
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
count = matches.length
|
|
143
|
+
} else {
|
|
144
|
+
if (matches.length > 1) {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
content: '',
|
|
148
|
+
error: 'old_string is not unique in file. Use replace_all or make it more specific.',
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const idx = matches[0]!
|
|
153
|
+
result = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
|
|
154
|
+
count = 1
|
|
126
155
|
}
|
|
127
156
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
// O_NOFOLLOW: reject a symlink swapped in after the read (TOCTOU) instead of
|
|
158
|
+
// writing through it to an out-of-workspace target.
|
|
159
|
+
try {
|
|
160
|
+
writeFileNoFollow(filePath, result, constants.O_WRONLY | constants.O_TRUNC)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (isSymlinkLoop(err)) {
|
|
163
|
+
return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
|
|
133
164
|
}
|
|
165
|
+
throw err
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: true,
|
|
169
|
+
content: `Replaced ${count} occurrence${count === 1 ? '' : 's'} in ${filePath}`,
|
|
134
170
|
}
|
|
135
|
-
|
|
136
|
-
const idx = matches[0]!
|
|
137
|
-
const updated = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
|
|
138
|
-
writeFileSync(filePath, updated, 'utf-8')
|
|
139
|
-
return { success: true, content: `Replaced 1 occurrence in ${filePath}` }
|
|
140
171
|
},
|
|
141
172
|
}
|
package/src/tools/file/grep.ts
CHANGED
|
@@ -90,8 +90,22 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
90
90
|
// rg not installed → fall through to grep
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// 2. fallback
|
|
94
|
-
|
|
93
|
+
// 2. fallback: `find -type f -exec grep -Hn {} +`
|
|
94
|
+
// `-type f` skips symlinks (file and dir) so the fallback can't follow
|
|
95
|
+
// one out of the workspace — macOS BSD `grep -r` follows symlinks and
|
|
96
|
+
// would leak target contents, unlike ripgrep's default no-follow.
|
|
97
|
+
const grepArgs = [
|
|
98
|
+
'find',
|
|
99
|
+
searchPath,
|
|
100
|
+
'-type',
|
|
101
|
+
'f',
|
|
102
|
+
'-exec',
|
|
103
|
+
'grep',
|
|
104
|
+
'-Hn',
|
|
105
|
+
pattern,
|
|
106
|
+
'{}',
|
|
107
|
+
'+',
|
|
108
|
+
]
|
|
95
109
|
try {
|
|
96
110
|
const { stdout, timedOut, exitCode } = await runSearch(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
|
|
97
111
|
if (timedOut) {
|
package/src/tools/file/read.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
|
|
2
2
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
3
|
import { resolveSafe } from '../../security/path'
|
|
4
|
+
import { openNoFollow, isSymlinkLoop } from '../../security/fd'
|
|
4
5
|
import type { Service } from '../../vajra'
|
|
5
6
|
import { toolKey } from '../seam'
|
|
6
7
|
import { withValidation } from '../validation'
|
|
@@ -23,25 +24,44 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
23
24
|
},
|
|
24
25
|
async execute(params, ctx) {
|
|
25
26
|
const filePath = resolveSafe(ctx.cwd, params.file_path as string)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
|
|
28
|
+
// O_NOFOLLOW: fail closed if the path was swapped to a symlink after
|
|
29
|
+
// resolveSafe (TOCTOU) — never follow it to read outside the workspace.
|
|
30
|
+
let fd: number
|
|
31
|
+
try {
|
|
32
|
+
fd = openNoFollow(filePath, constants.O_RDONLY)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (isSymlinkLoop(err)) {
|
|
35
|
+
return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
|
|
36
|
+
}
|
|
37
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
38
|
+
return { success: false, content: '', error: `File not found: ${filePath}` }
|
|
39
|
+
}
|
|
40
|
+
throw err
|
|
32
41
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
content: '',
|
|
39
|
-
|
|
42
|
+
|
|
43
|
+
let content: string
|
|
44
|
+
try {
|
|
45
|
+
const stat = fstatSync(fd)
|
|
46
|
+
if (stat.isDirectory()) {
|
|
47
|
+
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
48
|
+
}
|
|
49
|
+
// Prevent OOM on single-line files (e.g. 500MB JSON blob)
|
|
50
|
+
const MAX_FILE_SIZE = 50_000_000 // 50 MB
|
|
51
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
content: '',
|
|
55
|
+
error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
|
|
56
|
+
}
|
|
40
57
|
}
|
|
58
|
+
content = readFileSync(fd, 'utf-8')
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd)
|
|
41
61
|
}
|
|
62
|
+
|
|
42
63
|
const offset = (params.offset as number) || 0
|
|
43
64
|
const limit = (params.limit as number) || 2000
|
|
44
|
-
const content = readFileSync(filePath, 'utf-8')
|
|
45
65
|
|
|
46
66
|
// ── Read tracking: mark file as read for Write tool safety ──
|
|
47
67
|
ctx.readFiles?.add(filePath)
|
package/src/tools/file/write.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, existsSync, constants } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
4
|
import { resolveSafe } from '../../security/path'
|
|
5
|
+
import { writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
|
|
5
6
|
|
|
6
7
|
export const writeTool: ToolDefinition = {
|
|
7
8
|
name: 'Write',
|
|
@@ -42,7 +43,25 @@ export const writeTool: ToolDefinition = {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
45
|
-
|
|
46
|
+
|
|
47
|
+
// O_NOFOLLOW: reject a symlink swapped in after resolveSafe (TOCTOU) —
|
|
48
|
+
// writing through it would clobber an out-of-workspace target.
|
|
49
|
+
try {
|
|
50
|
+
writeFileNoFollow(
|
|
51
|
+
filePath,
|
|
52
|
+
content,
|
|
53
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
|
|
54
|
+
)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (isSymlinkLoop(err)) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
content: '',
|
|
60
|
+
error: `Refusing to write through a symbolic link: ${filePath}`,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
46
65
|
|
|
47
66
|
// Track as read since we just wrote it (future writes allowed)
|
|
48
67
|
ctx.readFiles?.add(filePath)
|