projectops 4.1.1 → 4.1.3

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
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.0.4 (2026-07-08)
10
+ ## 최신 버전 : v4.1.2 (2026-07-09)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI (구 SUH-DEVOPS-TEMPLATE 마법사)",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/args.js CHANGED
@@ -10,6 +10,7 @@ export function parseArgs(argv) {
10
10
  primaryType: "",
11
11
  includeNexus: null, // null=미설정
12
12
  includeSecretBackup: null,
13
+ includeNpmPublish: null,
13
14
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
14
15
  force: false,
15
16
  help: false,
@@ -50,6 +51,8 @@ export function parseArgs(argv) {
50
51
  case "--no-nexus": result.includeNexus = false; break;
51
52
  case "--secret-backup": result.includeSecretBackup = true; break;
52
53
  case "--no-secret-backup": result.includeSecretBackup = false; break;
54
+ case "--npm-publish": result.includeNpmPublish = true; break;
55
+ case "--no-npm-publish": result.includeNpmPublish = false; break;
53
56
  case "--paths": result.pathsCsv = args.shift() ?? ""; break;
54
57
  case "-h": case "--help": result.help = true; break;
55
58
  default:
package/src/cli/help.js CHANGED
@@ -15,6 +15,7 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
15
15
  --paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
16
16
  --nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
17
17
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
18
+ --npm-publish / --no-npm-publish npm 패키지 publish 워크플로우 포함/제외
18
19
  --force 모든 확인 생략, 비대화형 기본값 사용
19
20
  -v, --version projectops 버전 출력
20
21
  -h, --help 이 도움말 표시
@@ -22,7 +22,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
22
22
  export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
23
23
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
24
24
  force = true, now, today, templateVersion = "unknown",
25
- includeNexus = false, includeSecretBackup = false } = context;
25
+ includeNexus = false, includeSecretBackup = false, includeNpmPublish = false } = context;
26
26
 
27
27
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
28
28
  const pathMarkers = new Map();
@@ -38,7 +38,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
38
38
  buildVersionYml({
39
39
  version, types, paths, pathMarkers, branch, versionCode, now, today,
40
40
  deployValues,
41
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, optionsDate: today },
41
+ templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeNpmPublish, optionsDate: today },
42
42
  }));
43
43
 
44
44
  // 2. README 버전 섹션
@@ -82,6 +82,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
82
82
  // 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
83
83
  let includeNexus = existing?.options?.nexus ?? false;
84
84
  let includeSecretBackup = existing?.options?.secretBackup ?? false;
85
+ let includeNpmPublish = existing?.options?.npmPublish ?? false;
85
86
  const showOptional = mode === "full" || mode === "workflows";
86
87
  const realTty = process.stdout.isTTY === true;
87
88
 
@@ -92,12 +93,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
92
93
  if (showOptional) {
93
94
  const r = await askAllOptionalWorkflows({
94
95
  tempDir, types, targetRoot: cwd,
95
- current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null },
96
+ current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null, npmPublish: existing?.options?.npmPublish ?? null },
96
97
  force: false, tty: realTty,
97
98
  io: { confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue) },
98
99
  });
99
100
  includeNexus = r.nexus;
100
101
  includeSecretBackup = r.secretBackup;
102
+ includeNpmPublish = r.npmPublish;
101
103
  }
102
104
 
103
105
  // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
@@ -106,9 +108,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
106
108
  while (!confirmed) {
107
109
  // 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
108
110
  if (io.analysisCard) {
109
- io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths });
111
+ io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional, paths });
110
112
  } else {
111
- io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
113
+ io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional }), "프로젝트 분석 결과");
112
114
  }
113
115
  const choice = await io.confirmProjectMenu();
114
116
  if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
@@ -143,6 +145,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
143
145
  } else if (what === "secret") {
144
146
  const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
145
147
  if (!isCancel(y)) includeSecretBackup = y === true;
148
+ } else if (what === "npm-publish") {
149
+ const y = await io.askYesNo("npm publish 워크플로우를 포함할까요?", includeNpmPublish);
150
+ if (!isCancel(y)) includeNpmPublish = y === true;
146
151
  }
147
152
  }
148
153
  }
@@ -171,7 +176,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
171
176
 
172
177
  const { now, today } = clock || utcNow();
173
178
  const ctx = createContext({
174
- mode, force: true, types, version, versionCode, branch, paths, includeNexus, includeSecretBackup,
179
+ mode, force: true, types, version, versionCode, branch, paths, includeNexus, includeSecretBackup, includeNpmPublish,
175
180
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
176
181
  });
177
182
  ctx.templateVersion = templateVersion;
@@ -222,7 +227,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
222
227
  }
223
228
  }
224
229
 
225
- function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }) {
230
+ function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional }) {
226
231
  const lines = [
227
232
  `통합 모드 : ${modeLabel(mode)}`,
228
233
  `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
@@ -232,6 +237,7 @@ function summarize({ mode, types, version, branch, includeNexus, includeSecretBa
232
237
  if (showOptional) {
233
238
  lines.push(`Nexus publish : ${includeNexus ? "포함" : "제외"}`);
234
239
  lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
240
+ lines.push(`npm publish : ${includeNpmPublish ? "포함" : "제외"}`);
235
241
  }
236
242
  return lines.join("\n");
237
243
  }
@@ -12,7 +12,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
12
12
 
13
13
  export function runVersion(context, tempDir, targetRoot = ".") {
14
14
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
15
- now, today, templateVersion = "unknown", includeNexus = false, includeSecretBackup = false } = context;
15
+ now, today, templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, includeNpmPublish = false } = context;
16
16
 
17
17
  const pathMarkers = new Map();
18
18
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -20,7 +20,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
20
20
  writeText(join(targetRoot, PATHS.versionFile),
21
21
  buildVersionYml({
22
22
  version, types, paths, pathMarkers, branch, versionCode, now, today,
23
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, optionsDate: today },
23
+ templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeNpmPublish, optionsDate: today },
24
24
  }));
25
25
  addVersionSectionToReadme(version, targetRoot);
26
26
  copyScripts(tempDir, targetRoot);
package/src/context.js CHANGED
@@ -16,6 +16,7 @@ export function createContext(overrides = {}) {
16
16
  branch: "",
17
17
  paths: new Map(), // type -> path
18
18
  includeNexus: null, // null=미설정, true/false=명시
19
+ includeNpmPublish: null, // null=미설정, true/false=명시 (node/npm-publish/)
19
20
  includeSecretBackup: null,
20
21
  templateVersion: "",
21
22
  tempDir: "",
@@ -5,9 +5,14 @@ import { chmodSync } from "node:fs";
5
5
  import { PATHS } from "../paths.js";
6
6
  import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
7
7
 
8
- // version_manager.sh, changelog_manager.py 2개만 무조건 덮어쓰기 + chmod +x.
8
+ // 버전관리/릴리스노트 스크립트만 무조건 덮어쓰기 + chmod +x.
9
+ // version_manager는 .sh(위임 shim) + .py(실 로직) 한 쌍 — 둘 다 복사해야 동작 (#448).
9
10
  export function copyScripts(tempDir, targetRoot = ".") {
10
- const scripts = ["version_manager.sh", "changelog_manager.py"];
11
+ const scripts = [
12
+ "version_manager.sh", "version_manager.py",
13
+ "changelog_manager.py",
14
+ "truncate_release_notes.sh", "truncate_release_notes.py",
15
+ ];
11
16
  let copied = 0;
12
17
  for (const s of scripts) {
13
18
  const src = join(tempDir, PATHS.scriptsDir, s);
@@ -150,7 +150,7 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
150
150
  }
151
151
 
152
152
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
153
- const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
153
+ const { includeNexus, includeNpmPublish = false, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
154
154
  const typeDir = join(projectTypesDir, type);
155
155
  const envOpts = envOptsFor(type);
156
156
  let unchangedNames = [];
@@ -195,8 +195,25 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
195
195
  }
196
196
  }
197
197
 
198
+ // npm-publish (opt-in — 현재 node/npm-publish/만 존재)
199
+ const npmPublishDir = join(typeDir, "npm-publish");
200
+ if (exists(npmPublishDir) && includeNpmPublish) {
201
+ for (const filename of listYamlFiles(npmPublishDir)) {
202
+ const src = join(npmPublishDir, filename);
203
+ const dst = join(workflowsDir, filename);
204
+ if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
205
+ counters.skipped++;
206
+ continue;
207
+ }
208
+ if (existsSync(dst)) renameSync(dst, dst + ".bak");
209
+ copyFileSync(src, dst);
210
+ counters.optionalCopied++;
211
+ counters.copied++;
212
+ }
213
+ }
214
+
198
215
  // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
199
- for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
216
+ for (const srcDir of [typeDir, serverDeployDir, nexusDir, npmPublishDir]) {
200
217
  if (!exists(srcDir)) continue;
201
218
  for (const filename of listYamlFiles(srcDir)) {
202
219
  const target = join(workflowsDir, filename);
@@ -49,8 +49,8 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
49
49
  // 모든 opt-in 워크플로우를 순서대로 질문 (.sh ask_all_optional_workflows 등가).
50
50
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
51
51
  // (copyWorkflows와 동일 규약. 테스트 픽스처용으로 {tempDir}/project-types 도 허용.)
52
- // current: { nexus: bool|null, secretBackup: bool|null } — CLI(--nexus 등) 명시값
53
- // 반환: { nexus: bool, secretBackup: bool } (미결정 null은 false로 확정)
52
+ // current: { nexus: bool|null, secretBackup: bool|null, npmPublish: bool|null } — CLI(--nexus 등) 명시값
53
+ // 반환: { nexus: bool, secretBackup: bool, npmPublish: bool } (미결정 null은 false로 확정)
54
54
  export async function askAllOptionalWorkflows({
55
55
  tempDir, types = [], current = {}, targetRoot = ".",
56
56
  force = false, tty = true, io = {}, forceAsk = false,
@@ -58,6 +58,7 @@ export async function askAllOptionalWorkflows({
58
58
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
59
59
  let nexus = current.nexus ?? null;
60
60
  let secretBackup = current.secretBackup ?? null;
61
+ let npmPublish = current.npmPublish ?? null;
61
62
 
62
63
  // ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다 (.sh L2715~2717).
63
64
  // CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
@@ -73,6 +74,10 @@ export async function askAllOptionalWorkflows({
73
74
  secretBackup = saved.secretBackup;
74
75
  say(`Secret 백업 옵션: version.yml 저장값(${secretBackup}) 유지 — 재질문 생략`);
75
76
  }
77
+ if (npmPublish === null && saved.npmPublish !== null) {
78
+ npmPublish = saved.npmPublish;
79
+ say(`npm publish 옵션: version.yml 저장값(${npmPublish}) 유지 — 재질문 생략`);
80
+ }
76
81
  }
77
82
  }
78
83
 
@@ -88,6 +93,14 @@ export async function askAllOptionalWorkflows({
88
93
  current: nexus, force, tty, io, forceAsk, say,
89
94
  });
90
95
  }
96
+ // ②-2 npm publish: 각 타입의 npm-publish/ 폴더 (현재 node만 존재)
97
+ for (const t of types) {
98
+ npmPublish = await askOptionalWorkflow({
99
+ dir: join(ptDir, t, "npm-publish"), icon: "📦", short: "npm 패키지 publish",
100
+ desc: "패키지를 공개 npmjs 레지스트리에 자동 배포하는 워크플로우입니다. npm 라이브러리/CLI 프로젝트에만 필요합니다 (NPM_TOKEN secret 필요).",
101
+ current: npmPublish, force, tty, io, forceAsk, say,
102
+ });
103
+ }
91
104
  // ③ Secret 백업: 공통 폴더 (.sh L2726~2729)
92
105
  secretBackup = await askOptionalWorkflow({
93
106
  dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
@@ -96,5 +109,5 @@ export async function askAllOptionalWorkflows({
96
109
  });
97
110
 
98
111
  // ④ 미결정(null)은 false로 확정 — .sh에서 빈 INCLUDE_* 가 이후 false 취급되는 것과 동일
99
- return { nexus: nexus === true, secretBackup: secretBackup === true };
112
+ return { nexus: nexus === true, secretBackup: secretBackup === true, npmPublish: npmPublish === true };
100
113
  }
@@ -41,11 +41,11 @@ const HEADER = `# ==============================================================
41
41
  `;
42
42
 
43
43
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
44
- // 반환: { nexus: bool|null, secretBackup: bool|null } — null=미기재.
44
+ // 반환: { nexus: bool|null, secretBackup: bool|null, npmPublish: bool|null } — null=미기재.
45
45
  // 구 synology 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다.
46
46
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
47
47
  export function parseTemplateOptions(content) {
48
- const out = { nexus: null, secretBackup: null };
48
+ const out = { nexus: null, secretBackup: null, npmPublish: null };
49
49
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
50
50
  const strip = (s) => String(s).replace(/["']/g, "").trim();
51
51
  let inTemplate = false;
@@ -68,6 +68,13 @@ export function parseTemplateOptions(content) {
68
68
  if (v === "false") out.secretBackup = false;
69
69
  continue;
70
70
  }
71
+ m = line.match(/^\s+npm_publish:\s*(.+)/);
72
+ if (m) {
73
+ const v = strip(m[1]);
74
+ if (v === "true") out.npmPublish = true;
75
+ if (v === "false") out.npmPublish = false;
76
+ continue;
77
+ }
71
78
  // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
72
79
  if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
73
80
  }
@@ -170,7 +177,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
170
177
 
171
178
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
172
179
  if (templateOptions) {
173
- const { templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, optionsDate = today } = templateOptions;
180
+ const { templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, includeNpmPublish = false, optionsDate = today } = templateOptions;
174
181
  out += ` template:\n`;
175
182
  out += ` source: "projectops"\n`;
176
183
  out += ` version: "${templateVersion}"\n`;
@@ -179,6 +186,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
179
186
  out += ` options:\n`;
180
187
  out += ` nexus: ${includeNexus}\n`;
181
188
  out += ` secret_backup: ${includeSecretBackup}\n`;
189
+ out += ` npm_publish: ${includeNpmPublish}\n`;
182
190
  }
183
191
  return out;
184
192
  }
package/src/index.js CHANGED
@@ -108,6 +108,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
108
108
  // 옵션 워크플로우: CLI 플래그 최우선 → version.yml 저장 옵션(.sh read_template_options 등가) → false
109
109
  includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
110
110
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
111
+ includeNpmPublish: opts.includeNpmPublish ?? existing?.options?.npmPublish ?? false,
111
112
  repoName,
112
113
  // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
113
114
  resolvers: makeResolvers(cwd, repoName, paths),
package/src/ui/prompts.js CHANGED
@@ -41,6 +41,7 @@ export async function editMenu({ showOptional = false } = {}) {
41
41
  if (showOptional) {
42
42
  options.push({ value: "nexus", label: "Nexus publish 포함 여부" });
43
43
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
44
+ options.push({ value: "npm-publish", label: "npm publish 포함 여부" });
44
45
  }
45
46
  options.push({ value: "done", label: "모두 맞음, 계속" });
46
47
  return engine.select({ message: "어떤 항목을 수정할까요?", options });
@@ -25,7 +25,7 @@ export function printDetectionLog({ types = [], version = "", branch = "" }, out
25
25
 
26
26
  // 층3 — 프로젝트 분석 개요 카드 (.ps1 Print-ProjectAnalysis 등가+)
27
27
  export function printAnalysisCard({ mode = "", modeLabel = "", types = [], version = "", branch = "",
28
- includeNexus = null, includeSecretBackup = null, paths = new Map(), showOptional = false },
28
+ includeNexus = null, includeSecretBackup = null, includeNpmPublish = null, paths = new Map(), showOptional = false },
29
29
  out = (s) => process.stdout.write(s)) {
30
30
  out(`${HEAD} ${paint("프로젝트 분석 결과", A.bold)}\n`);
31
31
  const row = (icon, label, value) => out(`${GUT} ${icon} ${label.padEnd(10)} ${value}\n`);
@@ -36,6 +36,7 @@ export function printAnalysisCard({ mode = "", modeLabel = "", types = [], versi
36
36
  if (showOptional) {
37
37
  row("📦", "Nexus", includeNexus === true ? paint("포함", A.green) : paint("제외", A.dim));
38
38
  row("🔐", "Secret백업", includeSecretBackup === true ? paint("포함", A.green) : paint("제외", A.dim));
39
+ row("📦", "npm publish", includeNpmPublish === true ? paint("포함", A.green) : paint("제외", A.dim));
39
40
  }
40
41
  // 모노레포 경로 — 루트가 아닌 항목이 하나라도 있으면 표시
41
42
  const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");