projectops 3.0.193 → 3.0.195

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 (41) hide show
  1. package/README.md +1 -1
  2. package/bin/projectops.js +7 -34
  3. package/package.json +7 -2
  4. package/src/cli/args.js +84 -0
  5. package/src/cli/help.js +23 -0
  6. package/src/commands/full.js +64 -0
  7. package/src/commands/interactive.js +135 -0
  8. package/src/commands/issues.js +8 -0
  9. package/src/commands/skills.js +82 -0
  10. package/src/commands/version.js +30 -0
  11. package/src/commands/workflows.js +53 -0
  12. package/src/context.js +25 -0
  13. package/src/core/assets.js +44 -0
  14. package/src/core/breaking.js +26 -0
  15. package/src/core/copy/coderabbit.js +25 -0
  16. package/src/core/copy/gitignore.js +55 -0
  17. package/src/core/copy/readme.js +30 -0
  18. package/src/core/copy/simple.js +55 -0
  19. package/src/core/copy/util.js +22 -0
  20. package/src/core/copy/workflows.js +145 -0
  21. package/src/core/detect-fs.js +64 -0
  22. package/src/core/detect.js +62 -0
  23. package/src/core/exclusions.js +25 -0
  24. package/src/core/fsutil.js +40 -0
  25. package/src/core/ide/adapter.js +36 -0
  26. package/src/core/ide/adapters/claude.js +94 -0
  27. package/src/core/ide/adapters/codex.js +49 -0
  28. package/src/core/ide/adapters/cursor.js +76 -0
  29. package/src/core/ide/adapters/gemini.js +34 -0
  30. package/src/core/ide/adapters/pi-common.js +63 -0
  31. package/src/core/ide/adapters/pi-harness.js +48 -0
  32. package/src/core/ide/adapters/pi.js +45 -0
  33. package/src/core/ide/registry.js +27 -0
  34. package/src/core/ide/runner.js +59 -0
  35. package/src/core/ide/util.js +18 -0
  36. package/src/core/paths.js +14 -0
  37. package/src/core/version-yml.js +146 -0
  38. package/src/core/wizard-env.js +101 -0
  39. package/src/index.js +108 -0
  40. package/src/ui/prompts.js +82 -0
  41. package/src/ui/skills-prompts.js +37 -0
@@ -0,0 +1,26 @@
1
+ // .sh compare_versions 등가: v 접두 제거, 3자리 숫자 비교, 누락 자리=0
2
+ export function compareVersions(a, b) {
3
+ const parse = (v) => String(v).replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
4
+ const pa = parse(a), pb = parse(b);
5
+ for (let i = 0; i < 3; i++) {
6
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
7
+ if (x > y) return 1;
8
+ if (x < y) return -1;
9
+ }
10
+ return 0;
11
+ }
12
+
13
+ // breaking-changes.json에서 current < ver <= target 범위 항목 수집.
14
+ // ⚠️ .sh 버그 수정: target은 하드코딩 1.3.14가 아니라 실제 templateVersion을 넘긴다 (D2).
15
+ // _ 로 시작하는 키(메타) 제외. severity critical / 그 외(warning).
16
+ export function collectBreaking(json, current, target) {
17
+ const critical = [], warnings = [];
18
+ for (const [ver, entry] of Object.entries(json || {})) {
19
+ if (ver.startsWith("_")) continue;
20
+ if (compareVersions(current, ver) < 0 && compareVersions(ver, target) <= 0) {
21
+ const rec = { version: ver, ...entry };
22
+ (entry?.severity === "critical" ? critical : warnings).push(rec);
23
+ }
24
+ }
25
+ return { critical, warnings };
26
+ }
@@ -0,0 +1,25 @@
1
+ // .coderabbit.yaml 복사 (.sh copy_coderabbit_config 등가, force 경로) — template_integrator.sh 3938~3990.
2
+ // SP2-B는 force/비대화형 경로만 구현. 대화형 덮어쓰기/건너뛰기 메뉴는 SP2-C.
3
+ import { join } from "node:path";
4
+ import { exists, copyFileSync } from "../fsutil.js";
5
+
6
+ // 반환: 'skip-no-src' | 'copied-new' | 'overwritten-backup' | 'skip-non-tty'
7
+ // opts: { force, tty } — SP2-B 검증은 force:true 경로.
8
+ export function copyCoderabbit(tempDir, { force = false, tty = false } = {}, targetRoot = ".") {
9
+ const src = join(tempDir, ".coderabbit.yaml");
10
+ if (!exists(src)) return "skip-no-src";
11
+ const dst = join(targetRoot, ".coderabbit.yaml");
12
+
13
+ if (exists(dst)) {
14
+ if (force) {
15
+ copyFileSync(dst, dst + ".bak"); // 백업 후 덮어쓰기
16
+ copyFileSync(src, dst);
17
+ return "overwritten-backup";
18
+ }
19
+ if (!tty) return "skip-non-tty"; // 비TTY & !force → 유지
20
+ // 대화형 메뉴는 SP2-C — 여기서는 force가 아니면 유지
21
+ return "skip-non-tty";
22
+ }
23
+ copyFileSync(src, dst); // 신규
24
+ return "copied-new";
25
+ }
@@ -0,0 +1,55 @@
1
+ // .gitignore 보장 (.sh ensure_gitignore + normalize/check 등가) — template_integrator.sh 3996~4111.
2
+ import { join } from "node:path";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+
5
+ const REQUIRED_ENTRIES = ["/.idea", "/.claude/settings.local.json"];
6
+
7
+ // .sh normalize_gitignore_entry: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
8
+ export function normalizeGitignoreEntry(entry) {
9
+ let e = String(entry);
10
+ e = e.replace(/#.*$/, ""); // 주석 제거
11
+ e = e.trim(); // 앞뒤 공백
12
+ e = e.replace(/^\//, ""); // 앞 /
13
+ e = e.replace(/^\.\//, ""); // 앞 ./
14
+ e = e.replace(/\/$/, ""); // 뒤 /
15
+ return e === "" ? String(entry) : e;
16
+ }
17
+
18
+ function entryExists(target, content) {
19
+ const nt = normalizeGitignoreEntry(target);
20
+ for (const line of content.split("\n")) {
21
+ if (/^\s*#/.test(line)) continue;
22
+ if (/^\s*$/.test(line)) continue;
23
+ if (normalizeGitignoreEntry(line) === nt) return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ const NEW_FILE_CONTENT =
29
+ "# IDE Settings\n" +
30
+ "/.idea\n" +
31
+ "\n" +
32
+ "# Claude AI Settings\n" +
33
+ "/.claude/settings.local.json\n";
34
+
35
+ // 반환: {created, added:[...]}
36
+ export function ensureGitignore(targetRoot = ".") {
37
+ const p = join(targetRoot, ".gitignore");
38
+ if (!existsSync(p)) {
39
+ writeFileSync(p, NEW_FILE_CONTENT);
40
+ return { created: true, added: REQUIRED_ENTRIES.slice() };
41
+ }
42
+ let content = readFileSync(p, "utf8");
43
+ const toAdd = REQUIRED_ENTRIES.filter((e) => !entryExists(e, content));
44
+ if (toAdd.length === 0) return { created: false, added: [] };
45
+
46
+ // 파일 끝에 개행 없으면 추가 (.sh: tail -c 1 이 non-empty면 echo "")
47
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
48
+ content += "\n";
49
+ content += "# ====================================================================\n";
50
+ content += "# projectops: Auto-added entries\n";
51
+ content += "# ====================================================================\n";
52
+ for (const e of toAdd) content += e + "\n";
53
+ writeFileSync(p, content);
54
+ return { created: false, added: toAdd };
55
+ }
@@ -0,0 +1,30 @@
1
+ // README 버전 섹션 추가 (.sh add_version_section_to_readme 등가) — template_integrator.sh 2145~2181.
2
+ import { join } from "node:path";
3
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
4
+
5
+ const MARKER = "<!-- AUTO-VERSION-SECTION";
6
+ // ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
7
+ const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
8
+
9
+ // README.md 없으면 스킵. 마커 또는 버전 라인 있으면 스킵. 없으면 파일 끝에 append.
10
+ // 반환: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
11
+ export function addVersionSectionToReadme(version, targetRoot = ".") {
12
+ const p = join(targetRoot, "README.md");
13
+ if (!existsSync(p)) return "skip-no-readme";
14
+ const content = readFileSync(p, "utf8");
15
+ if (content.includes(MARKER)) return "skip-marker";
16
+ if (VERSION_LINE_RE.test(content)) return "skip-version-line";
17
+
18
+ // .sh: cat >> README.md << EOF — EOF 다음 첫 줄이 빈 줄이므로 append 본문은 "\n---\n..."로 시작.
19
+ // (원본 파일이 개행으로 끝난다는 전제는 .sh와 동일 — heredoc은 원본 끝에 그대로 붙는다.)
20
+ const section =
21
+ "\n" +
22
+ "---\n" +
23
+ "\n" +
24
+ "<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n" +
25
+ `## 최신 버전 : v${version}\n` +
26
+ "\n" +
27
+ "[전체 버전 기록 보기](CHANGELOG.md)\n";
28
+ appendFileSync(p, section);
29
+ return "added";
30
+ }
@@ -0,0 +1,55 @@
1
+ // 단순 복사 함수 (무조건 덮어쓰기류) — .sh copy_scripts/config/issue/discussion/setup_guide 등가.
2
+ // 실측: template_integrator.sh 3818, 3845, 3872, 3895, 4114.
3
+ import { join } from "node:path";
4
+ import { chmodSync } from "node:fs";
5
+ import { PATHS } from "../paths.js";
6
+ import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
7
+
8
+ // version_manager.sh, changelog_manager.py 2개만 무조건 덮어쓰기 + chmod +x.
9
+ export function copyScripts(tempDir, targetRoot = ".") {
10
+ const scripts = ["version_manager.sh", "changelog_manager.py"];
11
+ let copied = 0;
12
+ for (const s of scripts) {
13
+ const src = join(tempDir, PATHS.scriptsDir, s);
14
+ if (exists(src)) {
15
+ const dst = join(targetRoot, PATHS.scriptsDir, s);
16
+ copyFileSync(src, dst);
17
+ try { chmodSync(dst, 0o755); } catch { /* Windows 등 chmod 무의미 */ }
18
+ copied++;
19
+ }
20
+ }
21
+ return copied;
22
+ }
23
+
24
+ // .github/config 폴더 전체 덮어쓰기. 없으면 스킵.
25
+ export function copyConfigFolder(tempDir, targetRoot = ".") {
26
+ const src = join(tempDir, ".github", "config");
27
+ if (!exists(src)) return false;
28
+ copyDirSync(src, join(targetRoot, ".github", "config"));
29
+ return true;
30
+ }
31
+
32
+ // .github/ISSUE_TEMPLATE/ 전체 + PULL_REQUEST_TEMPLATE.md 덮어쓰기.
33
+ export function copyIssueTemplates(tempDir, targetRoot = ".") {
34
+ const srcIssue = join(tempDir, ".github", "ISSUE_TEMPLATE");
35
+ if (exists(srcIssue)) copyDirSync(srcIssue, join(targetRoot, ".github", "ISSUE_TEMPLATE"));
36
+ const srcPr = join(tempDir, ".github", "PULL_REQUEST_TEMPLATE.md");
37
+ if (exists(srcPr)) copyFileSync(srcPr, join(targetRoot, ".github", "PULL_REQUEST_TEMPLATE.md"));
38
+ }
39
+
40
+ // .github/DISCUSSION_TEMPLATE/ 전체. 없으면 스킵.
41
+ export function copyDiscussionTemplates(tempDir, targetRoot = ".") {
42
+ const src = join(tempDir, ".github", "DISCUSSION_TEMPLATE");
43
+ if (!exists(src)) return false;
44
+ copyDirSync(src, join(targetRoot, ".github", "DISCUSSION_TEMPLATE"));
45
+ return true;
46
+ }
47
+
48
+ // SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md 루트로 덮어쓰기. 없으면 스킵.
49
+ export const SETUP_GUIDE_NAME = "SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md";
50
+ export function copySetupGuide(tempDir, targetRoot = ".") {
51
+ const src = join(tempDir, SETUP_GUIDE_NAME);
52
+ if (!exists(src)) return false;
53
+ copyFileSync(src, join(targetRoot, SETUP_GUIDE_NAME));
54
+ return true;
55
+ }
@@ -0,0 +1,22 @@
1
+ // util 모듈 복사 (.sh copy_util_modules 등가, force 경로) — template_integrator.sh 4203~.
2
+ // tempDir/.github/util/<type>/ 있으면 (force) .github/util/<type>/로 전체 복사, 모듈 수 카운트.
3
+ import { join } from "node:path";
4
+ import { readdirSync } from "node:fs";
5
+ import { exists, copyDirSync } from "../fsutil.js";
6
+
7
+ // 반환: {copied:bool, moduleCount:number}. force가 아니면(비TTY) 스킵.
8
+ export function copyUtilModules(tempDir, type, { force = false } = {}, targetRoot = ".") {
9
+ const src = join(tempDir, ".github", "util", type);
10
+ if (!exists(src)) return { copied: false, moduleCount: 0 };
11
+ if (!force) return { copied: false, moduleCount: 0 }; // SP2-B: force 경로만
12
+
13
+ const dst = join(targetRoot, ".github", "util", type);
14
+ copyDirSync(src, dst);
15
+
16
+ // 하위 디렉토리 개수 = 모듈 수 (.sh: for dir in "$util_dst"/*/)
17
+ let moduleCount = 0;
18
+ for (const e of readdirSync(dst, { withFileTypes: true })) {
19
+ if (e.isDirectory()) moduleCount++;
20
+ }
21
+ return { copied: true, moduleCount };
22
+ }
@@ -0,0 +1,145 @@
1
+ // 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
2
+ // 실측: template_integrator.sh 3398~3815. SP2-B는 --force 경로만 구현.
3
+ // (대화형 3지선 메뉴·env 계획 질문은 SP2-C. force/비TTY의 기본 선택은 "건너뛰기"이므로 등가.)
4
+ import { join, basename } from "node:path";
5
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
6
+ import { PATHS } from "../paths.js";
7
+ import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
8
+ import { isUnchanged, substituteEnv } from "../wizard-env.js";
9
+
10
+ // 한 파일에 env 치환(기본값)을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
11
+ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null }) {
12
+ const content = readFileSync(targetPath, "utf8");
13
+ if (!content.includes("@wizard")) return;
14
+ const out = substituteEnv(content, { type, useDefaults: true, projectPath, repoName, resolvers, collectAsks });
15
+ writeFileSync(targetPath, out);
16
+ }
17
+
18
+ // 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
19
+ function classify(srcDir, workflowsDir, envOpts) {
20
+ const result = { newFiles: [], unchanged: [], changed: [] };
21
+ for (const filename of listYamlFiles(srcDir)) {
22
+ const src = join(srcDir, filename);
23
+ const dst = join(workflowsDir, filename);
24
+ if (existsSync(dst)) {
25
+ const tpl = readFileSync(src, "utf8");
26
+ const inst = readFileSync(dst, "utf8");
27
+ if (isUnchanged(tpl, inst, envOpts)) result.unchanged.push(filename);
28
+ else result.changed.push(filename);
29
+ } else {
30
+ result.newFiles.push(filename);
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+
36
+ // copy_workflows 본체.
37
+ // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers }
38
+ // tempDir: 다운로드 원본. targetRoot: 통합 대상 루트.
39
+ // 반환: {copied, skipped, templateAdded, optionalCopied}
40
+ export function copyWorkflows(context, tempDir, targetRoot = ".") {
41
+ const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
42
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
43
+ const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
44
+ if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
45
+
46
+ const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
47
+ const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
48
+ counters.deployValues = deployValues;
49
+ const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers });
50
+
51
+ // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기
52
+ const commonDir = join(projectTypesDir, "common");
53
+ if (exists(commonDir)) {
54
+ for (const filename of listYamlFiles(commonDir)) {
55
+ const src = join(commonDir, filename);
56
+ const dst = join(workflowsDir, filename);
57
+ if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
58
+ counters.skipped++;
59
+ continue;
60
+ }
61
+ copyFileSync(src, dst);
62
+ counters.copied++;
63
+ }
64
+ }
65
+
66
+ // (2~4) 타입별
67
+ for (const type of types) {
68
+ const asks = new Map();
69
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks }, counters);
70
+ if (asks.size) deployValues.set(type, asks);
71
+ }
72
+
73
+ // (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
74
+ const secretDir = join(commonDir, "secret-backup");
75
+ if (exists(secretDir) && includeSecretBackup) {
76
+ for (const filename of listYamlFiles(secretDir)) {
77
+ const dst = join(workflowsDir, filename);
78
+ if (existsSync(dst)) continue; // 이미 존재하면 스킵
79
+ copyFileSync(join(secretDir, filename), dst);
80
+ counters.optionalCopied++;
81
+ counters.copied++;
82
+ }
83
+ }
84
+
85
+ return counters;
86
+ }
87
+
88
+ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
89
+ const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null } = ctx;
90
+ const typeDir = join(projectTypesDir, type);
91
+ const envOpts = envOptsFor(type);
92
+ let unchangedNames = [];
93
+
94
+ // 타입별 워크플로우 (직하위)
95
+ if (exists(typeDir)) {
96
+ const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
97
+ unchangedNames = unchanged.slice();
98
+ for (const f of unchanged) counters.skipped++;
99
+ for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; }
100
+ // changed: force/비TTY 기본은 "건너뛰기"(기존 유지)
101
+ if (!force) { /* 대화형 메뉴는 SP2-C */ }
102
+ for (const f of changed) counters.skipped++; // force 경로 = skip
103
+ }
104
+
105
+ // server-deploy
106
+ const serverDeployDir = join(typeDir, "server-deploy");
107
+ if (exists(serverDeployDir)) {
108
+ if (includeNexus) {
109
+ // Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
110
+ } else {
111
+ const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
112
+ for (const f of unchanged) counters.skipped++;
113
+ for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
114
+ for (const f of changed) counters.skipped++; // force = skip
115
+ }
116
+ }
117
+
118
+ // nexus (opt-in)
119
+ const nexusDir = join(typeDir, "nexus");
120
+ if (exists(nexusDir) && includeNexus) {
121
+ for (const filename of listYamlFiles(nexusDir)) {
122
+ const src = join(nexusDir, filename);
123
+ const dst = join(workflowsDir, filename);
124
+ if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
125
+ counters.skipped++;
126
+ continue;
127
+ }
128
+ if (existsSync(dst)) renameSync(dst, dst + ".bak");
129
+ copyFileSync(src, dst);
130
+ counters.optionalCopied++;
131
+ counters.copied++;
132
+ }
133
+ }
134
+
135
+ // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
136
+ for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
137
+ if (!exists(srcDir)) continue;
138
+ for (const filename of listYamlFiles(srcDir)) {
139
+ const target = join(workflowsDir, filename);
140
+ if (!existsSync(target)) continue; // 건너뛴 파일 제외
141
+ if (unchangedNames.includes(filename)) continue; // unchanged 제외
142
+ configureEnv(target, { type, projectPath: paths.get(type) || ".", repoName, resolvers, collectAsks });
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,64 @@
1
+ // 실 파일시스템 프로젝트 감지 (.sh detect_* 실행부 등가).
2
+ // SP2-A detect.js 순수 함수를 fs/git으로 구동한다.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join, basename } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { detectTypesFromMarkers, detectVersionFromFiles } from "./detect.js";
7
+ import { parseExisting } from "./version-yml.js";
8
+
9
+ const hasFile = (root) => (rel) => existsSync(join(root, rel));
10
+ const readFile = (root) => (rel) => {
11
+ try { return readFileSync(join(root, rel), "utf8"); } catch { return null; }
12
+ };
13
+
14
+ function gitOut(root, args) {
15
+ try {
16
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
17
+ } catch { return ""; }
18
+ }
19
+
20
+ // 타입 감지 — version.yml의 project_types 최우선(source of truth), 없으면 마커 스캔.
21
+ export function detectTypes(root) {
22
+ const vy = join(root, "version.yml");
23
+ if (existsSync(vy)) {
24
+ const { types } = parseExisting(readFileSync(vy, "utf8"));
25
+ if (types.length) return types; // basic 포함, 명시돼 있으면 그대로
26
+ }
27
+ return detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
28
+ }
29
+
30
+ // 버전 감지 — .sh detect_version 순서. jq 유무는 command 존재로 판정.
31
+ export function detectVersion(root, { hasJq } = {}) {
32
+ const read = readFile(root);
33
+ const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
34
+ const jq = hasJq ?? hasCommand("jq");
35
+ const gitTag = gitOut(root, ["describe", "--tags", "--abbrev=0"]);
36
+ return detectVersionFromFiles({ read, readJson, hasJq: jq, gitTag });
37
+ }
38
+
39
+ // 기본 브랜치 감지 — symbolic-ref → remote show → main.
40
+ export function detectDefaultBranch(root) {
41
+ let b = gitOut(root, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
42
+ if (b) return b.replace(/^refs\/remotes\/origin\//, "");
43
+ const show = gitOut(root, ["remote", "show", "origin"]);
44
+ const m = show.match(/HEAD branch:\s*(\S+)/);
45
+ if (m) return m[1];
46
+ return "main";
47
+ }
48
+
49
+ // 레포명 — git remote get-url origin 마지막 세그먼트, 실패 시 폴더명.
50
+ export function detectRepoName(root) {
51
+ const url = gitOut(root, ["remote", "get-url", "origin"]);
52
+ if (url) {
53
+ const seg = url.replace(/\.git$/, "").split(/[/:]/).pop();
54
+ if (seg) return seg;
55
+ }
56
+ return basename(root);
57
+ }
58
+
59
+ function hasCommand(cmd) {
60
+ try {
61
+ execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
62
+ return true;
63
+ } catch { return false; }
64
+ }
@@ -0,0 +1,62 @@
1
+ // package.json 내용 분류 (.sh classify_package_json 등가) — 원본 파일 텍스트에 대한
2
+ // grep 부분문자열 매칭. dependencies 파싱이 아니라 raw 텍스트 검사여야 등가.
3
+ // 입력은 package.json의 원문 문자열(raw). 순서 중요.
4
+ export function classifyPackageText(raw) {
5
+ const s = String(raw || "");
6
+ if (s.includes("@react-native") || s.includes("react-native")) {
7
+ return s.includes("expo") ? "react-native-expo" : "react-native";
8
+ }
9
+ if (s.includes('"next"')) return "next";
10
+ if (s.includes('"react"')) return "react";
11
+ return "node";
12
+ }
13
+
14
+ // 편의: 파싱된 객체를 받는 경우 원문으로 재직렬화해 위 규칙 적용
15
+ export function classifyPackageJson(pkgOrRaw) {
16
+ const raw = typeof pkgOrRaw === "string" ? pkgOrRaw : JSON.stringify(pkgOrRaw || {});
17
+ return classifyPackageText(raw);
18
+ }
19
+
20
+ // 마커 스캔 (동작명세 §3.1). has(relpath)=>bool 주입. node는 다른 타입 있으면 미추가.
21
+ // read(relpath)=>string|null 로 package.json 원문을 받아 classifyPackageText에 넘긴다.
22
+ export function detectTypesFromMarkers({ has, read }) {
23
+ const types = [];
24
+ if (has("pubspec.yaml")) types.push("flutter");
25
+ if (has("build.gradle") || has("build.gradle.kts") || has("pom.xml")) types.push("spring");
26
+ if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) types.push("python");
27
+ if (has("package.json")) {
28
+ const cls = classifyPackageText(read ? read("package.json") : "");
29
+ if (cls === "node") { if (types.length === 0) types.push("node"); }
30
+ else types.push(cls);
31
+ }
32
+ return types.length ? [...new Set(types)] : ["basic"];
33
+ }
34
+
35
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
36
+
37
+ // 버전 감지 (동작명세 §3.3) — 순서대로 첫 성공. read(relpath)=>string|null 주입.
38
+ export function detectVersionFromFiles({ read, readJson, hasJq, gitTag }) {
39
+ const pkg = readJson?.("package.json");
40
+ if (hasJq && pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
41
+ const grab = (content, re) => {
42
+ for (const line of (content || "").split("\n")) {
43
+ const m = line.match(re);
44
+ if (m && VERSION_RE.test(m[1])) return m[1];
45
+ }
46
+ return null;
47
+ };
48
+ let v;
49
+ if ((v = grab(read("build.gradle"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
50
+ if ((v = grab(read("pubspec.yaml"), /^version:\s*(\d+\.\d+\.\d+)/))) return v;
51
+ if ((v = grab(read("pyproject.toml"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
52
+ if (gitTag) { const t = String(gitTag).replace(/^v/, ""); if (VERSION_RE.test(t)) return t; }
53
+ return "0.0.1";
54
+ }
55
+
56
+ export function markerForType(type) {
57
+ return { flutter: "pubspec.yaml", "react-native-expo": "app.json", python: "pyproject.toml", spring: "build.gradle" }[type] || "package.json";
58
+ }
59
+
60
+ export function extraMarkers(type) {
61
+ return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
62
+ }
@@ -0,0 +1,25 @@
1
+ // 템플릿 다운로드 후 제거하는 항목 — .sh download_template 내부 배열과 동기화
2
+ // ⚠️ CLAUDE.md "3곳 동시 수정" 규칙의 4번째 동기화 지점:
3
+ // template_initializer.sh / template_integrator.sh / .ps1 / 이 파일
4
+ export const DOCS_TO_REMOVE = [
5
+ "CONTRIBUTING.md",
6
+ "CLAUDE.md",
7
+ "AGENTS.md",
8
+ "GEMINI.md",
9
+ "gemini-extension.json",
10
+ ];
11
+
12
+ export const PLUGIN_ITEMS_TO_REMOVE = [
13
+ ".claude-plugin",
14
+ ".codex-plugin",
15
+ ".agents",
16
+ ".cursor",
17
+ "scripts",
18
+ "package.json",
19
+ "harness",
20
+ "bin",
21
+ "src",
22
+ ".github/workflows/PROJECT-TEMPLATE-PLUGIN-VERSION-SYNC.yaml",
23
+ ".github/workflows/PROJECT-TEMPLATE-NPM-PUBLISH.yaml",
24
+ ];
25
+ // ⚠️ skills/ 는 제외하지 않는다 (Cursor 설치 소스로 보존)
@@ -0,0 +1,40 @@
1
+ // 파일시스템 공용 유틸 (LF 보존 바이트 복사). 텍스트는 그대로 복사해 원본 줄바꿈 유지.
2
+ import {
3
+ cpSync, existsSync, readFileSync, writeFileSync, mkdirSync,
4
+ readdirSync, rmSync,
5
+ } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ export const exists = (p) => existsSync(p);
9
+ export const readText = (p) => readFileSync(p, "utf8");
10
+
11
+ export function writeText(p, s) {
12
+ mkdirSync(dirname(p), { recursive: true });
13
+ writeFileSync(p, s);
14
+ }
15
+
16
+ // 단일 파일 복사 (부모 디렉토리 자동 생성, 바이트 그대로)
17
+ export function copyFileSync(src, dst) {
18
+ mkdirSync(dirname(dst), { recursive: true });
19
+ cpSync(src, dst);
20
+ }
21
+
22
+ // 디렉토리 재귀 복사 (내용을 dst 하위로)
23
+ export function copyDirSync(src, dst) {
24
+ mkdirSync(dst, { recursive: true });
25
+ cpSync(src, dst, { recursive: true });
26
+ }
27
+
28
+ // 파일/폴더 삭제 (없어도 무해)
29
+ export function remove(p) {
30
+ rmSync(p, { recursive: true, force: true });
31
+ }
32
+
33
+ // 디렉토리 직하위 .yaml/.yml 파일명 목록 (정렬). 하위 폴더 제외.
34
+ export function listYamlFiles(dir) {
35
+ if (!existsSync(dir)) return [];
36
+ return readdirSync(dir, { withFileTypes: true })
37
+ .filter((e) => e.isFile() && /\.(ya?ml)$/.test(e.name))
38
+ .map((e) => e.name)
39
+ .sort();
40
+ }
@@ -0,0 +1,36 @@
1
+ // IDE 어댑터 공통 계약 (확장 포인트의 핵심).
2
+ //
3
+ // 새 IDE(에디터/에이전트 CLI)를 지원하려면 이 shape을 만족하는 객체 하나를
4
+ // src/core/ide/adapters/<name>.js 에 만들고 registry.js 배열에 추가하기만 하면 된다.
5
+ // 오케스트레이터(commands/skills.js)는 어댑터의 필드만 알고, 개별 IDE 로직은 전혀 모른다.
6
+ //
7
+ // @typedef {Object} IdeStatus
8
+ // installed: boolean 설치돼 있는가
9
+ // version: string|null 설치 버전(알 수 있으면)
10
+ // cliMissing: boolean 전용 CLI가 없어 설치 불가한 상태인가 (안내만 가능)
11
+ // note: string 상태 표시줄에 덧붙일 짧은 메모(선택)
12
+ //
13
+ // @typedef {Object} IdeAdapter
14
+ // id: string 내부 고유키 (예: "claude"). 라우팅·preselect에 사용
15
+ // label: string 사람에게 보이는 이름 (예: "Claude Code")
16
+ // order: number 상태·메뉴 표시 순서 (작을수록 먼저)
17
+ // detect(io): IdeStatus 현재 설치 상태
18
+ // apply(io, ctx): boolean 설치 또는 업데이트(멱등). 성공 true
19
+ // remove(io, ctx): boolean 제거(미설치면 no-op, true)
20
+ // manualHint(io): string CLI 없을 때 보여줄 수동 설치 명령(선택)
21
+ //
22
+ // io = runner.defaultIo() 또는 테스트 stub: { which, run, home, log }
23
+ // ctx = { templateVersion, sourceSkillsDir } — 버전 표기·Cursor 복사 소스 등 공용 컨텍스트
24
+ //
25
+ // 규칙:
26
+ // - apply/remove/detect는 예외를 던지지 않는다(내부에서 흡수). 실패는 log + false로 표현.
27
+ // - CLI 미존재는 에러가 아니라 cliMissing 상태 + manualHint로 안내한다.
28
+ // - 어댑터는 io를 통해서만 외부와 상호작용한다(직접 spawnSync/console 금지) — 테스트 가능성.
29
+
30
+ // 어댑터가 최소 계약을 지키는지 개발 중 검증하는 헬퍼(런타임 방어).
31
+ export function assertAdapter(a) {
32
+ for (const k of ["id", "label", "detect", "apply", "remove"]) {
33
+ if (a[k] == null) throw new Error(`IDE 어댑터 '${a?.id || "?"}'에 필수 필드 '${k}'가 없습니다`);
34
+ }
35
+ return a;
36
+ }