@tyyyho/treg 0.1.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.
- package/README.md +92 -0
- package/package.json +42 -0
- package/scripts/init-project/cli.mjs +173 -0
- package/scripts/init-project/cli.test.mjs +116 -0
- package/scripts/init-project/frameworks/index.mjs +48 -0
- package/scripts/init-project/frameworks/next/index.mjs +10 -0
- package/scripts/init-project/frameworks/node/index.mjs +8 -0
- package/scripts/init-project/frameworks/nuxt/index.mjs +10 -0
- package/scripts/init-project/frameworks/react/index.mjs +35 -0
- package/scripts/init-project/frameworks/react/v18/index.mjs +6 -0
- package/scripts/init-project/frameworks/react/v19/index.mjs +6 -0
- package/scripts/init-project/frameworks/svelte/index.mjs +10 -0
- package/scripts/init-project/frameworks/vue/index.mjs +10 -0
- package/scripts/init-project/frameworks.test.mjs +63 -0
- package/scripts/init-project/index.mjs +89 -0
- package/scripts/init-project/mrm-core.mjs +5 -0
- package/scripts/init-project/mrm-rules/ai-skills.mjs +220 -0
- package/scripts/init-project/mrm-rules/ai-skills.test.mjs +91 -0
- package/scripts/init-project/mrm-rules/format.mjs +55 -0
- package/scripts/init-project/mrm-rules/husky.mjs +78 -0
- package/scripts/init-project/mrm-rules/index.mjs +35 -0
- package/scripts/init-project/mrm-rules/lint.mjs +18 -0
- package/scripts/init-project/mrm-rules/shared.mjs +61 -0
- package/scripts/init-project/mrm-rules/test-jest.mjs +75 -0
- package/scripts/init-project/mrm-rules/test-vitest.mjs +64 -0
- package/scripts/init-project/mrm-rules/typescript.mjs +44 -0
- package/scripts/init-project/package-manager.mjs +68 -0
- package/scripts/init-project/package-manager.test.mjs +21 -0
- package/scripts/init-project/utils.mjs +12 -0
- package/scripts/init-project/utils.test.mjs +22 -0
- package/scripts/init-project.mjs +7 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
parseArgs,
|
|
6
|
+
printSupportedTargets,
|
|
7
|
+
resolveFeatures,
|
|
8
|
+
USAGE,
|
|
9
|
+
} from "./cli.mjs"
|
|
10
|
+
import { resolveFramework } from "./frameworks/index.mjs"
|
|
11
|
+
import { runFeatureRules } from "./mrm-rules/index.mjs"
|
|
12
|
+
import { detectPackageManager, runScript } from "./package-manager.mjs"
|
|
13
|
+
import { formatStep } from "./utils.mjs"
|
|
14
|
+
|
|
15
|
+
const TOTAL_STEPS = 3
|
|
16
|
+
|
|
17
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
18
|
+
let options
|
|
19
|
+
try {
|
|
20
|
+
options = parseArgs(argv)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(error.message ?? error)
|
|
23
|
+
console.log(USAGE)
|
|
24
|
+
process.exitCode = 1
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.help) {
|
|
29
|
+
console.log(USAGE)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (options.command === "list") {
|
|
33
|
+
printSupportedTargets()
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projectDir = path.resolve(options.projectDir ?? process.cwd())
|
|
38
|
+
const packageJsonPath = path.join(projectDir, "package.json")
|
|
39
|
+
if (!existsSync(packageJsonPath)) {
|
|
40
|
+
console.error(`package.json not found in ${projectDir}`)
|
|
41
|
+
process.exitCode = 1
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"))
|
|
46
|
+
const pm =
|
|
47
|
+
!options.pm || options.pm === "auto"
|
|
48
|
+
? detectPackageManager(projectDir)
|
|
49
|
+
: options.pm
|
|
50
|
+
const framework = resolveFramework(
|
|
51
|
+
options.framework,
|
|
52
|
+
options.frameworkVersion,
|
|
53
|
+
packageJson
|
|
54
|
+
)
|
|
55
|
+
if (options.frameworkVersion && framework.id !== "react") {
|
|
56
|
+
console.error(
|
|
57
|
+
`Unsupported --framework-version for framework: ${framework.id}`
|
|
58
|
+
)
|
|
59
|
+
process.exitCode = 1
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const enabledFeatures = resolveFeatures(options)
|
|
63
|
+
|
|
64
|
+
const context = {
|
|
65
|
+
...options,
|
|
66
|
+
projectDir,
|
|
67
|
+
pm,
|
|
68
|
+
framework,
|
|
69
|
+
enabledFeatures,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(formatStep(1, TOTAL_STEPS, "Resolve plan", options.dryRun))
|
|
73
|
+
console.log(
|
|
74
|
+
`${options.dryRun ? "[dry-run] " : ""}Framework=${framework.id}${framework.variant ? `/${framework.variant}` : ""}, features=${Object.entries(
|
|
75
|
+
enabledFeatures
|
|
76
|
+
)
|
|
77
|
+
.filter(([, enabled]) => enabled)
|
|
78
|
+
.map(([name]) => name)
|
|
79
|
+
.join(", ")}, testRunner=${options.testRunner}`
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
console.log(formatStep(2, TOTAL_STEPS, "Run mrm rules", options.dryRun))
|
|
83
|
+
await runFeatureRules(context)
|
|
84
|
+
|
|
85
|
+
console.log(formatStep(3, TOTAL_STEPS, "Finalize", options.dryRun))
|
|
86
|
+
if (enabledFeatures.format) {
|
|
87
|
+
runScript(pm, "format", projectDir, options.dryRun)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const START_MARKER = "<!-- treg:skills:start -->"
|
|
6
|
+
const END_MARKER = "<!-- treg:skills:end -->"
|
|
7
|
+
const SKILLS_BASE_DIR = ".treg/skills"
|
|
8
|
+
|
|
9
|
+
const FEATURE_SKILLS = {
|
|
10
|
+
format: {
|
|
11
|
+
name: "treg/format",
|
|
12
|
+
description: "Run and verify formatting rules.",
|
|
13
|
+
when: "在提交前或大範圍改動後,統一格式化程式碼。",
|
|
14
|
+
checklist: ["執行 format", "執行 format:check", "確認未變動非目標檔案"],
|
|
15
|
+
},
|
|
16
|
+
husky: {
|
|
17
|
+
name: "treg/husky",
|
|
18
|
+
description: "Verify and maintain git hook automation.",
|
|
19
|
+
when: "需要保證 pre-commit / pre-push 自動檢查時。",
|
|
20
|
+
checklist: [
|
|
21
|
+
"確認 hooks 可執行",
|
|
22
|
+
"確認含 format:check 與 lint:check",
|
|
23
|
+
"若啟用型別/測試,也要納入 hooks",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
lint: {
|
|
27
|
+
name: "treg/lint",
|
|
28
|
+
description: "Run and validate lint rules.",
|
|
29
|
+
when: "新增規則或調整工具鏈後,驗證 lint 一致性。",
|
|
30
|
+
checklist: ["執行 lint", "執行 lint:check", "修正 max-warnings 問題"],
|
|
31
|
+
},
|
|
32
|
+
test: {
|
|
33
|
+
name: "treg/test",
|
|
34
|
+
description: "Validate test runner setup and execution.",
|
|
35
|
+
when: "新增測試規則或調整測試設定時。",
|
|
36
|
+
checklist: [
|
|
37
|
+
"確認 test runner 與專案一致",
|
|
38
|
+
"執行 test",
|
|
39
|
+
"視需要執行 test:coverage",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
typescript: {
|
|
43
|
+
name: "treg/typescript",
|
|
44
|
+
description: "Validate TypeScript strictness and config.",
|
|
45
|
+
when: "調整 tsconfig 或型別嚴格度規則時。",
|
|
46
|
+
checklist: [
|
|
47
|
+
"執行 type-check",
|
|
48
|
+
"確認 strict 相關選項仍生效",
|
|
49
|
+
"檢查 exclude 不含產品邏輯路徑",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveSkillsDoc(projectDir) {
|
|
55
|
+
const agentsPath = path.join(projectDir, "AGENTS.md")
|
|
56
|
+
if (existsSync(agentsPath)) {
|
|
57
|
+
return agentsPath
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const claudePath = path.join(projectDir, "CLAUDE.md")
|
|
61
|
+
if (existsSync(claudePath)) {
|
|
62
|
+
return claudePath
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getEnabledFeatures(enabledFeatures) {
|
|
69
|
+
return Object.entries(enabledFeatures)
|
|
70
|
+
.filter(([, value]) => value)
|
|
71
|
+
.map(([name]) => name)
|
|
72
|
+
.sort((a, b) => a.localeCompare(b))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getSkillRelativePath(feature) {
|
|
76
|
+
return `${SKILLS_BASE_DIR}/${feature}/SKILL.md`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildSkillFile(feature, skill, testRunner) {
|
|
80
|
+
const extra =
|
|
81
|
+
feature === "test"
|
|
82
|
+
? `\n## Current Test Runner\n\n- \`${testRunner}\`\n`
|
|
83
|
+
: ""
|
|
84
|
+
return `---
|
|
85
|
+
name: ${skill.name}
|
|
86
|
+
description: ${skill.description}
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# ${skill.name}
|
|
90
|
+
|
|
91
|
+
## When To Use
|
|
92
|
+
|
|
93
|
+
${skill.when}
|
|
94
|
+
|
|
95
|
+
## Validation Checklist
|
|
96
|
+
|
|
97
|
+
- ${skill.checklist.join("\n- ")}
|
|
98
|
+
${extra}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ensureSkillFiles(projectDir, enabled, testRunner, dryRun) {
|
|
102
|
+
for (const feature of enabled) {
|
|
103
|
+
const skill = FEATURE_SKILLS[feature]
|
|
104
|
+
if (!skill) continue
|
|
105
|
+
|
|
106
|
+
const relativePath = getSkillRelativePath(feature)
|
|
107
|
+
const fullPath = path.join(projectDir, relativePath)
|
|
108
|
+
const content = buildSkillFile(feature, skill, testRunner)
|
|
109
|
+
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
console.log(`[dry-run] Would upsert ${relativePath}`)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
116
|
+
const current = existsSync(fullPath)
|
|
117
|
+
? await fs.readFile(fullPath, "utf8")
|
|
118
|
+
: null
|
|
119
|
+
if (current === content) {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
await fs.writeFile(fullPath, content, "utf8")
|
|
123
|
+
console.log(`${current === null ? "Created" : "Updated"} ${relativePath}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildSkillSection(context) {
|
|
128
|
+
const { enabledFeatures, testRunner } = context
|
|
129
|
+
const enabled = getEnabledFeatures(enabledFeatures)
|
|
130
|
+
|
|
131
|
+
const lines = [
|
|
132
|
+
START_MARKER,
|
|
133
|
+
"## treg AI Skills",
|
|
134
|
+
"",
|
|
135
|
+
"以下 skill 可在 agent 任務完成前引用,確保基礎建設規則正確且可重跑:",
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
for (const feature of enabled) {
|
|
140
|
+
const skill = FEATURE_SKILLS[feature]
|
|
141
|
+
if (!skill) continue
|
|
142
|
+
const skillRelativePath = getSkillRelativePath(feature)
|
|
143
|
+
|
|
144
|
+
lines.push(`### ${feature}`)
|
|
145
|
+
lines.push(`- Skill: [${skill.name}](${skillRelativePath})`)
|
|
146
|
+
lines.push(`- 使用時機: ${skill.when}`)
|
|
147
|
+
if (feature === "test") {
|
|
148
|
+
lines.push(`- 目前測試工具: \`${testRunner}\``)
|
|
149
|
+
}
|
|
150
|
+
lines.push(`- 驗證清單: ${skill.checklist.join("、")}`)
|
|
151
|
+
lines.push("")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push(END_MARKER)
|
|
155
|
+
lines.push("")
|
|
156
|
+
return lines.join("\n")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function upsertSkillSection(content, nextSection) {
|
|
160
|
+
const start = content.indexOf(START_MARKER)
|
|
161
|
+
const end = content.indexOf(END_MARKER)
|
|
162
|
+
|
|
163
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
164
|
+
const suffixStart = end + END_MARKER.length
|
|
165
|
+
const before = content.slice(0, start).trimEnd()
|
|
166
|
+
const after = content.slice(suffixStart).trimStart()
|
|
167
|
+
const rebuilt = `${before}\n\n${nextSection.trim()}\n`
|
|
168
|
+
return after ? `${rebuilt}\n${after}\n` : `${rebuilt}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!content.trim()) {
|
|
172
|
+
return `${nextSection.trim()}\n`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return `${content.trimEnd()}\n\n${nextSection.trim()}\n`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function runAiSkillsRule(context) {
|
|
179
|
+
const { projectDir, dryRun } = context
|
|
180
|
+
const targetFile = resolveSkillsDoc(projectDir)
|
|
181
|
+
|
|
182
|
+
if (!targetFile) {
|
|
183
|
+
console.log("Skip ai-skills (AGENTS.md/CLAUDE.md not found)")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const enabled = getEnabledFeatures(context.enabledFeatures)
|
|
188
|
+
await ensureSkillFiles(projectDir, enabled, context.testRunner, dryRun)
|
|
189
|
+
|
|
190
|
+
const section = buildSkillSection(context)
|
|
191
|
+
const current = await fs.readFile(targetFile, "utf8")
|
|
192
|
+
const updated = upsertSkillSection(current, section)
|
|
193
|
+
|
|
194
|
+
if (dryRun) {
|
|
195
|
+
console.log(
|
|
196
|
+
`[dry-run] Would update ${path.basename(targetFile)} with AI skill guidance`
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (updated !== current) {
|
|
202
|
+
await fs.writeFile(targetFile, updated, "utf8")
|
|
203
|
+
console.log(`Updated ${path.basename(targetFile)} with AI skill guidance`)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log(
|
|
208
|
+
`${path.basename(targetFile)} already contains latest AI skill guidance`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const __testables__ = {
|
|
213
|
+
buildSkillSection,
|
|
214
|
+
buildSkillFile,
|
|
215
|
+
ensureSkillFiles,
|
|
216
|
+
getEnabledFeatures,
|
|
217
|
+
getSkillRelativePath,
|
|
218
|
+
resolveSkillsDoc,
|
|
219
|
+
upsertSkillSection,
|
|
220
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals"
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { readFile } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { __testables__ } from "./ai-skills.mjs"
|
|
7
|
+
|
|
8
|
+
describe("ai-skills helpers", () => {
|
|
9
|
+
it("builds skill section from enabled features", () => {
|
|
10
|
+
const content = __testables__.buildSkillSection({
|
|
11
|
+
enabledFeatures: {
|
|
12
|
+
lint: true,
|
|
13
|
+
format: true,
|
|
14
|
+
typescript: false,
|
|
15
|
+
test: true,
|
|
16
|
+
husky: false,
|
|
17
|
+
},
|
|
18
|
+
testRunner: "vitest",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(content).toContain("## treg AI Skills")
|
|
22
|
+
expect(content).toContain("### format")
|
|
23
|
+
expect(content).toContain("### lint")
|
|
24
|
+
expect(content).toContain("### test")
|
|
25
|
+
expect(content).toContain("目前測試工具: `vitest`")
|
|
26
|
+
expect(content).not.toContain("### typescript")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("upserts an existing skill section", () => {
|
|
30
|
+
const replaced = __testables__.upsertSkillSection(
|
|
31
|
+
"# Header\n\n<!-- treg:skills:start -->\nold\n<!-- treg:skills:end -->\n",
|
|
32
|
+
"<!-- treg:skills:start -->\nnew\n<!-- treg:skills:end -->"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(replaced).toContain("new")
|
|
36
|
+
expect(replaced).not.toContain("old")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("prefers AGENTS.md when both docs exist", () => {
|
|
40
|
+
const dir = mkdtempSync(path.join(tmpdir(), "treg-skill-"))
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(path.join(dir, "AGENTS.md"), "# Agents\n", "utf8")
|
|
43
|
+
writeFileSync(path.join(dir, "CLAUDE.md"), "# Claude\n", "utf8")
|
|
44
|
+
|
|
45
|
+
expect(__testables__.resolveSkillsDoc(dir)).toBe(
|
|
46
|
+
path.join(dir, "AGENTS.md")
|
|
47
|
+
)
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("builds skill file content with frontmatter", () => {
|
|
54
|
+
const content = __testables__.buildSkillFile(
|
|
55
|
+
"test",
|
|
56
|
+
{
|
|
57
|
+
name: "treg/test",
|
|
58
|
+
description: "Validate test runner setup and execution.",
|
|
59
|
+
when: "新增測試規則或調整測試設定時。",
|
|
60
|
+
checklist: ["確認 test runner 與專案一致", "執行 test"],
|
|
61
|
+
},
|
|
62
|
+
"vitest"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(content).toContain("name: treg/test")
|
|
66
|
+
expect(content).toContain("description: Validate test runner setup")
|
|
67
|
+
expect(content).toContain("## Current Test Runner")
|
|
68
|
+
expect(content).toContain("`vitest`")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("creates skill files for enabled features", async () => {
|
|
72
|
+
const dir = mkdtempSync(path.join(tmpdir(), "treg-skill-files-"))
|
|
73
|
+
try {
|
|
74
|
+
await __testables__.ensureSkillFiles(dir, ["lint", "test"], "jest", false)
|
|
75
|
+
|
|
76
|
+
const lintSkill = await readFile(
|
|
77
|
+
path.join(dir, ".treg/skills/lint/SKILL.md"),
|
|
78
|
+
"utf8"
|
|
79
|
+
)
|
|
80
|
+
const testSkill = await readFile(
|
|
81
|
+
path.join(dir, ".treg/skills/test/SKILL.md"),
|
|
82
|
+
"utf8"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(lintSkill).toContain("name: treg/lint")
|
|
86
|
+
expect(testSkill).toContain("## Current Test Runner")
|
|
87
|
+
} finally {
|
|
88
|
+
rmSync(dir, { recursive: true, force: true })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { lines, packageJson } from "../mrm-core.mjs"
|
|
2
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.mjs"
|
|
3
|
+
|
|
4
|
+
const PRETTIER_CONFIG = `{
|
|
5
|
+
"semi": false,
|
|
6
|
+
"trailingComma": "es5",
|
|
7
|
+
"singleQuote": false,
|
|
8
|
+
"printWidth": 80,
|
|
9
|
+
"tabWidth": 2,
|
|
10
|
+
"useTabs": false,
|
|
11
|
+
"bracketSpacing": true,
|
|
12
|
+
"arrowParens": "avoid",
|
|
13
|
+
"endOfLine": "lf"
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const PRETTIER_IGNORE = [
|
|
18
|
+
"node_modules",
|
|
19
|
+
".git",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
"coverage",
|
|
23
|
+
"*.log",
|
|
24
|
+
".env*",
|
|
25
|
+
".vercel",
|
|
26
|
+
"pnpm-lock.yaml",
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"yarn.lock",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export async function runFormatRule(context) {
|
|
32
|
+
const { projectDir, pm, force, dryRun } = context
|
|
33
|
+
installPackages(projectDir, pm, ["prettier"], true, dryRun)
|
|
34
|
+
|
|
35
|
+
await writeFile(
|
|
36
|
+
projectDir,
|
|
37
|
+
".prettierrc.json",
|
|
38
|
+
PRETTIER_CONFIG,
|
|
39
|
+
force,
|
|
40
|
+
dryRun
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
withProjectCwd(projectDir, () => {
|
|
44
|
+
if (dryRun) {
|
|
45
|
+
console.log("[dry-run] Would update .prettierignore")
|
|
46
|
+
console.log("[dry-run] Would set package scripts: format, format:check")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
lines(".prettierignore").add(PRETTIER_IGNORE).save()
|
|
50
|
+
packageJson()
|
|
51
|
+
.setScript("format", "prettier --write .")
|
|
52
|
+
.setScript("format:check", "prettier --check .")
|
|
53
|
+
.save()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { packageJson } from "../mrm-core.mjs"
|
|
4
|
+
import { existsSync } from "node:fs"
|
|
5
|
+
import { getRunCommand, runCommand } from "../package-manager.mjs"
|
|
6
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.mjs"
|
|
7
|
+
|
|
8
|
+
function buildHookCommands(runner, enabledFeatures) {
|
|
9
|
+
const preCommit = [
|
|
10
|
+
`${runner} format:check || exit 1`,
|
|
11
|
+
`${runner} lint:check || exit 1`,
|
|
12
|
+
]
|
|
13
|
+
if (enabledFeatures.typescript) {
|
|
14
|
+
preCommit.push(`${runner} type-check || exit 1`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const prePush = [...preCommit]
|
|
18
|
+
if (enabledFeatures.test) {
|
|
19
|
+
prePush.push(`${runner} test || exit 1`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { preCommit, prePush }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runHuskyRule(context) {
|
|
26
|
+
const { projectDir, pm, force, dryRun, skipHuskyInstall, enabledFeatures } =
|
|
27
|
+
context
|
|
28
|
+
|
|
29
|
+
installPackages(projectDir, pm, ["husky"], true, dryRun)
|
|
30
|
+
const runner = getRunCommand(pm)
|
|
31
|
+
const { preCommit, prePush } = buildHookCommands(runner, enabledFeatures)
|
|
32
|
+
|
|
33
|
+
await writeFile(
|
|
34
|
+
projectDir,
|
|
35
|
+
".husky/pre-commit",
|
|
36
|
+
`# Husky pre-commit\n${preCommit.join("\n")}\n`,
|
|
37
|
+
force,
|
|
38
|
+
dryRun
|
|
39
|
+
)
|
|
40
|
+
await writeFile(
|
|
41
|
+
projectDir,
|
|
42
|
+
".husky/pre-push",
|
|
43
|
+
`# Husky pre-push\n${prePush.join("\n")}\n`,
|
|
44
|
+
force,
|
|
45
|
+
dryRun
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if (!dryRun) {
|
|
49
|
+
await fs.chmod(path.join(projectDir, ".husky/pre-commit"), 0o755)
|
|
50
|
+
await fs.chmod(path.join(projectDir, ".husky/pre-push"), 0o755)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
withProjectCwd(projectDir, () => {
|
|
54
|
+
if (dryRun) {
|
|
55
|
+
console.log("[dry-run] Would set package script: prepare")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
packageJson().setScript("prepare", "husky").save()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (skipHuskyInstall) {
|
|
62
|
+
console.log("Skip husky install (--skip-husky-install)")
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (!existsSync(path.join(projectDir, ".git"))) {
|
|
66
|
+
console.log("Skip husky install (.git not found)")
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (!dryRun) {
|
|
70
|
+
if (pm === "pnpm") {
|
|
71
|
+
runCommand("pnpm exec husky", projectDir, false)
|
|
72
|
+
} else if (pm === "yarn") {
|
|
73
|
+
runCommand("yarn husky", projectDir, false)
|
|
74
|
+
} else {
|
|
75
|
+
runCommand("npx husky", projectDir, false)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { runAiSkillsRule } from "./ai-skills.mjs"
|
|
2
|
+
import { runFormatRule } from "./format.mjs"
|
|
3
|
+
import { runHuskyRule } from "./husky.mjs"
|
|
4
|
+
import { runLintRule } from "./lint.mjs"
|
|
5
|
+
import { runTestJestRule } from "./test-jest.mjs"
|
|
6
|
+
import { runTestVitestRule } from "./test-vitest.mjs"
|
|
7
|
+
import { runTypescriptRule } from "./typescript.mjs"
|
|
8
|
+
|
|
9
|
+
export async function runFeatureRules(context) {
|
|
10
|
+
const { enabledFeatures, skills, testRunner } = context
|
|
11
|
+
|
|
12
|
+
if (enabledFeatures.format) {
|
|
13
|
+
await runFormatRule(context)
|
|
14
|
+
}
|
|
15
|
+
if (enabledFeatures.lint) {
|
|
16
|
+
await runLintRule(context)
|
|
17
|
+
}
|
|
18
|
+
if (enabledFeatures.typescript) {
|
|
19
|
+
await runTypescriptRule(context)
|
|
20
|
+
}
|
|
21
|
+
if (enabledFeatures.test) {
|
|
22
|
+
if (testRunner === "vitest") {
|
|
23
|
+
await runTestVitestRule(context)
|
|
24
|
+
} else {
|
|
25
|
+
await runTestJestRule(context)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (enabledFeatures.husky) {
|
|
29
|
+
await runHuskyRule(context)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (skills) {
|
|
33
|
+
await runAiSkillsRule(context)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { packageJson } from "../mrm-core.mjs"
|
|
2
|
+
import { installPackages, withProjectCwd } from "./shared.mjs"
|
|
3
|
+
|
|
4
|
+
export async function runLintRule(context) {
|
|
5
|
+
const { projectDir, pm, dryRun } = context
|
|
6
|
+
installPackages(projectDir, pm, ["eslint"], true, dryRun)
|
|
7
|
+
|
|
8
|
+
withProjectCwd(projectDir, () => {
|
|
9
|
+
if (dryRun) {
|
|
10
|
+
console.log("[dry-run] Would set package scripts: lint, lint:check")
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
packageJson()
|
|
14
|
+
.setScript("lint", "eslint .")
|
|
15
|
+
.setScript("lint:check", "eslint . --max-warnings 0")
|
|
16
|
+
.save()
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { installPackages as installByPackageManager } from "../package-manager.mjs"
|
|
4
|
+
|
|
5
|
+
export function withProjectCwd(projectDir, fn) {
|
|
6
|
+
const original = process.cwd()
|
|
7
|
+
process.chdir(projectDir)
|
|
8
|
+
try {
|
|
9
|
+
return fn()
|
|
10
|
+
} finally {
|
|
11
|
+
process.chdir(original)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getInstallOptions(pm) {
|
|
16
|
+
if (pm === "pnpm") return { pnpm: true }
|
|
17
|
+
if (pm === "yarn") return { yarn: true }
|
|
18
|
+
return {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function installPackages(
|
|
22
|
+
projectDir,
|
|
23
|
+
pm,
|
|
24
|
+
packages,
|
|
25
|
+
dev = true,
|
|
26
|
+
dryRun = false
|
|
27
|
+
) {
|
|
28
|
+
if (packages.length === 0) return
|
|
29
|
+
installByPackageManager(pm, projectDir, packages, dev, dryRun)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeFile(
|
|
33
|
+
projectDir,
|
|
34
|
+
relativePath,
|
|
35
|
+
content,
|
|
36
|
+
force,
|
|
37
|
+
dryRun
|
|
38
|
+
) {
|
|
39
|
+
const targetPath = path.join(projectDir, relativePath)
|
|
40
|
+
try {
|
|
41
|
+
await fs.access(targetPath)
|
|
42
|
+
if (!force) {
|
|
43
|
+
console.log(`Skip ${relativePath} (already exists)`)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
console.log(
|
|
52
|
+
`[dry-run] Would ${force ? "update" : "create"} ${relativePath}`
|
|
53
|
+
)
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true })
|
|
58
|
+
await fs.writeFile(targetPath, content, "utf8")
|
|
59
|
+
console.log(`${force ? "Updated" : "Created"} ${relativePath}`)
|
|
60
|
+
return true
|
|
61
|
+
}
|