projectops 4.71.3 → 4.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/cli/args.js +2 -0
- package/src/cli/help.js +6 -0
- package/src/commands/doctor.js +17 -0
- package/src/commands/full.js +1 -1
- package/src/commands/version.js +1 -1
- package/src/core/copy/readme.js +13 -5
- package/src/core/copy/simple.js +3 -1
- package/src/core/development-branch.js +58 -0
- package/src/core/ide/adapter.js +1 -1
- package/src/core/ide/adapters/copilot.js +84 -0
- package/src/core/ide/adapters/cursor.js +16 -99
- package/src/core/ide/home-skills.js +52 -0
- package/src/core/ide/registry.js +2 -0
- package/src/core/ide/runner.js +2 -0
- package/src/core/ide/skills-copy.js +147 -0
- package/src/core/ide/stable-entry.js +51 -0
- package/src/core/migration-guide.js +11 -3
- package/src/core/options-ask.js +11 -9
- package/src/core/options-schema.js +11 -1
- package/src/core/placeholder-triggers.js +80 -0
- package/src/i18n/doctor.en.js +4 -0
- package/src/i18n/doctor.ko.js +4 -0
- package/src/i18n/en.js +2 -2
- package/src/i18n/ide.en.js +15 -2
- package/src/i18n/ide.ko.js +15 -2
- package/src/i18n/ko.js +2 -2
- package/src/i18n/summary.en.js +5 -1
- package/src/i18n/summary.ko.js +5 -1
- package/src/index.js +13 -1
- package/src/ui/summary.js +11 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
**Status: actively maintained.** npm `latest` is exactly what is on the `main` branch; work in progress on `develop` is never published. Versions can still ship several times a day. To stay on a version you have tested, run `npx projectops@<version>` ([releases](https://github.com/Cassiiopeia/projectops/releases)).
|
|
12
12
|
|
|
13
13
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
14
|
-
## Latest version : v4.71.
|
|
14
|
+
## Latest version : v4.71.3 (2026-10-22)
|
|
15
15
|
|
|
16
16
|
[View full version history](CHANGELOG.md)
|
|
17
17
|
|
|
@@ -229,7 +229,7 @@ flowchart TD
|
|
|
229
229
|
</details>
|
|
230
230
|
|
|
231
231
|
<details>
|
|
232
|
-
<summary>Install Agent Skills only (Claude Code, Gemini CLI, Codex CLI, Cursor)</summary>
|
|
232
|
+
<summary>Install Agent Skills only (Claude Code, Gemini CLI, Codex CLI, Cursor, GitHub Copilot)</summary>
|
|
233
233
|
|
|
234
234
|
```bash
|
|
235
235
|
# Claude Code
|
|
@@ -252,7 +252,7 @@ The `--mode skills` wizard registers the Codex marketplace and also prepares the
|
|
|
252
252
|
Where the Codex plugin marketplace is unavailable, use the fallback install described in the [Skills guide](docs/en/skills.md).
|
|
253
253
|
|
|
254
254
|
```bash
|
|
255
|
-
# Cursor / the full Agent Skills install menu (recommended: npx)
|
|
255
|
+
# Cursor / GitHub Copilot (~/.copilot/skills) / the full Agent Skills install menu (recommended: npx)
|
|
256
256
|
npx projectops --mode skills
|
|
257
257
|
```
|
|
258
258
|
|
package/package.json
CHANGED
package/src/cli/args.js
CHANGED
|
@@ -37,6 +37,7 @@ export function parseArgs(argv) {
|
|
|
37
37
|
projectsSync: null, // #716 — Projects 보드 동기화 워크플로우 포함 여부 (null=미지정)
|
|
38
38
|
json: false, // --json: 기계가 읽는 출력 (--mode options)
|
|
39
39
|
removeLegacy: false, // #809 — 구세대 배포 워크플로우(confirm 티어)도 .bak 으로 치운다
|
|
40
|
+
createDevelopmentBranch: false, // #715 — 비대화형에서 개발 브랜치 생성·push (원격에 쓰므로 명시할 때만)
|
|
40
41
|
pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
|
|
41
42
|
force: false,
|
|
42
43
|
lang: null, // --lang en|ko (null = decided by src/i18n resolveLang)
|
|
@@ -177,6 +178,7 @@ export function parseArgs(argv) {
|
|
|
177
178
|
case "--projects-sync": result.projectsSync = true; break;
|
|
178
179
|
case "--no-projects-sync": result.projectsSync = false; break;
|
|
179
180
|
case "--remove-legacy": result.removeLegacy = true; break;
|
|
181
|
+
case "--create-development-branch": result.createDevelopmentBranch = true; break;
|
|
180
182
|
case "--json": result.json = true; break;
|
|
181
183
|
case "--npm-publish":
|
|
182
184
|
process.stderr.write(t("args.npmPublishDeprecated") + "\n");
|
package/src/cli/help.js
CHANGED
|
@@ -50,6 +50,9 @@ Options
|
|
|
50
50
|
--publish CSV library registries: nexus,npm,github-packages. omitted: none
|
|
51
51
|
--deploy-branch NAME the DEVELOPMENT branch (head of the release PR), default develop. It is NOT the branch that deploys;
|
|
52
52
|
that is the repository default branch. This flag does not create the branch
|
|
53
|
+
--create-development-branch create the development branch from the default branch and push it to origin when it
|
|
54
|
+
is missing. Does nothing if it exists. A failed push does not fail the run; the summary prints
|
|
55
|
+
the command to run yourself. omitted: off (pushing to the remote is never done without asking)
|
|
53
56
|
--secret-backup / --no-secret-backup upload GitHub Secrets to your server over SSH (needs SERVER_* secrets). omitted: excluded
|
|
54
57
|
--ai-summary / --no-ai-summary AI change summary comment on work PRs. Works with no extra setup. omitted: included
|
|
55
58
|
--coderabbit / --no-coderabbit install .coderabbit.yaml (only --mode full). Needs the CodeRabbit GitHub app. An existing
|
|
@@ -129,6 +132,9 @@ AI agent 나 CI 는 항상 이렇게 실행한다: npx projectops --mode <
|
|
|
129
132
|
--publish CSV 라이브러리 레지스트리: nexus,npm,github-packages. 생략: 없음
|
|
130
133
|
--deploy-branch NAME **개발** 브랜치(릴리스 PR 의 head), 기본 develop. 배포가 도는 브랜치가 아니다 —
|
|
131
134
|
그것은 레포 기본 브랜치다. 이 플래그는 브랜치를 만들지 않는다
|
|
135
|
+
--create-development-branch 개발 브랜치가 없으면 기본 브랜치에서 만들고 origin 에 push 한다. 이미 있으면
|
|
136
|
+
아무것도 하지 않는다. push 가 실패해도 실행은 실패하지 않고 직접 실행할 명령을 요약에 남긴다.
|
|
137
|
+
생략: 꺼짐 (묻지 않고 원격에 push 하지 않는다)
|
|
132
138
|
--secret-backup / --no-secret-backup GitHub Secret 을 SSH 로 서버에 올린다 (SERVER_* secret 필요). 생략: 제외
|
|
133
139
|
--ai-summary / --no-ai-summary 작업 PR 에 AI 변경 요약 댓글. 추가 설정 없이 동작. 생략: 포함
|
|
134
140
|
--coderabbit / --no-coderabbit .coderabbit.yaml 설치 (--mode full 에서만). CodeRabbit GitHub 앱이 필요하다.
|
package/src/commands/doctor.js
CHANGED
|
@@ -20,6 +20,7 @@ import { verifyInstall } from "../core/verify.js";
|
|
|
20
20
|
import { readBaseline } from "../core/baseline.js";
|
|
21
21
|
import { detectRepoName } from "../core/detect-fs.js";
|
|
22
22
|
import { detectMigrations } from "../core/migrations/index.js";
|
|
23
|
+
import { scanPlaceholderTriggers } from "../core/placeholder-triggers.js";
|
|
23
24
|
import { t } from "../i18n/index.js";
|
|
24
25
|
|
|
25
26
|
// 언어 전환(withLang 등) 시점에 평가되도록 함수로 둔다
|
|
@@ -96,6 +97,22 @@ export function localChecks(cwd = ".") {
|
|
|
96
97
|
],
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
// 자동 트리거 + 예시 값 (#662) — 미치환 토큰 검사에 안 걸리는 your-project 류 값이 실패 런을 계속 만든다
|
|
101
|
+
const autoPlaceholders = scanPlaceholderTriggers(wfDir);
|
|
102
|
+
add({
|
|
103
|
+
name: t("doctor.ph.name"), purpose: t("doctor.ph.purpose"),
|
|
104
|
+
status: autoPlaceholders.length ? "WARN" : "OK",
|
|
105
|
+
value: autoPlaceholders.length ? t("doctor.count", { n: autoPlaceholders.length }) : t("doctor.none"),
|
|
106
|
+
detail: autoPlaceholders.length ? [
|
|
107
|
+
t("doctor.ph.l1"),
|
|
108
|
+
...autoPlaceholders.flatMap((w) => [
|
|
109
|
+
` ${w.file} (${w.triggers.join(", ")})`,
|
|
110
|
+
...w.values.map((p) => ` ${w.file}:${p.line} ${p.key}: "${p.value}"`),
|
|
111
|
+
]),
|
|
112
|
+
t("doctor.ph.fix"),
|
|
113
|
+
] : null,
|
|
114
|
+
});
|
|
115
|
+
|
|
99
116
|
const secretNames = [...v.secrets.keys()];
|
|
100
117
|
add({
|
|
101
118
|
name: t("doctor.sec.name"), purpose: t("doctor.sec.purpose"),
|
package/src/commands/full.js
CHANGED
|
@@ -58,7 +58,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
58
58
|
})), { version, versionCode });
|
|
59
59
|
|
|
60
60
|
// 2. README 버전 섹션
|
|
61
|
-
step("update-readme", () => addVersionSectionToReadme(version, targetRoot), { version });
|
|
61
|
+
step("update-readme", () => addVersionSectionToReadme(version, targetRoot, language), { version });
|
|
62
62
|
|
|
63
63
|
// 5. scripts / config
|
|
64
64
|
// 워크플로우 밖 영역도 기록한다 (#561) — "내 스크립트가 갱신됐나"를 로그만 보고 알 수 있게.
|
package/src/commands/version.js
CHANGED
|
@@ -37,7 +37,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
|
|
|
37
37
|
// 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
|
|
38
38
|
templateOptions: toTemplateOptions(context, { mode: recordMode, optionsDate: today }),
|
|
39
39
|
}));
|
|
40
|
-
addVersionSectionToReadme(version, targetRoot);
|
|
40
|
+
addVersionSectionToReadme(version, targetRoot, context.language);
|
|
41
41
|
copyScripts(tempDir, targetRoot);
|
|
42
42
|
copyConfigFolder(tempDir, targetRoot);
|
|
43
43
|
applyLabelStyle(targetRoot, labelStyle); // #776
|
package/src/core/copy/readme.js
CHANGED
|
@@ -3,27 +3,35 @@ import { join } from "node:path";
|
|
|
3
3
|
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
4
4
|
|
|
5
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;
|
|
6
|
+
// ## (최신 버전|최신버전|Latest version|Version|버전) : vX.Y.Z (대소문자 무시)
|
|
7
|
+
const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Latest\s+version|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
|
|
8
|
+
|
|
9
|
+
// 레포 언어별 섹션 문구 (.github/scripts/i18n 의 readme.version_heading 과 같은 값)
|
|
10
|
+
const SECTION_TEXT = {
|
|
11
|
+
en: { heading: "Latest version", changelogLink: "View the full version history" },
|
|
12
|
+
ko: { heading: "최신 버전", changelogLink: "전체 버전 기록 보기" },
|
|
13
|
+
};
|
|
8
14
|
|
|
9
15
|
// README.md 없으면 스킵. 마커 또는 버전 라인 있으면 스킵. 없으면 파일 끝에 append.
|
|
10
16
|
// 반환: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
|
|
11
|
-
|
|
17
|
+
// language 미지정은 ko — 언어를 넘기지 않는 기존 호출부의 결과를 바꾸지 않기 위함
|
|
18
|
+
export function addVersionSectionToReadme(version, targetRoot = ".", language = "ko") {
|
|
12
19
|
const p = join(targetRoot, "README.md");
|
|
13
20
|
if (!existsSync(p)) return "skip-no-readme";
|
|
14
21
|
const content = readFileSync(p, "utf8");
|
|
15
22
|
if (content.includes(MARKER)) return "skip-marker";
|
|
16
23
|
if (VERSION_LINE_RE.test(content)) return "skip-version-line";
|
|
17
24
|
|
|
25
|
+
const text = SECTION_TEXT[language] ?? SECTION_TEXT.ko;
|
|
18
26
|
// append 본문은 빈 줄 + "---"로 시작 (원본 파일이 개행으로 끝난다는 전제).
|
|
19
27
|
const section =
|
|
20
28
|
"\n" +
|
|
21
29
|
"---\n" +
|
|
22
30
|
"\n" +
|
|
23
31
|
"<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n" +
|
|
24
|
-
`##
|
|
32
|
+
`## ${text.heading} : v${version}\n` +
|
|
25
33
|
"\n" +
|
|
26
|
-
|
|
34
|
+
`[${text.changelogLink}](CHANGELOG.md)\n`;
|
|
27
35
|
appendFileSync(p, section);
|
|
28
36
|
return "added";
|
|
29
37
|
}
|
package/src/core/copy/simple.js
CHANGED
|
@@ -35,6 +35,8 @@ export function copyScripts(tempDir, targetRoot = ".") {
|
|
|
35
35
|
"asc_client.py",
|
|
36
36
|
// iOS 버전 사전 점검, 업로드 거부 분류, 아카이브부터 업로드까지 재시도 (#643).
|
|
37
37
|
"ios_release.py",
|
|
38
|
+
// Flutter iOS 빌드의 Xcode 선택(빈 값 = 러너 최신)과 SDK 누락 진단 (#758). 선택 규칙의 유일한 위치.
|
|
39
|
+
"select_xcode.py",
|
|
38
40
|
// 릴리스 머지 때 완료 라벨이 붙은 이슈를 닫는다 (#771). close_on_release 옵션이 켜진 레포에서만 동작한다.
|
|
39
41
|
"close_issues_on_release.py",
|
|
40
42
|
// Play Store/TestFlight 배포 모드를 코드 설정(.github/config/store-deploy.json)에서 읽는다 (#767).
|
|
@@ -60,7 +62,7 @@ export function copyScripts(tempDir, targetRoot = ".") {
|
|
|
60
62
|
const i18nSrc = join(tempDir, PATHS.scriptsDir, "i18n");
|
|
61
63
|
if (exists(i18nSrc)) {
|
|
62
64
|
for (const f of readdirSync(i18nSrc)) {
|
|
63
|
-
if (!/\.(py|json)$/.test(f)) continue; // __pycache__ 등은 제외
|
|
65
|
+
if (!/\.(py|json|cjs)$/.test(f)) continue; // __pycache__ 등은 제외 (cjs: 워크플로우 댓글 런타임)
|
|
64
66
|
copyFileSync(join(i18nSrc, f), join(targetRoot, PATHS.scriptsDir, "i18n", f));
|
|
65
67
|
copied++;
|
|
66
68
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 비대화형 개발(릴리스 소스) 브랜치 생성 (#715) — --create-development-branch 를 준 실행에서만.
|
|
2
|
+
// 대화형 ensureDeployBranch 를 "모든 질문에 예"로 다시 쓴다: 존재 판단·생성·push 경로를 한 벌로 유지.
|
|
3
|
+
// 실패해도 예외를 던지지 않는다 — 통합 전체를 실패시키지 않고 수동 명령만 남긴다.
|
|
4
|
+
import { ensureDeployBranch } from "./options-ask.js";
|
|
5
|
+
|
|
6
|
+
// ensureDeployBranch state > 결과 3종 (created · skipped · failed)
|
|
7
|
+
const STATUS_BY_STATE = {
|
|
8
|
+
"exists-local": "skipped",
|
|
9
|
+
"exists-remote": "skipped",
|
|
10
|
+
"pushed": "created",
|
|
11
|
+
"push-failed": "failed",
|
|
12
|
+
"create-failed": "failed",
|
|
13
|
+
"not-a-repo": "failed",
|
|
14
|
+
"not-checked": "failed",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 실패 사유별 수동 명령 (push 실패는 로컬 브랜치가 이미 생겼으므로 push 만)
|
|
18
|
+
function manualCommandFor(state, branch, base) {
|
|
19
|
+
if (state === "push-failed") return `git push -u origin ${branch}`;
|
|
20
|
+
if (state === "create-failed" && base) return `git branch ${branch} ${base} && git push -u origin ${branch}`;
|
|
21
|
+
return `git checkout -b ${branch} && git push -u origin ${branch}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 개발 브랜치 확인 > 없으면 기본 브랜치에서 생성 > origin push
|
|
26
|
+
* 반환: { status, reason, branch, base, pushed, manualCommand(실패 시만) }
|
|
27
|
+
*/
|
|
28
|
+
export async function createDevelopmentBranchUnattended({ targetRoot = ".", branch = "develop", defaultBranch = "", say = () => {} }) {
|
|
29
|
+
const answerYes = { confirm: async () => true };
|
|
30
|
+
const outcome = await ensureDeployBranch({ targetRoot, deployBranch: branch, defaultBranch, io: answerYes, say });
|
|
31
|
+
const status = STATUS_BY_STATE[outcome.state] ?? "failed";
|
|
32
|
+
return {
|
|
33
|
+
status,
|
|
34
|
+
reason: outcome.state,
|
|
35
|
+
branch,
|
|
36
|
+
base: defaultBranch,
|
|
37
|
+
pushed: outcome.pushed === true,
|
|
38
|
+
manualCommand: status === "failed" ? manualCommandFor(outcome.state, branch, defaultBranch) : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 실행 단계 래퍼: 플래그 확인 > 생성 시도 > 실행 기록(trace) 남김
|
|
44
|
+
* 플래그가 없으면 만들지 않고 "왜 안 만들었는지"만 기록. 반환: 결과 | null(플래그 없음)
|
|
45
|
+
*/
|
|
46
|
+
export async function runDevelopmentBranchStep({ isRequested = false, targetRoot, branch, defaultBranch, trace = null, say = () => {} }) {
|
|
47
|
+
const name = branch || "develop";
|
|
48
|
+
if (!isRequested) {
|
|
49
|
+
trace?.event("dev-branch", "not-requested", name, { flag: "--create-development-branch", note: "left as a manual step" });
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const result = await createDevelopmentBranchUnattended({ targetRoot, branch: name, defaultBranch, say });
|
|
53
|
+
// action 이 곧 결과 — failed 는 로그에서 ERROR 로 보인다 (run-trace levelOf)
|
|
54
|
+
trace?.event("dev-branch", result.status, name, {
|
|
55
|
+
base: defaultBranch || null, reason: result.reason, pushed: result.pushed, manualCommand: result.manualCommand,
|
|
56
|
+
});
|
|
57
|
+
return result;
|
|
58
|
+
}
|
package/src/core/ide/adapter.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// remove(io, ctx): boolean 제거(미설치면 no-op, true)
|
|
20
20
|
// manualHint(io): string CLI 없을 때 보여줄 수동 설치 명령(선택)
|
|
21
21
|
//
|
|
22
|
-
// io = runner.defaultIo() 또는 테스트 stub: { which, run, home, log }
|
|
22
|
+
// io = runner.defaultIo() 또는 테스트 stub: { which, run, home, log, projectopsHome?(없으면 home/.projectops) }
|
|
23
23
|
// ctx = { templateVersion, sourceSkillsDir } — 버전 표기·Cursor 복사 소스 등 공용 컨텍스트
|
|
24
24
|
//
|
|
25
25
|
// 규칙:
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// GitHub Copilot 어댑터 (agent skills 복사·메타 기록·제거).
|
|
2
|
+
// 개인 스킬 폴더 ~/.copilot/skills/<이름>/SKILL.md 를 VS Code·Copilot CLI·JetBrains 플러그인이 모두 기본으로 읽는다
|
|
3
|
+
// (JetBrains 1.18 기본 위치: .agents/.github/.claude skills + ~/.agents·~/.copilot·~/.claude skills)
|
|
4
|
+
// ~/.copilot/intellij-skills 는 JetBrains 플러그인이 내장 스킬을 덮어쓰는 관리 폴더라 쓰지 않는다.
|
|
5
|
+
// 스킬 이름(frontmatter name)은 폴더명과 같아야 로드된다 — pro-* 폴더가 이미 그 규칙을 지킨다.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
+
import { migrateConfigRoot } from "../legacy.js";
|
|
9
|
+
import { t } from "../../../i18n/index.js";
|
|
10
|
+
import { readSkillsMeta } from "../skills-copy.js";
|
|
11
|
+
import { applyHomeSkills, removeHomeSkills } from "../home-skills.js";
|
|
12
|
+
|
|
13
|
+
const META_FILE_NAME = "copilot-skills-meta.json";
|
|
14
|
+
const MESSAGE_PREFIX = "ide.copilot";
|
|
15
|
+
// Copilot 사용 흔적 4종: copilot CLI, ~/.copilot 폴더(CLI·JetBrains 가 만듦), VS Code 확장, JetBrains 플러그인
|
|
16
|
+
const COPILOT_CLI_NAME = "copilot";
|
|
17
|
+
const COPILOT_HOME_DIR = ".copilot";
|
|
18
|
+
const VSCODE_EXTENSION_DIRS = [".vscode/extensions", ".vscode-insiders/extensions"];
|
|
19
|
+
// VS Code 확장 폴더명: github.copilot-1.2.3 · github.copilot-chat-0.30.0
|
|
20
|
+
const VSCODE_EXTENSION_PREFIX = "github.copilot";
|
|
21
|
+
// IDE 설정 루트 (macOS · Windows · Linux) > <제품><버전>/plugins/github-copilot-intellij
|
|
22
|
+
// Linux 는 플러그인이 <제품><버전>/ 바로 아래에 깔린다. Android Studio 도 같은 플러그인을 쓴다.
|
|
23
|
+
const JETBRAINS_CONFIG_ROOTS = [
|
|
24
|
+
"Library/Application Support/JetBrains", "AppData/Roaming/JetBrains", ".local/share/JetBrains",
|
|
25
|
+
"Library/Application Support/Google", "AppData/Roaming/Google", ".local/share/Google",
|
|
26
|
+
];
|
|
27
|
+
const JETBRAINS_PLUGIN_PREFIX = "github-copilot";
|
|
28
|
+
|
|
29
|
+
function skillsDir(io) { return join(io.home(), COPILOT_HOME_DIR, "skills"); }
|
|
30
|
+
function metaPath(io) { return join(skillsDir(io), META_FILE_NAME); }
|
|
31
|
+
function homeSkillsOptions(io) { return { skillsDest: skillsDir(io), metaFileName: META_FILE_NAME, messagePrefix: MESSAGE_PREFIX }; }
|
|
32
|
+
|
|
33
|
+
// 쓰지 않는 사람 홈에 ~/.copilot 을 만들지 않도록, 흔적이 있을 때만 설치 후보로 둔다
|
|
34
|
+
function isCopilotDetected(io) {
|
|
35
|
+
if (io.which(COPILOT_CLI_NAME)) return true;
|
|
36
|
+
if (existsSync(join(io.home(), COPILOT_HOME_DIR))) return true;
|
|
37
|
+
if (VSCODE_EXTENSION_DIRS.some((dir) => hasEntryWithPrefix(join(io.home(), dir), VSCODE_EXTENSION_PREFIX))) return true;
|
|
38
|
+
return JETBRAINS_CONFIG_ROOTS.some((root) => hasJetBrainsCopilotPlugin(join(io.home(), root)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasJetBrainsCopilotPlugin(configRoot) {
|
|
42
|
+
for (const productDir of listNames(configRoot)) {
|
|
43
|
+
const productPath = join(configRoot, productDir);
|
|
44
|
+
if (hasEntryWithPrefix(join(productPath, "plugins"), JETBRAINS_PLUGIN_PREFIX)) return true;
|
|
45
|
+
if (hasEntryWithPrefix(productPath, JETBRAINS_PLUGIN_PREFIX)) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasEntryWithPrefix(dir, prefix) {
|
|
51
|
+
return listNames(dir).some((name) => name.toLowerCase().startsWith(prefix));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listNames(dir) {
|
|
55
|
+
if (!existsSync(dir)) return [];
|
|
56
|
+
try { return readdirSync(dir); } catch { return []; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function detect(io) {
|
|
60
|
+
const { installed, version } = readSkillsMeta(metaPath(io));
|
|
61
|
+
if (installed) return { installed, version, cliMissing: false };
|
|
62
|
+
if (isCopilotDetected(io)) return { installed: false, version: null, cliMissing: false, note: t("ide.copilot.detected") };
|
|
63
|
+
return { installed: false, version: null, cliMissing: true, note: t("ide.copilot.notDetected") };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function apply(io, ctx = {}) {
|
|
67
|
+
if (detect(io).cliMissing) { io.log(manualHint()); return false; }
|
|
68
|
+
migrateConfigRoot(io); // 공용 config 루트 이관
|
|
69
|
+
return applyHomeSkills(io, ctx, homeSkillsOptions(io));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 사용자가 직접 넣은 스킬이 남으면 폴더 유지
|
|
73
|
+
function remove(io, ctx = {}) {
|
|
74
|
+
return removeHomeSkills(io, ctx, homeSkillsOptions(io));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function manualHint() {
|
|
78
|
+
return t("ide.copilot.manual");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const copilotAdapter = {
|
|
82
|
+
id: "copilot", label: "GitHub Copilot", order: 45,
|
|
83
|
+
detect, apply, remove, manualHint,
|
|
84
|
+
};
|
|
@@ -1,95 +1,35 @@
|
|
|
1
1
|
// Cursor 어댑터 (skills 복사·메타 기록·제거).
|
|
2
|
-
// 마켓플레이스 없음 → ~/.cursor/skills/ 에 skills/
|
|
2
|
+
// 마켓플레이스 없음 → ~/.cursor/skills/ 에 skills/ 폴더, ~/.cursor/scripts/ 에 공용 파이썬을 복사하고 meta.json으로 버전 추적.
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { existsSync, readFileSync,
|
|
4
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
5
5
|
import { migrateConfigRoot, isLegacyVersion } from "../legacy.js";
|
|
6
6
|
import { t } from "../../../i18n/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { readSkillsMeta, ownedEntries } from "../skills-copy.js";
|
|
8
|
+
import { applyHomeSkills, removeHomeSkills } from "../home-skills.js";
|
|
8
9
|
|
|
9
10
|
const LEGACY_NAMES = ["cassiiopeia", "suh-devops-template"];
|
|
10
11
|
const LEGACY_MAX_VERSION = "4.2.4";
|
|
12
|
+
const META_FILE_NAME = "cursor-skills-meta.json";
|
|
13
|
+
const MESSAGE_PREFIX = "ide.cursor";
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const TEST_PATH = /(^|[\\/])(tests?|__pycache__)([\\/]|$)/;
|
|
16
|
-
|
|
17
|
-
function metaPath(io) { return join(io.home(), ".cursor/skills/cursor-skills-meta.json"); }
|
|
15
|
+
function skillsDir(io) { return join(io.home(), ".cursor/skills"); }
|
|
16
|
+
function metaPath(io) { return join(skillsDir(io), META_FILE_NAME); }
|
|
17
|
+
function homeSkillsOptions(io) { return { skillsDest: skillsDir(io), metaFileName: META_FILE_NAME, messagePrefix: MESSAGE_PREFIX }; }
|
|
18
18
|
|
|
19
19
|
function detect(io) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
let version = null;
|
|
23
|
-
try {
|
|
24
|
-
const m = JSON.parse(readFileSync(mp, "utf8"));
|
|
25
|
-
version = m.version || null;
|
|
26
|
-
} catch { /* meta 손상 → 설치는 됐다고 봄 */ }
|
|
27
|
-
return { installed: true, version, cliMissing: false };
|
|
20
|
+
const { installed, version } = readSkillsMeta(metaPath(io));
|
|
21
|
+
return { installed, version, cliMissing: false };
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
function apply(io, ctx = {}) {
|
|
31
25
|
migrateLegacy(io, ctx); // 옛 이름/버전 감지 시 projectops 소유 항목 선별 삭제
|
|
32
26
|
migrateConfigRoot(io); // 공용 config 루트 이관
|
|
33
|
-
|
|
34
|
-
if (!src) { io.log(t("ide.cursor.noSource")); return false; }
|
|
35
|
-
const dest = join(io.home(), ".cursor/skills");
|
|
36
|
-
io.log(t("ide.cursor.copying"));
|
|
37
|
-
try {
|
|
38
|
-
mkdirSync(dest, { recursive: true });
|
|
39
|
-
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
40
|
-
if (SKIP_IN_COPY.has(e.name)) continue;
|
|
41
|
-
cpSync(join(src, e.name), join(dest, e.name), {
|
|
42
|
-
recursive: true,
|
|
43
|
-
// 이 저장소 자기 코드를 검사하는 테스트는 남의 컴퓨터로 나갈 이유가 없다 (#591).
|
|
44
|
-
filter: (p) => !TEST_PATH.test(p),
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
// 소스에서 사라진 우리 스킬을 지운다. 복사는 overlay 라 덮어쓰기만 하면
|
|
48
|
-
// **폐기한 스킬이 남의 컴퓨터에 영영 남아** 계속 잡힌다 — util 복사가 같은
|
|
49
|
-
// 함정에 빠졌던 자리다 (#500). 남의 스킬은 접두사로 걸러 건드리지 않는다.
|
|
50
|
-
const srcNames = new Set(readdirSync(src));
|
|
51
|
-
for (const name of readdirSync(dest)) {
|
|
52
|
-
if (srcNames.has(name) || name === "cursor-skills-meta.json") continue;
|
|
53
|
-
if (!/^(pro|suh)-/.test(name)) continue;
|
|
54
|
-
try {
|
|
55
|
-
rmSync(join(dest, name), { recursive: true, force: true });
|
|
56
|
-
io.log(t("ide.cursor.obsolete", { name }));
|
|
57
|
-
} catch { /* 무시 — 지우지 못해도 설치는 계속한다 */ }
|
|
58
|
-
}
|
|
59
|
-
writeMeta(io, dest, ctx.templateVersion);
|
|
60
|
-
io.log(t("ide.cursor.installed", { dest, version: ctx.templateVersion || "unknown" }));
|
|
61
|
-
return true;
|
|
62
|
-
} catch {
|
|
63
|
-
io.log(t("ide.cursor.copyFailed"));
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
27
|
+
return applyHomeSkills(io, ctx, homeSkillsOptions(io));
|
|
66
28
|
}
|
|
67
29
|
|
|
30
|
+
// 타 스킬(somansa-tools 등) 남으면 폴더 유지
|
|
68
31
|
function remove(io, ctx = {}) {
|
|
69
|
-
|
|
70
|
-
if (!existsSync(join(dir, "cursor-skills-meta.json"))) { io.log(t("ide.cursor.removeNone")); return true; }
|
|
71
|
-
try {
|
|
72
|
-
for (const name of ownedEntries(io, ctx)) rmSync(join(dir, name), { recursive: true, force: true });
|
|
73
|
-
// 폴더가 비면 폴더 자체 제거, 타 스킬(somansa-tools 등) 남으면 유지
|
|
74
|
-
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
75
|
-
io.log(t("ide.cursor.removed", { dir }));
|
|
76
|
-
return true;
|
|
77
|
-
} catch { io.log(t("ide.cursor.removeFailed", { dir })); return false; }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// projectops가 이 폴더에 설치했다고 볼 항목만 골라낸다.
|
|
81
|
-
// 소스 skills/ 폴더명(pro-*, references, config.json.example) ∪ /^suh-/ ∪ meta 파일.
|
|
82
|
-
// 접두어 없는 폴더(analyze·gitlab 등 somansa-tools)는 제외 → 보존.
|
|
83
|
-
function ownedEntries(io, ctx) {
|
|
84
|
-
const dir = join(io.home(), ".cursor/skills");
|
|
85
|
-
if (!existsSync(dir)) return [];
|
|
86
|
-
let srcNames = new Set();
|
|
87
|
-
const src = resolveSkillsSrc(ctx);
|
|
88
|
-
if (src && existsSync(src)) srcNames = new Set(readdirSync(src));
|
|
89
|
-
const EXTRA = new Set(["cursor-skills-meta.json"]);
|
|
90
|
-
// suh- 는 옛 접두사, pro- 는 현행 접두사다. 둘 다 우리 것으로 친다 —
|
|
91
|
-
// 폐기한 스킬은 소스에 없으므로 srcNames 로는 잡히지 않는다 (#619).
|
|
92
|
-
return readdirSync(dir).filter((name) => srcNames.has(name) || /^(pro|suh)-/.test(name) || EXTRA.has(name));
|
|
32
|
+
return removeHomeSkills(io, ctx, homeSkillsOptions(io));
|
|
93
33
|
}
|
|
94
34
|
|
|
95
35
|
// 옛 이름/버전 meta면 projectops 소유 항목만 선별 삭제 → apply가 신규 재설치.
|
|
@@ -101,8 +41,8 @@ function migrateLegacy(io, ctx) {
|
|
|
101
41
|
const oldName = meta.name && LEGACY_NAMES.includes(String(meta.name).toLowerCase());
|
|
102
42
|
const oldVer = isLegacyVersion(meta.version, LEGACY_MAX_VERSION);
|
|
103
43
|
if (!(oldName || oldVer)) return;
|
|
104
|
-
for (const name of ownedEntries(io, ctx)) {
|
|
105
|
-
try { rmSync(join(io
|
|
44
|
+
for (const name of ownedEntries(skillsDir(io), ctx, META_FILE_NAME)) {
|
|
45
|
+
try { rmSync(join(skillsDir(io), name), { recursive: true, force: true }); } catch { /* 무시 */ }
|
|
106
46
|
}
|
|
107
47
|
io.log(t("ide.cursor.legacy", { name: meta.name, version: meta.version }));
|
|
108
48
|
}
|
|
@@ -111,29 +51,6 @@ function manualHint() {
|
|
|
111
51
|
return t("ide.cursor.manual");
|
|
112
52
|
}
|
|
113
53
|
|
|
114
|
-
// ── 헬퍼 ──
|
|
115
|
-
// 스킬 소스: 다운로드된 템플릿(TEMP)/skills 우선, 없으면 로컬 skills/.
|
|
116
|
-
function resolveSkillsSrc(ctx) {
|
|
117
|
-
const cands = [ctx.sourceSkillsDir, ctx.tempDir && join(ctx.tempDir, "skills"), "skills"].filter(Boolean);
|
|
118
|
-
for (const c of cands) if (existsSync(c)) return c;
|
|
119
|
-
return "";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function writeMeta(io, destDir, templateVersion) {
|
|
123
|
-
const version = templateVersion || "unknown";
|
|
124
|
-
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
125
|
-
const file = join(destDir, "cursor-skills-meta.json");
|
|
126
|
-
let installedAt = now;
|
|
127
|
-
if (existsSync(file)) { try { installedAt = JSON.parse(readFileSync(file, "utf8")).installedAt || now; } catch { /* 무해 */ } }
|
|
128
|
-
const meta = {
|
|
129
|
-
name: "projectops", version, scope: "user",
|
|
130
|
-
source: TEMPLATE_REPO_WEB_URL,
|
|
131
|
-
installPath: destDir, installedAt, lastUpdated: now,
|
|
132
|
-
};
|
|
133
|
-
mkdirSync(destDir, { recursive: true });
|
|
134
|
-
writeFileSync(file, JSON.stringify(meta, null, 2) + "\n");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
54
|
export const cursorAdapter = {
|
|
138
55
|
id: "cursor", label: "Cursor", order: 20,
|
|
139
56
|
detect, apply, remove, manualHint,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 홈 복사형 IDE(Cursor·Copilot) 설치·제거 흐름 공용: 소스 찾기 > 복사 > 진입점 > 로그
|
|
2
|
+
// 메시지는 어댑터별 키 묶음(<messagePrefix>.copying 등)을 쓴다.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { resolveSkillsSrc, resolveScriptsSrc, installHomeSkills, removeOwnedEntries } from "./skills-copy.js";
|
|
7
|
+
import { refreshStableEntrypoints, projectopsHomeOf } from "./stable-entry.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 설치·업데이트: 스킬 소스 확인 > 스킬·공용 파이썬 복사 > 고정 진입점 > 결과 로그
|
|
11
|
+
* options: { skillsDest, metaFileName, messagePrefix }
|
|
12
|
+
*/
|
|
13
|
+
export function applyHomeSkills(io, ctx, { skillsDest, metaFileName, messagePrefix }) {
|
|
14
|
+
const skillsSrc = resolveSkillsSrc(ctx);
|
|
15
|
+
if (!skillsSrc) { io.log(t(`${messagePrefix}.noSource`)); return false; }
|
|
16
|
+
const scriptsSrc = resolveScriptsSrc(ctx, skillsSrc);
|
|
17
|
+
io.log(t(`${messagePrefix}.copying`));
|
|
18
|
+
try {
|
|
19
|
+
const { obsoleteNames, hasSharedScripts } = installHomeSkills({
|
|
20
|
+
skillsSrc, scriptsSrc, skillsDest, metaFileName, templateVersion: ctx.templateVersion,
|
|
21
|
+
});
|
|
22
|
+
for (const name of obsoleteNames) io.log(t("ide.skillsCopy.obsolete", { name }));
|
|
23
|
+
// 공용 파이썬이 없으면 스킬 CLI 가 import 에 실패 → 알림만 (문서형 스킬은 동작)
|
|
24
|
+
if (!hasSharedScripts) io.log(t("ide.skillsCopy.noScripts"));
|
|
25
|
+
io.log(t(`${messagePrefix}.installed`, { dest: skillsDest, version: ctx.templateVersion || "unknown" }));
|
|
26
|
+
} catch {
|
|
27
|
+
io.log(t(`${messagePrefix}.copyFailed`));
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
installStableEntrypoints(io, skillsSrc, scriptsSrc);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 진입점 실패는 설치 실패가 아니다 (CLI 첫 실행 때 파이썬이 다시 깐다)
|
|
35
|
+
function installStableEntrypoints(io, skillsSrc, scriptsSrc) {
|
|
36
|
+
try {
|
|
37
|
+
const projectopsHome = projectopsHomeOf(io);
|
|
38
|
+
for (const name of refreshStableEntrypoints(projectopsHome, { skillsSrc, scriptsSrc })) {
|
|
39
|
+
io.log(t("ide.skillsCopy.entrypoint", { path: join(projectopsHome, "bin", name) }));
|
|
40
|
+
}
|
|
41
|
+
} catch { io.log(t("ide.skillsCopy.entrypointFailed")); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 제거: 메타 없으면 건너뜀 > 우리 항목·공용 파이썬만 삭제 (남의 스킬이 남으면 폴더 유지)
|
|
45
|
+
export function removeHomeSkills(io, ctx, { skillsDest, metaFileName, messagePrefix }) {
|
|
46
|
+
if (!existsSync(join(skillsDest, metaFileName))) { io.log(t(`${messagePrefix}.removeNone`)); return true; }
|
|
47
|
+
try {
|
|
48
|
+
removeOwnedEntries(skillsDest, ctx, metaFileName);
|
|
49
|
+
io.log(t(`${messagePrefix}.removed`, { dir: skillsDest }));
|
|
50
|
+
return true;
|
|
51
|
+
} catch { io.log(t(`${messagePrefix}.removeFailed`, { dir: skillsDest })); return false; }
|
|
52
|
+
}
|
package/src/core/ide/registry.js
CHANGED
|
@@ -9,6 +9,7 @@ import { claudeAdapter } from "./adapters/claude.js";
|
|
|
9
9
|
import { cursorAdapter } from "./adapters/cursor.js";
|
|
10
10
|
import { geminiAdapter } from "./adapters/gemini.js";
|
|
11
11
|
import { codexAdapter } from "./adapters/codex.js";
|
|
12
|
+
import { copilotAdapter } from "./adapters/copilot.js";
|
|
12
13
|
import { piAdapter } from "./adapters/pi.js";
|
|
13
14
|
import { piHarnessAdapter } from "./adapters/pi-harness.js";
|
|
14
15
|
|
|
@@ -18,6 +19,7 @@ export const ADAPTERS = [
|
|
|
18
19
|
cursorAdapter,
|
|
19
20
|
geminiAdapter,
|
|
20
21
|
codexAdapter,
|
|
22
|
+
copilotAdapter,
|
|
21
23
|
piAdapter,
|
|
22
24
|
piHarnessAdapter,
|
|
23
25
|
].map(assertAdapter).sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
package/src/core/ide/runner.js
CHANGED
|
@@ -54,6 +54,8 @@ export function defaultIo() {
|
|
|
54
54
|
which,
|
|
55
55
|
run,
|
|
56
56
|
home: () => homedir(),
|
|
57
|
+
// 파이썬 stable_entry 와 같은 규칙: PROJECTOPS_HOME > ~/.projectops
|
|
58
|
+
projectopsHome: () => process.env.PROJECTOPS_HOME || join(homedir(), ".projectops"),
|
|
57
59
|
log: (msg) => console.error(msg), // 안내는 stderr로 (stdout 파이프 오염 방지)
|
|
58
60
|
};
|
|
59
61
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 스킬 폴더 복사 공용 (Cursor·Copilot): skills/ + 공용 파이썬 scripts/ > 홈 설치 루트, 테스트 제외, 폐기 정리, 메타 기록
|
|
2
|
+
// 마켓플레이스 없이 SKILL.md 폴더를 그대로 읽는 IDE 가 같은 규칙을 쓰도록 한 곳에 둔다.
|
|
3
|
+
// 설치 모양은 플러그인 캐시와 같다: <설치 루트>/skills/pro-*/scripts/x_cli.py + <설치 루트>/scripts/common
|
|
4
|
+
// (스킬 CLI bootstrap 이 x_cli.py 기준 3단계 위의 scripts/ 를 import 경로로 쓴다)
|
|
5
|
+
import { dirname, join, relative } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync, mkdirSync, cpSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { TEMPLATE_REPO_WEB_URL } from "../paths.js";
|
|
8
|
+
|
|
9
|
+
// 스킬을 쓰는 사람에게 필요 없는 것. projectops 자신을 검사하는 테스트다.
|
|
10
|
+
// skills/ 는 통째로 복사되므로 여기서 걸러내지 않으면 사용자 홈에 그대로 쌓인다.
|
|
11
|
+
export const SKIP_IN_COPY = new Set(["conftest.py", "pytest.ini"]);
|
|
12
|
+
export const TEST_PATH = /(^|[\\/])(tests?|__pycache__)([\\/]|$)/;
|
|
13
|
+
// 우리 스킬 접두사: pro- 현행, suh- 옛 이름
|
|
14
|
+
const OWNED_SKILL_NAME = /^(pro|suh)-/;
|
|
15
|
+
// 공용 파이썬 패키지 표식: scripts/common/__init__.py
|
|
16
|
+
const SHARED_SCRIPTS_MARKER = join("common", "__init__.py");
|
|
17
|
+
const SHARED_SCRIPTS_DIR_NAME = "scripts";
|
|
18
|
+
const PYTHON_CACHE_DIR_NAME = "__pycache__";
|
|
19
|
+
|
|
20
|
+
// 스킬 소스: 다운로드된 템플릿(TEMP)/skills 우선, 없으면 로컬 skills/
|
|
21
|
+
export function resolveSkillsSrc(ctx = {}) {
|
|
22
|
+
const cands = [ctx.sourceSkillsDir, ctx.tempDir && join(ctx.tempDir, "skills"), "skills"].filter(Boolean);
|
|
23
|
+
for (const c of cands) if (existsSync(c)) return c;
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 공용 파이썬 소스: 직접 지정 > 템플릿(TEMP)/scripts > 스킬 소스 옆 scripts/ (같은 레포)
|
|
28
|
+
export function resolveScriptsSrc(ctx = {}, skillsSrc = "") {
|
|
29
|
+
const cands = [ctx.sourceScriptsDir, ctx.tempDir && join(ctx.tempDir, SHARED_SCRIPTS_DIR_NAME),
|
|
30
|
+
skillsSrc && join(dirname(skillsSrc), SHARED_SCRIPTS_DIR_NAME)].filter(Boolean);
|
|
31
|
+
for (const c of cands) if (existsSync(join(c, SHARED_SCRIPTS_MARKER))) return c;
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 메타 파일 > {installed, version, sharedScripts} (손상된 메타는 설치됐다고 봄)
|
|
36
|
+
export function readSkillsMeta(metaFile) {
|
|
37
|
+
if (!existsSync(metaFile)) return { installed: false, version: null, sharedScripts: [] };
|
|
38
|
+
try {
|
|
39
|
+
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
40
|
+
return { installed: true, version: meta.version || null, sharedScripts: Array.isArray(meta.sharedScripts) ? meta.sharedScripts : [] };
|
|
41
|
+
} catch { return { installed: true, version: null, sharedScripts: [] }; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 소스 최상위 항목 복사 (테스트·conftest 제외). 필터는 소스 기준 상대경로로 본다 —
|
|
45
|
+
// 절대경로로 보면 소스가 ~/test/... 아래에 있을 때 전부 걸러진다.
|
|
46
|
+
function copyEntriesWithoutTests(src, dest) {
|
|
47
|
+
mkdirSync(dest, { recursive: true });
|
|
48
|
+
const copiedNames = [];
|
|
49
|
+
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
50
|
+
if (SKIP_IN_COPY.has(e.name) || TEST_PATH.test(e.name)) continue;
|
|
51
|
+
cpSync(join(src, e.name), join(dest, e.name), {
|
|
52
|
+
recursive: true,
|
|
53
|
+
// 이 저장소 자기 코드를 검사하는 테스트는 남의 컴퓨터로 나갈 이유가 없다 (#591).
|
|
54
|
+
filter: (p) => !TEST_PATH.test(relative(src, p)),
|
|
55
|
+
});
|
|
56
|
+
copiedNames.push(e.name);
|
|
57
|
+
}
|
|
58
|
+
return copiedNames;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 스킬 복사: 소스 > dest (테스트 제외) > 소스에서 사라진 우리 스킬 제거
|
|
63
|
+
* 폐기한 스킬 이름 목록 반환. 복사 실패는 예외로 올린다 (호출부가 로그·false 처리).
|
|
64
|
+
*/
|
|
65
|
+
export function copySkillsTree(src, dest, metaFileName) {
|
|
66
|
+
copyEntriesWithoutTests(src, dest);
|
|
67
|
+
// 복사는 overlay 라 덮어쓰기만 하면 **폐기한 스킬이 남의 컴퓨터에 영영 남아** 계속 잡힌다 (#500).
|
|
68
|
+
// 남의 스킬은 접두사로 걸러 건드리지 않는다.
|
|
69
|
+
const srcNames = new Set(readdirSync(src));
|
|
70
|
+
const obsoleteNames = [];
|
|
71
|
+
for (const name of readdirSync(dest)) {
|
|
72
|
+
if (srcNames.has(name) || name === metaFileName) continue;
|
|
73
|
+
if (!OWNED_SKILL_NAME.test(name)) continue;
|
|
74
|
+
try {
|
|
75
|
+
rmSync(join(dest, name), { recursive: true, force: true });
|
|
76
|
+
obsoleteNames.push(name);
|
|
77
|
+
} catch { /* 무시 — 지우지 못해도 설치는 계속한다 */ }
|
|
78
|
+
}
|
|
79
|
+
return obsoleteNames;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 공용 파이썬 교체 복사: 지난 설치 항목 제거 > 소스 전체 복사 (폐기 모듈이 남지 않게). 복사한 항목 이름 반환
|
|
83
|
+
function replaceSharedScripts(scriptsSrc, scriptsDest, previousNames) {
|
|
84
|
+
removeNamedEntries(scriptsDest, previousNames);
|
|
85
|
+
return copyEntriesWithoutTests(scriptsSrc, scriptsDest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 이름 목록 + 파이썬 실행이 만든 __pycache__ 삭제 > 폴더가 비면 폴더도 삭제
|
|
89
|
+
function removeNamedEntries(dir, names) {
|
|
90
|
+
for (const name of [...names, PYTHON_CACHE_DIR_NAME]) {
|
|
91
|
+
// 메타에서 읽은 이름이라 경로 이동(../) 차단
|
|
92
|
+
if (!name || name.includes("/") || name.includes("\\") || name === "..") continue;
|
|
93
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 홈 설치 1회: 스킬 복사 > 공용 파이썬 교체 복사 > 메타 기록
|
|
100
|
+
* {obsoleteNames, hasSharedScripts} 반환. 실패는 예외로 올린다.
|
|
101
|
+
*/
|
|
102
|
+
export function installHomeSkills({ skillsSrc, scriptsSrc, skillsDest, metaFileName, templateVersion }) {
|
|
103
|
+
const metaFile = join(skillsDest, metaFileName);
|
|
104
|
+
const previousSharedScripts = readSkillsMeta(metaFile).sharedScripts;
|
|
105
|
+
const obsoleteNames = copySkillsTree(skillsSrc, skillsDest, metaFileName);
|
|
106
|
+
const scriptsDest = join(dirname(skillsDest), SHARED_SCRIPTS_DIR_NAME);
|
|
107
|
+
const sharedScripts = scriptsSrc ? replaceSharedScripts(scriptsSrc, scriptsDest, previousSharedScripts) : previousSharedScripts;
|
|
108
|
+
writeSkillsMeta(skillsDest, metaFileName, templateVersion, { sharedScripts });
|
|
109
|
+
return { obsoleteNames, hasSharedScripts: !!scriptsSrc };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// projectops 가 이 폴더에 설치했다고 볼 항목: 소스 폴더명 ∪ pro-/suh- 접두사 ∪ 메타 파일
|
|
113
|
+
// 접두어 없는 폴더(somansa-tools 등 남의 스킬)는 제외 → 보존
|
|
114
|
+
export function ownedEntries(dir, ctx, metaFileName) {
|
|
115
|
+
if (!existsSync(dir)) return [];
|
|
116
|
+
let srcNames = new Set();
|
|
117
|
+
const src = resolveSkillsSrc(ctx);
|
|
118
|
+
if (src && existsSync(src)) srcNames = new Set(readdirSync(src));
|
|
119
|
+
// 폐기한 스킬은 소스에 없으므로 srcNames 로는 잡히지 않는다 (#619).
|
|
120
|
+
return readdirSync(dir).filter((name) => srcNames.has(name) || OWNED_SKILL_NAME.test(name) || name === metaFileName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 우리 항목만 삭제 (메타에 적힌 공용 파이썬 포함) > 폴더가 비면 폴더도 삭제. 실패는 예외로 올린다.
|
|
124
|
+
export function removeOwnedEntries(dir, ctx, metaFileName) {
|
|
125
|
+
const { sharedScripts } = readSkillsMeta(join(dir, metaFileName));
|
|
126
|
+
for (const name of ownedEntries(dir, ctx, metaFileName)) rmSync(join(dir, name), { recursive: true, force: true });
|
|
127
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
const scriptsDest = join(dirname(dir), SHARED_SCRIPTS_DIR_NAME);
|
|
129
|
+
if (sharedScripts.length && existsSync(scriptsDest)) removeNamedEntries(scriptsDest, sharedScripts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 메타 기록: 버전 추적용 (installedAt 은 첫 설치 시각 유지). extraFields: sharedScripts 등
|
|
133
|
+
export function writeSkillsMeta(destDir, metaFileName, templateVersion, extraFields = {}) {
|
|
134
|
+
const version = templateVersion || "unknown";
|
|
135
|
+
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
136
|
+
const file = join(destDir, metaFileName);
|
|
137
|
+
let installedAt = now;
|
|
138
|
+
if (existsSync(file)) { try { installedAt = JSON.parse(readFileSync(file, "utf8")).installedAt || now; } catch { /* 무해 */ } }
|
|
139
|
+
const meta = {
|
|
140
|
+
name: "projectops", version, scope: "user",
|
|
141
|
+
source: TEMPLATE_REPO_WEB_URL,
|
|
142
|
+
installPath: destDir, installedAt, lastUpdated: now,
|
|
143
|
+
...extraFields,
|
|
144
|
+
};
|
|
145
|
+
mkdirSync(destDir, { recursive: true });
|
|
146
|
+
writeFileSync(file, JSON.stringify(meta, null, 2) + "\n");
|
|
147
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 고정 진입점 설치: ~/.projectops/bin/{projectops_cli.py, launch_cli.py} (#942·#897)
|
|
2
|
+
// SKILL.md 는 이 진입점을 먼저 부른다. 파이썬 쪽은 CLI 를 한 번 실행해야 진입점을 깔므로,
|
|
3
|
+
// 홈 복사 설치(Cursor·Copilot)는 첫 호출부터 길을 찾도록 설치 때 깐다 (#200).
|
|
4
|
+
// 규칙은 파이썬 refresh_stable_entrypoint 와 같다: 소스 RESOLVER_REVISION 이 깔린 것보다 높을 때만 덮는다.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, chmodSync, renameSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const REVISION_PATTERN = /^RESOLVER_REVISION = (\d+)/m;
|
|
9
|
+
// 진입점 권한: 직접 실행 가능 (rwxr-xr-x)
|
|
10
|
+
const STABLE_ENTRYPOINT_MODE = 0o755;
|
|
11
|
+
// 진입점 2종: (설치 이름, 소스 경로 계산)
|
|
12
|
+
const STABLE_ENTRYPOINTS = [
|
|
13
|
+
{ name: "projectops_cli.py", source: ({ scriptsSrc }) => scriptsSrc && join(scriptsSrc, "projectops_cli.py") },
|
|
14
|
+
{ name: "launch_cli.py", source: ({ skillsSrc }) => skillsSrc && join(skillsSrc, "pro-launch", "scripts", "launch_resolver.py") },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// 파일 > 내용 번호 (없거나 못 읽으면 -1)
|
|
18
|
+
function readRevision(file) {
|
|
19
|
+
try {
|
|
20
|
+
const match = REVISION_PATTERN.exec(readFileSync(file, "utf8"));
|
|
21
|
+
return match ? Number(match[1]) : -1;
|
|
22
|
+
} catch { return -1; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 진입점 갱신: 소스 번호 > 깔린 번호일 때만 임시 파일 > 권한 > 교체
|
|
27
|
+
* 바꾼 진입점 이름 목록 반환. 실패는 예외로 올린다.
|
|
28
|
+
*/
|
|
29
|
+
export function refreshStableEntrypoints(projectopsHome, sources) {
|
|
30
|
+
const binDir = join(projectopsHome, "bin");
|
|
31
|
+
const updatedNames = [];
|
|
32
|
+
for (const entry of STABLE_ENTRYPOINTS) {
|
|
33
|
+
const sourceFile = entry.source(sources);
|
|
34
|
+
if (!sourceFile || !existsSync(sourceFile)) continue;
|
|
35
|
+
const target = join(binDir, entry.name);
|
|
36
|
+
if (readRevision(sourceFile) <= readRevision(target)) continue;
|
|
37
|
+
mkdirSync(binDir, { recursive: true });
|
|
38
|
+
// 다른 세션이 동시에 읽어도 반쯤 쓴 파일을 보지 않게
|
|
39
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(temporary, readFileSync(sourceFile, "utf8"));
|
|
41
|
+
chmodSync(temporary, STABLE_ENTRYPOINT_MODE);
|
|
42
|
+
renameSync(temporary, target);
|
|
43
|
+
updatedNames.push(entry.name);
|
|
44
|
+
}
|
|
45
|
+
return updatedNames;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// projectops 홈: io 가 주면 그 값 (테스트 격리), 아니면 ~/.projectops
|
|
49
|
+
export function projectopsHomeOf(io) {
|
|
50
|
+
return typeof io.projectopsHome === "function" ? io.projectopsHome() : join(io.home(), ".projectops");
|
|
51
|
+
}
|
|
@@ -75,7 +75,7 @@ export function deriveEnvApplied(events = []) {
|
|
|
75
75
|
// 실행 엔트리 렌더링. report:
|
|
76
76
|
// { now, mode, types, repoName, templateFrom, templateTo,
|
|
77
77
|
// options: {deploy, publish, secretBackup, coderabbit, changelogProvider, intent},
|
|
78
|
-
// branches: {defaultBranch, deployBranch, ready, created},
|
|
78
|
+
// branches: {defaultBranch, deployBranch, ready, created, result}, // result: #715 비대화형 생성 결과 | null
|
|
79
79
|
// breaking: {current, target, critical:[], warnings:[]} | null,
|
|
80
80
|
// migrations: {applied:[], confirmPending:[], askPending:[]} | null,
|
|
81
81
|
// orphans: {cleaned:[], pending:[]} | null,
|
|
@@ -130,7 +130,15 @@ export function renderGuideEntry(report) {
|
|
|
130
130
|
for (const [k, v] of kv) checklist.push(` - ${t} \`${k}\` = \`${v}\``);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
const devBranch = r.branches?.deployBranch ?? "develop";
|
|
134
|
+
const devBranchResult = r.branches?.result ?? null; // #715 --create-development-branch 결과
|
|
135
|
+
if (devBranchResult?.status === "created") {
|
|
136
|
+
checklist.push(`- [x] Development (release source) branch \`${devBranch}\`: created from \`${devBranchResult.base || "HEAD"}\` and pushed (--create-development-branch)`);
|
|
137
|
+
} else if (devBranchResult?.status === "skipped") {
|
|
138
|
+
checklist.push(`- [x] Development (release source) branch \`${devBranch}\`: already exists, nothing changed (--create-development-branch)`);
|
|
139
|
+
} else if (devBranchResult?.status === "failed") {
|
|
140
|
+
checklist.push(`- [ ] **Create the development (release source) branch \`${devBranch}\`**: automatic creation failed (${devBranchResult.reason}). Run: \`${devBranchResult.manualCommand}\``);
|
|
141
|
+
} else if (r.branches?.created === true) {
|
|
134
142
|
checklist.push(`- [x] Development (release source) branch \`${r.branches?.deployBranch ?? "develop"}\`: created and verified by the wizard`);
|
|
135
143
|
} else if (r.branches?.ready === false) {
|
|
136
144
|
checklist.push(`- [ ] Create the development (release source) branch \`${r.branches?.deployBranch ?? "develop"}\` (required for the release PR to work)`);
|
|
@@ -165,7 +173,7 @@ export function renderGuideEntry(report) {
|
|
|
165
173
|
L.push(`mode: ${r.mode || "full"}`);
|
|
166
174
|
L.push(`types: ${ylist(r.types)}`);
|
|
167
175
|
L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true}, language: ${yq(r.options?.language ?? "")}, label_style: ${yq(r.options?.labelStyle ?? "")}, close_on_release: ${r.options?.closeOnRelease === true}, app_release: ${r.options?.appRelease === true} }`);
|
|
168
|
-
L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
|
|
176
|
+
L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true}, create_result: ${devBranchResult ? yq(devBranchResult.status) : "null"} }`);
|
|
169
177
|
L.push("workflows:");
|
|
170
178
|
L.push(` added: ${ylist(wf.added)}`);
|
|
171
179
|
L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
|
package/src/core/options-ask.js
CHANGED
|
@@ -21,35 +21,37 @@ import { DEPLOY_TARGETS, PUBLISH_TARGETS, CHANGELOG_PROVIDERS, DEFAULT_CODE_REVI
|
|
|
21
21
|
// true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
|
|
22
22
|
// false = 없는데 사용자가 거절/생성 실패 → 안내 유지
|
|
23
23
|
// null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
|
|
24
|
+
// 반환 state (#715 — 비대화형 결과 기록용): not-checked · not-a-repo · exists-local · exists-remote ·
|
|
25
|
+
// declined · create-failed · push-declined · pushed · push-failed
|
|
24
26
|
export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
|
|
25
|
-
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
|
|
27
|
+
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null, state: "not-checked" };
|
|
26
28
|
const st = branchStatus(targetRoot, deployBranch);
|
|
27
|
-
if (!st.isRepo) return { created: false, pushed: false, ready: null };
|
|
28
|
-
if (st.local) return { created: false, pushed: false, ready: true };
|
|
29
|
+
if (!st.isRepo) return { created: false, pushed: false, ready: null, state: "not-a-repo" };
|
|
30
|
+
if (st.local) return { created: false, pushed: false, ready: true, state: "exists-local" };
|
|
29
31
|
if (st.remote === true) {
|
|
30
32
|
say(msg("options.branch.remoteOnly", { branch: deployBranch }));
|
|
31
|
-
return { created: false, pushed: false, ready: true };
|
|
33
|
+
return { created: false, pushed: false, ready: true, state: "exists-remote" };
|
|
32
34
|
}
|
|
33
35
|
say(msg("options.branch.missing", { branch: deployBranch, base: defaultBranch || msg("options.branch.baseFallback") }));
|
|
34
36
|
const mk = await io.confirm({ message: msg("options.branch.createPrompt", { base: defaultBranch || msg("options.branch.currentFallback"), branch: deployBranch }), initialValue: true });
|
|
35
37
|
if (mk !== true) {
|
|
36
38
|
say(msg("options.branch.skipped", { branch: deployBranch }));
|
|
37
|
-
return { created: false, pushed: false, ready: false };
|
|
39
|
+
return { created: false, pushed: false, ready: false, state: "declined" };
|
|
38
40
|
}
|
|
39
41
|
if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
|
|
40
42
|
say(msg("options.branch.createFailed", { branch: deployBranch, from: defaultBranch ? ` ${defaultBranch}` : "" }));
|
|
41
|
-
return { created: false, pushed: false, ready: false };
|
|
43
|
+
return { created: false, pushed: false, ready: false, state: "create-failed" };
|
|
42
44
|
}
|
|
43
45
|
say(msg("options.branch.created", { branch: deployBranch }));
|
|
44
46
|
// #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
|
|
45
47
|
const up = await io.confirm({ message: msg("options.branch.pushPrompt", { branch: deployBranch }), initialValue: true });
|
|
46
|
-
if (up !== true) return { created: true, pushed: false, ready: true };
|
|
48
|
+
if (up !== true) return { created: true, pushed: false, ready: true, state: "push-declined" };
|
|
47
49
|
if (pushBranch(targetRoot, deployBranch)) {
|
|
48
50
|
say(msg("options.branch.pushed", { branch: deployBranch }));
|
|
49
|
-
return { created: true, pushed: true, ready: true };
|
|
51
|
+
return { created: true, pushed: true, ready: true, state: "pushed" };
|
|
50
52
|
}
|
|
51
53
|
say(msg("options.branch.pushFailed", { branch: deployBranch }));
|
|
52
|
-
return { created: true, pushed: false, ready: true };
|
|
54
|
+
return { created: true, pushed: false, ready: true, state: "push-failed" };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
|
|
@@ -48,6 +48,8 @@ export const FLAGS = [
|
|
|
48
48
|
description: "Registries a library is published to. Several allowed.", versionYml: "metadata.template.options.publish" },
|
|
49
49
|
{ flag: "--deploy-branch", value: "NAME", default: "develop",
|
|
50
50
|
description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.development_branch" },
|
|
51
|
+
{ flag: "--create-development-branch", default: "off",
|
|
52
|
+
description: "Non-interactive runs: create the development branch from the default branch and push it to origin when it does not exist." },
|
|
51
53
|
{ flag: "--language", value: "LANG", values: [...REPO_LANGUAGES], default: "en for new installs, ko for existing installs",
|
|
52
54
|
description: "Language of issue/PR templates and bot messages written into the repo.", versionYml: "metadata.template.options.language" },
|
|
53
55
|
{ flag: "--label-style", value: "STYLE", values: [...LABEL_STYLES], default: "en for new installs, ko for existing installs",
|
|
@@ -183,11 +185,18 @@ export const FLAG_DETAILS = {
|
|
|
183
185
|
},
|
|
184
186
|
"--deploy-branch": {
|
|
185
187
|
when: "Only if your development branch is not named develop.",
|
|
186
|
-
effect: "Writes metadata.development_branch (and the old deploy_branch with the same value) and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag.",
|
|
188
|
+
effect: "Writes metadata.development_branch (and the old deploy_branch with the same value) and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag (use --create-development-branch).",
|
|
187
189
|
omitted: "develop.",
|
|
188
190
|
example: "npx projectops --mode full --force --deploy-branch dev",
|
|
189
191
|
caution: "Despite the name this is the development branch (head of the release PR), not the branch that deploys. The deploying branch is the repository default branch.",
|
|
190
192
|
},
|
|
193
|
+
"--create-development-branch": {
|
|
194
|
+
when: "Agents and CI integrating a repo that has no development branch yet. Interactive runs ask instead.",
|
|
195
|
+
effect: "After the install, checks the development branch (--deploy-branch, default develop). Exists locally or on origin: nothing is done. Missing: creates it from the repository default branch (no checkout) and pushes it with git push -u origin <branch>. The result (created, skipped or failed) is printed in the completion summary and recorded in .github/.projectops/logs/ and the migration guide checklist.",
|
|
196
|
+
omitted: "The branch is never created or pushed; the completion summary lists it as a manual step with the command to run.",
|
|
197
|
+
example: "npx projectops --mode full --force --create-development-branch",
|
|
198
|
+
caution: "It pushes to the remote. A failed push (credentials, network, no origin) does not fail the run: the exit code stays 0 and the summary prints the command to run yourself.",
|
|
199
|
+
},
|
|
191
200
|
"--language": {
|
|
192
201
|
when: "Set it only to change the language of an existing repo or to start a new repo in Korean.",
|
|
193
202
|
effect: "Chooses the language of the issue/PR templates and bot comments written into the repo, and of the comments inside version.yml.",
|
|
@@ -294,6 +303,7 @@ export const RECIPES = [
|
|
|
294
303
|
{ goal: "Never install a template workflow again", command: "Add its file name to metadata.template.options.excluded_workflows in version.yml", note: "Delete the installed file too; the update will not bring it back." },
|
|
295
304
|
{ goal: "Stop retired workflows from running next to their replacements", command: "npx projectops --mode workflows --force --remove-legacy", note: "Run only after the new workflow is working." },
|
|
296
305
|
{ goal: "Keep every release a patch bump", command: "Set metadata.template.options.semver_auto: false in version.yml" },
|
|
306
|
+
{ goal: "Install everything and create the development branch (develop) in one run", command: "npx projectops --mode full --force --create-development-branch", note: "Pushes the new branch to origin. Does nothing if it already exists." },
|
|
297
307
|
{ goal: "Install a library publish workflow", command: "npx projectops --mode workflows --force --type node --publish npm", note: "Needs the NPM_TOKEN secret." },
|
|
298
308
|
];
|
|
299
309
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 자동 트리거 + 예시 값이 함께 남은 워크플로우 감지 (#662)
|
|
2
|
+
// 예시 값(your-project 등)은 미치환 토큰(__X__) 검사에 걸리지 않아, 푸시·라벨마다 실패 런이 쌓여도 doctor 가 통과시켰다.
|
|
3
|
+
// ⚠️ YAML 파서 없이 라인 단위로 본다 (의존성 0 — 설치 CLI 는 dependencies 가 비어 있다).
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// 사람이 직접 눌러야 도는 트리거 3개 — 나머지는 전부 자동 트리거로 본다
|
|
8
|
+
const MANUAL_EVENTS = new Set(["workflow_dispatch", "workflow_call", "repository_dispatch"]);
|
|
9
|
+
|
|
10
|
+
// 예시 값 판정: your-/your_ 접두 단어, example.com 류 도메인, 미치환 토큰 (heredoc 구분자 __SUH_*__ 제외)
|
|
11
|
+
const PLACEHOLDER_VALUE_RES = [
|
|
12
|
+
/(^|[^a-z0-9])your[-_][a-z0-9]/i,
|
|
13
|
+
/(^|[.@/])example\.(com|org|net)\b/i,
|
|
14
|
+
/__(?!SUH_)[A-Z][A-Z0-9_]*__/,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// 환경변수형 줄만 본다: ` KEY: "값"` (run 스크립트 안 URL·문구 오탐 방지)
|
|
18
|
+
const ENV_LINE_RE = /^\s*([A-Z][A-Z0-9_]*):\s*(?:"([^"]*)"|'([^']*)'|([^\s#][^#]*?))\s*(?:#.*)?$/;
|
|
19
|
+
|
|
20
|
+
const isCommented = (line) => /^\s*#/.test(line);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* on: 블록 > 이벤트 이름 목록 (인라인 `on: push`, `on: [a, b]`, 블록 형식 3가지)
|
|
24
|
+
*/
|
|
25
|
+
export function parseTriggerEvents(text) {
|
|
26
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
27
|
+
const start = lines.findIndex((l) => /^(on|"on"|'on'):/.test(l));
|
|
28
|
+
if (start < 0) return [];
|
|
29
|
+
const inline = lines[start].replace(/^[^:]*:\s*/, "").replace(/#.*$/, "").trim();
|
|
30
|
+
if (inline) return inline.replace(/^\[|\]$/g, "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
|
|
32
|
+
// 블록: 첫 하위 줄의 들여쓰기 깊이에 있는 키만 이벤트로 센다
|
|
33
|
+
const events = [];
|
|
34
|
+
let depth = null;
|
|
35
|
+
for (const line of lines.slice(start + 1)) {
|
|
36
|
+
if (!line.trim() || isCommented(line)) continue;
|
|
37
|
+
const indent = line.match(/^\s*/)[0].length;
|
|
38
|
+
if (indent === 0) break;
|
|
39
|
+
if (depth === null) depth = indent;
|
|
40
|
+
if (indent !== depth) continue;
|
|
41
|
+
const key = line.trim().match(/^([A-Za-z_]+):/);
|
|
42
|
+
if (key) events.push(key[1]);
|
|
43
|
+
}
|
|
44
|
+
return events;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 워크플로우 본문 > 예시 값이 남은 환경변수 줄 [{ key, value, line }]
|
|
49
|
+
*/
|
|
50
|
+
export function findPlaceholderValues(text) {
|
|
51
|
+
const found = [];
|
|
52
|
+
String(text || "").split(/\r?\n/).forEach((line, index) => {
|
|
53
|
+
if (isCommented(line)) return;
|
|
54
|
+
const match = line.match(ENV_LINE_RE);
|
|
55
|
+
if (!match) return;
|
|
56
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
57
|
+
if (PLACEHOLDER_VALUE_RES.some((re) => re.test(value))) {
|
|
58
|
+
found.push({ key: match[1], value, line: index + 1 });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 설치된 워크플로우 폴더 > 자동 트리거와 예시 값을 함께 가진 파일 [{ file, triggers, values }]
|
|
66
|
+
*/
|
|
67
|
+
export function scanPlaceholderTriggers(workflowsDir) {
|
|
68
|
+
if (!existsSync(workflowsDir)) return [];
|
|
69
|
+
const files = readdirSync(workflowsDir).filter((f) => /\.ya?ml$/.test(f)).sort();
|
|
70
|
+
const result = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
let text;
|
|
73
|
+
try { text = readFileSync(join(workflowsDir, file), "utf8"); } catch { continue; }
|
|
74
|
+
const triggers = parseTriggerEvents(text).filter((e) => !MANUAL_EVENTS.has(e));
|
|
75
|
+
if (!triggers.length) continue;
|
|
76
|
+
const values = findPlaceholderValues(text);
|
|
77
|
+
if (values.length) result.push({ file, triggers, values });
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
package/src/i18n/doctor.en.js
CHANGED
|
@@ -20,6 +20,10 @@ export default {
|
|
|
20
20
|
"doctor.unres.intro": "Template values are still present at the locations below.",
|
|
21
21
|
"doctor.unres.more": " ... and {n} more",
|
|
22
22
|
"doctor.unres.outro": "If left as is, those workflows fail at the deploy step. Fill in the values yourself.",
|
|
23
|
+
"doctor.ph.name": "Example values with automatic triggers",
|
|
24
|
+
"doctor.ph.purpose": "Workflows that fail on every trigger until configured",
|
|
25
|
+
"doctor.ph.l1": "These workflows run automatically (push, labels and so on) but still hold example values, so each trigger can create a failed run.",
|
|
26
|
+
"doctor.ph.fix": "Fix: fill in the real values, or delete the workflows you do not use.",
|
|
23
27
|
"doctor.sec.name": "Required secrets",
|
|
24
28
|
"doctor.sec.purpose": "Values the installed workflows require",
|
|
25
29
|
"doctor.sec.line": " {name} <- {n} workflow(s)",
|
package/src/i18n/doctor.ko.js
CHANGED
|
@@ -20,6 +20,10 @@ export default {
|
|
|
20
20
|
"doctor.unres.intro": "아래 위치에 템플릿 값이 그대로 남아 있습니다.",
|
|
21
21
|
"doctor.unres.more": " … 외 {n}건",
|
|
22
22
|
"doctor.unres.outro": "그대로 두면 해당 워크플로우가 배포 단계에서 실패합니다. 직접 값을 채워주세요.",
|
|
23
|
+
"doctor.ph.name": "자동 실행되는 예시 값",
|
|
24
|
+
"doctor.ph.purpose": "설정 전까지 실행될 때마다 실패하는 워크플로우",
|
|
25
|
+
"doctor.ph.l1": "아래 워크플로우는 자동으로 실행되는데(push, 라벨 등) 예시 값이 그대로 남아 있어, 실행될 때마다 실패 런이 생길 수 있습니다.",
|
|
26
|
+
"doctor.ph.fix": "조치: 실제 값을 채우거나, 쓰지 않는 워크플로우는 삭제하세요.",
|
|
23
27
|
"doctor.sec.name": "필요한 Secret",
|
|
24
28
|
"doctor.sec.purpose": "설치된 워크플로우가 요구하는 값",
|
|
25
29
|
"doctor.sec.line": " {name} ← {n}개 워크플로우",
|
package/src/i18n/en.js
CHANGED
|
@@ -31,7 +31,7 @@ export default {
|
|
|
31
31
|
"mode.version": "Versioning only (automation system)",
|
|
32
32
|
"mode.workflows": "Workflows only (GitHub Actions build and deploy)",
|
|
33
33
|
"mode.issues": "Issue/PR templates only",
|
|
34
|
-
"mode.skills": "AI skills only (Claude, Cursor, Gemini, Codex, PI)",
|
|
34
|
+
"mode.skills": "AI skills only (Claude, Cursor, Gemini, Codex, Copilot, PI)",
|
|
35
35
|
|
|
36
36
|
// confirm and edit menus
|
|
37
37
|
"confirm.prompt": "Continue with these settings?",
|
|
@@ -113,7 +113,7 @@ export default {
|
|
|
113
113
|
"flow.askBranch": "Default branch",
|
|
114
114
|
"flow.badVersion": "Invalid version format (use x.y.z). Keeping the current value.",
|
|
115
115
|
"flow.badVersionTitle": "Version",
|
|
116
|
-
"flow.askSkills": "Also install or update AI agent skills (Claude, Cursor, Gemini, Codex, PI)?",
|
|
116
|
+
"flow.askSkills": "Also install or update AI agent skills (Claude, Cursor, Gemini, Codex, Copilot, PI)?",
|
|
117
117
|
"flow.done": "Done. Installed in {mode} mode.",
|
|
118
118
|
"flow.updateScope": "Update ({scope} scope)",
|
|
119
119
|
"flow.conflictsTitle": "{count} workflows changed in the new template",
|
package/src/i18n/ide.en.js
CHANGED
|
@@ -30,14 +30,27 @@ export default {
|
|
|
30
30
|
"ide.codex.legacy": " Removed legacy Codex skills: {name}",
|
|
31
31
|
"ide.cursor.noSource": " Could not find the skills source (a skills/ folder is required).",
|
|
32
32
|
"ide.cursor.copying": "Copying Cursor Skills...",
|
|
33
|
-
"ide.cursor.obsolete": " Removed obsolete skill: {name}",
|
|
34
33
|
"ide.cursor.installed": " Cursor Skills installed ({dest}/, v{version})",
|
|
35
34
|
"ide.cursor.copyFailed": " Cursor Skills copy failed. Check the skills/ folder.",
|
|
36
35
|
"ide.cursor.removeNone": " No Cursor Skills are installed, skipping",
|
|
37
36
|
"ide.cursor.removed": " Cursor Skills removed (projectops-owned only, {dir}/)",
|
|
38
37
|
"ide.cursor.removeFailed": " Cursor Skills removal failed. Check manually: {dir}",
|
|
39
38
|
"ide.cursor.legacy": " Cleaned up legacy Cursor Skills (selective): name={name}, v={version}, reinstalling",
|
|
40
|
-
"ide.cursor.manual": " Tip for Cursor: copy
|
|
39
|
+
"ide.cursor.manual": " Tip for Cursor: copy skills/ to ~/.cursor/skills/ and scripts/ to ~/.cursor/scripts/ (the skill CLIs import scripts/common).",
|
|
40
|
+
"ide.skillsCopy.obsolete": " Removed obsolete skill: {name}",
|
|
41
|
+
"ide.skillsCopy.noScripts": " Shared Python scripts (scripts/common) not found: the skills' Python CLIs will not run until reinstalled from the full template.",
|
|
42
|
+
"ide.skillsCopy.entrypoint": " Stable entry point installed: {path}",
|
|
43
|
+
"ide.skillsCopy.entrypointFailed": " Could not install the stable entry point under ~/.projectops/bin (the first CLI run retries).",
|
|
44
|
+
"ide.copilot.detected": "Installable (Copilot detected)",
|
|
45
|
+
"ide.copilot.notDetected": "Copilot not detected (no copilot CLI, ~/.copilot, VS Code Copilot extension or JetBrains Copilot plugin)",
|
|
46
|
+
"ide.copilot.noSource": " Could not find the skills source (a skills/ folder is required).",
|
|
47
|
+
"ide.copilot.copying": "Copying GitHub Copilot Skills...",
|
|
48
|
+
"ide.copilot.installed": " GitHub Copilot Skills installed ({dest}/, v{version}). Call them with /pro- in Copilot chat (VS Code, JetBrains) or the Copilot CLI",
|
|
49
|
+
"ide.copilot.copyFailed": " GitHub Copilot Skills copy failed. Check the skills/ folder.",
|
|
50
|
+
"ide.copilot.removeNone": " No GitHub Copilot Skills are installed, skipping",
|
|
51
|
+
"ide.copilot.removed": " GitHub Copilot Skills removed (projectops-owned only, {dir}/)",
|
|
52
|
+
"ide.copilot.removeFailed": " GitHub Copilot Skills removal failed. Check manually: {dir}",
|
|
53
|
+
"ide.copilot.manual": " Tip for GitHub Copilot: copy skills/ to ~/.copilot/skills/ and scripts/ to ~/.copilot/scripts/ (the skill CLIs import scripts/common).",
|
|
41
54
|
"ide.gemini.updating": "Updating the Gemini CLI extension...",
|
|
42
55
|
"ide.gemini.updated": " Gemini CLI extension updated",
|
|
43
56
|
"ide.gemini.installing": "Installing the Gemini CLI extension...",
|
package/src/i18n/ide.ko.js
CHANGED
|
@@ -30,14 +30,27 @@ export default {
|
|
|
30
30
|
"ide.codex.legacy": " 레거시 Codex skills 정리: {name}",
|
|
31
31
|
"ide.cursor.noSource": " 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요).",
|
|
32
32
|
"ide.cursor.copying": "Cursor Skills 복사 중...",
|
|
33
|
-
"ide.cursor.obsolete": " 폐기된 스킬 제거: {name}",
|
|
34
33
|
"ide.cursor.installed": " Cursor Skills 설치 완료 ({dest}/, v{version})",
|
|
35
34
|
"ide.cursor.copyFailed": " Cursor Skills 복사 실패 — skills/ 폴더를 확인하세요.",
|
|
36
35
|
"ide.cursor.removeNone": " 설치된 Cursor Skills가 없어 건너뜁니다",
|
|
37
36
|
"ide.cursor.removed": " Cursor Skills 제거 완료 (projectops 소유만, {dir}/)",
|
|
38
37
|
"ide.cursor.removeFailed": " Cursor Skills 제거 실패 — 수동 확인: {dir}",
|
|
39
38
|
"ide.cursor.legacy": " 레거시 Cursor Skills 정리(선별): name={name}, v={version} → 재설치",
|
|
40
|
-
"ide.cursor.manual": " 💡 Cursor: skills/
|
|
39
|
+
"ide.cursor.manual": " 💡 Cursor: skills/ 를 ~/.cursor/skills/ 로, scripts/ 를 ~/.cursor/scripts/ 로 복사하면 됩니다 (스킬 CLI 가 scripts/common 을 import).",
|
|
40
|
+
"ide.skillsCopy.obsolete": " 폐기된 스킬 제거: {name}",
|
|
41
|
+
"ide.skillsCopy.noScripts": " 공용 파이썬(scripts/common)을 찾지 못했습니다 — 전체 템플릿으로 다시 설치하기 전까지 스킬의 파이썬 CLI 는 실행되지 않습니다.",
|
|
42
|
+
"ide.skillsCopy.entrypoint": " 고정 진입점 설치: {path}",
|
|
43
|
+
"ide.skillsCopy.entrypointFailed": " ~/.projectops/bin 고정 진입점 설치 실패 (CLI 첫 실행 때 다시 시도)",
|
|
44
|
+
"ide.copilot.detected": "설치 가능 (Copilot 감지됨)",
|
|
45
|
+
"ide.copilot.notDetected": "Copilot 미감지 (copilot CLI · ~/.copilot · VS Code Copilot 확장 · JetBrains Copilot 플러그인 없음)",
|
|
46
|
+
"ide.copilot.noSource": " 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요).",
|
|
47
|
+
"ide.copilot.copying": "GitHub Copilot Skills 복사 중...",
|
|
48
|
+
"ide.copilot.installed": " GitHub Copilot Skills 설치 완료 ({dest}/, v{version}) — Copilot 채팅(VS Code·JetBrains)·Copilot CLI 에서 /pro- 로 호출",
|
|
49
|
+
"ide.copilot.copyFailed": " GitHub Copilot Skills 복사 실패 — skills/ 폴더를 확인하세요.",
|
|
50
|
+
"ide.copilot.removeNone": " 설치된 GitHub Copilot Skills가 없어 건너뜁니다",
|
|
51
|
+
"ide.copilot.removed": " GitHub Copilot Skills 제거 완료 (projectops 소유만, {dir}/)",
|
|
52
|
+
"ide.copilot.removeFailed": " GitHub Copilot Skills 제거 실패 — 수동 확인: {dir}",
|
|
53
|
+
"ide.copilot.manual": " 💡 GitHub Copilot: skills/ 를 ~/.copilot/skills/ 로, scripts/ 를 ~/.copilot/scripts/ 로 복사하면 됩니다 (스킬 CLI 가 scripts/common 을 import).",
|
|
41
54
|
"ide.gemini.updating": "Gemini CLI extension 업데이트 중...",
|
|
42
55
|
"ide.gemini.updated": " Gemini CLI extension 업데이트 완료",
|
|
43
56
|
"ide.gemini.installing": "Gemini CLI extension 설치 중...",
|
package/src/i18n/ko.js
CHANGED
|
@@ -29,7 +29,7 @@ export default {
|
|
|
29
29
|
"mode.version": "버전 관리 전용 (자동화 시스템)",
|
|
30
30
|
"mode.workflows": "워크플로우 전용 (GitHub Actions 빌드, 배포)",
|
|
31
31
|
"mode.issues": "이슈/PR 템플릿 전용",
|
|
32
|
-
"mode.skills": "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, PI)",
|
|
32
|
+
"mode.skills": "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, Copilot, PI)",
|
|
33
33
|
|
|
34
34
|
"confirm.prompt": "이 정보로 진행할까요?",
|
|
35
35
|
"confirm.continue": "예, 계속 진행",
|
|
@@ -106,7 +106,7 @@ export default {
|
|
|
106
106
|
"flow.askBranch": "기본 브랜치",
|
|
107
107
|
"flow.badVersion": "버전 형식이 올바르지 않습니다 (x.y.z 형태). 기존 값을 유지합니다.",
|
|
108
108
|
"flow.badVersionTitle": "버전",
|
|
109
|
-
"flow.askSkills": "AI 에이전트 스킬(Claude, Cursor, Gemini, Codex, PI)도 설치/업데이트할까요?",
|
|
109
|
+
"flow.askSkills": "AI 에이전트 스킬(Claude, Cursor, Gemini, Codex, Copilot, PI)도 설치/업데이트할까요?",
|
|
110
110
|
"flow.done": "통합 완료. {mode} 모드로 설치했습니다.",
|
|
111
111
|
"flow.updateScope": "업데이트 ({scope} 범위)",
|
|
112
112
|
"flow.conflictsTitle": "템플릿이 갱신된 워크플로우 {count}개",
|
package/src/i18n/summary.en.js
CHANGED
|
@@ -10,7 +10,7 @@ export default {
|
|
|
10
10
|
"done.feat.coderabbit": "CodeRabbit AI review settings",
|
|
11
11
|
"done.feat.gitignore": "Required .gitignore entries",
|
|
12
12
|
"done.feat.guide": "Setup guide (PROJECTOPS-SETUP-GUIDE.md)",
|
|
13
|
-
"done.feat.skills": "Agent skills (Claude, Cursor, Gemini, Codex, PI)",
|
|
13
|
+
"done.feat.skills": "Agent skills (Claude, Cursor, Gemini, Codex, Copilot, PI)",
|
|
14
14
|
"done.addedFiles": "Added files:",
|
|
15
15
|
"done.versionYml": "version.yml (version: {version}, types: {types})",
|
|
16
16
|
"done.readmeAdded": "README.md (version section added)",
|
|
@@ -56,6 +56,10 @@ export default {
|
|
|
56
56
|
"done.todo.pat2": "and allows automerge even with branch protection rules.",
|
|
57
57
|
"done.todo.branchReady": "Branch {branch} is ready: the wizard checked and created it (nothing to do)",
|
|
58
58
|
"done.todo.branch": "Create the {branch} branch (if it does not exist yet)",
|
|
59
|
+
"done.todo.branchCreated": "Branch {branch} was created from {base} and pushed to origin (--create-development-branch)",
|
|
60
|
+
"done.todo.branchExists": "Branch {branch} already exists: nothing was changed (--create-development-branch)",
|
|
61
|
+
"done.todo.branchFailed": "Branch {branch} could not be created or pushed ({reason}). The install itself completed. Run it yourself:",
|
|
62
|
+
"done.todo.branchFlagHint": "Non-interactive runs can create and push it for you: add --create-development-branch",
|
|
59
63
|
"done.todo.ai": "(Optional) Get AI-polished release notes",
|
|
60
64
|
"done.todo.ai1": "Release notes already work: they are built by analyzing commits, no setup needed.",
|
|
61
65
|
"done.todo.ai2": "Do the steps below only if you want smoother wording.",
|
package/src/i18n/summary.ko.js
CHANGED
|
@@ -10,7 +10,7 @@ export default {
|
|
|
10
10
|
"done.feat.coderabbit": "CodeRabbit AI 리뷰 설정",
|
|
11
11
|
"done.feat.gitignore": ".gitignore 필수 항목",
|
|
12
12
|
"done.feat.guide": "템플릿 설정 가이드 (PROJECTOPS-SETUP-GUIDE.md)",
|
|
13
|
-
"done.feat.skills": "Agent Skill 설치 (Claude, Cursor, Gemini, Codex, PI)",
|
|
13
|
+
"done.feat.skills": "Agent Skill 설치 (Claude, Cursor, Gemini, Codex, Copilot, PI)",
|
|
14
14
|
"done.addedFiles": "추가된 파일:",
|
|
15
15
|
"done.versionYml": "version.yml (버전: {version}, 타입: {types})",
|
|
16
16
|
"done.readmeAdded": "README.md (버전 섹션 추가)",
|
|
@@ -56,6 +56,10 @@ export default {
|
|
|
56
56
|
"done.todo.pat2": "더 확실하게 이어지고, 브랜치 보호 규칙이 있어도 자동 머지가 가능합니다.",
|
|
57
57
|
"done.todo.branchReady": "{branch} 브랜치 준비 완료: 마법사가 확인 및 생성했습니다 (추가 작업 불필요)",
|
|
58
58
|
"done.todo.branch": "{branch} 브랜치 생성 (아직 없다면)",
|
|
59
|
+
"done.todo.branchCreated": "{branch} 브랜치를 {base}에서 만들고 origin에 push했습니다 (--create-development-branch)",
|
|
60
|
+
"done.todo.branchExists": "{branch} 브랜치가 이미 있어 아무것도 바꾸지 않았습니다 (--create-development-branch)",
|
|
61
|
+
"done.todo.branchFailed": "{branch} 브랜치를 만들거나 push하지 못했습니다 ({reason}). 통합은 완료됐습니다. 직접 실행하세요:",
|
|
62
|
+
"done.todo.branchFlagHint": "비대화형 실행에서 --create-development-branch 를 주면 자동으로 만들고 push합니다",
|
|
59
63
|
"done.todo.ai": "(선택) AI가 다듬은 릴리스 노트 받기",
|
|
60
64
|
"done.todo.ai1": "지금 상태로도 릴리스 노트는 나옵니다. 커밋 내용을 분석해 만들기 때문에 아무 설정을 하지 않아도 됩니다.",
|
|
61
65
|
"done.todo.ai2": "아래는 문장을 더 매끄럽게 하고 싶을 때만 하세요.",
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
|
|
|
20
20
|
import { appendGuideEntry } from "./core/migration-guide.js";
|
|
21
21
|
import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
|
|
22
22
|
import { resolveOptions } from "./core/resolve-options.js";
|
|
23
|
+
import { runDevelopmentBranchStep } from "./core/development-branch.js";
|
|
23
24
|
import { printBannerCompact } from "./ui/banner.js";
|
|
24
25
|
import { printSummary } from "./ui/summary.js";
|
|
25
26
|
import { runFull } from "./commands/full.js";
|
|
@@ -304,6 +305,14 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
|
|
|
304
305
|
// 알 수 없는 모드 → 복사 0건, 에러 아님
|
|
305
306
|
break;
|
|
306
307
|
}
|
|
308
|
+
// 개발 브랜치 생성 (#715) — --create-development-branch 일 때만. 실패해도 실행은 계속(수동 명령 안내)
|
|
309
|
+
const developmentBranchResult = await runDevelopmentBranchStep({
|
|
310
|
+
isRequested: opts.createDevelopmentBranch === true, targetRoot: cwd,
|
|
311
|
+
branch: context.deployBranch || "develop", defaultBranch: branch, trace,
|
|
312
|
+
say: (line) => console.error(line),
|
|
313
|
+
});
|
|
314
|
+
const isDevelopmentBranchReady = developmentBranchResult ? developmentBranchResult.status !== "failed" : null;
|
|
315
|
+
|
|
307
316
|
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
|
|
308
317
|
let migrationGuidePath = null;
|
|
309
318
|
// 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
|
|
@@ -316,7 +325,8 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
|
|
|
316
325
|
now, mode: opts.mode, types, repoName,
|
|
317
326
|
templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
|
|
318
327
|
options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease, language: context.language, labelStyle: context.labelStyle, closeOnRelease: context.closeOnRelease, projectsSync: context.projectsSync, excludedWorkflows: context.excludedWorkflows, storeLocales: context.storeLocales, storeLocalesIos: context.storeLocalesIos, storeLocalesPlay: context.storeLocalesPlay },
|
|
319
|
-
branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready:
|
|
328
|
+
branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: isDevelopmentBranchReady,
|
|
329
|
+
created: developmentBranchResult ? developmentBranchResult.status === "created" : null, result: developmentBranchResult },
|
|
320
330
|
breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
|
|
321
331
|
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
322
332
|
traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
|
|
@@ -326,6 +336,8 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
|
|
|
326
336
|
// 완료 요약 (CLI 모드에서도 출력)
|
|
327
337
|
printSummary({
|
|
328
338
|
mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
|
|
339
|
+
developmentBranchResult, // #715 생성 결과 (플래그 없으면 null)
|
|
340
|
+
shouldSuggestCreateBranchFlag: !developmentBranchResult, // #715 플래그 없이 돌았으면 다음엔 쓰라고 안내
|
|
329
341
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
330
342
|
skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
|
|
331
343
|
replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
|
package/src/ui/summary.js
CHANGED
|
@@ -184,6 +184,7 @@ export function printSummary(ctx, targetRoot = ".", write = null) {
|
|
|
184
184
|
err(" • iOS TestFlight: .github/util/flutter/testflight-wizard/testflight-wizard.html");
|
|
185
185
|
err(" • Android Play Store: .github/util/flutter/playstore-wizard/playstore-wizard.html");
|
|
186
186
|
err(" • Firebase App Distribution: .github/util/flutter/firebase-wizard/firebase-wizard.html");
|
|
187
|
+
err(" • Kakao / Google login (OAuth): .github/util/flutter/oauth-wizard/oauth-wizard.html");
|
|
187
188
|
err(` • ${t("done.flutter.open")}`);
|
|
188
189
|
err("");
|
|
189
190
|
}
|
|
@@ -241,11 +242,20 @@ export function printSummary(ctx, targetRoot = ".", write = null) {
|
|
|
241
242
|
err(" → Secret Name: _GITHUB_PAT_TOKEN / Scopes: repo, workflow");
|
|
242
243
|
err("");
|
|
243
244
|
// Do not re-instruct a task the wizard already did.
|
|
244
|
-
|
|
245
|
+
const branchResult = ctx?.developmentBranchResult ?? null; // #715 --create-development-branch outcome
|
|
246
|
+
if (branchResult?.status === "created") {
|
|
247
|
+
err(` 2️⃣ ✅ ${t("done.todo.branchCreated", { branch: deployBranchName, base: branchResult.base || "HEAD" })}`);
|
|
248
|
+
} else if (branchResult?.status === "skipped") {
|
|
249
|
+
err(` 2️⃣ ✅ ${t("done.todo.branchExists", { branch: deployBranchName })}`);
|
|
250
|
+
} else if (branchResult?.status === "failed") {
|
|
251
|
+
err(` 2️⃣ ${t("done.todo.branchFailed", { branch: deployBranchName, reason: branchResult.reason })}`);
|
|
252
|
+
err(` → ${branchResult.manualCommand}`);
|
|
253
|
+
} else if (deployBranchReady) {
|
|
245
254
|
err(` 2️⃣ ✅ ${t("done.todo.branchReady", { branch: deployBranchName })}`);
|
|
246
255
|
} else {
|
|
247
256
|
err(` 2️⃣ ${t("done.todo.branch", { branch: deployBranchName })}`);
|
|
248
257
|
err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
|
|
258
|
+
if (ctx?.shouldSuggestCreateBranchFlag === true) err(` 💡 ${t("done.todo.branchFlagHint")}`);
|
|
249
259
|
}
|
|
250
260
|
err("");
|
|
251
261
|
// Only explain what the user selected: instructions for a disabled feature blur what to do.
|