projectops 4.73.0 → 4.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.72.0 (2026-10-22)
14
+ ## Latest version : v4.74.0 (2026-10-22)
15
15
 
16
16
  [View full version history](CHANGELOG.md)
17
17
 
@@ -168,7 +168,7 @@ What it changed: descriptions were cut to short English triggers, a shared contr
168
168
  |------|------|
169
169
  | `/pro-figma` | Move a Figma design into code, then compare the result with the design pixel by pixel |
170
170
  | `/pro-launch` | Launch, drive, and capture apps, web, and servers: fixed status bar, width changes, response mocking (empty list, failures), render from code |
171
- | `/pro-desktop-control` | Drive programs on this computer's screen that are not web pages or emulators (installer wizards, OS dialogs, desktop apps): reads elements, presses without moving the mouse, asks before installs, hands security prompts to the user. Windows for now (macOS planned, issue #985) |
171
+ | `/pro-desktop-control` | Drive programs on this computer's screen that are not web pages or emulators (installer wizards, OS dialogs, desktop apps): reads elements, presses without moving the mouse, asks before installs, hands security prompts to the user. Windows and macOS (macOS also answers notifications and system dialogs, such as the Apple Account sign-in code) |
172
172
  | `/pro-design-brief` | A design request for a screen you built before the mockup exists: state captures, alternatives, copy candidates, and must-keep rules as a board for the designer |
173
173
  | `/pro-oss-consult` | Diagnose a GitHub repo as an open-source project: type detection, 6-axis scoring, maturity stage, and edits to About, topics, labels, and files only after approval. One repo or a whole account |
174
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.73.0",
3
+ "version": "4.74.1",
4
4
  "description": "ProjectOps: installer CLI for a fully automated GitHub project management template (versioning, CI/CD, release notes, agent skills)",
5
5
  "keywords": [
6
6
  "devops",
@@ -71,6 +71,8 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
71
71
  // 7. issue / discussion 템플릿
72
72
  step("copy-templates", () => {
73
73
  copyIssueTemplates(tempDir, targetRoot);
74
+ // Discussion 템플릿도 오버레이 대상 (#988) — 오버레이보다 먼저 복사해야 한국어판이 덮인다
75
+ copyDiscussionTemplates(tempDir, targetRoot);
74
76
  // 템플릿 언어(#769) — 원본은 영문이라 한국어 레포는 복사 직후 오버레이로 덮는다.
75
77
  // 라벨 표기 변환(apply-label-style)보다 반드시 먼저여야 한다.
76
78
  const { changed: overlaid, failed: overlayFailed } = applyRepoLanguage(tempDir, targetRoot, language);
@@ -79,7 +81,6 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
79
81
  // 담당자 고정값 제거(#782) — 오버레이가 다시 넣으므로 그 뒤에 한다.
80
82
  hooks.trace?.event("copy", "strip-assignees", "", { files: stripIssueTemplateAssignees(targetRoot).length });
81
83
  if (overlayFailed.length) console.error(t("language.overlayFailed", { n: overlayFailed.length, files: overlayFailed.map((f) => f.file).join(", ") }));
82
- copyDiscussionTemplates(tempDir, targetRoot);
83
84
  });
84
85
 
85
86
  // 8. coderabbit / gitignore / setup guide
@@ -7,9 +7,9 @@ import { copyIssueTemplates, copyDiscussionTemplates, stripIssueTemplateAssignee
7
7
 
8
8
  export function runIssues(context, tempDir, targetRoot = ".") {
9
9
  copyIssueTemplates(tempDir, targetRoot);
10
+ copyDiscussionTemplates(tempDir, targetRoot); // #988 — 오버레이·라벨 변환 대상이라 그보다 먼저
10
11
  const { failed } = applyRepoLanguage(tempDir, targetRoot, context.language ?? "en"); // #769 — 라벨 변환보다 먼저
11
12
  if (failed.length) console.error(t("language.overlayFailed", { n: failed.length, files: failed.map((f) => f.file).join(", ") }));
12
13
  stripIssueTemplateAssignees(targetRoot); // #782 — 오버레이가 다시 넣으므로 그 뒤에
13
14
  applyLabelStyle(targetRoot, context.labelStyle ?? "en"); // #776
14
- copyDiscussionTemplates(tempDir, targetRoot);
15
15
  }
@@ -2,9 +2,9 @@
2
2
  // collectBreaking(순수 비교)은 breaking.js — 이 모듈은 로드(원격→clone본 폴백)·표시·확인 게이트.
3
3
  import { readFileSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { collectBreaking } from "./breaking.js";
5
+ import { collectBreaking, localizeBreakingEntry } from "./breaking.js";
6
6
  import { parseExisting } from "./version-yml.js";
7
- import { t } from "../i18n/index.js";
7
+ import { t, getLang } from "../i18n/index.js";
8
8
 
9
9
  const BC_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/.github/config/breaking-changes.json";
10
10
 
@@ -46,11 +46,13 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
46
46
  // 요약 리스트 표시 (#473 — 전문 통덤프는 벽글이 되어 정작 CRITICAL이 안 읽혔고,
47
47
  // 긴 본문 래핑이 ║ 박스 경계를 붕괴시켰다. 버전·제목만 한 줄씩, 전문은 선택 열람.)
48
48
  const e = (s = "") => process.stderr.write(s + "\n");
49
+ // 화면은 화면 언어 문구 (onItems 로 넘긴 가이드용 항목은 영어 원문 그대로 — 실행 기록은 영어)
50
+ const localized = (item) => localizeBreakingEntry(item, getLang());
49
51
  e("");
50
52
  e(t("breaking.title", { current, target: templateVersion, critical: critical.length, warnings: warnings.length }));
51
53
  e("");
52
- for (const c of critical) e(` ❗ [CRITICAL] ${c.version} - ${c.title || ""}`);
53
- for (const w of warnings) e(` ⚠️ [WARNING] ${w.version} - ${w.title || ""}`);
54
+ for (const c of critical) e(` ❗ [CRITICAL] ${c.version} - ${localized(c).title}`);
55
+ for (const w of warnings) e(` ⚠️ [WARNING] ${w.version} - ${localized(w).title}`);
54
56
  e("");
55
57
 
56
58
  if (askYesNo) {
@@ -59,8 +61,9 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
59
61
  if (detail === true) {
60
62
  for (const it of [...critical, ...warnings]) {
61
63
  e("");
62
- e(`■ ${it.version} - ${it.title || ""}`);
63
- e(` ${it.message || ""}`);
64
+ const { title, message } = localized(it);
65
+ e(`■ ${it.version} - ${title}`);
66
+ e(` ${message}`);
64
67
  }
65
68
  e("");
66
69
  }
@@ -24,3 +24,13 @@ export function collectBreaking(json, current, target) {
24
24
  }
25
25
  return { critical, warnings };
26
26
  }
27
+
28
+ // 항목 문구 > 화면 언어: ko면 title_ko/message_ko, 없거나 그 외 언어면 영어 원문(title/message)
29
+ // 영어 키가 정본 — 구버전 CLI는 main의 이 파일에서 title/message만 읽음 (#988)
30
+ export function localizeBreakingEntry(entry, lang) {
31
+ const pickField = (field) => {
32
+ const koreanText = lang === "ko" ? entry?.[`${field}_ko`] : null;
33
+ return koreanText || entry?.[field] || "";
34
+ };
35
+ return { title: pickField("title"), message: pickField("message") };
36
+ }
@@ -27,7 +27,8 @@ function manualCommandFor(state, branch, base) {
27
27
  */
28
28
  export async function createDevelopmentBranchUnattended({ targetRoot = ".", branch = "develop", defaultBranch = "", say = () => {} }) {
29
29
  const answerYes = { confirm: async () => true };
30
- const outcome = await ensureDeployBranch({ targetRoot, deployBranch: branch, defaultBranch, io: answerYes, say });
30
+ // 무인 실행: 자격 증명을 묻지 않고 실패시켜 failed 경로(수동 명령)로 보낸다 (#988)
31
+ const outcome = await ensureDeployBranch({ targetRoot, deployBranch: branch, defaultBranch, io: answerYes, say, isNonInteractive: true });
31
32
  const status = STATUS_BY_STATE[outcome.state] ?? "failed";
32
33
  return {
33
34
  status,
@@ -2,18 +2,36 @@
2
2
  // 모든 함수는 실패 시 조용히 false/null을 반환한다 (git 미설치·비레포·원격 없음에서 마법사가 죽지 않게).
3
3
  import { execFileSync } from "node:child_process";
4
4
 
5
- function git(root, args) {
5
+ function git(root, args, env = undefined) {
6
6
  try {
7
- return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env }).trim();
8
8
  } catch { return null; }
9
9
  }
10
10
 
11
+ /**
12
+ * 비대화형 원격 접근용 환경 (#988): 자격 증명 질문 차단 > 즉시 실패 > 수동 명령 안내
13
+ * 차단 4개: 터미널 프롬프트, askpass(GIT_/SSH_), Git Credential Manager 창, ssh 비밀번호 질문
14
+ * ssh 는 사용자 지정 명령(GIT_SSH_COMMAND·GIT_SSH·core.sshCommand)이 없을 때만 BatchMode 로 덮는다
15
+ */
16
+ export function nonInteractiveGitEnv(baseEnv = process.env, { hasCustomSshCommand = false } = {}) {
17
+ const env = { ...baseEnv, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "", GCM_INTERACTIVE: "never" };
18
+ if (!hasCustomSshCommand && !baseEnv.GIT_SSH_COMMAND && !baseEnv.GIT_SSH) env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
19
+ return env;
20
+ }
21
+
22
+ // 원격 명령 환경: 대화형은 상속(undefined), 비대화형은 질문 차단 환경
23
+ function remoteEnv(root, isNonInteractive) {
24
+ if (!isNonInteractive) return undefined;
25
+ const hasCustomSshCommand = Boolean(git(root, ["config", "--get", "core.sshCommand"]));
26
+ return nonInteractiveGitEnv(process.env, { hasCustomSshCommand });
27
+ }
28
+
11
29
  // 브랜치 존재 상태. remote는 origin 조회 실패(원격 없음·네트워크) 시 null(불명).
12
- export function branchStatus(root, name) {
30
+ export function branchStatus(root, name, { isNonInteractive = false } = {}) {
13
31
  const isRepo = git(root, ["rev-parse", "--git-dir"]) !== null;
14
32
  if (!isRepo || !name) return { isRepo, local: false, remote: null };
15
33
  const local = git(root, ["show-ref", "--verify", `refs/heads/${name}`]) !== null;
16
- const ls = git(root, ["ls-remote", "--heads", "origin", name]);
34
+ const ls = git(root, ["ls-remote", "--heads", "origin", name], remoteEnv(root, isNonInteractive));
17
35
  const remote = ls === null ? null : ls.length > 0;
18
36
  return { isRepo, local, remote };
19
37
  }
@@ -29,6 +47,7 @@ export function createBranch(root, name, fromBranch = "") {
29
47
  }
30
48
 
31
49
  // 원격(origin)에 브랜치 push (-u). 자격/네트워크 실패 시 false.
32
- export function pushBranch(root, name) {
33
- return git(root, ["push", "-u", "origin", name]) !== null;
50
+ // 비대화형은 자격 증명을 묻지 않고 바로 실패 (#988 — 묻는 순간 무인 실행이 멈춘다)
51
+ export function pushBranch(root, name, { isNonInteractive = false } = {}) {
52
+ return git(root, ["push", "-u", "origin", name], remoteEnv(root, isNonInteractive)) !== null;
34
53
  }
@@ -16,6 +16,10 @@ const OWNED_SKILL_NAME = /^(pro|suh)-/;
16
16
  const SHARED_SCRIPTS_MARKER = join("common", "__init__.py");
17
17
  const SHARED_SCRIPTS_DIR_NAME = "scripts";
18
18
  const PYTHON_CACHE_DIR_NAME = "__pycache__";
19
+ // 공용 파이썬 중 저장소 관리자만 쓰는 최상위 항목 3개: 안내 문서, 트러블슈팅 색인 생성기, extras 사본 동기화 (#988)
20
+ // 허용 목록이 아니라 제외 목록인 이유: 새 런타임 모듈을 빠뜨리면 스킬이 import 에서 죽지만,
21
+ // 새 개발 파일을 빠뜨리면 파일 하나가 더 갈 뿐이다. 런타임이 쓰는 항목이 여기 들어오면 test/ide-home-install-e2e.test.js 가 잡는다
22
+ export const SHARED_SCRIPTS_DEV_ONLY = new Set(["README.md", "build_troubleshooting.py", "sync_extras.py"]);
19
23
 
20
24
  // 스킬 소스: 다운로드된 템플릿(TEMP)/skills 우선, 없으면 로컬 skills/
21
25
  export function resolveSkillsSrc(ctx = {}) {
@@ -43,11 +47,12 @@ export function readSkillsMeta(metaFile) {
43
47
 
44
48
  // 소스 최상위 항목 복사 (테스트·conftest 제외). 필터는 소스 기준 상대경로로 본다 —
45
49
  // 절대경로로 보면 소스가 ~/test/... 아래에 있을 때 전부 걸러진다.
46
- function copyEntriesWithoutTests(src, dest) {
50
+ // skipNames: 추가로 빼는 최상위 이름 (공용 파이썬의 개발 전용 항목)
51
+ function copyEntriesWithoutTests(src, dest, skipNames = new Set()) {
47
52
  mkdirSync(dest, { recursive: true });
48
53
  const copiedNames = [];
49
54
  for (const e of readdirSync(src, { withFileTypes: true })) {
50
- if (SKIP_IN_COPY.has(e.name) || TEST_PATH.test(e.name)) continue;
55
+ if (SKIP_IN_COPY.has(e.name) || TEST_PATH.test(e.name) || skipNames.has(e.name)) continue;
51
56
  cpSync(join(src, e.name), join(dest, e.name), {
52
57
  recursive: true,
53
58
  // 이 저장소 자기 코드를 검사하는 테스트는 남의 컴퓨터로 나갈 이유가 없다 (#591).
@@ -79,10 +84,10 @@ export function copySkillsTree(src, dest, metaFileName) {
79
84
  return obsoleteNames;
80
85
  }
81
86
 
82
- // 공용 파이썬 교체 복사: 지난 설치 항목 제거 > 소스 전체 복사 (폐기 모듈이 남지 않게). 복사한 항목 이름 반환
87
+ // 공용 파이썬 교체 복사: 지난 설치 항목 제거 > 런타임 항목만 복사 (폐기 모듈·예전에 깔린 개발 파일이 남지 않게). 복사한 항목 이름 반환
83
88
  function replaceSharedScripts(scriptsSrc, scriptsDest, previousNames) {
84
89
  removeNamedEntries(scriptsDest, previousNames);
85
- return copyEntriesWithoutTests(scriptsSrc, scriptsDest);
90
+ return copyEntriesWithoutTests(scriptsSrc, scriptsDest, SHARED_SCRIPTS_DEV_ONLY);
86
91
  }
87
92
 
88
93
  // 이름 목록 + 파이썬 실행이 만든 __pycache__ 삭제 > 폴더가 비면 폴더도 삭제
@@ -69,6 +69,21 @@ export function applyLabelStyle(targetRoot, style) {
69
69
  }
70
70
  }
71
71
 
72
+ // Discussion 템플릿 기본 라벨 (#988): 맨 위 labels: 목록의 "- 이름" 줄만 대상
73
+ const discussionDir = join(gh, "DISCUSSION_TEMPLATE");
74
+ if (existsSync(discussionDir)) {
75
+ for (const name of readdirSync(discussionDir).filter((f) => /\.ya?ml$/.test(f))) {
76
+ const f = join(discussionDir, name);
77
+ if (rewrite(f, (txt) => txt.replace(/^labels:\s*\n((?:[ \t]+-[^\n]*\n)+)/m, (m, list) => {
78
+ let out = list;
79
+ for (const [en, ko] of LABEL_PAIRS) {
80
+ out = out.replace(new RegExp(`^([ \\t]+-[ \\t]*)["']?${quoted(en)}["']?[ \\t]*$`, "gm"), `$1"${ko}"`);
81
+ }
82
+ return `labels:\n${out}`;
83
+ }))) changed.push(`.github/DISCUSSION_TEMPLATE/${name}`);
84
+ }
85
+ }
86
+
72
87
  // QA 이슈 생성 봇이 붙이는 기본 라벨
73
88
  const bot = join(gh, "workflows", "PROJECT-COMMON-QA-ISSUE-CREATION-BOT.yaml");
74
89
  if (rewrite(bot, (txt) => txt.replace(/labels:\s*\['status: todo'\]/g, `labels: ['${EN_TO_KO.get("status: todo")}']`))) {
@@ -23,9 +23,10 @@ import { DEPLOY_TARGETS, PUBLISH_TARGETS, CHANGELOG_PROVIDERS, DEFAULT_CODE_REVI
23
23
  // null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
24
24
  // 반환 state (#715 — 비대화형 결과 기록용): not-checked · not-a-repo · exists-local · exists-remote ·
25
25
  // declined · create-failed · push-declined · pushed · push-failed
26
- export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
26
+ // isNonInteractive: 원격 조회·push 에서 자격 증명 질문 차단 (#988 — 대화형은 기존 그대로)
27
+ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {}, isNonInteractive = false }) {
27
28
  if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null, state: "not-checked" };
28
- const st = branchStatus(targetRoot, deployBranch);
29
+ const st = branchStatus(targetRoot, deployBranch, { isNonInteractive });
29
30
  if (!st.isRepo) return { created: false, pushed: false, ready: null, state: "not-a-repo" };
30
31
  if (st.local) return { created: false, pushed: false, ready: true, state: "exists-local" };
31
32
  if (st.remote === true) {
@@ -46,7 +47,7 @@ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "",
46
47
  // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
47
48
  const up = await io.confirm({ message: msg("options.branch.pushPrompt", { branch: deployBranch }), initialValue: true });
48
49
  if (up !== true) return { created: true, pushed: false, ready: true, state: "push-declined" };
49
- if (pushBranch(targetRoot, deployBranch)) {
50
+ if (pushBranch(targetRoot, deployBranch, { isNonInteractive })) {
50
51
  say(msg("options.branch.pushed", { branch: deployBranch }));
51
52
  return { created: true, pushed: true, ready: true, state: "pushed" };
52
53
  }
@@ -195,7 +195,7 @@ export const FLAG_DETAILS = {
195
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
196
  omitted: "The branch is never created or pushed; the completion summary lists it as a manual step with the command to run.",
197
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.",
198
+ caution: "It pushes to the remote and never asks for credentials (terminal, askpass and ssh password prompts are turned off for that push), so a remote that needs a login fails at once instead of waiting. 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
199
  },
200
200
  "--language": {
201
201
  when: "Set it only to change the language of an existing repo or to start a new repo in Korean.",