projectops 4.2.21 → 4.2.22

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.2.20 (2026-07-17)
10
+ ## 최신 버전 : v4.2.21 (2026-07-20)
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.2.21",
3
+ "version": "4.2.22",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -41,7 +41,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
41
41
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
42
42
  deployValues,
43
43
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
44
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent },
44
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full" },
45
45
  }));
46
46
 
47
47
  // 2. README 버전 섹션
@@ -18,6 +18,7 @@ import { createRunTrace } from "../core/run-trace.js";
18
18
  import { appendGuideEntry } from "../core/migration-guide.js";
19
19
  import { promptEnvPlan } from "../ui/env-plan.js";
20
20
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
21
+ import { extractEnvValues } from "../core/wizard-env.js";
21
22
  import { createContext, VALID_TYPES } from "../context.js";
22
23
  import { runFull } from "./full.js";
23
24
  import { runVersion } from "./version.js";
@@ -53,9 +54,14 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
53
54
  io.ideStatus?.();
54
55
  io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
55
56
 
56
- // 1) 모드 선택
57
- const mode = await io.selectMode();
58
- if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
57
+ // 1) 모드 선택 — 기존 통합 레포면 업데이트 항목을 맨 위에 노출 (#502)
58
+ const updateInfo = existing?.templateVersion ? { from: existing.templateVersion, to: templateVersion } : null;
59
+ const picked = await io.selectMode(updateInfo ? { update: updateInfo } : {});
60
+ if (picked === CANCEL || picked == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
61
+ // 업데이트 모드(#502): 저장된 통합 범위(templateMode, 없으면 full)를 재실행하고
62
+ // 이하 updateRun 분기가 질문을 최소화한다 ("저장된 설정 그대로 반영"이 계약).
63
+ const updateRun = picked === "update";
64
+ const mode = updateRun ? (existing?.templateMode || "full") : picked;
59
65
 
60
66
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
61
67
  let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
@@ -84,6 +90,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
84
90
 
85
91
  // full/version/workflows — 감지 (version은 기존 version.yml 최우선)
86
92
  let types = detectTypes(cwd);
93
+ // 업데이트 모드 — 저장된 타입 우선 (재감지로 타입 집합이 흔들리지 않게, #502)
94
+ if (updateRun && existing?.types?.length) {
95
+ const saved = existing.types.filter((t) => VALID_TYPES.includes(t));
96
+ if (saved.length) types = saved;
97
+ }
87
98
  let version = (existing?.version) || detectVersion(cwd);
88
99
  let branch = detectDefaultBranch(cwd);
89
100
  const repoName = detectRepoName(cwd);
@@ -140,6 +151,15 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
140
151
  // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
141
152
  let paths = new Map();
142
153
  let confirmed = false;
154
+ // 업데이트 모드 — 카드는 보여주되 확인 루프 생략 (#502: 설정 변경은 기존 모드의 몫)
155
+ if (updateRun) {
156
+ if (io.analysisCard) {
157
+ io.analysisCard({ mode, modeLabel: `업데이트 (${modeLabel(mode)} 범위)`, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, paths });
158
+ } else {
159
+ io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "업데이트 — 저장된 설정으로 진행");
160
+ }
161
+ confirmed = true;
162
+ }
143
163
  while (!confirmed) {
144
164
  // 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
145
165
  if (io.analysisCard) {
@@ -217,10 +237,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
217
237
  for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
218
238
  }
219
239
 
220
- // @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
240
+ // @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만).
241
+ // 업데이트 모드는 생략 — 신규 파일은 기본값, 교체 파일은 아래 carryover가 기존 실값을 이월한다.
221
242
  const resolvers = makeResolvers(cwd, repoName, paths);
222
243
  let envValues = new Map(), envUseDefaults = true;
223
- if (showOptional) {
244
+ if (showOptional && !updateRun) {
224
245
  const plan = await promptEnvPlan({
225
246
  tempDir, types, io: io.engineIo ?? null, force: false,
226
247
  resolvers, deployTarget, publishTargets, targetRoot: cwd, repoName,
@@ -229,17 +250,45 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
229
250
  envUseDefaults = plan.useDefaults;
230
251
  }
231
252
 
253
+ // 업데이트 모드 충돌 처리 (#502) — 타입별 3지선다 대신 일괄 확인 1회(기본 Y: .bak 백업 후 교체).
254
+ // 교체를 선택하면 기존 설치본의 env 실값을 추출해 새 파일에 이월(carryover)한다 —
255
+ // 기본값 재치환으로 사용자가 채운 값(FIREBASE_APP_ID 등)이 초기화되는 것을 막는 핵심.
256
+ let updateDecisions = null;
257
+ if (showOptional && updateRun) {
258
+ const liteCtx = { types, paths, deployTarget, publishTargets, repoName, resolvers, branch, deployBranch };
259
+ const conflicts = listWorkflowConflicts(liteCtx, tempDir, cwd);
260
+ if (conflicts.length) {
261
+ io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), `♻️ 템플릿이 갱신된 워크플로우 ${conflicts.length}개`);
262
+ const yes = await io.askYesNo(`위 ${conflicts.length}개를 .bak 백업 후 새 버전으로 교체할까요? (기존 설정값은 유지됩니다)`, true);
263
+ const decision = yes === true ? "backup" : "skip";
264
+ updateDecisions = new Map();
265
+ for (const { filename } of conflicts) updateDecisions.set(filename, decision);
266
+ if (decision === "backup") {
267
+ const carried = new Map();
268
+ for (const { filename } of conflicts) {
269
+ const p = join(cwd, PATHS.workflowsDir, filename);
270
+ if (!existsSync(p)) continue;
271
+ for (const [k, v] of extractEnvValues(readFileSync(p, "utf8"))) carried.set(k, v);
272
+ }
273
+ if (carried.size) { envValues = carried; envUseDefaults = false; }
274
+ }
275
+ }
276
+ }
277
+
232
278
  const { now, today } = clock || utcNow();
233
279
  const ctx = createContext({
234
280
  mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
235
281
  codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
236
282
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
283
+ // #502 — version 모드가 기존 full 기록을 강등하지 않도록 (full이 우세)
284
+ recordMode: existing?.templateMode === "full" ? "full" : "version",
237
285
  });
238
286
  ctx.templateVersion = templateVersion;
239
287
 
240
288
  // 기존 워크플로우 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
241
- let hooks = {};
242
- if (showOptional && io.engineIo?.select) {
289
+ // 업데이트 모드는 위에서 일괄 결정(updateDecisions)했으므로 3지선다를 건너뛴다 (#502).
290
+ let hooks = updateDecisions ? { decisions: updateDecisions } : {};
291
+ if (showOptional && io.engineIo?.select && !updateRun) {
243
292
  const conflicts = listWorkflowConflicts(ctx, tempDir, cwd);
244
293
  if (conflicts.length) {
245
294
  const perType = new Map();
@@ -302,8 +351,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
302
351
  else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
303
352
 
304
353
  // 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
305
- const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
306
- if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
354
+ // 업데이트 모드(#502): 이미 설치된 IDE 스킬만 질문 없이 최신화 (미설치 IDE는 건드리지 않음)
355
+ if (updateRun) {
356
+ await skills({ templateVersion, tempDir, interactive: false, installedOnly: true });
357
+ } else {
358
+ const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
359
+ if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
360
+ }
307
361
 
308
362
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
309
363
  let migrationGuidePath = null;
@@ -351,7 +405,7 @@ function summarize({ mode, types, version, branch, deployTarget, publishTargets,
351
405
  }
352
406
 
353
407
  function modeLabel(m) {
354
- return { full: "전체 설치", version: "버전 관리만", workflows: "워크플로우만", issues: "이슈·PR 템플릿만", skills: "AI 스킬만" }[m] || m;
408
+ return { full: "전체 설치", version: "버전 관리만", workflows: "워크플로우만", issues: "이슈·PR 템플릿만", skills: "AI 스킬만", update: "업데이트" }[m] || m;
355
409
  }
356
410
 
357
411
  function utcNow(date = new Date()) {
@@ -30,7 +30,9 @@ export function formatStatuses(rows, templateVersion) {
30
30
  });
31
31
  }
32
32
 
33
- // 대화형/비대화형 공통 진입. opts: {io, templateVersion, sourceSkillsDir, tempDir, interactive, ui}
33
+ // 대화형/비대화형 공통 진입. opts: {io, templateVersion, sourceSkillsDir, tempDir, interactive, ui, installedOnly}
34
+ // installedOnly(#502 — 업데이트 모드): 비대화형 apply를 이미 설치된 어댑터로 한정한다.
35
+ // 미설치 IDE에 새로 설치하지 않는다 — 업데이트는 "있는 것을 최신으로"가 계약.
34
36
  export async function runSkills(opts = {}) {
35
37
  const io = opts.io || defaultIo();
36
38
  const ui = opts.ui || skillsPrompts;
@@ -43,8 +45,12 @@ export async function runSkills(opts = {}) {
43
45
  io.log("");
44
46
 
45
47
  // 비대화형: 감지된(=관리 가능) 모든 어댑터에 apply(설치/업데이트) 순차 실행.
48
+ // installedOnly면 설치된 어댑터만 (업데이트 모드 — 미설치 IDE는 건드리지 않음).
46
49
  if (!opts.interactive) {
47
- for (const { adapter } of rows) safe(() => adapter.apply(io, ctx), false);
50
+ for (const { adapter, status } of rows) {
51
+ if (opts.installedOnly && !status.installed) continue;
52
+ safe(() => adapter.apply(io, ctx), false);
53
+ }
48
54
  return 0;
49
55
  }
50
56
 
@@ -14,7 +14,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
14
14
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
15
15
  now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
16
16
  changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
17
- deployBranch = "" } = context;
17
+ deployBranch = "", recordMode = "version" } = context;
18
18
 
19
19
  const pathMarkers = new Map();
20
20
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -22,8 +22,10 @@ export function runVersion(context, tempDir, targetRoot = ".") {
22
22
  writeText(join(targetRoot, PATHS.versionFile),
23
23
  buildVersionYml({
24
24
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
25
+ // mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
26
+ // 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
25
27
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
26
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit },
28
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode },
27
29
  }));
28
30
  addVersionSectionToReadme(version, targetRoot);
29
31
  copyScripts(tempDir, targetRoot);
@@ -222,14 +222,17 @@ export function parseExisting(content) {
222
222
  else if (/^\S/.test(l)) inPaths = false; // 들여쓰기 끝 → 블록 종료
223
223
  }
224
224
  }
225
- // template: 블록 내 version
225
+ // template: 블록 내 version + mode (#502 — 업데이트 모드가 재실행할 통합 범위)
226
226
  let templateVersion = "";
227
+ let templateMode = null;
227
228
  let inTemplate = false;
228
229
  for (const l of text.split("\n")) {
229
230
  if (/^\s*template:/.test(l)) { inTemplate = true; continue; }
230
231
  if (inTemplate) {
231
- const m = l.match(/^\s*version:\s*"([0-9][0-9.]*)"/);
232
- if (m) { templateVersion = m[1]; break; }
232
+ const vm = l.match(/^\s*version:\s*"([0-9][0-9.]*)"/);
233
+ if (vm && !templateVersion) templateVersion = vm[1];
234
+ const mm = l.match(/^\s*mode:\s*"([a-z]+)"/);
235
+ if (mm && ["full", "version", "workflows"].includes(mm[1])) templateMode = mm[1];
233
236
  if (/^\S/.test(l)) break;
234
237
  }
235
238
  }
@@ -238,7 +241,7 @@ export function parseExisting(content) {
238
241
  const deployBranch = line(/^\s*deploy_branch:\s*"([^"]*)"/);
239
242
  // 선택 워크플로우 옵션 (metadata.template.options — nexus/secret_backup)
240
243
  const options = parseTemplateOptions(text);
241
- return { version, versionCode, types, paths, templateVersion, options, defaultBranch, deployBranch };
244
+ return { version, versionCode, types, paths, templateVersion, templateMode, options, defaultBranch, deployBranch };
242
245
  }
243
246
 
244
247
  // version.yml 전체 생성 (.sh create_version_yml + save_template_options 신규 케이스 등가).
@@ -291,13 +294,15 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
291
294
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
292
295
  if (templateOptions) {
293
296
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
294
- changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null } = templateOptions;
297
+ changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null, mode = null } = templateOptions;
295
298
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
296
299
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
297
300
  const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
298
301
  out += ` template:\n`;
299
302
  out += ` source: "projectops"\n`;
300
303
  out += ` version: "${templateVersion}"\n`;
304
+ // mode(#502) — 이 레포에 통합된 범위. 업데이트 모드가 같은 범위를 재실행하는 기준.
305
+ if (mode) out += ` mode: "${mode}" # 통합 범위(full/version/workflows) — 업데이트 모드 재실행 기준\n`;
301
306
  out += ` integrated_date: "${optionsDate}"\n`;
302
307
  out += ` last_update_date: "${optionsDate}"\n`;
303
308
  out += ` options:\n`;
@@ -34,6 +34,19 @@ export function setEnvLine(line, key, value) {
34
34
  return out + cr;
35
35
  }
36
36
 
37
+ // 설치본에서 env 실값 추출 (#502 — 업데이트 모드 carryover).
38
+ // 치환 시 @wizard 주석은 제거되지만 `KEY: "값"` 라인은 남는다(setEnvLine) — 그 값을 걷어온다.
39
+ // 템플릿 지식 불필요: substituteEnv가 values를 ask 키에만 참조하므로 무관 키가 섞여도 무해하다.
40
+ // 빈 값("")은 제외 — substituteEnv도 빈 chosen을 무시하고 기본값을 쓴다 (동일 규칙).
41
+ export function extractEnvValues(installedContent) {
42
+ const values = new Map();
43
+ for (const raw of String(installedContent || "").split(/\r?\n/)) {
44
+ const m = raw.match(/^\s*([A-Z_]+):\s*"([^"]*)"/);
45
+ if (m && m[2] !== "") values.set(m[1], m[2]);
46
+ }
47
+ return values;
48
+ }
49
+
37
50
  // resolver — .sh resolve_token 등가. 값 계산은 주입된 resolvers로 위임(순수성 유지).
38
51
  // resolvers: { repo, "spring-app-yml-dir"(type), "spring-app-yml-path"(type), "flutter-root" }
39
52
  export function resolveToken(name, type, resolvers = {}) {
package/src/index.js CHANGED
@@ -132,6 +132,8 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
132
132
  deployTarget,
133
133
  publishTargets,
134
134
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
135
+ // #502 — version 모드가 기존 full 통합 기록(mode)을 강등하지 않도록 (full이 우세)
136
+ recordMode: existing?.templateMode === "full" ? "full" : "version",
135
137
  // 릴리스 배포 브랜치(#456): CLI 플래그 → version.yml 저장값 → 빈 값(미출력, 스킬이 develop 폴백)
136
138
  deployBranch: opts.deployBranch || existing?.options?.deployBranch || "",
137
139
  intent,
package/src/ui/prompts.js CHANGED
@@ -6,17 +6,22 @@ import * as engine from "./readline-engine.js";
6
6
  export const CANCEL = engine.CANCEL;
7
7
 
8
8
  // 모드 선택 — 한국어 라벨, 내부 키 반환. 취소 시 CANCEL.
9
- export async function selectMode() {
10
- return engine.select({
11
- message: "무엇을 설치할까요?",
12
- options: [
13
- { value: "full", label: "전체 설치 — 버전관리 + 자동화 워크플로우 + 이슈·PR 템플릿 (처음이라면 추천)" },
14
- { value: "version", label: "버전 관리만 버전 자동 증가·동기화 시스템만 설치" },
15
- { value: "workflows", label: "워크플로우만빌드·배포 GitHub Actions만 설치" },
16
- { value: "issues", label: "이슈·PR 템플릿만 — GitHub 이슈/PR 양식만 설치" },
17
- { value: "skills", label: "AI 스킬만 — Claude·Cursor·Gemini·Codex·PI용 스킬만 설치" },
18
- ],
19
- });
9
+ // update(#502): { from, to } — 기존 통합 레포로 판별된 경우. 맨 위에 업데이트 항목을 추가하고
10
+ // 기본 선택으로 둔다 (첫 항목 = 기본). 신규 레포(update 미전달)는 현행 5개 그대로.
11
+ export async function selectMode({ update = null } = {}) {
12
+ const options = [];
13
+ if (update) {
14
+ const range = update.from ? `v${update.from} v${update.to}` : `v${update.to}`;
15
+ options.push({ value: "update", label: `업데이트저장된 설정 그대로 ${range} 반영 (추천)` });
16
+ }
17
+ options.push(
18
+ { value: "full", label: `전체 설치 — 버전관리 + 자동화 워크플로우 + 이슈·PR 템플릿${update ? "" : " (처음이라면 추천)"}` },
19
+ { value: "version", label: "버전 관리만 — 버전 자동 증가·동기화 시스템만 설치" },
20
+ { value: "workflows", label: "워크플로우만 — 빌드·배포 GitHub Actions만 설치" },
21
+ { value: "issues", label: "이슈·PR 템플릿만 — GitHub 이슈/PR 양식만 설치" },
22
+ { value: "skills", label: "AI 스킬만 — Claude·Cursor·Gemini·Codex·PI용 스킬만 설치" },
23
+ );
24
+ return engine.select({ message: "무엇을 설치할까요?", options });
20
25
  }
21
26
 
22
27
  // 프로젝트 확인 화면 메뉴 (계속/수정/취소).
@@ -85,7 +85,7 @@ export function printIdeStatus(statuses, out = (s) => process.stdout.write(s)) {
85
85
  export function printInstallKind({ currentTemplateVersion = "", templateVersion = "" }, out = (s) => process.stdout.write(s)) {
86
86
  if (currentTemplateVersion) {
87
87
  out(`${GUT} ♻️ ${paint("업데이트", A.bold)} — 템플릿 ${paint(`v${currentTemplateVersion}`, A.dim)} → ${paint(`v${templateVersion}`, A.green)}\n`);
88
- out(`${GUT} ${paint("version.yml에 이전 통합 기록이 있어 업데이트로 진행합니다", A.dim)}\n`);
88
+ out(`${GUT} ${paint("version.yml에 이전 통합 기록이 있습니다 메뉴 맨 위 '업데이트'가 저장된 설정 그대로 반영합니다", A.dim)}\n`);
89
89
  } else {
90
90
  out(`${GUT} 🆕 ${paint("신규 통합", A.bold)} — 이 프로젝트에 처음 설치합니다 (템플릿 ${paint(`v${templateVersion}`, A.green)})\n`);
91
91
  out(`${GUT} ${paint("version.yml에 이전 통합 기록이 없어 신규로 봅니다", A.dim)}\n`);