create-ssnode-cli 0.2.5 → 0.2.7
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/index.js +17 -21
- package/package.json +1 -1
- package/secrets.js +32 -34
- package/templates/backend/Dockerfile +18 -8
- package/templates/backend/config/config.base.yaml +130 -0
- package/templates/backend/config/config.develop.yaml +8 -75
- package/templates/backend/config/config.prod.yaml +12 -67
- package/templates/backend/docker-compose.yml +30 -22
- package/templates/backend/ops/docker/config.sh +30 -15
- package/templates/backend/ops/docker/install.sh +25 -34
- package/templates/backend/ops/docker/run-container.sh +100 -0
- package/templates/backend/ops/docker/start.sh +37 -8
- package/templates/backend/ops/docker/stop.sh +1 -1
- package/templates/backend/ops/ensure-encrypt.cjs +217 -0
- package/templates/backend/ops/install.sh +249 -148
- package/templates/backend/ops/lib-encrypt.sh +98 -0
- package/templates/backend/ops/lib.sh +22 -0
- package/templates/backend/ops/pm2/config.sh +11 -6
- package/templates/backend/ops/pm2/install.sh +27 -3
- package/templates/backend/ops/pm2/start.sh +1 -1
- package/templates/backend/ops/start.sh +7 -5
- package/templates/backend/ops/status.sh +7 -5
- package/templates/backend/ops/stop.sh +7 -5
- package/templates/backend/ops/systemd/config.sh +13 -6
- package/templates/backend/ops/systemd/install.sh +28 -1
- package/templates/backend/ops/systemd/start.sh +1 -1
- package/templates/backend/ops/versionswitch.sh +34 -122
- package/templates/backend/package-lock.json +7561 -0
- package/templates/backend/package.json +3 -1
- package/templates/backend/scripts/build.cjs +104 -10
- package/templates/backend/scripts/config-crypt.cjs +108 -0
- package/templates/backend/scripts/lib/ssh.cjs +72 -3
- package/templates/backend/scripts/publish.cjs +17 -9
- package/templates/backend/scripts/pulldocker.cjs +208 -0
- package/templates/backend/scripts/pushdocker.cjs +323 -0
- package/templates/backend/scripts/rollback.cjs +6 -3
- package/templates/backend/scripts/server.config.example.cjs +43 -15
- package/templates/backend/scripts/slowsql-report.mjs +2 -2
- package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
- package/templates/backend/src/common/config-crypto.ts +133 -0
- package/templates/backend/src/config/config.interface.ts +22 -3
- package/templates/backend/src/config/configuration.ts +173 -86
- package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
- package/templates/backend/src/database/slow-sql.logger.ts +1 -1
- package/templates/backend/src/database/sqlite.config.ts +2 -2
- package/templates/backend/src/main.ts +3 -3
- package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
- package/templates/backend/src/modules/ops/ops.module.ts +2 -11
- package/templates/backend/src/modules/user/user.service.ts +2 -2
- package/templates/docs/config-encryption.md +164 -0
- package/templates/root/AGENTS.md +5 -4
- package/templates/root/CLAUDE.md +1 -1
- package/templates/root/README.md +25 -15
- package/templates/root/_gitignore +5 -0
- package/templates/root/scripts/publish.cjs +20 -2
- package/templates/root/scripts/rollback.cjs +20 -2
- package/templates/web/config/router.config.ts +8 -0
- package/templates/web/package.json +5 -1
- package/templates/web/scripts/compress-src-images.cjs +332 -0
- package/templates/web/scripts/lib/interactive.cjs +134 -0
- package/templates/web/scripts/publish.cjs +299 -200
- package/templates/web/scripts/rollback.cjs +110 -41
- package/templates/web/scripts/server.config.example.json +17 -0
- package/templates/web/src/layouts/Background.module.less +0 -4
- package/templates/web/src/layouts/Background.tsx +48 -74
- package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
- package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
- package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
- package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
- package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
- package/templates/web/src/pages/Maintenance/index.module.less +177 -0
- package/templates/web/src/pages/Maintenance/index.tsx +142 -0
- package/templates/web/src/services/api.ts +4 -0
- package/templates/web/src/services/app-request.ts +8 -1
- package/templates/web/src/services/maintenance.ts +67 -0
- package/templates/web/src/services/request.ts +5 -0
- package/transforms.js +21 -28
- package/templates/backend/ops/docker/.env.example +0 -40
- package/templates/backend/ops/sqlite/README.md +0 -57
- package/templates/backend/ops/sqlite/backup.sh +0 -65
- package/templates/backend/ops/sqlite/config.sh +0 -91
- package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
- package/templates/backend/ops/sqlite/restore.sh +0 -105
- package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
- package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
- package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* web/src 存量图片批压缩(视觉无损,构建产物瘦身)
|
|
4
|
+
*
|
|
5
|
+
* 背景:src 内设计稿类 PNG 大量超展示需求(最大 12.7MB、多张 1-3MB 级),
|
|
6
|
+
* 上传链已有 compress-image.ts 压缩(2026-10-15)管不到 src 静态资源,本脚本补存量。
|
|
7
|
+
*
|
|
8
|
+
* 用法(在 web/ 下运行):
|
|
9
|
+
* node scripts/compress-src-images.cjs # 交互询问是否保留原图(默认保留)
|
|
10
|
+
* node scripts/compress-src-images.cjs --dry-run # 只算不写,预览前后大小
|
|
11
|
+
* node scripts/compress-src-images.cjs --no-keep # 免交互,不留原图备份
|
|
12
|
+
* node scripts/compress-src-images.cjs --limit N # 只处理前 N 张(渐进验证,计实际解码尝试)
|
|
13
|
+
* node scripts/compress-src-images.cjs --dir <path> # 换扫描根(默认 src/;沙箱回归用)
|
|
14
|
+
*
|
|
15
|
+
* 前提:sharp 为 NATIVE 依赖,禁入 web 依赖(浏览器侧包),隔离安装(同 backend
|
|
16
|
+
* compress-column-images.cjs 先例):
|
|
17
|
+
* npm i --prefix /tmp/compress-libs sharp
|
|
18
|
+
* 脚本按 node_modules → /tmp/compress-libs 顺序找,找不到打印装法退出。
|
|
19
|
+
*
|
|
20
|
+
* 压缩参数与 web/src/utils/compress-image.ts、backend/scripts/compress-column-images.cjs
|
|
21
|
+
* 同语义,勿单边改:长边钳制 3840 / EXIF 方向烘焙 + 元数据剥离 / WebP q0.92 /
|
|
22
|
+
* 只有更小(原 95% 以下)才替换。「无损」按仓库既有口径 = 视觉无损(严格数学无损
|
|
23
|
+
* 对设计稿类位图收益≈0,两条既有链同取舍);编码器是 sharp(本地批处理)非浏览器
|
|
24
|
+
* canvas——参数一致、观测级等价。
|
|
25
|
+
*
|
|
26
|
+
* 换名方案(引用代码零改动):
|
|
27
|
+
* 压缩结果(WebP 字节)落**原文件名**(BG.png 名不变,import / css url() 全不动;
|
|
28
|
+
* <img>/css 背景按内容嗅探渲染,浏览器对图片不按扩展名强制 MIME);
|
|
29
|
+
* 原图改名 BG.png.orig 同目录保留(幂等标记 + 本机回退件,已 gitignore 勿入库)。
|
|
30
|
+
*
|
|
31
|
+
* 幂等 / 跳过规则(跳过只报告不动文件):
|
|
32
|
+
* 内容已是 WebP(RIFF..WEBP 魔数——不依赖 .orig 标记,--no-keep 模式重跑也安全)
|
|
33
|
+
* / 陈旧 .orig 标记(.orig 在但主文件非 WebP,多为 git 还原后残留,提示人工确认)
|
|
34
|
+
* / 非静态位图(gif 动图丢帧 / svg 非位图,扫描即过滤)/ 不超尺寸小图(≤300KB 且
|
|
35
|
+
* 长边 ≤3840)/ 压缩无收益(≥原图 95%)。
|
|
36
|
+
*
|
|
37
|
+
* 安全:
|
|
38
|
+
* - 开跑交互询问是否保留原图,默认保留;回退单张 = mv xxx.png.orig xxx.png,
|
|
39
|
+
* 整批走 git checkout(原图入库过,git 历史即真备份);
|
|
40
|
+
* - 写入走同目录 .new 临时文件 + 两步 rename(原图先改名、新图后顶上),崩溃不产
|
|
41
|
+
* 生半张图;开跑前先扫中断残留自修复(.orig 在主文件不在 = 接续换名或回滚原图,
|
|
42
|
+
* .new 残留 = 清理陈旧临时件);
|
|
43
|
+
* - 每张压缩结果落盘前重新解码校验尺寸一致(防截断/坏写)。
|
|
44
|
+
*/
|
|
45
|
+
"use strict";
|
|
46
|
+
|
|
47
|
+
const fs = require("node:fs");
|
|
48
|
+
const path = require("node:path");
|
|
49
|
+
const readline = require("node:readline");
|
|
50
|
+
|
|
51
|
+
// ── 与 compress-image.ts / compress-column-images.cjs 对齐的常量(勿单边改)──
|
|
52
|
+
const MAX_EDGE = 3840;
|
|
53
|
+
const WEBP_QUALITY = 92;
|
|
54
|
+
const SKIP_SIZE = 300 * 1024; // 不超尺寸的小图不重编码
|
|
55
|
+
const ADOPT_RATIO = 0.95; // 压缩结果须小于原图此比例才替换
|
|
56
|
+
const RASTER_RE = /\.(png|jpe?g|webp|bmp)$/i; // gif 动图 / svg 非位图,一律不动
|
|
57
|
+
const ORIG_SUFFIX = ".orig"; // 原图备份后缀(幂等标记 + 回退件)
|
|
58
|
+
const NEW_SUFFIX = ".new"; // 落盘临时后缀(同目录 rename 原子换名)
|
|
59
|
+
const DEFAULT_SCAN_DIR = path.resolve(__dirname, "..", "src");
|
|
60
|
+
|
|
61
|
+
// ── sharp 隔离加载(不入仓库)──
|
|
62
|
+
function loadSharp() {
|
|
63
|
+
const candidates = [
|
|
64
|
+
"sharp", // 万一通过 node_modules 全局可达(如本机 pnpm 已装)
|
|
65
|
+
path.resolve("/tmp/compress-libs/node_modules/sharp"), // 隔离安装位(同 backend 批迁模式)
|
|
66
|
+
];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
70
|
+
return require(candidate);
|
|
71
|
+
} catch {
|
|
72
|
+
// 找不到换下一个候选位
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.error(
|
|
76
|
+
"缺 sharp(NATIVE 依赖,禁止入 web package.json——浏览器侧包)。隔离安装:\n" +
|
|
77
|
+
" npm i --prefix /tmp/compress-libs sharp",
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 内容魔数是否已是 WebP('RIFF'....'WEBP')——幂等判据,不依赖文件名/备份标记 */
|
|
83
|
+
function isWebP(buf) {
|
|
84
|
+
return (
|
|
85
|
+
buf.length > 12 &&
|
|
86
|
+
buf[0] === 0x52 &&
|
|
87
|
+
buf[1] === 0x49 &&
|
|
88
|
+
buf[2] === 0x46 &&
|
|
89
|
+
buf[3] === 0x46 && // RIFF
|
|
90
|
+
buf[8] === 0x57 &&
|
|
91
|
+
buf[9] === 0x45 &&
|
|
92
|
+
buf[10] === 0x42 &&
|
|
93
|
+
buf[11] === 0x50 // WEBP
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 递归收集扫描树内全部文件(位图 / 备份残留 / 临时件分类在调用侧) */
|
|
98
|
+
function walkFiles(dir, out = []) {
|
|
99
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
+
const p = path.join(dir, ent.name);
|
|
101
|
+
if (ent.isDirectory()) walkFiles(p, out);
|
|
102
|
+
else if (ent.isFile()) out.push(p);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 开跑前自修复上次中断残留(顺序敏感:先 .orig 后 .new):
|
|
109
|
+
* .orig 在主文件不在:.new 在 = 压缩已落盘只差最后换名,接续完成;
|
|
110
|
+
* 否则崩在「原图已改名、新图未顶上」,回滚原图。
|
|
111
|
+
* .new 残留且主文件在:陈旧临时件,清掉。
|
|
112
|
+
*/
|
|
113
|
+
function repairInterrupted(allFiles) {
|
|
114
|
+
for (const f of allFiles.filter((x) => x.endsWith(ORIG_SUFFIX))) {
|
|
115
|
+
const main = f.slice(0, -ORIG_SUFFIX.length);
|
|
116
|
+
if (fs.existsSync(main)) continue; // 正常已压缩留备份态
|
|
117
|
+
const staged = main + NEW_SUFFIX;
|
|
118
|
+
if (fs.existsSync(staged)) {
|
|
119
|
+
fs.renameSync(staged, main);
|
|
120
|
+
console.log(`REPAIR 接续换名 ${path.basename(main)}`);
|
|
121
|
+
} else {
|
|
122
|
+
fs.renameSync(f, main);
|
|
123
|
+
console.log(`REPAIR 回滚原图 ${path.basename(main)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const f of allFiles.filter((x) => x.endsWith(NEW_SUFFIX))) {
|
|
127
|
+
if (!fs.existsSync(f)) continue; // 上一轮修复已消费
|
|
128
|
+
const main = f.slice(0, -NEW_SUFFIX.length);
|
|
129
|
+
if (fs.existsSync(main) || fs.existsSync(main + ORIG_SUFFIX)) {
|
|
130
|
+
fs.unlinkSync(f);
|
|
131
|
+
console.log(`REPAIR 清理临时件 ${path.basename(f)}`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(
|
|
134
|
+
`WARN 孤立临时件 ${path.basename(f)}(主文件/备份均不在,人工确认后删)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 交互询问是否保留原图备份;回车/非 n 均视为保留(默认保留);非 TTY 默认保留 */
|
|
141
|
+
function askKeepOriginal() {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return resolve(true);
|
|
144
|
+
const rl = readline.createInterface({
|
|
145
|
+
input: process.stdin,
|
|
146
|
+
output: process.stdout,
|
|
147
|
+
});
|
|
148
|
+
rl.question("是否保留原图备份(同目录 *.orig)?[Y/n] ", (ans) => {
|
|
149
|
+
rl.close();
|
|
150
|
+
resolve(!/^\s*n(o)?\s*$/i.test(ans || ""));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const argv = process.argv.slice(2);
|
|
156
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
157
|
+
const opt = (name, def) => {
|
|
158
|
+
const i = argv.indexOf(`--${name}`);
|
|
159
|
+
return i >= 0 ? argv[i + 1] : def;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const dryRun = flag("dry-run");
|
|
163
|
+
const limit = parseInt(opt("limit", "0"), 10) || 0;
|
|
164
|
+
const scanDir = path.resolve(opt("dir", DEFAULT_SCAN_DIR));
|
|
165
|
+
|
|
166
|
+
(async () => {
|
|
167
|
+
if (!fs.existsSync(scanDir)) {
|
|
168
|
+
console.error(`扫描目录不存在:${scanDir}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
const sharp = loadSharp();
|
|
172
|
+
|
|
173
|
+
const allFiles = walkFiles(scanDir);
|
|
174
|
+
repairInterrupted(allFiles);
|
|
175
|
+
const images = allFiles.filter((f) => RASTER_RE.test(f));
|
|
176
|
+
|
|
177
|
+
let keepOrig = true;
|
|
178
|
+
if (!dryRun) {
|
|
179
|
+
if (flag("no-keep")) keepOrig = false;
|
|
180
|
+
else if (flag("keep")) keepOrig = true;
|
|
181
|
+
else keepOrig = await askKeepOriginal();
|
|
182
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
183
|
+
console.log(
|
|
184
|
+
"[src-compress] 非交互环境(非 TTY),按默认保留原图(免交互不留备份用 --no-keep)",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rel = (f) => path.relative(scanDir, f);
|
|
190
|
+
const fmtKB = (n) => `${(n / 1024).toFixed(0)}KB`;
|
|
191
|
+
console.log(
|
|
192
|
+
`[src-compress] ${scanDir} 位图 ${images.length} 张 ${
|
|
193
|
+
dryRun ? "DRY-RUN" : keepOrig ? "保留原图(.orig)" : "不留原图"
|
|
194
|
+
}${limit ? ` limit=${limit}` : ""}`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const stats = {
|
|
198
|
+
compressed: 0,
|
|
199
|
+
noGain: 0,
|
|
200
|
+
smallSkip: 0,
|
|
201
|
+
webpDone: 0,
|
|
202
|
+
staleMark: 0,
|
|
203
|
+
failed: 0,
|
|
204
|
+
};
|
|
205
|
+
let beforeTotal = 0;
|
|
206
|
+
let saved = 0;
|
|
207
|
+
let processed = 0;
|
|
208
|
+
|
|
209
|
+
for (const file of images) {
|
|
210
|
+
if (limit && processed >= limit) {
|
|
211
|
+
console.log(`[src-compress] --limit ${limit} 已处理满,停止(其余未动)`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
// ── 幂等跳过 ──
|
|
216
|
+
const buf = fs.readFileSync(file);
|
|
217
|
+
if (isWebP(buf)) {
|
|
218
|
+
stats.webpDone += 1;
|
|
219
|
+
console.log(`SKIP 已压缩 ${fmtKB(buf.length)} ${rel(file)}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (fs.existsSync(file + ORIG_SUFFIX)) {
|
|
223
|
+
stats.staleMark += 1;
|
|
224
|
+
console.log(
|
|
225
|
+
`SKIP 陈旧标记 ${rel(
|
|
226
|
+
file,
|
|
227
|
+
)}(.orig 在但主文件非 WebP——git 还原后残留?确认后删 .orig 重跑)`,
|
|
228
|
+
);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
processed += 1;
|
|
232
|
+
|
|
233
|
+
// ── 压缩(语义同 compress-image.ts)──
|
|
234
|
+
const meta = await sharp(buf).metadata();
|
|
235
|
+
const scale = Math.min(
|
|
236
|
+
1,
|
|
237
|
+
MAX_EDGE / Math.max(meta.width || 1, meta.height || 1),
|
|
238
|
+
);
|
|
239
|
+
if (scale === 1 && buf.length <= SKIP_SIZE) {
|
|
240
|
+
stats.smallSkip += 1;
|
|
241
|
+
console.log(`SKIP 小图 ${fmtKB(buf.length)} ${rel(file)}`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const width = Math.max(1, Math.round((meta.width || 1) * scale));
|
|
245
|
+
const height = Math.max(1, Math.round((meta.height || 1) * scale));
|
|
246
|
+
// rotate() 无参 = 按 EXIF 方向摆正(等效 canvas 管线的 from-image),sharp 输出默认剥元数据
|
|
247
|
+
const webp = await sharp(buf)
|
|
248
|
+
.rotate()
|
|
249
|
+
.resize({
|
|
250
|
+
width,
|
|
251
|
+
height,
|
|
252
|
+
fit: "inside",
|
|
253
|
+
withoutEnlargement: true,
|
|
254
|
+
kernel: "lanczos3",
|
|
255
|
+
})
|
|
256
|
+
.webp({ quality: WEBP_QUALITY })
|
|
257
|
+
.toBuffer();
|
|
258
|
+
if (webp.length >= buf.length * ADOPT_RATIO) {
|
|
259
|
+
stats.noGain += 1;
|
|
260
|
+
console.log(
|
|
261
|
+
`SKIP 无收益 原${fmtKB(buf.length)} → ${fmtKB(
|
|
262
|
+
webp.length,
|
|
263
|
+
)} ${rel(file)}`,
|
|
264
|
+
);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 落盘前自校验:压缩结果能重新解码且长边一致(防截断/坏写;宽高允许 ±4px
|
|
269
|
+
// 的 WebP 有损重编码舍入误差——除以质量 q 的像素块取整,实测 3840→3836)
|
|
270
|
+
const verify = await sharp(webp).metadata();
|
|
271
|
+
const verifyLong = Math.max(verify.width || 0, verify.height || 0);
|
|
272
|
+
const expectLong = Math.max(width, height);
|
|
273
|
+
if (
|
|
274
|
+
!verify.width ||
|
|
275
|
+
!verify.height ||
|
|
276
|
+
Math.abs(verifyLong - expectLong) > 4
|
|
277
|
+
) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`压缩结果自校验失败 ${verify.width}×${verify.height}(期望 ~${width}×${height})`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const line = `${fmtKB(buf.length)} → ${fmtKB(webp.length)}${
|
|
284
|
+
scale < 1 ? ` (${meta.width}×${meta.height} → ${width}×${height})` : ""
|
|
285
|
+
} ${rel(file)}`;
|
|
286
|
+
if (dryRun) {
|
|
287
|
+
stats.compressed += 1;
|
|
288
|
+
beforeTotal += buf.length;
|
|
289
|
+
saved += buf.length - webp.length;
|
|
290
|
+
console.log(`DRY ${line}`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 同目录 .new + 两步 rename 原子换名(崩溃不产生半张图)──
|
|
295
|
+
fs.writeFileSync(file + NEW_SUFFIX, webp);
|
|
296
|
+
if (keepOrig) fs.renameSync(file, file + ORIG_SUFFIX);
|
|
297
|
+
fs.renameSync(file + NEW_SUFFIX, file);
|
|
298
|
+
stats.compressed += 1;
|
|
299
|
+
beforeTotal += buf.length;
|
|
300
|
+
saved += buf.length - webp.length;
|
|
301
|
+
console.log(
|
|
302
|
+
`OK ${line}${
|
|
303
|
+
keepOrig ? `(原图留 ${path.basename(file)}${ORIG_SUFFIX})` : ""
|
|
304
|
+
}`,
|
|
305
|
+
);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
stats.failed += 1;
|
|
308
|
+
console.error(`FAIL ${rel(file)} —— ${err.message}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const fmtMB = (n) => `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
313
|
+
console.log(`\n[src-compress] 完成:${JSON.stringify(stats)}`);
|
|
314
|
+
if (stats.compressed) {
|
|
315
|
+
console.log(
|
|
316
|
+
`[src-compress] 收益:${fmtMB(beforeTotal)} → ${fmtMB(
|
|
317
|
+
beforeTotal - saved,
|
|
318
|
+
)}(省 ${fmtMB(saved)})`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (!dryRun && stats.compressed) {
|
|
322
|
+
console.log(
|
|
323
|
+
keepOrig
|
|
324
|
+
? "[src-compress] 回退:单张 mv xxx.png.orig xxx.png;整批 git checkout -- <目录>。.orig 已 gitignore,勿入库。"
|
|
325
|
+
: "[src-compress] 未留备份,回退只能走 git checkout -- <目录>。",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (stats.failed) process.exitCode = 1;
|
|
329
|
+
})().catch((e) => {
|
|
330
|
+
console.error("FATAL", e);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web 发布/回滚共享的终端交互 helper —— node 内置 readline 实现,零新增依赖。
|
|
3
|
+
* - selectEnvKey:多服务器目标交互选择(server.config.json 的 key 罗列成 `key(host)` 菜单)
|
|
4
|
+
* - selectVersion:发布前选版本策略(自增默认/维持当前/自定义输入)
|
|
5
|
+
* - selectRollbackVersion:回滚时罗列服务器归档版本,选中回退目标
|
|
6
|
+
* - confirm:危险操作前确认
|
|
7
|
+
* 仅在 TTY 下由调用方调用;非交互(CI/管道)走调用方各自的回落逻辑,不经此文件。
|
|
8
|
+
*/
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
|
|
11
|
+
// 不用 rl.question:它的回调只在有挂起问题时消费行,管道/整段粘贴的多行输入会在同一
|
|
12
|
+
// tick 同步 emit 完所有 line 事件、后续 question 还没注册,行被整批丢弃(实测第二个
|
|
13
|
+
// 提问直接 EOF 失败)。这里改为共享一个实例 + line 事件入队,你来我往都稳:
|
|
14
|
+
// 没人提问时到达的行先进 pending 队列,下一次 ask 先吃队列再等新行。
|
|
15
|
+
let rl = null;
|
|
16
|
+
let pendingLines = [];
|
|
17
|
+
let lineWaiter = null; // 挂起中的 ask 的 resolve
|
|
18
|
+
|
|
19
|
+
function getInterface() {
|
|
20
|
+
if (rl) return rl;
|
|
21
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
22
|
+
rl.on('line', (line) => {
|
|
23
|
+
if (lineWaiter) {
|
|
24
|
+
const resolve = lineWaiter;
|
|
25
|
+
lineWaiter = null;
|
|
26
|
+
resolve(line);
|
|
27
|
+
} else {
|
|
28
|
+
pendingLines.push(line);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// stdin EOF/关闭时唤醒挂起的 ask(按空输入处理,走各菜单的默认项),避免死等
|
|
32
|
+
rl.on('close', () => {
|
|
33
|
+
if (lineWaiter) {
|
|
34
|
+
const resolve = lineWaiter;
|
|
35
|
+
lineWaiter = null;
|
|
36
|
+
resolve('');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// Ctrl+C 与终端惯例一致:退出码 130
|
|
40
|
+
rl.on('SIGINT', () => {
|
|
41
|
+
process.stdout.write('\n');
|
|
42
|
+
process.exit(130);
|
|
43
|
+
});
|
|
44
|
+
return rl;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 异步提问 → Promise(答案 trim 后返回)
|
|
48
|
+
function ask(prompt) {
|
|
49
|
+
getInterface();
|
|
50
|
+
process.stdout.write(prompt);
|
|
51
|
+
if (pendingLines.length) {
|
|
52
|
+
return Promise.resolve(pendingLines.shift().trim());
|
|
53
|
+
}
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
lineWaiter = (line) => resolve(line.trim());
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 问答结束调用:关闭实例,释放 stdin 句柄,进程才能自然退出
|
|
60
|
+
function closeInteractive() {
|
|
61
|
+
if (rl) {
|
|
62
|
+
rl.close();
|
|
63
|
+
rl = null;
|
|
64
|
+
}
|
|
65
|
+
pendingLines = [];
|
|
66
|
+
lineWaiter = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 多服务器目标交互选择:罗列 server.config.json 所有 key,菜单文本 `key(host)`。
|
|
70
|
+
// 回车=默认 key(deploy 优先,缺则第一个)。单目标时直接返回、不交互。
|
|
71
|
+
// 返回选中的 key;无效输入循环重问。
|
|
72
|
+
async function selectEnvKey(serverConfig, { defaultKey = 'deploy' } = {}) {
|
|
73
|
+
const keys = Object.keys(serverConfig);
|
|
74
|
+
if (keys.length <= 1) return keys[0] || null;
|
|
75
|
+
|
|
76
|
+
console.log('🎯 选择部署目标(server.config.json):');
|
|
77
|
+
keys.forEach((key, i) => console.log(` ${i + 1}) ${key}(${serverConfig[key].host})`));
|
|
78
|
+
const fallback = keys.includes(defaultKey) ? defaultKey : keys[0];
|
|
79
|
+
const fallbackIndex = keys.indexOf(fallback) + 1;
|
|
80
|
+
|
|
81
|
+
while (true) {
|
|
82
|
+
const input = await ask(`输入序号(回车=默认 ${fallbackIndex} [${fallback}]): `);
|
|
83
|
+
if (!input) return fallback;
|
|
84
|
+
const num = Number(input);
|
|
85
|
+
if (Number.isInteger(num) && num >= 1 && num <= keys.length) return keys[num - 1];
|
|
86
|
+
console.log(`❌ 无效序号 "${input}",请输入 1~${keys.length}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 发布版本策略交互:1=自增(默认) 2=维持当前 3=自定义输入。返回最终采用的版本号。
|
|
91
|
+
async function selectVersion(current, next) {
|
|
92
|
+
console.log(`📌 当前版本: ${current}`);
|
|
93
|
+
console.log('选择本次发布的版本号:');
|
|
94
|
+
console.log(` 1) 自增至 ${next}(默认)`);
|
|
95
|
+
console.log(' 2) 维持当前版本');
|
|
96
|
+
console.log(' 3) 自定义输入');
|
|
97
|
+
while (true) {
|
|
98
|
+
const input = await ask('输入序号(回车=1 自增): ');
|
|
99
|
+
if (!input || input === '1') return next;
|
|
100
|
+
if (input === '2') return current;
|
|
101
|
+
if (input === '3') {
|
|
102
|
+
const custom = await ask('输入版本号(形如 1.2.3): ');
|
|
103
|
+
if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(custom)) return custom;
|
|
104
|
+
console.log(`❌ 版本号格式不对: "${custom}",应为 1.2.3 形式`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
console.log('❌ 无效序号,请输入 1/2/3');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 回滚版本选择:releases 为新→旧数组 [{name, version, time}],返回选中的那一项。
|
|
112
|
+
// 默认选第 2 个(上一个版本)——出故障的当前版本通常就是最新归档,选它等于原地不动。
|
|
113
|
+
async function selectRollbackVersion(releases) {
|
|
114
|
+
console.log('🕰️ 可回退版本(新→旧):');
|
|
115
|
+
releases.forEach((r, i) => console.log(` ${i + 1}) ${r.version} ${r.time} (${r.name})`));
|
|
116
|
+
const defaultIndex = releases.length > 1 ? 2 : 1;
|
|
117
|
+
while (true) {
|
|
118
|
+
const input = await ask(
|
|
119
|
+
`输入要回退到的版本序号(回车=${defaultIndex} [${releases[defaultIndex - 1].version}]): `
|
|
120
|
+
);
|
|
121
|
+
if (!input) return releases[defaultIndex - 1];
|
|
122
|
+
const num = Number(input);
|
|
123
|
+
if (Number.isInteger(num) && num >= 1 && num <= releases.length) return releases[num - 1];
|
|
124
|
+
console.log(`❌ 无效序号 "${input}",请输入 1~${releases.length}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 危险操作前的 y/N 确认;非 y(含直接回车)一律视为取消,返回 false。
|
|
129
|
+
async function confirm(prompt) {
|
|
130
|
+
const input = await ask(`${prompt} (y/N): `);
|
|
131
|
+
return input === 'y' || input === 'Y';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { ask, closeInteractive, selectEnvKey, selectVersion, selectRollbackVersion, confirm };
|