@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.
- package/README.md +115 -3
- package/adapters/webpack/auto-export.js +52 -52
- package/bin/cli.js +12 -0
- package/build/build-utils/command.js +57 -57
- package/build/build-utils/git.js +254 -254
- package/build/build-utils/preview.js +78 -78
- package/build/build-utils/webhook-single.js +136 -136
- package/build/docker/compose-command.js +73 -73
- package/build/docker/compose-release.js +19 -12
- package/build/docker/deployment/deploy.sh +30 -0
- package/build/docker/deployment/health-check.sh +28 -0
- package/build/docker/deployment/promote-image.sh +61 -0
- package/build/docker/deployment/release.sh +74 -0
- package/build/docker/deployment-bundle.js +87 -0
- package/build/docker/target-release.js +180 -0
- package/build/project-files.js +47 -47
- package/cli/progress.js +2 -0
- package/cli/project-command.js +96 -12
- package/cli/select-option.js +62 -30
- package/core/async/concurrency.js +29 -29
- package/package.json +2 -2
- package/script/git-automation/submodule-utils.js +16 -16
- package/workspace/config.js +144 -144
|
@@ -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 };
|
package/build/project-files.js
CHANGED
|
@@ -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)
|
package/cli/project-command.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 配置驱动的公共项目命令编排。
|
|
2
|
-
const {
|
|
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
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|