@infly/libs 2.0.38 → 2.0.42

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.
@@ -0,0 +1,5 @@
1
+ function shouldManageBuildScripts(inflyConfig) {
2
+ return inflyConfig?.buildConfigs?.manageScripts !== false;
3
+ }
4
+
5
+ module.exports = { shouldManageBuildScripts };
@@ -0,0 +1,16 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { shouldManageBuildScripts } = require("./script-management");
5
+
6
+ test("shouldManageBuildScripts defaults to existing automatic script management", () => {
7
+ assert.equal(shouldManageBuildScripts(), true);
8
+ assert.equal(shouldManageBuildScripts({ buildConfigs: {} }), true);
9
+ });
10
+
11
+ test("shouldManageBuildScripts allows a project to own its build scripts", () => {
12
+ assert.equal(
13
+ shouldManageBuildScripts({ buildConfigs: { manageScripts: false } }),
14
+ false,
15
+ );
16
+ });
@@ -0,0 +1,74 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const {
5
+ EXIT_SELECTION,
6
+ selectOption: defaultSelectOption,
7
+ } = require("../../tools/select-option");
8
+ const { composeRelease: defaultComposeRelease } = require("./compose-release");
9
+
10
+ function loadTargetConfig(configPath) {
11
+ const resolvedPath = path.resolve(configPath);
12
+ delete require.cache[require.resolve(resolvedPath)];
13
+ return {
14
+ config: require(resolvedPath),
15
+ configDir: path.dirname(resolvedPath),
16
+ };
17
+ }
18
+
19
+ function loadPackageTargetConfig(cwd = process.cwd()) {
20
+ const packagePath = path.resolve(cwd, "package.json");
21
+ if (!fs.existsSync(packagePath)) {
22
+ throw new Error(`Package config not found: ${packagePath}`);
23
+ }
24
+ delete require.cache[require.resolve(packagePath)];
25
+ const packageJson = require(packagePath);
26
+ return {
27
+ config: packageJson.infly?.docker,
28
+ configDir: path.dirname(packagePath),
29
+ };
30
+ }
31
+
32
+ async function runComposeCommand(options = {}, dependencies = {}) {
33
+ const composeRelease = dependencies.composeRelease || defaultComposeRelease;
34
+ const usesDirectConfig = !options.config
35
+ && !options.configObject
36
+ && (options.projectDir || options.file);
37
+ if (usesDirectConfig) return composeRelease(options);
38
+
39
+ const loaded = options.config
40
+ ? loadTargetConfig(options.config)
41
+ : options.configObject
42
+ ? { config: options.configObject, configDir: options.configDir || process.cwd() }
43
+ : loadPackageTargetConfig(options.cwd || process.cwd());
44
+ const targets = loaded.config?.targets;
45
+ if (!targets || Object.keys(targets).length === 0) {
46
+ throw new Error("Docker target config must define at least one target.");
47
+ }
48
+
49
+ const selectOption = dependencies.selectOption || defaultSelectOption;
50
+ const log = dependencies.log || console.log;
51
+ const targetName = options.target || await selectOption(
52
+ "请选择 Docker 发布目标",
53
+ Object.entries(targets).map(([value, target]) => ({
54
+ value,
55
+ label: target.label || value,
56
+ })),
57
+ );
58
+ if (targetName === EXIT_SELECTION) {
59
+ log("已退出,未执行任何操作。");
60
+ return { cancelled: true };
61
+ }
62
+ const target = targets[targetName];
63
+ if (!target) throw new Error(`Unknown Docker target: ${targetName}`);
64
+
65
+ return composeRelease({
66
+ projectDir: loaded.configDir,
67
+ file: target.file,
68
+ bump: options.bump || "patch",
69
+ push: Boolean(options.push),
70
+ dryRun: Boolean(options.dryRun),
71
+ });
72
+ }
73
+
74
+ module.exports = { loadPackageTargetConfig, loadTargetConfig, runComposeCommand };
@@ -0,0 +1,148 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+
7
+ const { runComposeCommand } = require("./compose-command");
8
+ const { EXIT_SELECTION } = require("../../tools/select-option");
9
+
10
+ test("runComposeCommand resolves a configured target relative to its config file", async () => {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "compose-command-"));
12
+ const configPath = path.join(dir, "docker.targets.js");
13
+ fs.writeFileSync(
14
+ configPath,
15
+ `module.exports = { targets: { qdxy: { label: "青岛信跃", file: "docker-compose.qdxy.yaml" } } };`,
16
+ );
17
+ const calls = [];
18
+
19
+ try {
20
+ await runComposeCommand(
21
+ { config: configPath, target: "qdxy", push: true, dryRun: true },
22
+ { composeRelease: (options) => calls.push(options) },
23
+ );
24
+
25
+ assert.deepEqual(calls, [{
26
+ projectDir: dir,
27
+ file: "docker-compose.qdxy.yaml",
28
+ bump: "patch",
29
+ push: true,
30
+ dryRun: true,
31
+ }]);
32
+ } finally {
33
+ fs.rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("runComposeCommand discovers targets from package.json in the working directory", async () => {
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "compose-package-config-"));
39
+ fs.writeFileSync(
40
+ path.join(dir, "package.json"),
41
+ JSON.stringify({
42
+ infly: {
43
+ docker: {
44
+ targets: {
45
+ qdxy: { label: "青岛信跃", file: "docker-compose.qdxy.yaml" },
46
+ },
47
+ },
48
+ },
49
+ }),
50
+ );
51
+ const calls = [];
52
+
53
+ try {
54
+ await runComposeCommand(
55
+ { cwd: dir, target: "qdxy", push: true, dryRun: true },
56
+ { composeRelease: (options) => calls.push(options) },
57
+ );
58
+
59
+ assert.deepEqual(calls, [{
60
+ projectDir: dir,
61
+ file: "docker-compose.qdxy.yaml",
62
+ bump: "patch",
63
+ push: true,
64
+ dryRun: true,
65
+ }]);
66
+ } finally {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test("runComposeCommand prompts for a target when omitted", async () => {
72
+ const calls = [];
73
+
74
+ await runComposeCommand(
75
+ {
76
+ configObject: {
77
+ targets: {
78
+ yhqy: { label: "邮惠权益", file: "docker-compose.yaml" },
79
+ qdxy: { label: "青岛信跃", file: "docker-compose.qdxy.yaml" },
80
+ },
81
+ },
82
+ configDir: "C:/dist",
83
+ dryRun: true,
84
+ },
85
+ {
86
+ selectOption: async (message, choices) => {
87
+ assert.equal(message, "请选择 Docker 发布目标");
88
+ assert.deepEqual(choices.map(({ value }) => value), ["yhqy", "qdxy"]);
89
+ return "qdxy";
90
+ },
91
+ composeRelease: (options) => calls.push(options),
92
+ },
93
+ );
94
+
95
+ assert.equal(calls[0].file, "docker-compose.qdxy.yaml");
96
+ });
97
+
98
+ test("runComposeCommand exits without publishing when the user chooses exit", async () => {
99
+ const logs = [];
100
+ const result = await runComposeCommand(
101
+ {
102
+ configObject: {
103
+ targets: {
104
+ qdxy: { label: "青岛信跃", file: "docker-compose.qdxy.yaml" },
105
+ },
106
+ },
107
+ configDir: "C:/dist",
108
+ },
109
+ {
110
+ selectOption: async () => EXIT_SELECTION,
111
+ log: (message) => logs.push(message),
112
+ composeRelease: () => assert.fail("release should not run after exit"),
113
+ },
114
+ );
115
+
116
+ assert.deepEqual(result, { cancelled: true });
117
+ assert.deepEqual(logs, ["已退出,未执行任何操作。"]);
118
+ });
119
+
120
+ test("runComposeCommand preserves direct project-dir and file usage", async () => {
121
+ const calls = [];
122
+
123
+ await runComposeCommand(
124
+ { projectDir: "apps/dist", file: "docker-compose.yaml", dryRun: true },
125
+ { composeRelease: (options) => calls.push(options) },
126
+ );
127
+
128
+ assert.deepEqual(calls, [{
129
+ projectDir: "apps/dist",
130
+ file: "docker-compose.yaml",
131
+ dryRun: true,
132
+ }]);
133
+ });
134
+
135
+ test("postal dist package owns its Docker target mapping", () => {
136
+ const projectDir = path.resolve(__dirname, "../../../../apps/postal-benefits-platform-dist");
137
+ const packageJson = require(path.join(projectDir, "package.json"));
138
+
139
+ assert.equal(packageJson.scripts["docker:release"], "infly-libs docker:compose --push");
140
+ assert.deepEqual(packageJson.infly.docker.targets, {
141
+ yhqy: { label: "邮惠权益默认前端", file: "docker-compose.frontend.yaml" },
142
+ sq223: { label: "宿迁贰贰叁", file: "docker-compose.frontend.sq223.yaml" },
143
+ qdxy: { label: "青岛信跃", file: "docker-compose.frontend.qdxy.yaml" },
144
+ xzya: { label: "徐州沂埃", file: "docker-compose.frontend.xzya.yaml" },
145
+ next: { label: "邮惠权益 Next", file: "docker-compose.frontend.next.yaml" },
146
+ });
147
+ assert.equal(fs.existsSync(path.join(projectDir, "docker.targets.js")), false);
148
+ });
@@ -0,0 +1,91 @@
1
+ const { execFileSync } = require("node:child_process");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ function bumpComposeImageVersion(content, bump = "patch") {
6
+ if (bump !== "patch") {
7
+ throw new Error(`Unsupported version bump: ${bump}. Only patch is supported.`);
8
+ }
9
+
10
+ const imageRegex = /^(\s*image:\s*)([^\s#]+):(\d+)\.(\d+)\.(\d+)(\s*(?:#.*)?)$/gm;
11
+ const matches = [...content.matchAll(imageRegex)];
12
+
13
+ if (matches.length !== 1) {
14
+ throw new Error(`Compose file must contain exactly one versioned image; found ${matches.length}.`);
15
+ }
16
+
17
+ const [, prefix, image, major, minor, patchVersion, suffix] = matches[0];
18
+ const from = `${image}:${major}.${minor}.${patchVersion}`;
19
+ const to = `${image}:${major}.${minor}.${Number(patchVersion) + 1}`;
20
+ const replacement = `${prefix}${to}${suffix}`;
21
+
22
+ return {
23
+ content: content.replace(imageRegex, replacement),
24
+ from,
25
+ to,
26
+ };
27
+ }
28
+
29
+ function resolveComposePath(projectDir, file) {
30
+ if (!projectDir) throw new Error("--project-dir is required.");
31
+ if (!file) throw new Error("--file is required.");
32
+
33
+ const resolvedProjectDir = path.resolve(projectDir);
34
+ const composePath = path.resolve(resolvedProjectDir, file);
35
+ const relative = path.relative(resolvedProjectDir, composePath);
36
+
37
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
38
+ throw new Error("Compose file must be inside project-dir.");
39
+ }
40
+ if (!fs.existsSync(composePath)) {
41
+ throw new Error(`Compose file not found: ${composePath}`);
42
+ }
43
+
44
+ return { projectDir: resolvedProjectDir, composePath };
45
+ }
46
+
47
+ function defaultRunDocker(args, options) {
48
+ execFileSync("docker", args, { cwd: options.cwd, stdio: "inherit" });
49
+ }
50
+
51
+ function composeRelease(options = {}, dependencies = {}) {
52
+ const {
53
+ projectDir,
54
+ file,
55
+ bump = "patch",
56
+ push = false,
57
+ dryRun = false,
58
+ } = options;
59
+ const runDocker = dependencies.runDocker || defaultRunDocker;
60
+ const resolved = resolveComposePath(projectDir, file);
61
+ const original = fs.readFileSync(resolved.composePath, "utf8");
62
+ const updated = bumpComposeImageVersion(original, bump);
63
+ const baseArgs = ["compose", "-f", resolved.composePath];
64
+
65
+ console.log(`Version update: ${updated.from} -> ${updated.to}`);
66
+
67
+ if (dryRun) {
68
+ console.log(`[dry-run] docker ${[...baseArgs, "config"].join(" ")}`);
69
+ console.log(`[dry-run] docker ${[...baseArgs, "build"].join(" ")}`);
70
+ if (push) console.log(`[dry-run] docker ${[...baseArgs, "push"].join(" ")}`);
71
+ return { ...updated, projectDir: resolved.projectDir, composePath: resolved.composePath };
72
+ }
73
+
74
+ fs.writeFileSync(resolved.composePath, updated.content, "utf8");
75
+ try {
76
+ runDocker([...baseArgs, "config"], { cwd: resolved.projectDir });
77
+ runDocker([...baseArgs, "build"], { cwd: resolved.projectDir });
78
+ if (push) runDocker([...baseArgs, "push"], { cwd: resolved.projectDir });
79
+ } catch (error) {
80
+ fs.writeFileSync(resolved.composePath, original, "utf8");
81
+ throw error;
82
+ }
83
+
84
+ return { ...updated, projectDir: resolved.projectDir, composePath: resolved.composePath };
85
+ }
86
+
87
+ module.exports = {
88
+ bumpComposeImageVersion,
89
+ composeRelease,
90
+ resolveComposePath,
91
+ };
@@ -0,0 +1,101 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+
7
+ const {
8
+ bumpComposeImageVersion,
9
+ composeRelease,
10
+ } = require("./compose-release");
11
+
12
+ const composeSource = `services:\n frontend:\n image: registry.example.com/team/app:1.2.9\n`;
13
+
14
+ test("bumpComposeImageVersion bumps exactly one image patch version", () => {
15
+ const result = bumpComposeImageVersion(composeSource, "patch");
16
+
17
+ assert.equal(
18
+ result.content,
19
+ `services:\n frontend:\n image: registry.example.com/team/app:1.2.10\n`,
20
+ );
21
+ assert.equal(result.from, "registry.example.com/team/app:1.2.9");
22
+ assert.equal(result.to, "registry.example.com/team/app:1.2.10");
23
+ });
24
+
25
+ test("bumpComposeImageVersion rejects zero or multiple versioned images", () => {
26
+ assert.throws(() => bumpComposeImageVersion("services: {}\n", "patch"), /exactly one versioned image/);
27
+ assert.throws(
28
+ () => bumpComposeImageVersion(`${composeSource} worker:\n image: team/worker:2.0.0\n`, "patch"),
29
+ /exactly one versioned image/,
30
+ );
31
+ });
32
+
33
+ test("composeRelease dry-run does not write or invoke Docker", () => {
34
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "compose-release-"));
35
+ const composeFile = "docker-compose.yaml";
36
+ const composePath = path.join(projectDir, composeFile);
37
+ fs.writeFileSync(composePath, composeSource);
38
+ const calls = [];
39
+
40
+ try {
41
+ const result = composeRelease(
42
+ { projectDir, file: composeFile, dryRun: true, push: true },
43
+ { runDocker: (args) => calls.push(args) },
44
+ );
45
+
46
+ assert.equal(fs.readFileSync(composePath, "utf8"), composeSource);
47
+ assert.deepEqual(calls, []);
48
+ assert.equal(result.to, "registry.example.com/team/app:1.2.10");
49
+ } finally {
50
+ fs.rmSync(projectDir, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test("composeRelease builds without pushing unless push is explicit", () => {
55
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "compose-release-"));
56
+ const composeFile = "docker-compose.yaml";
57
+ const composePath = path.join(projectDir, composeFile);
58
+ fs.writeFileSync(composePath, composeSource);
59
+ const calls = [];
60
+
61
+ try {
62
+ composeRelease(
63
+ { projectDir, file: composeFile },
64
+ { runDocker: (args) => calls.push(args) },
65
+ );
66
+
67
+ assert.deepEqual(calls, [
68
+ ["compose", "-f", composePath, "config"],
69
+ ["compose", "-f", composePath, "build"],
70
+ ]);
71
+ } finally {
72
+ fs.rmSync(projectDir, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test("composeRelease restores the compose file when Docker fails", () => {
77
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "compose-release-"));
78
+ const composeFile = "docker-compose.yaml";
79
+ const composePath = path.join(projectDir, composeFile);
80
+ fs.writeFileSync(composePath, composeSource);
81
+
82
+ try {
83
+ assert.throws(
84
+ () => composeRelease(
85
+ { projectDir, file: composeFile, push: true },
86
+ { runDocker: () => { throw new Error("docker failed"); } },
87
+ ),
88
+ /docker failed/,
89
+ );
90
+ assert.equal(fs.readFileSync(composePath, "utf8"), composeSource);
91
+ } finally {
92
+ fs.rmSync(projectDir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("composeRelease rejects files outside projectDir", () => {
97
+ assert.throws(
98
+ () => composeRelease({ projectDir: ".", file: "../docker-compose.yaml", dryRun: true }),
99
+ /inside project-dir/,
100
+ );
101
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Docker 镜像构建与推送(infly-libs 公共模块)
3
+ *
4
+ * 支持两种运行模式(自动检测):
5
+ *
6
+ * 1. Monorepo 模式 — 检测到 git submodule (--show-superproject-working-tree 有值)
7
+ * 构建上下文 = superproject 根,Dockerfile 使用 Dockerfile.monorepo
8
+ * @infly/* 包从 workspace 源码解析
9
+ *
10
+ * 2. 独立模式 — 非 submodule 且 cwd = git 根
11
+ * 构建上下文 = 当前目录,Dockerfile 使用 Dockerfile
12
+ * @infly/* 包从 npm registry 安装
13
+ *
14
+ * 用法:
15
+ * infly-libs build:docker [--local] [--env prod|dev] [--dry-run] [--cicd-url URL]
16
+ */
17
+
18
+ const { execSync } = require("child_process");
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+
22
+ // ---------- 默认配置 ----------
23
+ const DEFAULT_REGISTRY = "ccr.ccs.tencentyun.com";
24
+ const DEFAULT_NAMESPACE = "points";
25
+
26
+ function formatCicdLink(value) {
27
+ if (value === undefined) {
28
+ return "";
29
+ }
30
+ if (typeof value !== "string" || !value.trim()) {
31
+ throw new Error("--cicd-url 缺少参数值");
32
+ }
33
+
34
+ const rawUrl = value.trim();
35
+ if (/[\u0000-\u001f\u007f]/.test(rawUrl)) {
36
+ throw new Error("--cicd-url 不能包含控制字符");
37
+ }
38
+
39
+ let parsedUrl;
40
+ try {
41
+ parsedUrl = new URL(rawUrl);
42
+ } catch {
43
+ throw new Error("--cicd-url 必须是合法的 HTTP/HTTPS URL");
44
+ }
45
+
46
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
47
+ throw new Error("--cicd-url 只支持 HTTP/HTTPS URL");
48
+ }
49
+ if (parsedUrl.username || parsedUrl.password) {
50
+ throw new Error("--cicd-url 不能包含用户名或密码凭据");
51
+ }
52
+
53
+ return `发布系统:\x1b[34m${decodeURI(parsedUrl.href)}\x1b[0m`;
54
+ }
55
+
56
+ function detectMode() {
57
+ const cwd = path.resolve(process.cwd());
58
+
59
+ // git submodule → monorepo 模式,contextRoot = superproject 根
60
+ try {
61
+ const superproject = execSync("git rev-parse --show-superproject-working-tree", {
62
+ encoding: "utf-8", stdio: "pipe",
63
+ }).trim();
64
+ if (superproject) {
65
+ return { isStandalone: false, contextRoot: superproject };
66
+ }
67
+ } catch {}
68
+
69
+ // 非 submodule:cwd = git 根 → 独立模式,否则 monorepo 模式
70
+ const gitRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
71
+ const isStandalone = path.resolve(cwd) === path.resolve(gitRoot);
72
+
73
+ return {
74
+ isStandalone,
75
+ contextRoot: isStandalone ? cwd : gitRoot,
76
+ };
77
+ }
78
+
79
+ function run(cmd, { silent = false, dryRun = false, cwd } = {}) {
80
+ if (dryRun) {
81
+ console.log(` [DRY RUN] ${cmd}`);
82
+ return "";
83
+ }
84
+ return execSync(cmd, {
85
+ stdio: silent ? "pipe" : "inherit",
86
+ cwd: cwd || process.cwd(),
87
+ encoding: "utf-8",
88
+ });
89
+ }
90
+
91
+ function startDockerDesktop() {
92
+ console.log("\x1b[1;33mDocker Desktop 未运行,正在启动...\x1b[0m");
93
+ try {
94
+ try {
95
+ execSync(
96
+ `powershell.exe -Command "Start-Process 'C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe'"`,
97
+ { stdio: "ignore" }
98
+ );
99
+ } catch {
100
+ execSync(
101
+ `cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe"`,
102
+ { stdio: "ignore" }
103
+ );
104
+ }
105
+
106
+ process.stdout.write("等待 Docker 引擎就绪");
107
+ for (let i = 0; i < 30; i++) {
108
+ try {
109
+ execSync("docker info", { stdio: "ignore" });
110
+ console.log("\n\x1b[0;32m✓ Docker 已就绪\x1b[0m");
111
+ return true;
112
+ } catch {}
113
+ process.stdout.write(".");
114
+ execSync("sleep 2", { stdio: "ignore" });
115
+ }
116
+ console.log("");
117
+ return false;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ async function dockerBuildPush(options = {}) {
124
+ const {
125
+ env = "prod",
126
+ registry = DEFAULT_REGISTRY,
127
+ namespace = DEFAULT_NAMESPACE,
128
+ local = false,
129
+ dryRun = false,
130
+ cicdUrl,
131
+ } = options;
132
+ const cicdLink = formatCicdLink(cicdUrl);
133
+
134
+ // 1. 检测运行模式并定位关键路径
135
+ const cwd = path.resolve(process.cwd());
136
+ const { isStandalone, contextRoot } = detectMode();
137
+
138
+ const appPkgPath = path.join(cwd, "package.json");
139
+ if (!fs.existsSync(appPkgPath)) {
140
+ console.error("错误: 当前目录下缺少 package.json,请在 app 目录内执行");
141
+ process.exit(1);
142
+ }
143
+
144
+ let buildContext, dockerfileArg, modeLabel;
145
+ if (isStandalone) {
146
+ if (!fs.existsSync(path.join(cwd, "Dockerfile"))) {
147
+ console.error("错误: 当前目录下缺少 Dockerfile");
148
+ process.exit(1);
149
+ }
150
+ buildContext = cwd;
151
+ dockerfileArg = "Dockerfile";
152
+ modeLabel = "独立模式";
153
+ } else {
154
+ const appDir = path.relative(contextRoot, cwd).replace(/\\/g, "/");
155
+ if (!fs.existsSync(path.join(contextRoot, appDir, "Dockerfile.monorepo"))) {
156
+ console.error("错误: 缺少 Dockerfile.monorepo");
157
+ process.exit(1);
158
+ }
159
+ buildContext = contextRoot;
160
+ dockerfileArg = `${appDir}/Dockerfile.monorepo`;
161
+ modeLabel = `monorepo 模式 (${appDir})`;
162
+ }
163
+
164
+ // 2. 从 package.json 推导镜像名
165
+ const pkg = JSON.parse(fs.readFileSync(appPkgPath, "utf-8"));
166
+ const imageName = pkg.name.replace(/^@[^/]+\//, "");
167
+
168
+ // 3. 获取版本号(monorepo 模式取 superproject 的 commit)
169
+ const commitHash = execSync("git rev-parse --short HEAD", {
170
+ cwd: contextRoot, encoding: "utf-8",
171
+ }).trim();
172
+ const fullCommit = execSync("git rev-parse HEAD", {
173
+ cwd: contextRoot, encoding: "utf-8",
174
+ }).trim();
175
+
176
+ // 4. 镜像标签
177
+ const imageBase = `${registry}/${namespace}/${imageName}`;
178
+ const tags = [`${imageBase}:${commitHash}`, `${imageBase}:${env}`];
179
+ if (env === "prod") tags.push(`${imageBase}:latest`);
180
+
181
+ // 5. Docker Desktop 自动启动(仅本地模式)
182
+ if (local) {
183
+ console.log("\x1b[0;32m>>> 检查 Docker 运行状态\x1b[0m");
184
+ try {
185
+ execSync("docker info", { stdio: "ignore" });
186
+ console.log("\x1b[0;32m✓ Docker 已运行\x1b[0m");
187
+ } catch {
188
+ const ok = startDockerDesktop();
189
+ if (!ok) {
190
+ console.error("\x1b[0;31m错误: Docker Desktop 启动超时,请手动启动后重试\x1b[0m");
191
+ process.exit(1);
192
+ }
193
+ }
194
+ }
195
+
196
+ // 6. 打印信息
197
+ console.log("\x1b[0;34m==========================================");
198
+ console.log("构建并推送 Docker 镜像");
199
+ console.log("==========================================\x1b[0m");
200
+ console.log(`运行模式: ${modeLabel}`);
201
+ console.log(`镜像名称: ${imageName}`);
202
+ console.log(`环境: ${env}`);
203
+ console.log(`版本号: ${commitHash}`);
204
+ console.log("标签:");
205
+ tags.forEach((t) => console.log(` ${t}`));
206
+ console.log("");
207
+
208
+ // 7. 构建
209
+ console.log("\x1b[0;32m>>> 开始构建 Docker 镜像\x1b[0m");
210
+ const tagArgs = tags.map((t) => `-t ${t}`).join(" ");
211
+ const buildCmd = `docker build -f ${dockerfileArg} ${tagArgs} --build-arg GIT_COMMIT=${fullCommit} .`;
212
+ run(buildCmd, { cwd: buildContext, dryRun });
213
+ console.log("\x1b[0;32m✓ 镜像构建完成\x1b[0m");
214
+
215
+ // 8. 推送
216
+ console.log("");
217
+ console.log(`\x1b[0;32m>>> 推送镜像到 ${registry}\x1b[0m`);
218
+ for (const tag of tags) {
219
+ run(`docker push ${tag}`, { dryRun });
220
+ }
221
+
222
+ // 9. 结果摘要
223
+ console.log("");
224
+ console.log("\x1b[0;34m==========================================");
225
+ console.log("✅ 完成");
226
+ console.log("==========================================\x1b[0m");
227
+ console.log("可用标签:");
228
+ console.log(` ${tags[0]} (commit 精确版本)`);
229
+ console.log(` ${tags[1]} (${env} 环境)`);
230
+ if (env === "prod") console.log(` ${tags[2]} (最新)`);
231
+ if (cicdLink) {
232
+ console.log("");
233
+ console.log(cicdLink);
234
+ }
235
+ console.log("");
236
+
237
+ return { mode: isStandalone ? "standalone" : "monorepo", buildContext, imageName, commitHash, tags };
238
+ }
239
+
240
+ module.exports = { dockerBuildPush, detectMode, formatCicdLink };