projectops 3.0.192 → 3.0.194

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.
@@ -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,14 @@
1
+ // 경로 상수 (.sh readonly 상수군 등가) — template_integrator.sh 113~137
2
+ export const PATHS = {
3
+ tempDir: ".template_download_temp",
4
+ versionFile: "version.yml",
5
+ workflowsDir: ".github/workflows",
6
+ scriptsDir: ".github/scripts",
7
+ projectTypesDir: "project-types",
8
+ };
9
+
10
+ export const TEMPLATE_RAW_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main";
11
+ export const TEMPLATE_REPO = "https://github.com/Cassiiopeia/projectops.git";
12
+ export const WORKFLOW_PREFIX = "PROJECT";
13
+ export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
14
+ export const WORKFLOW_TEMPLATE_INIT = "PROJECT-TEMPLATE-INITIALIZER.yaml";
@@ -0,0 +1,146 @@
1
+ // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
2
+ // ⚠️ YAML 재직렬화 금지 — 주석이 데이터. .sh heredoc과 바이트 동일한 템플릿 문자열.
3
+ // 실측 기준: template_integrator.sh 2184~2354.
4
+
5
+ const HEADER = `# ===================================================================
6
+ # 프로젝트 버전 관리 파일
7
+ # ===================================================================
8
+ #
9
+ # 이 파일은 다양한 프로젝트 타입에서 버전 정보를 중앙 관리하기 위한 파일입니다.
10
+ # GitHub Actions 워크플로우가 이 파일을 읽어 자동으로 버전을 관리합니다.
11
+ #
12
+ # 사용법:
13
+ # 1. version: "1.0.0" - 사용자에게 표시되는 버전
14
+ # 2. version_code: 1 - Play Store/App Store 빌드 번호 (1부터 자동 증가)
15
+ # 3. project_type: 프로젝트 타입 지정
16
+ # 4. project_paths: 타입별 프로젝트 폴더 (레포 루트 기준 상대경로, 모노레포용)
17
+ #
18
+ # 자동 버전 업데이트:
19
+ # - patch: 자동으로 세 번째 자리 증가 (x.x.x -> x.x.x+1)
20
+ # - version_code: 매 빌드마다 자동으로 1씩 증가
21
+ # - minor/major: 수동으로 직접 수정 필요
22
+ #
23
+ # 프로젝트 타입별 동기화 파일:
24
+ # - spring: build.gradle (version = "x.y.z")
25
+ # - flutter: pubspec.yaml (version: x.y.z+i, buildNumber 포함)
26
+ # - react/node: package.json ("version": "x.y.z")
27
+ # - react-native: iOS Info.plist 또는 Android build.gradle
28
+ # - react-native-expo: app.json (expo.version)
29
+ # - python: pyproject.toml (version = "x.y.z")
30
+ # - basic/기타: version.yml 파일만 사용
31
+ #
32
+ # 연관된 워크플로우:
33
+ # - .github/workflows/PROJECT-VERSION-CONTROL.yaml
34
+ # - .github/workflows/PROJECT-README-VERSION-UPDATE.yaml
35
+ # - .github/workflows/PROJECT-AUTO-CHANGELOG-CONTROL.yaml
36
+ #
37
+ # 주의사항:
38
+ # - project_type은 최초 설정 후 변경하지 마세요
39
+ # - 버전은 항상 높은 버전으로 자동 동기화됩니다
40
+ # ===================================================================
41
+ `;
42
+
43
+ // 기존 version.yml에서 값 추출 (.sh grep/sed 등가, 주석 라인 오탐 방지).
44
+ export function parseExisting(content) {
45
+ const text = String(content || "");
46
+ const line = (re) => {
47
+ for (const l of text.split("\n")) {
48
+ if (l.startsWith("#")) continue; // 주석 제외
49
+ const m = l.match(re);
50
+ if (m) return m[1];
51
+ }
52
+ return null;
53
+ };
54
+ // version: "x.y.z" (숫자.숫자.숫자 형태만)
55
+ const version = line(/^version:\s*["']?([0-9][0-9.]*)["']?/) || "";
56
+ // version_code: N (양의 정수, 아니면 1)
57
+ let versionCode = parseInt(line(/^version_code:\s*([0-9]+)/) || "", 10);
58
+ if (!Number.isInteger(versionCode) || versionCode <= 0) versionCode = 1;
59
+ // project_types: ["a","b"]
60
+ const typesRaw = line(/^project_types:\s*(\[[^\]]*\])/);
61
+ let types = [];
62
+ if (typesRaw) types = [...typesRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
63
+ // project_paths 블록: " type: "path""
64
+ const paths = new Map();
65
+ let inPaths = false;
66
+ for (const l of text.split("\n")) {
67
+ if (/^project_paths:/.test(l)) { inPaths = true; continue; }
68
+ if (inPaths) {
69
+ const m = l.match(/^\s+([a-z-]+):\s*"([^"]*)"/);
70
+ if (m) paths.set(m[1], m[2]);
71
+ else if (/^\S/.test(l)) inPaths = false; // 들여쓰기 끝 → 블록 종료
72
+ }
73
+ }
74
+ // template: 블록 내 version
75
+ let templateVersion = "";
76
+ let inTemplate = false;
77
+ for (const l of text.split("\n")) {
78
+ if (/^\s*template:/.test(l)) { inTemplate = true; continue; }
79
+ if (inTemplate) {
80
+ const m = l.match(/^\s*version:\s*"([0-9][0-9.]*)"/);
81
+ if (m) { templateVersion = m[1]; break; }
82
+ if (/^\S/.test(l)) break;
83
+ }
84
+ }
85
+ return { version, versionCode, types, paths, templateVersion };
86
+ }
87
+
88
+ // version.yml 전체 생성 (.sh create_version_yml + save_template_options 신규 케이스 등가).
89
+ // opts: { version, types:[], primaryType?, paths:Map, pathMarkers?:Map, branch, versionCode, now, today, templateOptions? }
90
+ // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입
91
+ // today = "YYYY-MM-DD" (UTC)
92
+ // pathMarkers = Map<type, markerFilename> (project_paths 주석용)
93
+ // templateOptions = { templateVersion, includeNexus, includeSecretBackup, optionsDate } (template 블록)
94
+ export function buildVersionYml({ version, types = [], primaryType, paths = new Map(), pathMarkers = new Map(), branch = "main", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
95
+ const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(",")}]` : `["basic"]`;
96
+ const primary = primaryType || types[0] || "basic";
97
+
98
+ let out = HEADER + "\n";
99
+ out += `version: "${version}"\n`;
100
+ out += `version_code: ${versionCode} # app build number\n`;
101
+ out += `project_types: ${typesJson} # 멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능\n`;
102
+ out += `project_type: "${primary}" # project_types[0] 자동 미러 — 직접 수정 금지 (spring, flutter, next, react, react-native, react-native-expo, node, python, basic)\n`;
103
+
104
+ // project_paths 블록. pathMarkers: Map<type, markerFilename> (있으면 " type: "path" # path/marker" 주석).
105
+ if (paths.size) {
106
+ out += `project_paths: # 타입별 프로젝트 폴더 (레포 루트 기준 상대경로)\n`;
107
+ for (const [t, p] of paths) {
108
+ const marker = pathMarkers.get(t) || "";
109
+ const pf = p === "." ? marker : (marker ? `${p}/${marker}` : p);
110
+ out += marker ? ` ${t}: "${p}" # ${pf}\n` : ` ${t}: "${p}"\n`;
111
+ }
112
+ }
113
+
114
+ out += `metadata:\n`;
115
+ out += ` last_updated: "${now}"\n`;
116
+ out += ` last_updated_by: "template_integrator"\n`;
117
+ out += ` default_branch: "${branch}"\n`;
118
+ out += ` integrated_from: "projectops"\n`;
119
+ out += ` integration_date: "${today}"\n`;
120
+
121
+ // deploy 블록 (.sh update_version_yml_deploy). deployValues: Map<type, Map<key,value>>.
122
+ // WF ask 값이 있는 타입만. metadata 뒤, template 앞. (앞에 빈 줄 1개)
123
+ const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
124
+ if (deployTypes.length) {
125
+ out += `\n`;
126
+ out += `deploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
127
+ for (const t of deployTypes) {
128
+ out += ` ${t}:\n`;
129
+ for (const [k, v] of deployValues.get(t)) out += ` ${k}: "${v}"\n`;
130
+ }
131
+ }
132
+
133
+ // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
134
+ if (templateOptions) {
135
+ const { templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, optionsDate = today } = templateOptions;
136
+ out += ` template:\n`;
137
+ out += ` source: "projectops"\n`;
138
+ out += ` version: "${templateVersion}"\n`;
139
+ out += ` integrated_date: "${optionsDate}"\n`;
140
+ out += ` last_update_date: "${optionsDate}"\n`;
141
+ out += ` options:\n`;
142
+ out += ` nexus: ${includeNexus}\n`;
143
+ out += ` secret_backup: ${includeSecretBackup}\n`;
144
+ }
145
+ return out;
146
+ }