@infly/libs 2.0.53 → 2.1.0

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,74 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ bundle_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
4
+ source "$bundle_dir/config.sh"
5
+ [[ $# -gt 0 ]] || { echo '需要组件=正式镜像参数' >&2; exit 2; }
6
+ cd "$project_dir"
7
+ for file in "$compose_file" "$secret_env_file" "$release_env_file" "$bundle_dir/docker-compose.release.yml"; do
8
+ [[ -f "$file" ]] || { echo "部署缺少文件: $file" >&2; exit 1; }
9
+ done
10
+ # 先持有目录锁再读基线,避免前后端两个任务同时发布时使用过期基线。
11
+ mkdir "$lock_dir" 2>/dev/null || { echo "已有发布任务持有锁: $project_dir/$lock_dir" >&2; exit 1; }
12
+ next_env=""
13
+ cleanup() { [[ -z "$next_env" ]] || rm -f -- "$next_env"; rmdir "$lock_dir" 2>/dev/null || true; }
14
+ trap cleanup EXIT
15
+ current_images=()
16
+ next_images=()
17
+ selected_indexes=()
18
+ selected_services=()
19
+ for i in "${!component_names[@]}"; do
20
+ key=${image_keys[$i]}
21
+ [[ "$(grep -c "^${key}=" "$release_env_file" || true)" == 1 ]] || { echo "基线必须包含唯一 $key" >&2; exit 1; }
22
+ image=$(grep "^${key}=" "$release_env_file"); image=${image#*=}
23
+ suffix=${image#"${repositories[$i]}"}
24
+ [[ "$image" != "$suffix" && ( "$suffix" =~ ^:[0-9a-f]{12}$ || "$suffix" =~ ^@sha256:[0-9a-f]{64}$ ) ]] || {
25
+ echo "当前基线不是配置仓库的不可变镜像: $key" >&2; exit 1;
26
+ }
27
+ current_images+=("$image"); next_images+=("$image")
28
+ done
29
+ for pair in "$@"; do
30
+ [[ "$pair" == *=* ]] || { echo '参数必须是组件=镜像' >&2; exit 2; }
31
+ name=${pair%%=*}; image=${pair#*=}; found=false
32
+ for i in "${!component_names[@]}"; do
33
+ [[ "$name" == "${component_names[$i]}" ]] || continue
34
+ found=true
35
+ for selected in "${selected_indexes[@]}"; do [[ "$selected" != "$i" ]] || { echo '组件重复' >&2; exit 2; }; done
36
+ suffix=${image#"${repositories[$i]}:"}
37
+ [[ "$suffix" != "$image" && "$suffix" =~ ^[0-9a-f]{12}$ ]] || { echo "只接受本组件正式 commit 镜像: $name" >&2; exit 1; }
38
+ next_images[$i]=$image
39
+ selected_indexes+=("$i")
40
+ read -r -a group <<< "${service_groups[$i]}"
41
+ selected_services+=("${group[@]}")
42
+ done
43
+ [[ "$found" == true ]] || { echo "未配置的组件: $name" >&2; exit 2; }
44
+ done
45
+ umask 077
46
+ mkdir -p .release-history
47
+ previous_env=$(mktemp ".release-history/deploy-${target}-$(date -u +%Y%m%dT%H%M%SZ)-XXXXXX.env")
48
+ cp -- "$release_env_file" "$previous_env"
49
+ chmod 600 "$previous_env"
50
+ next_env=$(mktemp "${release_env_file}.next.XXXXXX")
51
+ # 发布环境文件仅保存配置声明的镜像,敏感值仍由独立环境文件提供。
52
+ for i in "${!image_keys[@]}"; do printf '%s=%s\n' "${image_keys[$i]}" "${next_images[$i]}"; done > "$next_env"
53
+ compose=(docker compose --env-file "$secret_env_file" --env-file "$next_env" -f "$compose_file" -f "$bundle_dir/docker-compose.release.yml")
54
+ "${compose[@]}" config --quiet
55
+ "${compose[@]}" pull "${selected_services[@]}"
56
+ for i in "${selected_indexes[@]}"; do
57
+ image=${next_images[$i]}; short=${image##*:}
58
+ full=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")
59
+ [[ "$full" =~ ^[0-9a-f]{40}$ && "${full:0:12}" == "$short" ]] || { echo "镜像 revision 校验失败: $image" >&2; exit 1; }
60
+ done
61
+ mv -f -- "$next_env" "$release_env_file"
62
+ next_env=""
63
+ compose=(docker compose --env-file "$secret_env_file" --env-file "$release_env_file" -f "$compose_file" -f "$bundle_dir/docker-compose.release.yml")
64
+ rollback() {
65
+ echo "发布失败,恢复基线: $previous_env" >&2
66
+ cp -- "$previous_env" "$release_env_file"
67
+ chmod 600 "$release_env_file"
68
+ if ! "${compose[@]}" up -d --no-deps "${selected_services[@]}" || ! bash "$bundle_dir/health-check.sh"; then
69
+ echo "自动回滚未通过验证,需人工恢复: $project_dir/$previous_env" >&2
70
+ fi
71
+ }
72
+ if ! "${compose[@]}" up -d --no-deps "${selected_services[@]}"; then rollback; exit 1; fi
73
+ if ! bash "$bundle_dir/health-check.sh"; then rollback; exit 1; fi
74
+ echo "发布成功: $target; 上一版本记录: $project_dir/$previous_env"
@@ -0,0 +1,87 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const quote = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
5
+ const array = (name, values) => `${name}=(${values.map(quote).join(" ")})`;
6
+ function required(value, name, pattern) {
7
+ if (typeof value !== "string" || !value || (pattern && !pattern.test(value))) throw new Error(`部署配置无效: ${name}`);
8
+ return value;
9
+ }
10
+
11
+ function renderDeploymentConfig(config, target) {
12
+ const profile = config?.targets?.[target];
13
+ const deploy = profile?.deployment;
14
+ if (!deploy || deploy.ready !== true) throw new Error(`平台 ${target} 尚未完成人工服务器核验,禁止生产发布`);
15
+ const components = Object.entries(profile.components || {});
16
+ if (!components.length) throw new Error("部署必须配置 components");
17
+ const repositories = [], keys = [], serviceGroups = [];
18
+ for (const [name, component] of components) {
19
+ required(name, "component", /^[a-z][a-z0-9_-]*$/);
20
+ const repository = required(component.repository, `${name}.repository`, /^[a-z0-9][a-z0-9.:/_-]+$/);
21
+ if (repository.split("/").at(-1).includes(":")) throw new Error("镜像仓库不得带标签");
22
+ repositories.push(repository);
23
+ keys.push(required(component.imageEnv, `${name}.imageEnv`, /^[A-Z][A-Z0-9_]*$/));
24
+ if (!Array.isArray(component.services) || !component.services.length) throw new Error(`${name} 缺少 services`);
25
+ serviceGroups.push(component.services.map((service) => required(service, "service", /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/)).join(" "));
26
+ }
27
+ if (new Set(keys).size !== keys.length) throw new Error("imageEnv 不得重复");
28
+ const allServices = serviceGroups.flatMap((group) => group.split(" "));
29
+ if (new Set(allServices).size !== allServices.length) throw new Error("组件不能重复拥有同一个服务");
30
+ const lines = ["# 由 @infly/libs 根据项目配置生成;只含非敏感参数。",
31
+ `target=${quote(required(target, "target", /^[a-zA-Z0-9_-]+$/))}`,
32
+ `registry=${quote(required(config.registry, "registry", /^[a-zA-Z0-9.:-]+$/))}`,
33
+ `server=${quote(required(deploy.server, "server", /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/))}`,
34
+ `ssh_user=${quote(required(deploy.sshUser, "sshUser", /^[a-z_][a-z0-9_-]*$/))}`,
35
+ `project_dir=${quote(required(deploy.directory, "directory", /^\/(?!$)[a-zA-Z0-9_./-]+$/))}`,
36
+ ...[["compose_file", "composeFile"], ["secret_env_file", "secretEnvFile"], ["release_env_file", "releaseEnvFile"]].map(([shell, json]) => {
37
+ const value = required(deploy[json], json, /^[a-zA-Z0-9_./-]+$/);
38
+ if (value.startsWith("/") || value.split("/").includes("..")) throw new Error(`${json} 必须位于部署目录内`);
39
+ return `${shell}=${quote(value)}`;
40
+ }),
41
+ `lock_dir=${quote(required(deploy.lockName || ".infly-release.lock", "lockName", /^\.[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/))}`,
42
+ array("component_names", components.map(([name]) => name)), array("repositories", repositories),
43
+ array("image_keys", keys), array("service_groups", serviceGroups), array("health_services", allServices),
44
+ ];
45
+ const health = deploy.health || {};
46
+ if (!Array.isArray(health.urls) || !health.urls.length) throw new Error("必须配置 health.urls");
47
+ for (const url of health.urls) if (!/^https?:\/\//.test(url)) throw new Error("健康检查 URL 必须是 HTTP(S)");
48
+ lines.push(array("health_urls", health.urls));
49
+ for (const [shell, value] of [["health_attempts", health.attempts ?? 12], ["health_interval", health.intervalSeconds ?? 5], ["health_timeout", health.timeoutSeconds ?? 20]]) {
50
+ if (!Number.isInteger(value) || value < 1 || value > 300) throw new Error(`${shell} 必须为 1 至 300 的整数`);
51
+ lines.push(`${shell}=${value}`);
52
+ }
53
+ lines.push("check_application() {");
54
+ for (const check of health.exec || []) {
55
+ if (!allServices.includes(check.service) || !Array.isArray(check.command) || !check.command.length
56
+ || !check.command.every((arg) => typeof arg === "string" && !arg.includes("\0"))) throw new Error("health.exec 必须绑定已配置服务和命令数组");
57
+ lines.push(` "\${compose[@]}" exec -T ${quote(check.service)} ${check.command.map(quote).join(" ")} || return $?`);
58
+ }
59
+ lines.push(" return 0", "}");
60
+ return lines.join("\n") + "\n";
61
+ }
62
+
63
+ function createDeploymentBundle(options = {}) {
64
+ const projectDir = path.resolve(options.projectDir || process.cwd());
65
+ const config = options.configObject || (options.config
66
+ ? JSON.parse(fs.readFileSync(path.resolve(projectDir, options.config), "utf8"))
67
+ : JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")).infly?.docker);
68
+ const rendered = renderDeploymentConfig(config, options.target);
69
+ if (options.dryRun) return { target: options.target, config: rendered };
70
+ const output = path.resolve(projectDir, options.output || ".infly-deploy");
71
+ const relative = path.relative(projectDir, output);
72
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("输出必须是项目内独立子目录");
73
+ // 每次生成独立发布包,拒绝覆盖用户文件或上次中断的部署包。
74
+ if (fs.existsSync(output)) throw new Error(`输出目录已存在,请先人工处理: ${output}`);
75
+ const overlay = path.resolve(projectDir, config.targets[options.target].deployment.releaseComposeFile || "");
76
+ const overlayRelative = path.relative(projectDir, overlay);
77
+ if (overlayRelative.startsWith("..") || path.isAbsolute(overlayRelative) || !fs.statSync(overlay).isFile()) throw new Error("releaseComposeFile 必须是项目内文件");
78
+ fs.mkdirSync(output, { recursive: true });
79
+ for (const name of ["release.sh", "health-check.sh", "deploy.sh", "promote-image.sh"]) {
80
+ fs.writeFileSync(path.join(output, name), fs.readFileSync(path.join(__dirname, "deployment", name), "utf8").replaceAll("\r\n", "\n"));
81
+ }
82
+ fs.writeFileSync(path.join(output, "config.sh"), rendered, { mode: 0o600 });
83
+ fs.copyFileSync(overlay, path.join(output, "docker-compose.release.yml"));
84
+ return { target: options.target, output };
85
+ }
86
+
87
+ module.exports = { renderDeploymentConfig, createDeploymentBundle };
@@ -0,0 +1,180 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { execFileSync } = require("node:child_process");
5
+ const { EXIT_SELECTION, selectOption, selectOptions } = require("../../cli/select-option");
6
+
7
+ function parseReleaseArgs(argv) {
8
+ const options = { push: false };
9
+ const keys = { "--target": "target", "--platform": "target", "--components": "components",
10
+ "--project-dir": "projectDir", "--backend-dir": "backendDir", "--config": "config", "--output": "output" };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const [flag, ...suffix] = argv[i].split("=");
13
+ if (flag === "--") continue;
14
+ if (["--push", "--no-push", "--dry-run"].includes(flag) && suffix.length === 0) {
15
+ if (flag === "--dry-run") options.dryRun = true;
16
+ else options.push = flag === "--push";
17
+ continue;
18
+ }
19
+ if (!keys[flag]) throw new Error(`不支持的参数: ${argv[i]}`);
20
+ const value = suffix.length ? suffix.join("=") : argv[++i];
21
+ if (!value || value.startsWith("--")) throw new Error(`${flag} 缺少参数值`);
22
+ options[keys[flag]] = flag === "--components" ? value.split(",") : value;
23
+ }
24
+ return options;
25
+ }
26
+
27
+ function inside(root, relative) {
28
+ if (typeof relative !== "string" || !relative || path.isAbsolute(relative)) throw new Error(`必须配置相对路径: ${relative}`);
29
+ const result = path.resolve(root, relative);
30
+ const offset = path.relative(root, result);
31
+ if (offset === ".." || offset.startsWith(`..${path.sep}`)) throw new Error(`路径越界: ${relative}`);
32
+ return result;
33
+ }
34
+
35
+ function validateComponent(component, name) {
36
+ if (!component || !/^[a-z0-9][a-z0-9._:/-]*$/.test(component.repository || "") || component.repository.includes("@")) {
37
+ throw new Error(`${name} 必须配置有效的镜像 repository(不含标签)`);
38
+ }
39
+ if (component.repository.split("/").at(-1).includes(":")) throw new Error(`${name} repository 不得包含标签`);
40
+ if (!component.expectedRef && !component.source?.branch) throw new Error(`${name} 必须配置 expectedRef 或 source.branch`);
41
+ if (component.source && (!component.source.directory || !component.source.branch || component.source.branch.startsWith("-"))) {
42
+ throw new Error(`${name} source 必须配置 directory 和 branch`);
43
+ }
44
+ inside("/config", component.dockerfile || "Dockerfile");
45
+ inside("/config", component.context || ".");
46
+ if (component.requiredFiles) {
47
+ if (!Array.isArray(component.requiredFiles)) throw new Error(`${name} requiredFiles 必须是数组`);
48
+ for (const file of component.requiredFiles) inside("/config", file);
49
+ }
50
+ if (component.files) {
51
+ if (!Array.isArray(component.files) || component.files.length === 0) throw new Error(`${name} files 必须是非空数组`);
52
+ for (const file of component.files) { inside("/config", file.from); inside("/config", file.to); }
53
+ }
54
+ if (component.check) {
55
+ const { entrypoint, args, addHosts = [] } = component.check;
56
+ if (typeof entrypoint !== "string" || !entrypoint || !Array.isArray(args)
57
+ || !args.every((arg) => typeof arg === "string") || !Array.isArray(addHosts)
58
+ || !addHosts.every((host) => typeof host === "string" && host.includes(":"))) {
59
+ throw new Error(`${name} check 必须配置 entrypoint、args 数组及可选 addHosts 数组`);
60
+ }
61
+ }
62
+ }
63
+
64
+ function execute(command, args, options = {}) {
65
+ return execFileSync(command, args, { stdio: "inherit", ...options });
66
+ }
67
+
68
+ async function runTargetRelease(options = {}, dependencies = {}) {
69
+ const projectDir = path.resolve(options.projectDir || process.cwd());
70
+ const config = options.configObject || (options.config
71
+ ? JSON.parse(fs.readFileSync(path.resolve(projectDir, options.config), "utf8"))
72
+ : JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")).infly?.docker);
73
+ const targets = config?.targets;
74
+ if (!targets || !Object.keys(targets).length) throw new Error("请配置 infly.docker.targets");
75
+ const log = dependencies.log || console.log;
76
+ const target = options.target || await (dependencies.selectOption || selectOption)("请选择构建平台",
77
+ Object.entries(targets).filter(([, value]) => value.components).map(([value, item]) => ({ value, label: item.label || value })));
78
+ if (target === EXIT_SELECTION) return { cancelled: true };
79
+ const profile = targets[target];
80
+ if (!profile?.components) throw new Error(`平台 ${target} 未配置 components`);
81
+ const components = options.components || await (dependencies.selectOptions || selectOptions)("请选择构建范围",
82
+ Object.entries(profile.components).map(([value, item]) => ({ value, label: item.label || value })));
83
+ if (components === EXIT_SELECTION) return { cancelled: true };
84
+ if (!Array.isArray(components) || !components.length || new Set(components).size !== components.length) {
85
+ throw new Error("请至少选择一个构建范围,且不能重复");
86
+ }
87
+ const plan = components.map((name) => {
88
+ if (!Object.hasOwn(profile.components, name)) throw new Error(`不支持的构建范围: ${name}`);
89
+ const component = profile.components[name];
90
+ validateComponent(component, name);
91
+ return { name, ...component, source: component.source && { ...component.source,
92
+ directory: path.resolve(projectDir, name === "backend" && options.backendDir ? options.backendDir : component.source.directory) } };
93
+ });
94
+ // dry-run 只解析配置,不联网、不调用 Git/Docker、不创建临时目录。
95
+ if (options.dryRun) { log(JSON.stringify({ target, push: Boolean(options.push), plan }, null, 2)); return { target, plan }; }
96
+ const io = dependencies.fs || fs;
97
+ const run = dependencies.run || execute;
98
+ const capture = (command, args) => String(run(command, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) || "").trim();
99
+ // 全部组件先预检,缺少独立源码仓时不先推送部分镜像。
100
+ const missing = plan.filter((item) => item.source && !io.existsSync(path.join(item.source.directory, ".git")));
101
+ if (missing.length) {
102
+ for (const item of missing) {
103
+ const source = item.source;
104
+ log(`未找到 ${item.name} 源码仓: ${source.directory}`);
105
+ log(`请在 ${projectDir} 准备源码仓后重新运行:`);
106
+ if (source.url) log(`git clone "${source.url}" "${source.directory}"`);
107
+ log(`git -C "${source.directory}" fetch origin "${source.branch}"`);
108
+ const overrides = `${options.backendDir ? ` --backend-dir "${options.backendDir}"` : ""}${options.config ? ` --config "${options.config}"` : ""}`;
109
+ log(`infly-libs docker:release --project-dir "${projectDir}" --target ${target} --components ${components.join(",")}${overrides} ${options.push ? "--push" : "--no-push"}`);
110
+ }
111
+ return { target, missingSources: missing.map((item) => item.name) };
112
+ }
113
+ run("docker", ["info"], { cwd: projectDir });
114
+ run("docker", ["buildx", "version"], { cwd: projectDir });
115
+ const results = [];
116
+ for (const item of plan) {
117
+ let tempRoot;
118
+ let worktree;
119
+ let sourceDir = projectDir;
120
+ try {
121
+ if (item.source) {
122
+ run("git", ["-C", item.source.directory, "fetch", "origin"], { cwd: projectDir });
123
+ tempRoot = io.mkdtempSync(path.join(os.tmpdir(), "infly-image-"));
124
+ const candidate = path.join(tempRoot, "source");
125
+ run("git", ["-C", item.source.directory, "worktree", "add", "--detach", candidate, `origin/${item.source.branch}`], { cwd: projectDir });
126
+ worktree = candidate;
127
+ sourceDir = worktree;
128
+ }
129
+ const git = (...args) => capture("git", ["-C", sourceDir, ...args]);
130
+ if (git("status", "--porcelain", "--untracked-files=all")) throw new Error(`${item.name} 仓库存在未提交改动`);
131
+ const commit = git("rev-parse", "HEAD").toLowerCase();
132
+ const expectedRef = item.source ? `origin/${item.source.branch}` : item.expectedRef;
133
+ if (!/^[a-f0-9]{40}$/.test(commit) || commit !== git("rev-parse", `${expectedRef}^{commit}`).toLowerCase()) {
134
+ throw new Error(`${item.name} HEAD 与 ${expectedRef} 不一致,请先核对并 fetch 对应仓库`);
135
+ }
136
+ let context = inside(sourceDir, item.context || ".");
137
+ let dockerfile = inside(sourceDir, item.dockerfile || "Dockerfile");
138
+ for (const file of item.requiredFiles || []) {
139
+ if (!io.existsSync(inside(sourceDir, file))) throw new Error(`缺少必需构建产物: ${file}`);
140
+ }
141
+ if (item.files) {
142
+ tempRoot ||= io.mkdtempSync(path.join(os.tmpdir(), "infly-image-"));
143
+ context = path.join(tempRoot, "context");
144
+ io.mkdirSync(context, { recursive: true });
145
+ for (const file of item.files) {
146
+ const from = inside(sourceDir, file.from);
147
+ const to = inside(context, file.to);
148
+ // 仅复制配置声明的产物,避免把整个 monorepo 发送给 Docker。
149
+ io.mkdirSync(path.dirname(to), { recursive: true });
150
+ io.cpSync(from, to, { recursive: true, dereference: false });
151
+ }
152
+ dockerfile = inside(context, item.dockerfile || "Dockerfile");
153
+ }
154
+ if (!io.existsSync(dockerfile)) throw new Error(`缺少 Dockerfile: ${dockerfile}`);
155
+ const short = commit.slice(0, 12);
156
+ const image = `${item.repository}:qc-${short}`;
157
+ run("docker", ["buildx", "build", "--load", "--file", dockerfile,
158
+ "--label", `org.opencontainers.image.revision=${commit}`, "--label", `org.opencontainers.image.version=${short}`,
159
+ "--label", `com.infly.platform=${target}`, "--tag", image, context], { cwd: projectDir });
160
+ if (capture("docker", ["image", "inspect", "--format", '{{ index .Config.Labels "org.opencontainers.image.revision" }}', image]) !== commit) {
161
+ throw new Error(`${item.name} 镜像 revision 校验失败`);
162
+ }
163
+ if (item.check) {
164
+ const hosts = (item.check.addHosts || []).flatMap((host) => ["--add-host", host]);
165
+ run("docker", ["run", "--rm", ...hosts, "--entrypoint", item.check.entrypoint, image, ...item.check.args], { cwd: projectDir });
166
+ }
167
+ if (options.push) run("docker", ["push", image], { cwd: projectDir });
168
+ log(`${item.name}_commit=${commit}`);
169
+ log(`${item.name}=${image}`);
170
+ results.push({ component: item.name, commit, image, pushed: Boolean(options.push) });
171
+ } finally {
172
+ // 只移除本次创建的隔离 worktree;清理失败保留目录供人工恢复,绝不清空开发工作区。
173
+ if (worktree) run("git", ["-C", item.source.directory, "worktree", "remove", "--force", worktree], { cwd: projectDir });
174
+ if (tempRoot) io.rmSync(tempRoot, { recursive: true, force: true });
175
+ }
176
+ }
177
+ return { target, results };
178
+ }
179
+
180
+ module.exports = { parseReleaseArgs, runTargetRelease };
@@ -3,53 +3,53 @@ const path = require("path");
3
3
 
4
4
  // 兼容旧 tools/file-process.js 的 __dirname 路径语义。
5
5
  const legacyToolsDir = path.resolve(__dirname, "../tools");
6
-
7
- /**
8
- * 覆盖写入构建脚本
9
- * @param {String} filePath - 覆盖文件路径
10
- * @param {Array | Object} scriptsList - 写入脚本配置
11
- * @param {String} 更新字段值
12
- */
13
- function scriptWrite(filePath, scriptsList, updateKey = "scripts") {
14
- const pkg = JSON.parse(fs.readFileSync(filePath));
15
- pkg[updateKey] = pkg[updateKey] || {};
16
- if (Array.isArray(scriptsList)) {
17
- scriptsList.forEach(item => {
18
- if (pkg[updateKey][item.key]) {
19
- pkg[updateKey][`origin:${item.key}`] = pkg[updateKey][item.key];
20
- }
21
- pkg[updateKey][item.key] = item.value;
22
- });
23
- } else if (typeof scriptsList === "object") {
24
- pkg[updateKey] = scriptsList;
25
- }
26
-
27
- fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2));
28
- }
29
-
30
- /**
31
- * 获取文件绝对路径
32
- * @param {String} filePath - 文件路径
33
- * @returns
34
- */
35
- function targetProjectFileResolve(filePath) {
36
- // 初始脚本自动执行路径会自动读取node_modules
37
- const projectRoot = process.cwd();
38
- const absoluteProjectRoot = projectRoot.replace(/\\node_modules.*$/g, "");
39
- return path.resolve(absoluteProjectRoot, filePath);
40
- }
41
-
42
- /**
43
- * 获取文件绝对路径
44
- * @param {String} filePath - 文件路径
45
- * @returns
46
- */
6
+
7
+ /**
8
+ * 覆盖写入构建脚本
9
+ * @param {String} filePath - 覆盖文件路径
10
+ * @param {Array | Object} scriptsList - 写入脚本配置
11
+ * @param {String} 更新字段值
12
+ */
13
+ function scriptWrite(filePath, scriptsList, updateKey = "scripts") {
14
+ const pkg = JSON.parse(fs.readFileSync(filePath));
15
+ pkg[updateKey] = pkg[updateKey] || {};
16
+ if (Array.isArray(scriptsList)) {
17
+ scriptsList.forEach(item => {
18
+ if (pkg[updateKey][item.key]) {
19
+ pkg[updateKey][`origin:${item.key}`] = pkg[updateKey][item.key];
20
+ }
21
+ pkg[updateKey][item.key] = item.value;
22
+ });
23
+ } else if (typeof scriptsList === "object") {
24
+ pkg[updateKey] = scriptsList;
25
+ }
26
+
27
+ fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2));
28
+ }
29
+
30
+ /**
31
+ * 获取文件绝对路径
32
+ * @param {String} filePath - 文件路径
33
+ * @returns
34
+ */
35
+ function targetProjectFileResolve(filePath) {
36
+ // 初始脚本自动执行路径会自动读取node_modules
37
+ const projectRoot = process.cwd();
38
+ const absoluteProjectRoot = projectRoot.replace(/\\node_modules.*$/g, "");
39
+ return path.resolve(absoluteProjectRoot, filePath);
40
+ }
41
+
42
+ /**
43
+ * 获取文件绝对路径
44
+ * @param {String} filePath - 文件路径
45
+ * @returns
46
+ */
47
47
  function currentProjectFileResolve(filePath) {
48
48
  return path.join(legacyToolsDir, filePath);
49
49
  }
50
-
51
- module.exports = {
52
- scriptWrite,
53
- targetProjectFileResolve,
54
- currentProjectFileResolve
55
- };
50
+
51
+ module.exports = {
52
+ scriptWrite,
53
+ targetProjectFileResolve,
54
+ currentProjectFileResolve
55
+ };
package/cli/progress.js CHANGED
@@ -14,6 +14,7 @@ function createProgressReporter(options = {}) {
14
14
  status: "检查状态",
15
15
  deinit: "停用模块",
16
16
  init: "模块加载",
17
+ remote: "更新远端",
17
18
  };
18
19
  const labels = { ...defaultLabels, ...options.labels };
19
20
  return (event) => {
@@ -44,6 +45,7 @@ function formatSwitchSummary(name, totalMs, dependencyResult, timings) {
44
45
  status: "状态",
45
46
  deinit: "停用",
46
47
  init: "加载",
48
+ remote: "远端",
47
49
  install: "依赖",
48
50
  };
49
51
  const phases = Object.entries(labels)
@@ -1,5 +1,5 @@
1
1
  // 配置驱动的公共项目命令编排。
2
- const { execFileSync } = require("node:child_process");
2
+ const { spawn, spawnSync } = require("node:child_process");
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
 
@@ -77,17 +77,101 @@ function commandParts(command) {
77
77
  return { command: parts[0], args: parts.slice(1) };
78
78
  }
79
79
 
80
- function defaultRunCommand(command, args, options) {
81
- // Windows: node_modules/.bin 的 npm-script 命令是 .CMD 批处理垫片,
82
- // execFileSync shell CreateProcess 只解析 .exe → ENOENT;与 build-utils/command.js 约定一致。
83
- const useShell = process.platform === "win32";
80
+ function attachChildLifecycle(child, command, resolve, reject) {
81
+ // 把子进程的生命周期(信号转发/退出语义)挂接到一个已启动的 child 上。
82
+ const forwardSignal = (signal) => {
83
+ try {
84
+ child.kill(signal);
85
+ } catch {
86
+ // 子进程已退出时忽略转发失败
87
+ }
88
+ // Windows 下 cmd 批处理在 Ctrl+C 时会弹"终止批处理操作吗(Y/N)?"等待输入,主进程等
89
+ // 子进程 exit 会永久阻塞;补杀整棵进程树(taskkill /T)并立即退出主进程,保证终端干净。
90
+ if (process.platform === "win32" && child.pid) {
91
+ try {
92
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], {
93
+ stdio: "ignore",
94
+ windowsHide: true,
95
+ });
96
+ } catch {
97
+ // taskkill 失败不影响退出:子进程可能已结束
98
+ }
99
+ }
100
+ process.exit(0);
101
+ };
102
+ process.once("SIGINT", forwardSignal);
103
+ process.once("SIGTERM", forwardSignal);
104
+
105
+ const cleanup = () => {
106
+ process.removeListener("SIGINT", forwardSignal);
107
+ process.removeListener("SIGTERM", forwardSignal);
108
+ };
109
+
110
+ child.on("error", (error) => {
111
+ cleanup();
112
+ reject(error);
113
+ });
114
+ child.on("exit", (code, signal) => {
115
+ cleanup();
116
+ // 被信号终止(如用户 Ctrl+C)视为正常结束:dev server 由中断信号退出,
117
+ // 主进程随后返回,CLI 自然退出,不将 Ctrl+C 视为命令失败。
118
+ if (code === 0 || signal) {
119
+ resolve({ signal: signal || null });
120
+ return;
121
+ }
122
+ const error = new Error(`Command failed with exit code ${code ?? "unknown"}: ${command}`);
123
+ error.code = code;
124
+ reject(error);
125
+ });
126
+ }
127
+
128
+ function spawnCommand(command, args, options, useShell) {
84
129
  // shell=true 时按 build-utils/command.js 约定传单字符串,规避 DEP0190
85
130
  const [cmd, cmdArgs] = useShell ? [[command, ...args].join(" "), []] : [command, args];
86
- execFileSync(cmd, cmdArgs, {
87
- cwd: options.cwd,
88
- env: options.env,
89
- stdio: "inherit",
90
- shell: useShell,
131
+ // 用 spawn(异步、非阻塞)替代 execFileSync:dev 场景(长驻进程)下,
132
+ // execFileSync 同步阻塞 + 多层 cmd/pnpm 嵌套在 Windows 上 Ctrl+C 后易留孤儿进程占用终端与端口。
133
+ return new Promise((resolve, reject) => {
134
+ const child = spawn(cmd, cmdArgs, {
135
+ cwd: options.cwd,
136
+ env: options.env,
137
+ stdio: "inherit",
138
+ shell: useShell,
139
+ });
140
+ attachChildLifecycle(child, command, resolve, reject);
141
+ });
142
+ }
143
+
144
+ function defaultRunCommand(command, args, options) {
145
+ // 非 Windows 统一走无 shell 形态。
146
+ if (process.platform !== "win32") {
147
+ return spawnCommand(command, args, options, false);
148
+ }
149
+ // Windows: 先尝试无 shell 直跑(.exe,如 node)——避免 cmd 批处理层在 Ctrl+C 时弹
150
+ // "终止批处理操作吗(Y/N)?" 等待输入;.CMD 垫片(如 pnpm/npm)无 shell 会 ENOENT,回退 cmd shell。
151
+ return new Promise((resolve, reject) => {
152
+ const child = spawn(command, args, {
153
+ cwd: options.cwd,
154
+ env: options.env,
155
+ stdio: "inherit",
156
+ shell: false,
157
+ });
158
+ let settled = false;
159
+ child.once("error", (error) => {
160
+ if (settled) return;
161
+ settled = true;
162
+ if (error.code === "ENOENT") {
163
+ // .cmd/.bat 垫片:回退 cmd shell(保留原有行为)
164
+ spawnCommand(command, args, options, true).then(resolve, reject);
165
+ return;
166
+ }
167
+ reject(error);
168
+ });
169
+ child.once("spawn", () => {
170
+ if (settled) return;
171
+ settled = true;
172
+ // 无 shell 直跑成功:挂接生命周期到当前 child(不再二次启动)
173
+ attachChildLifecycle(child, command, resolve, reject);
174
+ });
91
175
  });
92
176
  }
93
177
 
@@ -146,7 +230,7 @@ async function runProjectCommand(options = {}, dependencies = {}) {
146
230
  const parts = commandParts(devConfig.command);
147
231
  const env = { ...process.env, VUE_APP_PLATFORM: targetName };
148
232
  if (options.dryRun) printDryRun(parts.command, parts.args, env);
149
- else runCommand(parts.command, parts.args, { cwd: configDir, env });
233
+ else await runCommand(parts.command, parts.args, { cwd: configDir, env });
150
234
  return { action, target: targetName };
151
235
  }
152
236
 
@@ -191,7 +275,7 @@ async function runProjectCommand(options = {}, dependencies = {}) {
191
275
  const parts = commandParts(step.command);
192
276
  const args = [...parts.args, ...step.extraArgs];
193
277
  if (options.dryRun) printDryRun(parts.command, args, env);
194
- else runCommand(parts.command, args, { cwd: configDir, env });
278
+ else await runCommand(parts.command, args, { cwd: configDir, env });
195
279
  }
196
280
  }
197
281