project-auto-wizard 0.13.0 → 0.13.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 +1 -1
- package/package.json +3 -1
- package/payload/scripts/version_manager.py +94 -19
- package/payload/version.yml.template +1 -0
- package/src/commands/doctor.js +9 -50
- package/src/commands/dry-run.js +5 -4
- package/src/commands/full.js +11 -8
- package/src/commands/install-settings.js +51 -0
- package/src/commands/interactive.js +16 -18
- package/src/commands/purge.js +8 -21
- package/src/commands/status.js +3 -12
- package/src/commands/uninstall.js +10 -55
- package/src/core/copy/app-files.js +28 -0
- package/src/core/copy/workflows.js +63 -84
- package/src/core/detect.js +49 -3
- package/src/core/flutter-doctor.js +49 -0
- package/src/core/flutter-hooks.js +29 -0
- package/src/core/removal-exec.js +67 -0
- package/src/core/types.js +23 -0
- package/src/index.js +19 -24
|
@@ -1,46 +1,14 @@
|
|
|
1
1
|
// uninstall 모드 — 마법사가 설치한 파일(planRemoval 판별분)에 더해 README/.gitignore/version.yml까지
|
|
2
2
|
// 선택적으로 제거한다. 대화형 체크리스트 또는 --force + --purge-* 로 항목별 opt-in.
|
|
3
3
|
// core/removal-plan.js는 읽기 전용으로만 재사용한다(아무것도 지우지 않는 순수 함수).
|
|
4
|
-
import { join
|
|
5
|
-
import { existsSync
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
6
|
import { PATHS } from "../core/paths.js";
|
|
7
|
-
import { remove } from "../core/fsutil.js";
|
|
8
7
|
import { planRemoval, backupArtifacts } from "../core/removal-plan.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { executeRemoval } from "../core/removal-exec.js";
|
|
9
|
+
import { hasVersionSection } from "../core/copy/readme.js";
|
|
10
|
+
import { hasAutoAddedEntries } from "../core/copy/gitignore.js";
|
|
11
11
|
import { CANCEL } from "../ui/prompts.js";
|
|
12
|
-
import { logRemovals } from "../core/logger.js";
|
|
13
|
-
|
|
14
|
-
// 파일을 지운 뒤 비게 된 상위 폴더를 레포 루트 직전까지 정리한다(마법사가 만든 fastlane/ 등).
|
|
15
|
-
// 비어 있지 않은 폴더를 만나면 멈추므로 사용자 파일이 남은 폴더는 건드리지 않는다.
|
|
16
|
-
export function pruneEmptyDirs(targetRoot, relDir) {
|
|
17
|
-
let rel = relDir;
|
|
18
|
-
while (rel && rel !== "." && rel !== "/") {
|
|
19
|
-
const abs = join(targetRoot, rel);
|
|
20
|
-
try {
|
|
21
|
-
if (readdirSync(abs).length) break;
|
|
22
|
-
rmdirSync(abs);
|
|
23
|
-
} catch { break; }
|
|
24
|
-
rel = posix.dirname(rel);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Flutter 앱 파일 제거 — 미수정분만 plan에 들어오므로 그대로 지우고 빈 폴더를 정리한다.
|
|
29
|
-
// 로그는 삭제가 끝난 뒤 한꺼번에 남기므로 기록할 항목을 done에 모은다.
|
|
30
|
-
export function removeAppFiles(targetRoot, appFiles, done = []) {
|
|
31
|
-
for (const rel of appFiles) {
|
|
32
|
-
remove(join(targetRoot, rel));
|
|
33
|
-
pruneEmptyDirs(targetRoot, posix.dirname(rel));
|
|
34
|
-
done.push(["remove", "flutter-app", rel]);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 설치물을 지운 뒤 비게 된 .github/workflows·.github/scripts(와 .github)를 정리한다.
|
|
39
|
-
// 이번에 그 폴더에서 무언가를 지웠을 때만 — 원래 비어 있던 폴더까지 손대지 않는다.
|
|
40
|
-
export function pruneInstallDirs(targetRoot, plan) {
|
|
41
|
-
if (plan.workflows.length || plan.baseline?.length) pruneEmptyDirs(targetRoot, PATHS.workflowsDir);
|
|
42
|
-
if (plan.scripts.length) pruneEmptyDirs(targetRoot, PATHS.scriptsDir);
|
|
43
|
-
}
|
|
44
12
|
|
|
45
13
|
// selection: { workflows, scripts, readme, gitignore, versionYml } (모두 boolean).
|
|
46
14
|
// 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
|
|
@@ -65,24 +33,11 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
|
|
|
65
33
|
// 반환: planUninstall과 동일한 형태 — 실제로 제거된 항목.
|
|
66
34
|
export function runUninstall(context, payloadRoot, targetRoot, selection) {
|
|
67
35
|
const plan = planUninstall(payloadRoot, targetRoot, selection);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
for (const p of plan.baseline || []) { remove(join(targetRoot, p)); done.push(["remove", "metadata", p]); }
|
|
74
|
-
pruneInstallDirs(targetRoot, plan);
|
|
75
|
-
// removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
|
|
76
|
-
// 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
|
|
77
|
-
// 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
|
|
78
|
-
const readmeRemoved = plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
|
|
79
|
-
const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
|
|
80
|
-
const gitignoreRemoved = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
|
|
81
|
-
if (readmeRemoved) done.push(["remove", "readme", "README.md 버전 섹션"]);
|
|
82
|
-
if (gitignoreStatus) done.push(["remove", "gitignore", `.gitignore 자동 추가 항목 (${gitignoreStatus})`]);
|
|
83
|
-
if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); done.push(["remove", "version", PATHS.versionFile]); }
|
|
84
|
-
logRemovals(targetRoot, done);
|
|
85
|
-
return { ...plan, readme: readmeRemoved, gitignore: gitignoreRemoved };
|
|
36
|
+
const { readme, gitignore } = executeRemoval(targetRoot, plan, {
|
|
37
|
+
logOrder: ["readme", "gitignore", "version"],
|
|
38
|
+
gitignoreDetail: (status) => `.gitignore 자동 추가 항목 (${status})`,
|
|
39
|
+
});
|
|
40
|
+
return { ...plan, readme, gitignore };
|
|
86
41
|
}
|
|
87
42
|
|
|
88
43
|
// ── 대화형 체크리스트 흐름 ────────────────────────────────────────────
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 타입 전용 앱 파일(사용자 소유, 없을 때만 생성) 설치 — 타입 훅(planAppFiles/copyAppFiles)을 돌려 합친다.
|
|
2
|
+
// 어떤 파일이 대상인지는 훅이 정하므로 여기서는 타입 이름을 알지 못한다.
|
|
3
|
+
import { hooksFor, typeInfo } from "../types.js";
|
|
4
|
+
|
|
5
|
+
// 읽기 전용 — dry-run이 쓴다. 반환: { created:[], kept:[] }
|
|
6
|
+
export function planTypeAppFiles(context, payloadRoot, targetRoot = ".") {
|
|
7
|
+
const result = { created: [], kept: [] };
|
|
8
|
+
for (const { hook } of hooksFor(context.types || [], "planAppFiles")) {
|
|
9
|
+
const r = hook(context, payloadRoot, targetRoot);
|
|
10
|
+
result.created.push(...r.created);
|
|
11
|
+
result.kept.push(...r.kept);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// onFile(tag, action, relPath): 파일마다 호출되는 로그 콜백. 반환: { created:[], kept:[] }
|
|
17
|
+
export function copyTypeAppFiles(context, payloadRoot, targetRoot = ".", onFile = () => {}) {
|
|
18
|
+
const result = { created: [], kept: [] };
|
|
19
|
+
for (const { id, hook } of hooksFor(context.types || [], "copyAppFiles")) {
|
|
20
|
+
const tag = typeInfo(id).hooks.appFilesTag ?? id;
|
|
21
|
+
const r = hook(context, payloadRoot, targetRoot);
|
|
22
|
+
for (const f of r.created) onFile(tag, "create", f);
|
|
23
|
+
for (const f of r.kept) onFile(tag, "keep", f);
|
|
24
|
+
result.created.push(...r.created);
|
|
25
|
+
result.kept.push(...r.kept);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
@@ -5,7 +5,7 @@ import { join, basename } from "node:path";
|
|
|
5
5
|
import {
|
|
6
6
|
deployFilter, isDeployWorkflow, activateDeployTrigger, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE,
|
|
7
7
|
} from "../deploy-style.js";
|
|
8
|
-
import {
|
|
8
|
+
import { typeInfo } from "../types.js";
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
10
10
|
import { PATHS, PAYLOAD, typeWorkflowDirs } from "../paths.js";
|
|
11
11
|
import { exists, writeText, listYamlFiles } from "../fsutil.js";
|
|
@@ -50,10 +50,45 @@ export function readSavedDeployValues(targetRoot = ".") {
|
|
|
50
50
|
export function buildTypeRootFilter(type, deployStyle, flutterStore, available = null) {
|
|
51
51
|
const filters = [];
|
|
52
52
|
if (deployStyle) filters.push(deployFilter(deployStyle, available));
|
|
53
|
-
|
|
53
|
+
const typeFilter = typeInfo(type)?.hooks?.workflowFilter?.({ flutterStore });
|
|
54
|
+
if (typeFilter) filters.push(typeFilter);
|
|
54
55
|
return (filename) => filters.every((keep) => keep(filename));
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// 한 타입이 설치하는 원본 디렉토리를 순서대로 돌려준다 — 타입 루트 → server-deploy.
|
|
59
|
+
// 각 항목: { srcDir, type, filter }. 폴더 존재 여부와 "배포 안 함" 제외를 여기서 한 번에 판단한다.
|
|
60
|
+
export function typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore = null, available = null }) {
|
|
61
|
+
const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
|
|
62
|
+
const sources = [];
|
|
63
|
+
// go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 루트에도 배포 방식 필터를 건다.
|
|
64
|
+
if (exists(typeDir)) sources.push({ srcDir: typeDir, type, filter: buildTypeRootFilter(type, deployStyle, flutterStore, available) });
|
|
65
|
+
// "배포 안 함"이면 폴더째 제외
|
|
66
|
+
if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
|
|
67
|
+
sources.push({ srcDir: serverDeployDir, type, filter: deployFilter(deployStyle, available) });
|
|
68
|
+
}
|
|
69
|
+
return sources;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// common 원본 — trunk-based 모드의 제외 필터를 반영한다. 폴더가 없으면 null.
|
|
73
|
+
export function commonWorkflowSource(context, payloadRoot) {
|
|
74
|
+
const commonDir = join(payloadRoot, PAYLOAD.workflowsDir, "common");
|
|
75
|
+
if (!exists(commonDir)) return null;
|
|
76
|
+
const branchMode = context.branches?.mode || "pr-flow";
|
|
77
|
+
return {
|
|
78
|
+
srcDir: commonDir, type: "common",
|
|
79
|
+
filter: (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 설치 대상 워크플로우 원본을 엔진 처리 순서(common → 타입 순회 → 타입 루트 → server-deploy)로 내준다.
|
|
84
|
+
// 복사·충돌 조사·미리보기 계획이 모두 이 순회를 써야 서로 다른 파일 집합을 보지 않는다.
|
|
85
|
+
export function* workflowSources(context, payloadRoot, { deployStyle, available }) {
|
|
86
|
+
const { types = [], flutterStore = null } = context;
|
|
87
|
+
const common = commonWorkflowSource(context, payloadRoot);
|
|
88
|
+
if (common) yield common;
|
|
89
|
+
for (const type of types) yield* typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available });
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신.
|
|
58
93
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
59
94
|
function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, savedValues = null }) {
|
|
@@ -211,10 +246,9 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
|
|
|
211
246
|
|
|
212
247
|
// (1) common — 타입별과 동일 규칙 (README 계약).
|
|
213
248
|
// trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const notExcluded = (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename));
|
|
249
|
+
const commonSource = commonWorkflowSource(context, payloadRoot);
|
|
250
|
+
if (commonSource) {
|
|
251
|
+
const { srcDir: commonDir, filter: notExcluded } = commonSource;
|
|
218
252
|
const c = processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
|
|
219
253
|
// env 치환 — 타입별 폴더(copyWorkflowsForType)와 동일하게, 손대지 않기로 한 파일(unchanged/localOnly)은
|
|
220
254
|
// 건너뛴다. common 최상위는 지금까지 @wizard 마커가 없어 이 루프가 없어도 드러나지 않았지만,
|
|
@@ -297,47 +331,26 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
|
|
|
297
331
|
// conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
|
|
298
332
|
// removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
|
|
299
333
|
export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
300
|
-
const {
|
|
334
|
+
const { paths = new Map(), repoName = "", resolvers = {} } = context;
|
|
301
335
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
302
|
-
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
303
336
|
const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
|
|
304
337
|
const srcText = makeSrcText(context.branches || null, deployStyle);
|
|
305
338
|
const baseline = readBaseline(targetRoot);
|
|
306
|
-
const branchMode = context.branches?.mode || "pr-flow";
|
|
307
339
|
const saved = readSavedDeployValues(targetRoot);
|
|
308
340
|
const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
|
|
309
341
|
const removed = [];
|
|
310
|
-
|
|
311
342
|
const available = payloadWorkflowNames(payloadRoot);
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
|
|
315
|
-
for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
|
|
316
|
-
for (const f of c.removed) { if (!skipFile(f)) removed.push({ filename: f, type }); }
|
|
317
|
-
};
|
|
343
|
+
const values = context.envValues || new Map();
|
|
344
|
+
const useDefaults = context.envUseDefaults !== false;
|
|
318
345
|
|
|
319
|
-
const
|
|
320
|
-
if (exists(commonDir)) {
|
|
321
|
-
collect(commonDir, {
|
|
322
|
-
type: "common", projectPath: ".", repoName, resolvers,
|
|
323
|
-
values: context.envValues || new Map(), useDefaults: context.envUseDefaults !== false,
|
|
324
|
-
}, "common",
|
|
325
|
-
(f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
for (const type of types) {
|
|
346
|
+
for (const { srcDir, type, filter } of workflowSources(context, payloadRoot, { deployStyle, available })) {
|
|
329
347
|
// 복사 엔진과 같은 값(이번 답변 포함)으로 분류해야 결정 목록이 실제 처리 대상과 맞는다.
|
|
330
|
-
const envOpts =
|
|
331
|
-
type, projectPath:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
collect(typeDir, envOpts, type, () => false, buildTypeRootFilter(type, deployStyle, flutterStore, available));
|
|
337
|
-
}
|
|
338
|
-
if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
|
|
339
|
-
collect(serverDeployDir, envOpts, type, () => false, keepDeploy);
|
|
340
|
-
}
|
|
348
|
+
const envOpts = type === "common"
|
|
349
|
+
? { type, projectPath: ".", repoName, resolvers, values, useDefaults }
|
|
350
|
+
: { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null, values, useDefaults };
|
|
351
|
+
const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
|
|
352
|
+
for (const f of c.changed) conflicts.push({ filename: f, type });
|
|
353
|
+
for (const f of c.removed) removed.push({ filename: f, type });
|
|
341
354
|
}
|
|
342
355
|
return { conflicts, removed };
|
|
343
356
|
}
|
|
@@ -364,37 +377,23 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
|
|
|
364
377
|
|
|
365
378
|
function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
|
|
366
379
|
const { deployStyle = "", flutterStore = null, envOptsFor, collectAsks = null, dirCtx } = ctx;
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
const { srcText, baselineTargets } = dirCtx;
|
|
370
|
-
const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
|
|
380
|
+
const { srcText } = dirCtx;
|
|
381
|
+
const sources = typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available: dirCtx.available });
|
|
371
382
|
const envOpts = envOptsFor(type);
|
|
372
383
|
// env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
|
|
373
384
|
// 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
|
|
374
385
|
const untouched = [];
|
|
375
386
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (exists(typeDir)) {
|
|
379
|
-
const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, keepTypeRoot);
|
|
380
|
-
untouched.push(...c.unchanged, ...c.localOnly);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// server-deploy
|
|
384
|
-
// "배포 안 함"이면 폴더째 제외 (복사 안 함)
|
|
385
|
-
if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
|
|
386
|
-
const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters, keepDeploy);
|
|
387
|
+
for (const { srcDir, filter } of sources) {
|
|
388
|
+
const c = processDir(srcDir, workflowsDir, envOpts, dirCtx, counters, filter);
|
|
387
389
|
untouched.push(...c.unchanged, ...c.localOnly);
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
// env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
|
|
391
|
-
for (const srcDir of
|
|
392
|
-
if (!exists(srcDir)) continue;
|
|
393
|
-
if (srcDir === serverDeployDir && deployStyle === NO_DEPLOY_STYLE) continue; // 폴더째 제외
|
|
393
|
+
for (const { srcDir, filter } of sources) {
|
|
394
394
|
for (const filename of listYamlFiles(srcDir)) {
|
|
395
395
|
const target = join(workflowsDir, filename);
|
|
396
|
-
if (
|
|
397
|
-
if (srcDir === typeDir && !keepTypeRoot(filename)) continue; // 타입 루트: 배제된 CD·안 고른 스토어 워크플로우
|
|
396
|
+
if (!filter(filename)) continue; // 배제된 CD·안 고른 배포 방식·안 고른 스토어 워크플로우
|
|
398
397
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
399
398
|
// unchanged/localOnly와 치환 마커가 이미 없는 유지본은 다시 치환하지 않는다. 대신 deploy 블록이
|
|
400
399
|
// 사라지지 않도록 저장값(없으면 기본값)으로 ask 값만 수집한다 — 파일은 건드리지 않는다.
|
|
@@ -411,44 +410,24 @@ function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
|
|
|
411
410
|
// changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed만
|
|
412
411
|
// 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
|
|
413
412
|
export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
414
|
-
const {
|
|
413
|
+
const { paths = new Map(), repoName = "", resolvers = {} } = context;
|
|
415
414
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
416
|
-
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
417
415
|
const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
|
|
418
416
|
const srcText = makeSrcText(context.branches || null, deployStyle);
|
|
419
417
|
const baseline = readBaseline(targetRoot);
|
|
420
418
|
const saved = readSavedDeployValues(targetRoot);
|
|
421
419
|
const available = payloadWorkflowNames(payloadRoot);
|
|
422
|
-
const branchMode = context.branches?.mode || "pr-flow";
|
|
423
420
|
// upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다.
|
|
424
421
|
const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
|
|
425
422
|
const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
|
|
426
423
|
|
|
427
|
-
const
|
|
424
|
+
for (const { srcDir, type, filter } of workflowSources(context, payloadRoot, { deployStyle, available })) {
|
|
425
|
+
const envOpts = type === "common"
|
|
426
|
+
? { type, projectPath: ".", repoName, resolvers }
|
|
427
|
+
: { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null };
|
|
428
|
+
const result = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
|
|
428
429
|
for (const bucket of BUCKETS) {
|
|
429
|
-
for (const filename of result[bucket]) {
|
|
430
|
-
if (excluded && excluded.has(filename)) continue;
|
|
431
|
-
plan[bucket].push({ filename, type });
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
const commonDir = join(projectTypesDir, "common");
|
|
437
|
-
if (exists(commonDir)) {
|
|
438
|
-
const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
|
|
439
|
-
merge(classify(commonDir, workflowsDir, envOpts, srcText, baseline), "common",
|
|
440
|
-
branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
for (const type of types) {
|
|
444
|
-
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null };
|
|
445
|
-
const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
|
|
446
|
-
if (exists(typeDir)) {
|
|
447
|
-
merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, buildTypeRootFilter(type, deployStyle, flutterStore, available)), type);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
|
|
451
|
-
merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle, available)), type);
|
|
430
|
+
for (const filename of result[bucket]) plan[bucket].push({ filename, type });
|
|
452
431
|
}
|
|
453
432
|
}
|
|
454
433
|
|
package/src/core/detect.js
CHANGED
|
@@ -81,14 +81,60 @@ export function detectVersionFromFiles({ read, readJson, list, gitTag, warn, hin
|
|
|
81
81
|
};
|
|
82
82
|
// 줄 시작 앵커가 없으면 ext.kotlin_version 같은 의존성 버전 변수가 먼저 걸린다.
|
|
83
83
|
// 따옴표로 감싼 값만 본다 — 릴리스 때 동기화가 고칠 수 있는 형태가 이것뿐이다.
|
|
84
|
-
const gradleRe =
|
|
84
|
+
const gradleRe = /^(\s*)version\s*=\s*(["'])([^"'\n]*)\2/;
|
|
85
|
+
// 들여쓰지 않은 `version =`을 우선하고, 없을 때만 allprojects/subprojects 블록 안의 들여쓴 줄을 쓴다.
|
|
86
|
+
// `node { version = '20.11.0' }` 같은 플러그인 설정 블록이 프로젝트 버전으로 읽히면 첫 릴리스에서 버전이 뛴다.
|
|
87
|
+
// 릴리스 때의 version_manager.py와 같은 규칙이어야 한다.
|
|
88
|
+
// 주석을 떼고 따옴표 안 글자는 공백으로 바꾼다 — url 'https://…'의 `//`나 문자열 속 중괄호가
|
|
89
|
+
// 블록 깊이 계산을 흐트러뜨리지 않게 한다. version_manager.py와 같은 규칙이어야 한다.
|
|
90
|
+
// quote는 이 줄이 시작될 때 열려 있던 따옴표다. 삼중따옴표(""" ''')는 여러 줄에 걸치므로
|
|
91
|
+
// { code, quote }로 줄 끝에서 열려 있는 따옴표를 돌려줘 다음 줄이 이어받게 한다.
|
|
92
|
+
// 한 줄짜리 따옴표는 줄 끝에서 닫힌 것으로 본다.
|
|
93
|
+
const gradleCode = (line, quote) => {
|
|
94
|
+
let out = "";
|
|
95
|
+
for (let i = 0; i < line.length;) {
|
|
96
|
+
const ch = line[i];
|
|
97
|
+
if (quote) {
|
|
98
|
+
if (ch === "\\" && i + 1 < line.length) { out += " "; i += 2; continue; }
|
|
99
|
+
if (line.startsWith(quote, i)) { out += quote; i += quote.length; quote = ""; continue; }
|
|
100
|
+
out += " ";
|
|
101
|
+
} else if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
|
|
102
|
+
quote = line.slice(i, i + 3); out += quote; i += 3; continue;
|
|
103
|
+
} else if (ch === "'" || ch === '"') { quote = ch; out += ch; }
|
|
104
|
+
else if (line.startsWith("//", i)) break;
|
|
105
|
+
else out += ch;
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
return { code: out, quote: quote.length === 3 ? quote : "" };
|
|
109
|
+
};
|
|
110
|
+
const gradleVersion = (content) => {
|
|
111
|
+
const top = [], shared = [], stack = [];
|
|
112
|
+
let quote = ""; // 여러 줄 문자열 안이면 그 따옴표. 문자열 속 줄은 코드가 아니다
|
|
113
|
+
for (const line of (content || "").split("\n")) {
|
|
114
|
+
const m = quote ? null : line.match(gradleRe);
|
|
115
|
+
if (m) {
|
|
116
|
+
if (m[1] === "") top.push(m[3]);
|
|
117
|
+
else if (stack.some((b) => b === "allprojects" || b === "subprojects")) shared.push(m[3]);
|
|
118
|
+
}
|
|
119
|
+
const r = gradleCode(line, quote);
|
|
120
|
+
quote = r.quote;
|
|
121
|
+
const code = r.code;
|
|
122
|
+
const name = code.match(/(\w+)\s*\{[^{}]*$/)?.[1] ?? "";
|
|
123
|
+
for (const ch of code) {
|
|
124
|
+
if (ch === "{") stack.push(name);
|
|
125
|
+
else if (ch === "}") stack.pop();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const v of top.length ? top : shared) { const c = coreVersion(v); if (c) return c; }
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
85
131
|
const sources = {
|
|
86
132
|
packageJson: () => coreVersion(readJson?.("package.json")?.version),
|
|
87
133
|
appJson: () => coreVersion(readJson?.("app.json")?.expo?.version),
|
|
88
134
|
// Groovy DSL과 Kotlin DSL은 같은 문법(`version = "x.y.z"`)이라 정규식을 공유한다.
|
|
89
135
|
// .kts를 빼먹으면 Kotlin DSL Spring 프로젝트가 전부 0.0.1로 초기화된다.
|
|
90
|
-
gradle: () =>
|
|
91
|
-
gradleKts: () =>
|
|
136
|
+
gradle: () => gradleVersion(read("build.gradle")),
|
|
137
|
+
gradleKts: () => gradleVersion(read("build.gradle.kts")),
|
|
92
138
|
pom: () => versionFromPom(read("pom.xml")),
|
|
93
139
|
pubspec: () => grab(read("pubspec.yaml"), /^version:\s*([^\s#]+)/),
|
|
94
140
|
pyproject: () => versionFromPyproject(read("pyproject.toml")),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Flutter 진단 항목 — doctor가 타입 훅(doctorChecks)으로 호출한다.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
import { PATHS } from "./paths.js";
|
|
5
|
+
import { STORE_PLATFORMS, STORE_APP_FILES, parseStoreList, storeAppFilesFor } from "./flutter-options.js";
|
|
6
|
+
import { inferInstalledStores } from "./installed-stores.js";
|
|
7
|
+
|
|
8
|
+
const PLATFORM_ROW_NAME = { android: "Flutter Android 배포 파일", ios: "Flutter iOS 배포 파일" };
|
|
9
|
+
const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g; // 감지 규칙은 ExportOptions.plist 템플릿·IOS-TESTFLIGHT 검증과 동일
|
|
10
|
+
const EXPORT_OPTIONS_REL = STORE_APP_FILES.ios.find((rel) => rel.endsWith("ExportOptions.plist"));
|
|
11
|
+
|
|
12
|
+
// Flutter 스토어 배포 진단 — 선택한 플랫폼의 필수 파일과 ExportOptions.plist 플레이스홀더.
|
|
13
|
+
// 스토어 시크릿 등록 여부는 이번 범위 밖이다. 반환: doctor 결과 행 배열.
|
|
14
|
+
export function flutterStoreChecks(cwd, existing, { docs }) {
|
|
15
|
+
const saved = existing.options.flutterStore == null ? null : parseStoreList(existing.options.flutterStore);
|
|
16
|
+
// 저장값 없는 기존 설치는 설치된 스토어 워크플로우로 추론한다 (interactive와 같은 규칙).
|
|
17
|
+
const stores = saved ?? inferInstalledStores(join(cwd, PATHS.workflowsDir));
|
|
18
|
+
const flutterRoot = existing.paths.get("flutter") || ".";
|
|
19
|
+
const rows = [];
|
|
20
|
+
|
|
21
|
+
for (const platform of STORE_PLATFORMS.filter((p) => stores.includes(p))) {
|
|
22
|
+
const files = storeAppFilesFor([platform]).map((rel) => posix.join(flutterRoot, rel));
|
|
23
|
+
const missing = files.filter((file) => !existsSync(join(cwd, file)));
|
|
24
|
+
const head = { name: PLATFORM_ROW_NAME[platform], purpose: "fastlane 스토어 업로드에 필요한 파일" };
|
|
25
|
+
rows.push(missing.length
|
|
26
|
+
? {
|
|
27
|
+
...head, status: "WARN", value: `없는 파일: ${missing.join(", ")}`,
|
|
28
|
+
impact: ["이 파일이 없으면 스토어 배포 워크플로우가 fastlane 단계에서 실패합니다."],
|
|
29
|
+
actions: ["마법사를 다시 실행하면 없는 파일만 새로 만들어 줍니다 (이미 있는 파일은 덮어쓰지 않음)"],
|
|
30
|
+
doc: docs.flutterStore,
|
|
31
|
+
}
|
|
32
|
+
: { ...head, status: "OK", value: `${files.length}개 있음` });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const plistPath = posix.join(flutterRoot, EXPORT_OPTIONS_REL);
|
|
36
|
+
if (stores.includes("ios") && existsSync(join(cwd, plistPath))) {
|
|
37
|
+
const head = { name: "ExportOptions.plist", purpose: "iOS 서명·내보내기 설정" };
|
|
38
|
+
const left = [...new Set(readFileSync(join(cwd, plistPath), "utf8").match(PLACEHOLDER_RE) ?? [])];
|
|
39
|
+
rows.push(left.length
|
|
40
|
+
? {
|
|
41
|
+
...head, status: "WARN", value: `채워지지 않은 값: ${left.join(", ")}`,
|
|
42
|
+
impact: ["채우지 않으면 IOS-TESTFLIGHT 워크플로우가 ExportOptions.plist 검증 단계에서 중단됩니다."],
|
|
43
|
+
actions: [`${plistPath} 의 플레이스홀더를 실제 값(Team ID · 번들 ID · 프로비저닝 프로파일 이름)으로 바꾸세요`],
|
|
44
|
+
doc: docs.flutterStore,
|
|
45
|
+
}
|
|
46
|
+
: { ...head, status: "OK", value: "플레이스홀더 없음" });
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Flutter 타입 훅 — types.js 레지스트리의 flutter 항목이 가리키는 구현 모음.
|
|
2
|
+
// 공통 코드는 flutter를 직접 알지 못하고 훅 이름으로만 호출한다(각 훅의 계약은 types.js 머리말 참고).
|
|
3
|
+
import { storeWorkflowFilter, cleanupDeselectedStoreWorkflows } from "./flutter-options.js";
|
|
4
|
+
import { planFlutterAppFiles, copyFlutterAppFiles } from "./copy/flutter-app.js";
|
|
5
|
+
import { flutterStoreChecks } from "./flutter-doctor.js";
|
|
6
|
+
|
|
7
|
+
// 저장값이 없으면 그 상태에서 실제로 적용되는 동작을 함께 알린다.
|
|
8
|
+
function statusLabels(options) {
|
|
9
|
+
return [
|
|
10
|
+
` env_mode=${options.envMode ?? "미설정(dotenv 유지)"}`,
|
|
11
|
+
` flutter_store=${options.flutterStore ?? "미설정(둘 다 설치)"}`,
|
|
12
|
+
` android_deploy_mode=${options.androidDeployMode ?? "미설정(store_only)"}`,
|
|
13
|
+
` ios_deploy_mode=${options.iosDeployMode ?? "미설정(store_only)"}`,
|
|
14
|
+
].join("");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const flutterHooks = {
|
|
18
|
+
// 스토어 대상이 배열일 때만 스토어 워크플로우 필터를 건다(null=미결정 → 전부 설치).
|
|
19
|
+
workflowFilter: ({ flutterStore }) => (Array.isArray(flutterStore) ? storeWorkflowFilter(flutterStore) : null),
|
|
20
|
+
// 선택 해제된 스토어 워크플로우 정리 — 대상이 미결정(null)이면 아무것도 지우지 않는다.
|
|
21
|
+
cleanupWorkflows: (workflowsDir, installed, context, baseline, opts) => (Array.isArray(context.flutterStore)
|
|
22
|
+
? cleanupDeselectedStoreWorkflows(workflowsDir, installed, context.flutterStore, baseline, opts)
|
|
23
|
+
: { removed: [], backedUp: [] }),
|
|
24
|
+
appFilesTag: "flutter-app",
|
|
25
|
+
planAppFiles: planFlutterAppFiles,
|
|
26
|
+
copyAppFiles: copyFlutterAppFiles,
|
|
27
|
+
statusLabels,
|
|
28
|
+
doctorChecks: flutterStoreChecks,
|
|
29
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// uninstall·purge 공통 삭제 실행부 — 두 명령은 옵션을 삭제 계획으로 바꾸기만 하고,
|
|
2
|
+
// 실제 삭제와 기록은 여기서 한 번에 처리한다. 대상 판별은 removal-plan.js(읽기 전용)가 맡는다.
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
import { readdirSync, rmdirSync } from "node:fs";
|
|
5
|
+
import { PATHS } from "./paths.js";
|
|
6
|
+
import { remove } from "./fsutil.js";
|
|
7
|
+
import { removeVersionSectionFromReadme } from "./copy/readme.js";
|
|
8
|
+
import { removeAutoAddedEntriesFromGitignore } from "./copy/gitignore.js";
|
|
9
|
+
import { logRemovals } from "./logger.js";
|
|
10
|
+
|
|
11
|
+
// 파일을 지운 뒤 비게 된 상위 폴더를 레포 루트 직전까지 정리한다(마법사가 만든 fastlane/ 등).
|
|
12
|
+
// 비어 있지 않은 폴더를 만나면 멈추므로 사용자 파일이 남은 폴더는 건드리지 않는다.
|
|
13
|
+
export function pruneEmptyDirs(targetRoot, relDir) {
|
|
14
|
+
let rel = relDir;
|
|
15
|
+
while (rel && rel !== "." && rel !== "/") {
|
|
16
|
+
const abs = join(targetRoot, rel);
|
|
17
|
+
try {
|
|
18
|
+
if (readdirSync(abs).length) break;
|
|
19
|
+
rmdirSync(abs);
|
|
20
|
+
} catch { break; }
|
|
21
|
+
rel = posix.dirname(rel);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Flutter 앱 파일 제거 — 미수정분만 plan에 들어오므로 그대로 지우고 빈 폴더를 정리한다.
|
|
26
|
+
// 로그는 삭제가 끝난 뒤 한꺼번에 남기므로 기록할 항목을 done에 모은다.
|
|
27
|
+
export function removeAppFiles(targetRoot, appFiles, done = []) {
|
|
28
|
+
for (const rel of appFiles) {
|
|
29
|
+
remove(join(targetRoot, rel));
|
|
30
|
+
pruneEmptyDirs(targetRoot, posix.dirname(rel));
|
|
31
|
+
done.push(["remove", "flutter-app", rel]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 설치물을 지운 뒤 비게 된 .github/workflows·.github/scripts(와 .github)를 정리한다.
|
|
36
|
+
// 이번에 그 폴더에서 무언가를 지웠을 때만 — 원래 비어 있던 폴더까지 손대지 않는다.
|
|
37
|
+
export function pruneInstallDirs(targetRoot, plan) {
|
|
38
|
+
if (plan.workflows.length || plan.baseline?.length) pruneEmptyDirs(targetRoot, PATHS.workflowsDir);
|
|
39
|
+
if (plan.scripts.length) pruneEmptyDirs(targetRoot, PATHS.scriptsDir);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// plan: { workflows, scripts, appFiles, baseline, versionYml, readme, gitignore, changelog } — 모두 "삭제 대상" 여부/목록.
|
|
43
|
+
// options.logOrder: 뒤쪽 항목(version·readme·gitignore·changelog)의 기록 순서 — 명령마다 기존 순서를 유지한다.
|
|
44
|
+
// options.gitignoreDetail(status): .gitignore 기록 문구. null을 돌려주면 기록하지 않는다.
|
|
45
|
+
// 반환: 실제로 제거된 결과 — readme/gitignore는 plan이 대상으로 봤어도 안전하게 포기(skip-*)될 수 있어 실제 결과로 덮어쓴다.
|
|
46
|
+
export function executeRemoval(targetRoot, plan, { logOrder, gitignoreDetail }) {
|
|
47
|
+
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
48
|
+
const done = [];
|
|
49
|
+
for (const name of plan.workflows) { remove(join(wfDir, name)); done.push(["remove", "workflow", name]); }
|
|
50
|
+
for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); done.push(["remove", "script", name]); }
|
|
51
|
+
removeAppFiles(targetRoot, plan.appFiles, done);
|
|
52
|
+
for (const p of plan.baseline || []) { remove(join(targetRoot, p)); done.push(["remove", "metadata", p]); }
|
|
53
|
+
pruneInstallDirs(targetRoot, plan);
|
|
54
|
+
|
|
55
|
+
const entries = { version: [], readme: [], gitignore: [], changelog: [] };
|
|
56
|
+
const readme = !!plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
|
|
57
|
+
const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
|
|
58
|
+
const gitignore = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
|
|
59
|
+
if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); entries.version.push(["remove", "version", PATHS.versionFile]); }
|
|
60
|
+
for (const f of plan.changelog || []) { remove(join(targetRoot, f)); entries.changelog.push(["remove", "changelog", f]); }
|
|
61
|
+
if (readme) entries.readme.push(["remove", "readme", "README.md 버전 섹션"]);
|
|
62
|
+
const detail = gitignoreStatus && gitignoreDetail(gitignoreStatus);
|
|
63
|
+
if (detail) entries.gitignore.push(["remove", "gitignore", detail]);
|
|
64
|
+
for (const k of logOrder) done.push(...entries[k]);
|
|
65
|
+
logRemovals(targetRoot, done);
|
|
66
|
+
return { readme, gitignore };
|
|
67
|
+
}
|
package/src/core/types.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { flutterHooks } from "./flutter-hooks.js";
|
|
2
|
+
|
|
1
3
|
// 프로젝트 타입 레지스트리 — 타입별 지식(감지 마커, 감지 순서, 버전·빌드 번호 파일)을 한 곳에 둔다.
|
|
2
4
|
// 타입 목록·마커 맵·감지 체인은 모두 이 표에서 만들어지므로, 새 타입은 여기에 한 줄을 더하는 것에서 시작한다.
|
|
3
5
|
// (릴리스 시점의 버전 파일 쓰기는 payload/scripts/version_manager.py가 따로 담당한다.)
|
|
@@ -13,6 +15,16 @@
|
|
|
13
15
|
// versionSources 설치 시 버전을 읽을 파일 키(detect.js의 sources) — 주 타입일 때 이 순서로 먼저 읽는다.
|
|
14
16
|
// buildNumberSource 모바일 빌드 번호를 읽을 파일 키(detect.js의 detectBuildNumberFromFiles).
|
|
15
17
|
// singleServerCd 배포 방식 변형 없이 서버 배포 CD가 하나뿐일 때 그 워크플로우 파일명.
|
|
18
|
+
// hooks 타입 전용 동작. 없는 훅은 "할 일 없음"이라 공통 코드는 hooksFor()로 있는 것만 호출한다.
|
|
19
|
+
// workflowFilter(context) 타입 루트 워크플로우 파일 필터(filename → bool) 또는 null
|
|
20
|
+
// cleanupWorkflows(dir, installed, context, baseline, opts)
|
|
21
|
+
// 선택에서 빠진 워크플로우 정리 → { removed, backedUp }
|
|
22
|
+
// planAppFiles / copyAppFiles(context, payloadRoot, targetRoot)
|
|
23
|
+
// 사용자 소유 앱 파일(없을 때만 생성) 계획/복사 → { created, kept }
|
|
24
|
+
// appFilesTag 앱 파일 복사 로그의 분류 이름
|
|
25
|
+
// statusLabels(options) status 옵션 줄에 덧붙일 문자열
|
|
26
|
+
// doctorChecks(cwd, existing, { docs }) doctor 진단 행 배열
|
|
27
|
+
// (타입 전용 옵션 해석·CLI 플래그·version.yml 블록·대화형 질문은 아직 각 호출부에 남아 있다.)
|
|
16
28
|
export const TYPES = [
|
|
17
29
|
{
|
|
18
30
|
id: "spring",
|
|
@@ -26,6 +38,7 @@ export const TYPES = [
|
|
|
26
38
|
detectBy: "markers", detectOrder: 1,
|
|
27
39
|
versionSources: ["pubspec"],
|
|
28
40
|
buildNumberSource: "pubspec",
|
|
41
|
+
hooks: flutterHooks,
|
|
29
42
|
},
|
|
30
43
|
{
|
|
31
44
|
id: "next",
|
|
@@ -91,6 +104,16 @@ const BY_ID = new Map(TYPES.map((t) => [t.id, t]));
|
|
|
91
104
|
// 알 수 없는 타입은 undefined — 호출부가 각자의 기본값을 정한다.
|
|
92
105
|
export const typeInfo = (id) => BY_ID.get(id);
|
|
93
106
|
|
|
107
|
+
// types 중 훅 name을 가진 것만 [{ id, hook }]으로 — 주어진 타입 순서를 유지한다.
|
|
108
|
+
export function hooksFor(types, name) {
|
|
109
|
+
const found = [];
|
|
110
|
+
for (const id of types) {
|
|
111
|
+
const hook = BY_ID.get(id)?.hooks?.[name];
|
|
112
|
+
if (hook !== undefined) found.push({ id, hook });
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
// 표시 순서 그대로의 타입 이름 목록
|
|
95
118
|
// VALID_TYPES·ALL_TYPES가 같은 배열을 공유하므로 한쪽에서 바꿔 다른 쪽이 오염되지 않게 고정한다.
|
|
96
119
|
export const TYPE_IDS = Object.freeze(TYPES.map((t) => t.id));
|