@negikirin/repo-pattern 0.1.1

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.
Files changed (39) hide show
  1. package/.claude/CLAUDE.md +61 -0
  2. package/.claude/settings.example.json +40 -0
  3. package/.claude/settings.local.example.json +24 -0
  4. package/.github/workflows/nodejs-package.yml +60 -0
  5. package/.repo-pattern.json +25 -0
  6. package/.repo-pattern.lock.json +23 -0
  7. package/LICENSE +21 -0
  8. package/README.md +145 -0
  9. package/THIRD_PARTY_NOTICES.md +51 -0
  10. package/docs/repo-pattern/setup-guide.md +725 -0
  11. package/docs/repo-pattern/workflow.md +429 -0
  12. package/mcp/profiles/backend.json +7 -0
  13. package/mcp/profiles/full.json +11 -0
  14. package/mcp/profiles/minimal.json +6 -0
  15. package/mcp/profiles/research.json +8 -0
  16. package/mcp/profiles/web.json +8 -0
  17. package/mcp/servers/chrome-devtools.json +10 -0
  18. package/mcp/servers/context7.json +13 -0
  19. package/mcp/servers/filesystem.json +11 -0
  20. package/mcp/servers/gitnexus.json +11 -0
  21. package/mcp/servers/playwright.json +12 -0
  22. package/mcp/servers/sequential-thinking.json +10 -0
  23. package/mcp/servers/tavily.json +13 -0
  24. package/package.json +71 -0
  25. package/scripts/lib/audit.mjs +127 -0
  26. package/scripts/lib/cleanup.mjs +35 -0
  27. package/scripts/lib/doctor.mjs +86 -0
  28. package/scripts/lib/ecc-rules.mjs +98 -0
  29. package/scripts/lib/ecc.mjs +61 -0
  30. package/scripts/lib/fs-utils.mjs +133 -0
  31. package/scripts/lib/mcp.mjs +203 -0
  32. package/scripts/lib/project-detect.mjs +107 -0
  33. package/scripts/lib/prompt.mjs +226 -0
  34. package/scripts/lib/provision.mjs +184 -0
  35. package/scripts/lib/rules.mjs +142 -0
  36. package/scripts/lib/setup.mjs +299 -0
  37. package/scripts/lib/skills.mjs +249 -0
  38. package/scripts/repo-pattern.mjs +156 -0
  39. package/scripts/self-check.mjs +175 -0
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { exists, isTracked, readJson } from "./fs-utils.mjs";
4
+ import { printBox, style } from "./prompt.mjs";
5
+ import { expectedOptionalSkillDirs } from "./skills.mjs";
6
+
7
+ const HARDCODED_PATH_RE = /"\/home\/|"\/Users\/|"[A-Za-z]:\\\\/;
8
+
9
+ async function fileContains(file, regex) {
10
+ if (!exists(file)) return false;
11
+ const text = await fs.readFile(file, "utf8");
12
+ return regex.test(text);
13
+ }
14
+
15
+ async function isOnlyEccRulesDir(target) {
16
+ const rulesDir = path.join(target, ".claude", "rules");
17
+ if (!exists(rulesDir)) return false;
18
+ try {
19
+ const entries = await fs.readdir(rulesDir);
20
+ return entries.length === 1 && entries[0] === "ecc";
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function hasNonEmptyObject(value) {
27
+ return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
28
+ }
29
+
30
+ async function listDirNames(dir) {
31
+ if (!exists(dir)) return [];
32
+ try {
33
+ const entries = await fs.readdir(dir, { withFileTypes: true });
34
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ export async function auditProject(target) {
41
+ const settings = await readJson(path.join(target, ".claude", "settings.json"), {});
42
+ const repoPattern = await readJson(path.join(target, ".repo-pattern.json"), null);
43
+
44
+ const actualSkillDirs = await listDirNames(path.join(target, ".claude", "skills"));
45
+ const expectedSkillDirs = expectedOptionalSkillDirs(repoPattern?.optionalSkills || []);
46
+ const hasOnlyManagedSkills = JSON.stringify(actualSkillDirs) === JSON.stringify(expectedSkillDirs);
47
+ const hasMcpJson = exists(path.join(target, ".mcp.json"));
48
+ const hasHardcodedMcpPath = hasMcpJson
49
+ ? await fileContains(path.join(target, ".mcp.json"), HARDCODED_PATH_RE)
50
+ : false;
51
+
52
+ const result = {
53
+ target,
54
+ hasClaudeDir: exists(path.join(target, ".claude")),
55
+ hasMcpJson,
56
+ hasHardcodedMcpPath,
57
+ hasSettingsLocalTracked: isTracked(target, ".claude/settings.local.json"),
58
+ hasSettingsHooks: hasNonEmptyObject(settings.hooks),
59
+ hasClaudeSkillsDir: exists(path.join(target, ".claude", "skills")),
60
+ actualSkillDirs,
61
+ expectedSkillDirs,
62
+ hasOnlyManagedSkills,
63
+ hasClaudeCommandsDir: exists(path.join(target, ".claude", "commands")),
64
+ hasClaudeHooksDir: exists(path.join(target, ".claude", "hooks")),
65
+ hasClaudeScriptsDir: exists(path.join(target, ".claude", "scripts")),
66
+ hasClaudeRulesDir: exists(path.join(target, ".claude", "rules")),
67
+ hasOnlyEccRulesDir: await isOnlyEccRulesDir(target),
68
+ hasRepoPatternJson: !!repoPattern,
69
+ repoPattern
70
+ };
71
+
72
+ const allowSourceSkills = repoPattern?.mode === "template";
73
+ const allowManagedSkills = repoPattern?.runtime?.localSkills === true && Array.isArray(repoPattern?.optionalSkills) && result.hasOnlyManagedSkills;
74
+ const legacy = (
75
+ result.hasSettingsHooks ||
76
+ (result.hasClaudeSkillsDir && !allowSourceSkills && !allowManagedSkills) ||
77
+ result.hasClaudeCommandsDir ||
78
+ result.hasClaudeHooksDir ||
79
+ result.hasClaudeScriptsDir ||
80
+ (result.hasClaudeRulesDir && !result.hasOnlyEccRulesDir)
81
+ );
82
+
83
+ if (!result.hasClaudeDir && !result.hasMcpJson && !result.hasRepoPatternJson) {
84
+ result.state = "EMPTY";
85
+ } else if (
86
+ result.hasRepoPatternJson &&
87
+ result.repoPattern?.workflow === "ecc-native" &&
88
+ !legacy
89
+ ) {
90
+ result.state = "ECC_NATIVE_MINIMAL";
91
+ } else if (legacy) {
92
+ result.state = "LEGACY_VENDOR";
93
+ } else {
94
+ result.state = "PARTIAL";
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ export function printAudit(audit) {
101
+ const warning = style("error", "⚠");
102
+ const rows = [
103
+ `Target ${audit.target}`,
104
+ `State ${audit.state}`
105
+ ];
106
+ const needsSetup = audit.state !== "EMPTY";
107
+ const issues = [
108
+ [needsSetup && !audit.hasClaudeDir, ".claude missing"],
109
+ [needsSetup && !audit.hasMcpJson, ".mcp.json missing"],
110
+ [needsSetup && !audit.hasRepoPatternJson, ".repo-pattern.json missing"],
111
+ [audit.hasHardcodedMcpPath, "hardcoded machine path in .mcp.json"],
112
+ [audit.hasSettingsLocalTracked, ".claude/settings.local.json tracked"],
113
+ [audit.hasSettingsHooks, ".claude/settings.json hooks not empty"],
114
+ [audit.repoPattern?.runtime?.localSkills === true && !audit.hasOnlyManagedSkills, ".claude/skills does not match managed optional skills"],
115
+ [audit.hasClaudeSkillsDir && !audit.hasOnlyManagedSkills, ".claude/skills contains unmanaged entries"],
116
+ [audit.hasClaudeCommandsDir, ".claude/commands present"],
117
+ [audit.hasClaudeHooksDir, ".claude/hooks present"],
118
+ [audit.hasClaudeScriptsDir, ".claude/scripts present"],
119
+ [audit.hasClaudeRulesDir && !audit.hasOnlyEccRulesDir, "non-ECC .claude/rules present"]
120
+ ].filter(([bad]) => bad);
121
+
122
+ if (issues.length > 0) {
123
+ rows.push("", ...issues.map(([, message]) => `${warning} ${message}`));
124
+ }
125
+
126
+ printBox("Audit", rows);
127
+ }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import { backupPaths, ensureDir, readJson, removePath, writeJson } from "./fs-utils.mjs";
3
+
4
+ export async function cleanupProject({ sourceRoot, target, dryRun = false }) {
5
+ console.log(`Cleaning target: ${target}`);
6
+
7
+ await backupPaths(target, [
8
+ ".claude/skills",
9
+ ".claude/commands",
10
+ ".claude/hooks",
11
+ ".claude/scripts",
12
+ ".claude/rules",
13
+ ".claude/settings.json",
14
+ ".claude/settings.local.json",
15
+ ".mcp.json"
16
+ ], { dryRun });
17
+
18
+ const removeList = [
19
+ ".claude/skills",
20
+ ".claude/commands",
21
+ ".claude/hooks",
22
+ ".claude/scripts",
23
+ ".claude/rules"
24
+ ];
25
+
26
+ for (const rel of removeList) {
27
+ await removePath(path.join(target, rel), { dryRun });
28
+ }
29
+
30
+ const settings = await readJson(path.join(sourceRoot, ".claude", "settings.example.json"), {});
31
+ await ensureDir(path.join(target, ".claude"), { dryRun });
32
+ await writeJson(path.join(target, ".claude", "settings.json"), settings, { dryRun });
33
+
34
+ console.log("Cleanup complete.");
35
+ }
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { auditProject } from "./audit.mjs";
3
+ import { isTracked, readJson, writeJson } from "./fs-utils.mjs";
4
+ import { printBox, style } from "./prompt.mjs";
5
+
6
+ function renderDoctor(target, checks, infoRows) {
7
+ const failed = checks.filter((row) => !row.ok);
8
+ const visibleChecks = checks.filter((row) => !row.silent);
9
+
10
+ printBox("Doctor", [
11
+ `Target ${target}`,
12
+ `Checks ${checks.length - failed.length}/${checks.length} ${style("success", "passed")}${failed.length ? `, ${failed.length} ${style("error", "failed")}` : ""}`,
13
+ "",
14
+ ...visibleChecks.map((row) => `${row.ok ? style("success", "✓") : style("error", "✗")} ${row.label}`),
15
+ ...infoRows.map((row) => `${style("info", "i")} ${row}`)
16
+ ]);
17
+ }
18
+
19
+ export async function doctorProject(target, { updateLock = false, dryRun = false } = {}) {
20
+ const audit = await auditProject(target);
21
+ const checks = [];
22
+ const infoRows = [];
23
+
24
+ const check = (condition, label, { silentPass = false } = {}) => {
25
+ checks.push({ ok: Boolean(condition), label, silent: Boolean(condition && silentPass) });
26
+ };
27
+
28
+ const lockPath = path.join(target, ".repo-pattern.lock.json");
29
+ const lock = await readJson(lockPath, {});
30
+ const allowSourceSkills = audit.repoPattern?.mode === "template";
31
+ const managedSkills = audit.repoPattern?.runtime?.localSkills === true && Array.isArray(audit.repoPattern?.optionalSkills) && audit.hasOnlyManagedSkills;
32
+ check(!audit.hasClaudeSkillsDir || allowSourceSkills || managedSkills, ".claude/skills does not exist unless repo-pattern-managed optional skills are enabled");
33
+ check(!audit.hasClaudeCommandsDir, ".claude/commands does not exist");
34
+ check(!audit.hasClaudeHooksDir, ".claude/hooks does not exist");
35
+ check(!audit.hasClaudeScriptsDir, ".claude/scripts does not exist");
36
+ check(!audit.hasClaudeRulesDir || audit.hasOnlyEccRulesDir, "no non-ECC .claude/rules");
37
+ check(audit.hasClaudeDir, ".claude exists");
38
+ check(!audit.hasSettingsHooks, ".claude/settings.json hooks is {}");
39
+ check(!isTracked(target, ".claude/settings.json"), ".claude/settings.json is not tracked");
40
+ check(!isTracked(target, ".claude/settings.local.json"), ".claude/settings.local.json is not tracked");
41
+ check(!isTracked(target, ".mcp.json"), ".mcp.json is not tracked");
42
+ check(!audit.hasHardcodedMcpPath, ".mcp.json has no absolute machine path");
43
+ check(audit.hasRepoPatternJson, ".repo-pattern.json exists");
44
+
45
+ const repoPattern = audit.repoPattern || {};
46
+ check(repoPattern.workflow === "ecc-native", ".repo-pattern.json workflow=ecc-native");
47
+ check(repoPattern.runtime?.localSkills === false || managedSkills, ".repo-pattern.json runtime.localSkills=false unless optional skills are managed");
48
+ if (managedSkills) check(audit.hasClaudeSkillsDir, ".claude/skills exists for managed optional skills");
49
+ check(repoPattern.runtime?.localCommands === false, ".repo-pattern.json runtime.localCommands=false");
50
+ check(repoPattern.runtime?.localHooks === false, ".repo-pattern.json runtime.localHooks=false");
51
+ check(repoPattern.runtime?.localScripts === false, ".repo-pattern.json runtime.localScripts=false");
52
+ check(repoPattern.runtime?.localRules === false, ".repo-pattern.json runtime.localRules=false");
53
+
54
+ const settings = await readJson(path.join(target, ".claude", "settings.json"), {});
55
+ const expectedMcpServers = lock.mcp?.enabledServers || [];
56
+ if (expectedMcpServers.length > 0) {
57
+ const actualMcpServers = settings.enabledMcpjsonServers || [];
58
+ check(
59
+ JSON.stringify(actualMcpServers) === JSON.stringify(expectedMcpServers),
60
+ ".claude/settings.json enabledMcpjsonServers matches MCP profile"
61
+ );
62
+ }
63
+ const appliedRules = lock.ecc?.appliedRules || [];
64
+ if (appliedRules.length > 0) {
65
+ check(audit.hasOnlyEccRulesDir, ".claude/rules/ecc contains ECC-managed project rules");
66
+ }
67
+ infoRows.push(`ECC setup status: ${lock.ecc?.status || "unknown"}`);
68
+ if (lock.ecc?.status === "manual-plugin-install-required") {
69
+ infoRows.push("Open Claude Code and run:");
70
+ infoRows.push("/plugin marketplace add https://github.com/affaan-m/ECC");
71
+ infoRows.push("/plugin install ecc@ecc");
72
+ }
73
+
74
+ if (updateLock) {
75
+ lock.repoPattern = lock.repoPattern || {};
76
+ lock.repoPattern.lastDoctorRun = new Date().toISOString();
77
+ await writeJson(lockPath, lock, { dryRun });
78
+ }
79
+
80
+ renderDoctor(target, checks, infoRows);
81
+
82
+ const failures = checks.filter((row) => !row.ok);
83
+ if (failures.length > 0) {
84
+ throw new Error(`Doctor failed with ${failures.length} failure(s).`);
85
+ }
86
+ }
@@ -0,0 +1,98 @@
1
+ export const ECC_RULE_PACKS = [
2
+ "common",
3
+ "angular",
4
+ "arkts",
5
+ "cpp",
6
+ "csharp",
7
+ "dart",
8
+ "fsharp",
9
+ "golang",
10
+ "java",
11
+ "kotlin",
12
+ "nuxt",
13
+ "perl",
14
+ "php",
15
+ "python",
16
+ "react",
17
+ "react-native",
18
+ "ruby",
19
+ "rust",
20
+ "swift",
21
+ "typescript",
22
+ "vue",
23
+ "web"
24
+ ];
25
+
26
+ const RULE_PACKS = new Set(ECC_RULE_PACKS);
27
+
28
+ export function normalizeEccRules(rules = []) {
29
+ return [...new Set(rules)].filter((name) => RULE_PACKS.has(name));
30
+ }
31
+
32
+ export function invalidEccRules(rules = []) {
33
+ return [...new Set(rules)].filter((name) => !RULE_PACKS.has(name));
34
+ }
35
+
36
+ export function selectEccRules(detection) {
37
+ const languages = new Set(detection.languages || []);
38
+ const frameworks = new Set(detection.frameworks || []);
39
+ const tools = new Set(detection.tools || []);
40
+ const selected = new Set(["common"]);
41
+
42
+ const hasNode = languages.has("javascript") || languages.has("typescript");
43
+ const hasFrontend = (
44
+ frameworks.has("nextjs") ||
45
+ frameworks.has("react") ||
46
+ frameworks.has("react-native") ||
47
+ frameworks.has("angular") ||
48
+ frameworks.has("vue") ||
49
+ frameworks.has("nuxt") ||
50
+ tools.has("frontend-tooling") ||
51
+ detection.repoType === "frontend" ||
52
+ detection.repoType === "fullstack"
53
+ );
54
+
55
+ if (hasNode || hasFrontend) selected.add("typescript");
56
+ if (hasFrontend) selected.add("web");
57
+
58
+ if (frameworks.has("react")) selected.add("react");
59
+ if (frameworks.has("react-native")) selected.add("react-native");
60
+ if (frameworks.has("angular")) selected.add("angular");
61
+ if (frameworks.has("vue")) selected.add("vue");
62
+ if (frameworks.has("nuxt")) {
63
+ selected.add("nuxt");
64
+ selected.add("vue");
65
+ }
66
+
67
+ if (languages.has("python")) selected.add("python");
68
+ if (languages.has("go")) selected.add("golang");
69
+ if (languages.has("java")) selected.add("java");
70
+ if (languages.has("kotlin")) selected.add("kotlin");
71
+ if (languages.has("rust")) selected.add("rust");
72
+ if (languages.has("dart")) selected.add("dart");
73
+ if (languages.has("cpp")) selected.add("cpp");
74
+ if (languages.has("csharp")) selected.add("csharp");
75
+ if (languages.has("fsharp")) selected.add("fsharp");
76
+ if (languages.has("perl")) selected.add("perl");
77
+ if (languages.has("php")) selected.add("php");
78
+ if (languages.has("ruby")) selected.add("ruby");
79
+ if (languages.has("swift")) selected.add("swift");
80
+ if (languages.has("arkts")) selected.add("arkts");
81
+
82
+ return normalizeEccRules(selected);
83
+ }
84
+
85
+ export function explainRules(detection, rules) {
86
+ const lines = [];
87
+ lines.push("Detected stack:");
88
+ lines.push(`- repo type: ${detection.repoType || "generic"}`);
89
+ lines.push(`- languages: ${(detection.languages || []).join(", ") || "none detected"}`);
90
+ lines.push(`- frameworks: ${(detection.frameworks || []).join(", ") || "none detected"}`);
91
+ lines.push(`- tools: ${(detection.tools || []).join(", ") || "none detected"}`);
92
+ lines.push(`- package manager: ${detection.packageManager || "unknown"}`);
93
+ lines.push(`- monorepo: ${detection.monorepo ? "yes" : "no"}`);
94
+ lines.push("");
95
+ lines.push("Selected ECC rules:");
96
+ for (const rule of rules) lines.push(`- ${rule}`);
97
+ return lines.join("\n");
98
+ }
@@ -0,0 +1,61 @@
1
+ import { execSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { readJson, writeJson } from "./fs-utils.mjs";
4
+ import { printSummary } from "./prompt.mjs";
5
+
6
+ function hasEccPlugin() {
7
+ try {
8
+ return execSync("claude plugin list", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).includes("ecc@ecc");
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export async function setupEcc({ target, dryRun = false }) {
15
+ let status = hasEccPlugin() ? "installed" : "manual-plugin-install-required";
16
+
17
+ if (status === "installed") {
18
+ printSummary("ECC", [["Status", "plugin detected in Claude Code"]]);
19
+ } else if (process.env.REPO_PATTERN_ECC_SETUP_CMD) {
20
+ printSummary("ECC", [["Status", "using REPO_PATTERN_ECC_SETUP_CMD"]]);
21
+ if (dryRun) {
22
+ console.log(`[dry-run] REPO_PATTERN_TARGET=${target} ${process.env.REPO_PATTERN_ECC_SETUP_CMD}`);
23
+ status = "dry-run";
24
+ } else {
25
+ execSync(process.env.REPO_PATTERN_ECC_SETUP_CMD, {
26
+ cwd: target,
27
+ stdio: "inherit",
28
+ env: {
29
+ ...process.env,
30
+ REPO_PATTERN_TARGET: target
31
+ },
32
+ shell: true
33
+ });
34
+ status = "installed";
35
+ }
36
+ } else {
37
+ console.log(`
38
+ Install ECC inside Claude Code:
39
+
40
+ /plugin marketplace add https://github.com/affaan-m/ECC
41
+ /plugin install ecc@ecc
42
+
43
+ repo-pattern does not vendor ECC skills, commands, hooks, scripts, or rules.
44
+ ECC runtime surfaces are plugin-managed.
45
+ `);
46
+ }
47
+
48
+ const lockPath = path.join(target, ".repo-pattern.lock.json");
49
+ const lock = await readJson(lockPath, {});
50
+ lock.ecc = {
51
+ ...(lock.ecc || {}),
52
+ installMode: "plugin",
53
+ status,
54
+ rulesSyncedBy: lock.ecc?.rulesSyncedBy || null,
55
+ hooks: "plugin-managed",
56
+ syncedAt: status === "installed" ? new Date().toISOString() : lock.ecc?.syncedAt || null
57
+ };
58
+ await writeJson(lockPath, lock, { dryRun });
59
+
60
+ return status;
61
+ }
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import fsSync from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export function exists(p) {
7
+ return fsSync.existsSync(p);
8
+ }
9
+
10
+ export function isTracked(root, relPath) {
11
+ try {
12
+ const output = execFileSync("git", ["ls-files", relPath], {
13
+ cwd: root,
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"]
16
+ });
17
+ return output.trim().length > 0;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function ensureDir(dir, { dryRun = false } = {}) {
24
+ if (dryRun) {
25
+ console.log(`[dry-run] mkdir -p ${dir}`);
26
+ return;
27
+ }
28
+ await fs.mkdir(dir, { recursive: true });
29
+ }
30
+
31
+ export async function readJson(file, fallback = null) {
32
+ if (!exists(file)) return fallback;
33
+ const text = await fs.readFile(file, "utf8");
34
+ return JSON.parse(text);
35
+ }
36
+
37
+ export async function writeJson(file, data, { dryRun = false } = {}) {
38
+ if (dryRun) {
39
+ console.log(`[dry-run] write JSON ${file}`);
40
+ return;
41
+ }
42
+ await ensureDir(path.dirname(file));
43
+ await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
44
+ }
45
+
46
+ export async function copyRecursive(src, dest, { dryRun = false } = {}) {
47
+ if (!exists(src)) return;
48
+ if (dryRun) {
49
+ console.log(`[dry-run] copy ${src} -> ${dest}`);
50
+ return;
51
+ }
52
+ await ensureDir(path.dirname(dest));
53
+ await fs.cp(src, dest, { recursive: true, force: true });
54
+ }
55
+
56
+ export async function removePath(p, { dryRun = false } = {}) {
57
+ if (!exists(p)) return;
58
+ if (dryRun) {
59
+ console.log(`[dry-run] rm -rf ${p}`);
60
+ return;
61
+ }
62
+ await fs.rm(p, { recursive: true, force: true });
63
+ }
64
+
65
+ export async function writeIfMissing(file, content, { dryRun = false } = {}) {
66
+ if (exists(file)) return false;
67
+ if (dryRun) {
68
+ console.log(`[dry-run] write if missing ${file}`);
69
+ return true;
70
+ }
71
+ await ensureDir(path.dirname(file));
72
+ await fs.writeFile(file, content, "utf8");
73
+ return true;
74
+ }
75
+
76
+ export async function replaceFile(file, content, { dryRun = false } = {}) {
77
+ if (dryRun) {
78
+ console.log(`[dry-run] replace ${file}`);
79
+ return;
80
+ }
81
+ await ensureDir(path.dirname(file));
82
+ await fs.writeFile(file, content, "utf8");
83
+ }
84
+
85
+ export async function appendGitignoreLine(target, line, { dryRun = false } = {}) {
86
+ const file = path.join(target, ".gitignore");
87
+ let content = "";
88
+ try {
89
+ content = await fs.readFile(file, "utf8");
90
+ } catch (error) {
91
+ if (error.code !== "ENOENT") throw error;
92
+ }
93
+ if (content.split(/\r?\n/).includes(line)) return;
94
+ const next = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${line}\n`;
95
+ if (dryRun) {
96
+ console.log(`[dry-run] append ${line} to ${file}`);
97
+ return;
98
+ }
99
+ await fs.writeFile(file, next, "utf8");
100
+ }
101
+
102
+ export function timestamp() {
103
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
104
+ }
105
+
106
+ export async function backupPaths(targetRoot, relativePaths, { dryRun = false } = {}) {
107
+ const backupRoot = path.join(targetRoot, ".repo-pattern", "backups", timestamp());
108
+ let copied = 0;
109
+
110
+ for (const rel of relativePaths) {
111
+ const src = path.join(targetRoot, rel);
112
+ if (!exists(src)) continue;
113
+ const dest = path.join(backupRoot, rel);
114
+
115
+ if (dryRun) {
116
+ console.log(`[dry-run] backup ${src} -> ${dest}`);
117
+ copied++;
118
+ continue;
119
+ }
120
+
121
+ await ensureDir(path.dirname(dest));
122
+ const stat = await fs.stat(src);
123
+ if (stat.isDirectory()) {
124
+ await fs.cp(src, dest, { recursive: true, force: true });
125
+ } else {
126
+ await fs.copyFile(src, dest);
127
+ }
128
+ copied++;
129
+ }
130
+
131
+ if (copied > 0) console.log(`Backup created: ${backupRoot}`);
132
+ return copied > 0 ? backupRoot : null;
133
+ }