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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.48",
|
|
4
4
|
"description": "全栈模板 - 后台服务(NestJS + SQLite + TypeORM)",
|
|
5
5
|
"private": true,
|
|
6
6
|
"engines": {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"build": "node scripts/build.cjs build",
|
|
11
11
|
"buildops": "node scripts/build.cjs buildops",
|
|
12
12
|
"publish": "node scripts/publish.cjs",
|
|
13
|
+
"pulldocker": "node scripts/pulldocker.cjs",
|
|
14
|
+
"pushdocker": "node scripts/pushdocker.cjs",
|
|
13
15
|
"rollback": "node scripts/rollback.cjs",
|
|
14
16
|
"dev": "nest start --watch",
|
|
15
17
|
"start": "node dist/main.js",
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
*
|
|
23
23
|
* buildops 产物(backend/release/<name>-<时间戳>/):
|
|
24
24
|
* ├── install.sh start.sh stop.sh versionswitch.sh status.sh (顶层薄封装,从 ops/ 提到包根)
|
|
25
|
-
* ├──
|
|
26
|
-
*
|
|
25
|
+
* ├── config/ (随行配置:config.base.yaml + 全部环境 overlay + 自动生成的
|
|
26
|
+
* │ .prod.docker.env;install.sh 部署时播种/刷新 APP_ROOT/config、env 文件
|
|
27
|
+
* │ 移入 APP_ROOT 根,包内原件随移删除。敏感值明文随包传输,服务器首部署
|
|
28
|
+
* │ 选「开启加密」时本地化为 ENC(...) 密文,见 ops/ensure-encrypt.cjs)
|
|
29
|
+
* ├── ops/ (mode dispatch:docker/pm2/systemd;顶层脚本不重复进 ops/)
|
|
30
|
+
* └── releases/<version>-<ts>/ (打完的包内容:dist/ + package.json + package-lock.json + Dockerfile + .dockerignore + docker-compose.yml + config/(base+prod 兜底) + .build-ts;子目录带 ts,同 version 多次打包不冲突)
|
|
27
31
|
*
|
|
28
32
|
* 产物目录名 = <name>-<时间戳>(仅本地产物目录命名,不替代 version);version 仍用于内层
|
|
29
33
|
* releases/<version>-<ts>/ 子目录与 docker 镜像 tag。publish.cjs 经 release/.last-build manifest 定位产物。
|
|
@@ -189,10 +193,20 @@ async function bumpVersion() {
|
|
|
189
193
|
// nest build (tsc) → dist/ 目录树(入口仍 dist/main.js,与 webpack 时代兼容)
|
|
190
194
|
function runNestBuild() {
|
|
191
195
|
console.log('📦 nest build (tsc,含类型检查) src/ → dist/ ...');
|
|
192
|
-
|
|
196
|
+
// 不走 npx:pnpm workspace monorepo 里,npm 的 npx 会经 root/node_modules/@scope/<pkg>
|
|
197
|
+
// 符号链接路由 bin(@iscp/backend → backend),pnpm shim 内的相对路径(../../../.pnpm/...)
|
|
198
|
+
// 随之错位 → MODULE_NOT_FOUND。改为 require.resolve 直取 @nestjs/cli/bin/nest.js 真实
|
|
199
|
+
// 路径(symlink 布局下即 .pnpm store 真身,node 默认 realpath),node 直接起进程,与
|
|
200
|
+
// npm/pnpm/monorepo 三种布局解耦。
|
|
201
|
+
let nestCliBin;
|
|
202
|
+
try {
|
|
203
|
+
nestCliBin = require.resolve('@nestjs/cli/bin/nest.js', { paths: [BACKEND] });
|
|
204
|
+
} catch {
|
|
205
|
+
throw new Error('未找到 @nestjs/cli —— 请先安装 backend 依赖(根目录 pnpm i / npm i)后重跑');
|
|
206
|
+
}
|
|
207
|
+
const r = require('child_process').spawnSync(process.execPath, [nestCliBin, 'build'], {
|
|
193
208
|
cwd: BACKEND,
|
|
194
|
-
stdio: 'inherit'
|
|
195
|
-
shell: true
|
|
209
|
+
stdio: 'inherit'
|
|
196
210
|
});
|
|
197
211
|
if (r.status !== 0) throw new Error('nest build 失败(退出码 ' + r.status + ')');
|
|
198
212
|
const out = path.join(BACKEND, 'dist', 'main.js');
|
|
@@ -350,6 +364,52 @@ function validateLockfile() {
|
|
|
350
364
|
}
|
|
351
365
|
}
|
|
352
366
|
|
|
367
|
+
// 深合并 base + prod overlay(与 configuration.ts 同语义),读合并后的 app.port。
|
|
368
|
+
// 仅用于自动生成 .prod.docker.env 的 PORT 缺省;overlay 未覆盖则回落 base。
|
|
369
|
+
function mergedProdPort() {
|
|
370
|
+
const cfgDir = path.join(BACKEND, 'config');
|
|
371
|
+
const load = (f) => {
|
|
372
|
+
const p = path.join(cfgDir, f);
|
|
373
|
+
if (!fs.existsSync(p)) return {};
|
|
374
|
+
try {
|
|
375
|
+
return require('js-yaml').load(fs.readFileSync(p, 'utf8')) || {};
|
|
376
|
+
} catch {
|
|
377
|
+
return {};
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const base = load('config.base.yaml');
|
|
381
|
+
const prod = load('config.prod.yaml');
|
|
382
|
+
const port = (prod.app && prod.app.port) || (base.app && base.app.port) || 3001;
|
|
383
|
+
return Number.isFinite(port) ? port : 3001;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 自动生成 docker 部署 env(纯部署件,buildops 产物;勿手工维护):
|
|
387
|
+
// 业务配置(密钥/密码/阈值等)一律走 APP_ROOT/config/config.yaml(部署位 overlay,挂载/CONFIG_DIR
|
|
388
|
+
// 注入),env 文件只剩部署拓扑项 —— 固定集合,永不随业务配置增长,这是「自动生成」能稳定成立的
|
|
389
|
+
// 前提。PORT 缺省 = base+prod 合并的 app.port(docker 权威,运维可改);IMAGE/TAG/DATA_DIR/
|
|
390
|
+
// CONTAINER_NAME 由 ops/docker/install.sh 每次部署按包 name/环境现场对齐(此处写模板缺省)。
|
|
391
|
+
function genDockerEnv() {
|
|
392
|
+
const pkg = readPkg();
|
|
393
|
+
// 与 ops/lib.sh::pkg_name 同口径:剥 @scope 取 basename
|
|
394
|
+
const image = pkg.name.split('/').pop();
|
|
395
|
+
return [
|
|
396
|
+
'# docker 部署 env(纯部署件)—— buildops 自动生成,勿手工添加业务配置/密码!',
|
|
397
|
+
'# 业务配置(密钥/密码/限流阈值等)一律在 APP_ROOT/config/config.yaml(部署位 overlay,',
|
|
398
|
+
'# install.sh 首部署从包内环境 overlay 播种;改它 + ./start.sh 即生效)。',
|
|
399
|
+
'# 本文件只含部署拓扑项,其中 IMAGE/TAG/DATA_DIR/CONTAINER_NAME 由 ops/docker/install.sh',
|
|
400
|
+
'# 每次部署现场对齐(勿手改);PORT 为运行端口权威(docker 模式),运维可改,改后 ./start.sh 生效。',
|
|
401
|
+
'',
|
|
402
|
+
`IMAGE=${image}`,
|
|
403
|
+
'TAG=',
|
|
404
|
+
`CONTAINER_NAME=${image}`,
|
|
405
|
+
`PORT=${mergedProdPort()}`,
|
|
406
|
+
'DATA_DIR=',
|
|
407
|
+
'LOGS_DIR=',
|
|
408
|
+
'NODE_ENV=prod',
|
|
409
|
+
''
|
|
410
|
+
].join('\n');
|
|
411
|
+
}
|
|
412
|
+
|
|
353
413
|
// 组装运维包
|
|
354
414
|
function buildOps(version) {
|
|
355
415
|
ensureLockfile();
|
|
@@ -368,7 +428,9 @@ function buildOps(version) {
|
|
|
368
428
|
|
|
369
429
|
const TOP_SCRIPTS = ['install.sh', 'start.sh', 'stop.sh', 'versionswitch.sh', 'status.sh'];
|
|
370
430
|
|
|
371
|
-
// 1) 内层 releases/<version>-<ts>/:dist + package.json + Dockerfile + .dockerignore + docker-compose.yml + config/
|
|
431
|
+
// 1) 内层 releases/<version>-<ts>/:dist + package.json + Dockerfile + .dockerignore + docker-compose.yml + config/(base+prod 兜底)
|
|
432
|
+
// 内层 config/ 只作镜像内兜底(容器无部署位挂载时回退 base+prod;正常部署经 run-container.sh
|
|
433
|
+
// 挂载 APP_ROOT/config 覆盖之)。systemd/pm2 的运行时配置走 CONFIG_DIR=APP_ROOT/config,不消费内层 config。
|
|
372
434
|
// docker-compose.yml 与 Dockerfile 同级(compose 备选部署方式,install.sh 默认用 raw docker run 不走 compose);
|
|
373
435
|
// .dockerignore 排除 docker-compose.yml,使其不进镜像 build context(image 不含 compose 文件)。
|
|
374
436
|
// (子目录名带时间戳:同一 version 可能多次打包,加 ts 避免覆盖/混淆)
|
|
@@ -405,14 +467,18 @@ function buildOps(version) {
|
|
|
405
467
|
fs.copyFileSync(path.join(BACKEND, '.dockerignore'), path.join(appDir, '.dockerignore'));
|
|
406
468
|
fs.copyFileSync(path.join(BACKEND, 'docker-compose.yml'), path.join(appDir, 'docker-compose.yml'));
|
|
407
469
|
fs.mkdirSync(path.join(appDir, 'config'), { recursive: true });
|
|
470
|
+
fs.copyFileSync(
|
|
471
|
+
path.join(BACKEND, 'config', 'config.base.yaml'),
|
|
472
|
+
path.join(appDir, 'config', 'config.base.yaml')
|
|
473
|
+
);
|
|
408
474
|
fs.copyFileSync(
|
|
409
475
|
path.join(BACKEND, 'config', 'config.prod.yaml'),
|
|
410
476
|
path.join(appDir, 'config', 'config.prod.yaml')
|
|
411
477
|
);
|
|
412
|
-
// .build-ts
|
|
478
|
+
// .build-ts:记录构建时间戳(溯源用)。docker 镜像 tag 已改纯 version(见 ops/docker/config.sh::read_tag),不消费此文件。
|
|
413
479
|
fs.writeFileSync(path.join(appDir, '.build-ts'), ts + '\n', 'utf8');
|
|
414
480
|
|
|
415
|
-
// 2) ops/ 模块:mode dispatch(docker/pm2/systemd
|
|
481
|
+
// 2) ops/ 模块:mode dispatch(docker/pm2/systemd),排除顶层脚本(已提到包根,不在 ops/ 重复)
|
|
416
482
|
const opsSrc = path.join(BACKEND, 'ops');
|
|
417
483
|
const opsDst = path.join(outDir, 'ops');
|
|
418
484
|
fs.mkdirSync(opsDst, { recursive: true });
|
|
@@ -427,6 +493,33 @@ function buildOps(version) {
|
|
|
427
493
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(outDir, s));
|
|
428
494
|
}
|
|
429
495
|
|
|
496
|
+
// 3b) 随行配置 → 包根 config/(install.sh 部署时播种部署位 APP_ROOT/config + 移件入 APP_ROOT 根):
|
|
497
|
+
// config.base.yaml 全量默认(每次发布刷新部署位副本,新增键/注释随发布到位)
|
|
498
|
+
// config.<env>.yaml 全部环境 overlay(首部署选定一份播种为部署位 config.yaml;
|
|
499
|
+
// 每个部署点一个环境 → 一次构建可发多个环境)
|
|
500
|
+
// .prod.docker.env 自动生成的纯部署件(genDockerEnv,不含任何业务配置/密码)
|
|
501
|
+
// 密钥不随包:根密钥改由服务器首部署选「开启加密」时在部署机生成(ops/lib-encrypt.sh),
|
|
502
|
+
// 敏感值在包内明文、落地即加密(部署位 config.yaml),见 docs/config-encryption.md。
|
|
503
|
+
const pkgCfgDir = path.join(outDir, 'config');
|
|
504
|
+
fs.mkdirSync(pkgCfgDir, { recursive: true });
|
|
505
|
+
const cfgSrcDir = path.join(BACKEND, 'config');
|
|
506
|
+
// overlay = config.<name>.yaml(排除 base);base 必在,overlay 至少一份(prod),缺失即拦
|
|
507
|
+
const baseYaml = path.join(cfgSrcDir, 'config.base.yaml');
|
|
508
|
+
if (!fs.existsSync(baseYaml)) {
|
|
509
|
+
throw new Error(`缺 backend/config/config.base.yaml(全量默认配置,包不完整)`);
|
|
510
|
+
}
|
|
511
|
+
fs.copyFileSync(baseYaml, path.join(pkgCfgDir, 'config.base.yaml'));
|
|
512
|
+
const overlays = fs
|
|
513
|
+
.readdirSync(cfgSrcDir)
|
|
514
|
+
.filter((n) => /^config\.[A-Za-z0-9_-]+\.yaml$/.test(n) && n !== 'config.base.yaml');
|
|
515
|
+
if (!overlays.includes('config.prod.yaml')) {
|
|
516
|
+
throw new Error(`缺 backend/config/config.prod.yaml(生产 overlay,包不完整)`);
|
|
517
|
+
}
|
|
518
|
+
for (const name of overlays) {
|
|
519
|
+
fs.copyFileSync(path.join(cfgSrcDir, name), path.join(pkgCfgDir, name));
|
|
520
|
+
}
|
|
521
|
+
fs.writeFileSync(path.join(pkgCfgDir, '.prod.docker.env'), genDockerEnv());
|
|
522
|
+
|
|
430
523
|
// 4) 清理 macOS AppleDouble/.DS_Store(防御性兜底;根因在打包侧 COPYFILE_DISABLE)
|
|
431
524
|
const junk = cleanAppleDoubles(outDir);
|
|
432
525
|
if (junk) console.log(` 🧹 清理 macOS AppleDouble/.DS_Store 垃圾文件 ${junk} 个`);
|
|
@@ -436,8 +529,9 @@ function buildOps(version) {
|
|
|
436
529
|
|
|
437
530
|
console.log(`\n✓ 运维包: ${path.relative(BACKEND, outDir)} (v${version}, 总 ${fmtSize(dirSize(outDir))})`);
|
|
438
531
|
console.log(' ├── install.sh start.sh stop.sh versionswitch.sh status.sh (顶层薄封装,包根)');
|
|
439
|
-
console.log(
|
|
440
|
-
console.log('
|
|
532
|
+
console.log(` ├── config/ (config.base.yaml + ${overlays.length} 份环境 overlay + .prod.docker.env[自动生成];密钥不随包,服务器首部署生成)`);
|
|
533
|
+
console.log(' ├── ops/ (docker/pm2/systemd)');
|
|
534
|
+
console.log(' └── releases/<version>-<ts>/ (dist + package.json + package-lock.json + Dockerfile + .dockerignore + docker-compose.yml + config/[base+prod 兜底] + .build-ts)');
|
|
441
535
|
}
|
|
442
536
|
|
|
443
537
|
async function main() {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* config-crypt —— 配置密文(jasypt 式)运维工具
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/config-crypt.cjs genkey 生成新密钥(base64 32 字节,注入 CONFIG_SECRET_KEY)
|
|
7
|
+
* node scripts/config-crypt.cjs encrypt "<明文>" [key] 加密 → 输出 ENC(...) 贴进环境 overlay yaml(部署位 config.yaml 同位)
|
|
8
|
+
* node scripts/config-crypt.cjs decrypt "ENC(...)" [key] 解密 → 校验/排障
|
|
9
|
+
*
|
|
10
|
+
* 密钥来源优先级:[key] 参数 > process.env.CONFIG_SECRET_KEY。
|
|
11
|
+
* 根密钥在服务器首部署选「开启配置加密」时生成(ops/lib-encrypt.sh),只存服务器
|
|
12
|
+
* APP_ROOT/.prod.secrets(600;三模式注入:systemd=EnvironmentFile / pm2=读入 ecosystem env /
|
|
13
|
+
* docker=run-container.sh 读取后 run -e)。仓库/开发机零密钥 —— dev 明文运行不涉及本工具。
|
|
14
|
+
* 日常轮换不需要手工用它:改部署位 config.yaml 明文 → 重跑 ./install.sh 自动重新加密。
|
|
15
|
+
*
|
|
16
|
+
* 密文格式:ENC(<b64(iv)>.<b64(authTag)>.<b64(ciphertext)>),AES-256-GCM,与
|
|
17
|
+
* backend/src/common/config-crypto.ts 运行时解密同源(每次加密 IV 随机,同明文密文不同)。
|
|
18
|
+
*
|
|
19
|
+
* 安全提示:本工具只在你本地/运维机使用;密钥与明文不要写进任何会被提交的文件。
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
const KEY_BYTES = 32;
|
|
25
|
+
|
|
26
|
+
function fail(msg, code = 1) {
|
|
27
|
+
console.error(`✗ ${msg}`);
|
|
28
|
+
process.exit(code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getKey(argKey) {
|
|
32
|
+
const raw = argKey || process.env.CONFIG_SECRET_KEY;
|
|
33
|
+
if (!raw) {
|
|
34
|
+
fail('缺少密钥:请传 [key] 参数,或设 CONFIG_SECRET_KEY 环境变量(用 genkey 生成)');
|
|
35
|
+
}
|
|
36
|
+
let buf;
|
|
37
|
+
try {
|
|
38
|
+
buf = Buffer.from(raw, 'base64');
|
|
39
|
+
} catch {
|
|
40
|
+
fail('密钥不是合法 base64');
|
|
41
|
+
}
|
|
42
|
+
if (buf.length !== KEY_BYTES) {
|
|
43
|
+
fail(`密钥解密后为 ${buf.length} 字节,期望 ${KEY_BYTES} 字节(AES-256),请用 genkey 重新生成`);
|
|
44
|
+
}
|
|
45
|
+
return buf;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function genkey() {
|
|
49
|
+
const key = crypto.randomBytes(KEY_BYTES).toString('base64');
|
|
50
|
+
console.log(key);
|
|
51
|
+
console.error('\n# 用法:');
|
|
52
|
+
console.error('# 服务器首部署选「开启配置加密」时会自动生成根密钥(APP_ROOT/.prod.secrets,600);');
|
|
53
|
+
console.error('# 手动使用:写入 APP_ROOT/.prod.secrets 的 CONFIG_SECRET_KEY=,并保存到密码管理器');
|
|
54
|
+
console.error('# 加密: node scripts/config-crypt.cjs encrypt "<明文>" ' + key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encrypt(plain, key) {
|
|
58
|
+
const iv = crypto.randomBytes(12);
|
|
59
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
60
|
+
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
61
|
+
const tag = cipher.getAuthTag();
|
|
62
|
+
const out = `ENC(${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')})`;
|
|
63
|
+
console.log(out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decrypt(value, key) {
|
|
67
|
+
const m = /^ENC\((.*)\)$/.exec(value.trim());
|
|
68
|
+
if (!m) fail('不是 ENC(...) 格式的密文');
|
|
69
|
+
const parts = m[1].split('.');
|
|
70
|
+
if (parts.length !== 3) fail(`密文格式非法:期望三段,实得 ${parts.length}`);
|
|
71
|
+
const [ivB64, tagB64, cipherB64] = parts;
|
|
72
|
+
try {
|
|
73
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
74
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
75
|
+
const ciphertext = Buffer.from(cipherB64, 'base64');
|
|
76
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
77
|
+
decipher.setAuthTag(tag);
|
|
78
|
+
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
79
|
+
console.log(dec.toString('utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
fail('解密失败(密钥不匹配或密文已损坏)');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function main() {
|
|
86
|
+
const [, , cmd, ...rest] = process.argv;
|
|
87
|
+
if (cmd === 'genkey') return genkey();
|
|
88
|
+
if (cmd === 'encrypt') {
|
|
89
|
+
const [plain, key] = rest;
|
|
90
|
+
if (plain === undefined) fail('用法: encrypt "<明文>" [key]');
|
|
91
|
+
return encrypt(plain, getKey(key));
|
|
92
|
+
}
|
|
93
|
+
if (cmd === 'decrypt') {
|
|
94
|
+
const [value, key] = rest;
|
|
95
|
+
if (value === undefined) fail('用法: decrypt "ENC(...)" [key]');
|
|
96
|
+
return decrypt(value, getKey(key));
|
|
97
|
+
}
|
|
98
|
+
console.error(`config-crypt —— 配置密文(jasypt 式)运维工具
|
|
99
|
+
|
|
100
|
+
genkey 生成新密钥(base64 32 字节)
|
|
101
|
+
encrypt "<明文>" [key] 加密 → 输出 ENC(...)
|
|
102
|
+
decrypt "ENC(...)" [key] 解密 → 明文
|
|
103
|
+
|
|
104
|
+
密钥:[key] 参数 > CONFIG_SECRET_KEY env`);
|
|
105
|
+
process.exit(cmd ? 1 : 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main();
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* backend 发布/回滚共享的 SSH helper —— 薄传输+触发用。
|
|
3
3
|
* - connect:用 server.config.cjs 的 server{} 建连。
|
|
4
|
+
* - takeEnvName/resolveTarget:多服务器目标解析(--env <名> 选 server.config.cjs targets[名])。
|
|
5
|
+
* - pickTarget:目标选择入口(--env 直选;未传且配了 targets → 编号交互列选,非 TTY 回落首个)。
|
|
4
6
|
* - sshExec:非交互远端命令(返回 stdout,失败抛错)。
|
|
5
7
|
* - sshInteractive:分配 pty + 透传 stdin/stdout,供远端 install.sh / versionswitch.sh 的
|
|
6
8
|
* read -rp 交互(首次部署选模式、回滚选版本)直接在本终端操作。
|
|
7
9
|
*/
|
|
8
10
|
const fs = require('fs');
|
|
9
11
|
const os = require('os');
|
|
10
|
-
|
|
11
|
-
try { ({ NodeSSH } = require('node-ssh')); } catch (e) { fatal('缺少 node-ssh: pnpm i -w node-ssh'); }
|
|
12
|
+
const readline = require('readline');
|
|
12
13
|
|
|
14
|
+
// 色彩/日志先于 node-ssh 兜底声明:依赖缺失时 fatal 才能干净输出(C 未初始化会 ReferenceError)
|
|
13
15
|
const C = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', cyan: '\x1b[36m', yellow: '\x1b[33m', gray: '\x1b[90m' };
|
|
14
16
|
const log = {
|
|
15
17
|
info: (m) => console.log(`${C.cyan}▶${C.reset} ${m}`),
|
|
@@ -20,6 +22,73 @@ const log = {
|
|
|
20
22
|
function fatal(m) { console.error(`${C.red}✗${C.reset} ${m}`); process.exit(1); }
|
|
21
23
|
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
22
24
|
|
|
25
|
+
let NodeSSH;
|
|
26
|
+
try { ({ NodeSSH } = require('node-ssh')); } catch (e) { fatal('缺少 node-ssh: pnpm i -w node-ssh'); }
|
|
27
|
+
|
|
28
|
+
// 解析 --env <名>(多服务器目标名)。值缺失/未知绝不静默回落默认机,fail-fast 防发错机器。
|
|
29
|
+
function takeEnvName() {
|
|
30
|
+
const i = process.argv.indexOf('--env');
|
|
31
|
+
if (i === -1) return null;
|
|
32
|
+
const v = process.argv[i + 1];
|
|
33
|
+
if (!v) fatal('--env 缺少值: --env <目标名>(server.config.cjs targets 的 key)');
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 目标解析:--env 选中 server.config.cjs 的 targets[名];未传 --env 用顶层单服务器形态
|
|
38
|
+
// (老配置零改动,顶层即默认目标)。目标 = { server, backendServiceDir }。
|
|
39
|
+
// 建议与 web/scripts/server.config.json 的 key 同名:根命令 pnpm publish -- --env <名> 即 web+backend 同发一台。
|
|
40
|
+
function resolveTarget(cfg, envName) {
|
|
41
|
+
if (!envName) return cfg;
|
|
42
|
+
const t = cfg.targets && cfg.targets[envName];
|
|
43
|
+
if (!t || !t.server || !t.backendServiceDir) {
|
|
44
|
+
const names = cfg.targets ? Object.keys(cfg.targets).join(', ') : '(未配置任何 targets)';
|
|
45
|
+
fatal(`server.config.cjs targets['${envName}'] 不存在或不完整(需 server + backendServiceDir)。可用目标: ${names}`);
|
|
46
|
+
}
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 交互提问(非 TTY(CI):不挂起,回落 def 并 warn —— 与 web 模板「非交互静默用首个目标」同语义)
|
|
51
|
+
function ask(q, def) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
54
|
+
log.warn(`非交互环境,${q.trim()} → 默认 ${def}`);
|
|
55
|
+
resolve(def);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
+
// 空输入(直接回车)回落 def —— 提示文案承诺了「回车=N」,不带 \|\| 的话回车得 '' → Number('')=0 → 无效序号死循环
|
|
60
|
+
rl.question(q, (ans) => { rl.close(); resolve(ans.trim() || def); });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 目标选择(publish/rollback/pushdocker/pulldocker 共用):
|
|
65
|
+
// --env <名> 直选(fail-fast 校验);未传:
|
|
66
|
+
// · 配了 targets → 编号列选(回车=1;非 TTY 回落首个并 warn,CI 兼容)
|
|
67
|
+
// · 未配 targets → 顶层单服务器形态(老配置零改动,顶层即默认目标)
|
|
68
|
+
// 返回 { name, target }:name 为目标名(顶层默认为 '')。
|
|
69
|
+
// 建议与 web/scripts/server.config.json 的 key 同名:根命令 pnpm publish -- --env <名> 即 web+backend 同发一台。
|
|
70
|
+
async function pickTarget(cfg) {
|
|
71
|
+
const envName = takeEnvName();
|
|
72
|
+
if (envName) return { name: envName, target: resolveTarget(cfg, envName) };
|
|
73
|
+
const names = cfg.targets ? Object.keys(cfg.targets) : [];
|
|
74
|
+
if (names.length === 0) {
|
|
75
|
+
if (!cfg.server || !cfg.server.host || !cfg.backendServiceDir) {
|
|
76
|
+
fatal('server.config.cjs 未配置:需顶层 server + backendServiceDir(单机)或 targets(多机),见 server.config.example.cjs');
|
|
77
|
+
}
|
|
78
|
+
return { name: '', target: cfg };
|
|
79
|
+
}
|
|
80
|
+
console.log(`\n选择目标服务器?(server.config.cjs targets)`);
|
|
81
|
+
names.forEach((n, i) => console.log(
|
|
82
|
+
` ${C.gray}${String(i + 1).padStart(2)}.${C.reset} ${n} ${C.gray}(${cfg.targets[n].server?.host || '?'} → ${cfg.targets[n].backendServiceDir || '?'})${C.reset}`));
|
|
83
|
+
const def = '1';
|
|
84
|
+
while (true) {
|
|
85
|
+
const ans = await ask(`选择序号 [1-${names.length},回车=${def}]: `, def);
|
|
86
|
+
const n = Number(ans);
|
|
87
|
+
if (Number.isInteger(n) && n >= 1 && n <= names.length) return { name: names[n - 1], target: resolveTarget(cfg, names[n - 1]) };
|
|
88
|
+
log.warn(`无效序号: ${ans}(1-${names.length} 或回车)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
23
92
|
async function connect(cfg) {
|
|
24
93
|
const s = cfg.server;
|
|
25
94
|
if (!s || !s.host || !s.username) fatal('server.config.cjs server.host/username 未配置');
|
|
@@ -75,4 +144,4 @@ function sshInteractive(ssh, cmd, { cwd } = {}) {
|
|
|
75
144
|
});
|
|
76
145
|
}
|
|
77
146
|
|
|
78
|
-
module.exports = { log, fatal, shq, connect, sshExec, sshInteractive };
|
|
147
|
+
module.exports = { log, fatal, shq, connect, sshExec, sshInteractive, takeEnvName, resolveTarget, pickTarget };
|
|
@@ -9,20 +9,24 @@
|
|
|
9
9
|
* `APP_ROOT_HINT=<dir> bash install.sh` —— install.sh 引导(选部署模式交互;
|
|
10
10
|
* APP_ROOT 由 hint 给定无需手输)→ 建结构、占位阻断、软链 current、派发 mode install。
|
|
11
11
|
* · 远端 APP_ROOT/install.sh 已存在(二次部署):跑 `bash APP_ROOT/install.sh <tar>`
|
|
12
|
-
* —— APP_ROOT
|
|
12
|
+
* —— APP_ROOT/.mode 已就位(顶层脚本自定位),无交互(停旧 → 轮转 → 改链 → 重派发)。
|
|
13
13
|
* 成功后清理远端 tar 包与 .bootstrap 临时目录。
|
|
14
14
|
*
|
|
15
|
-
* 不外传 --mode:首次部署的部署方式由服务器 install.sh
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* 不外传 --mode:首次部署的部署方式由服务器 install.sh 交互确认;二次部署用服务器 .mode 里的 MODE。
|
|
16
|
+
* 配置随包走:包内 config/ 随行 base + 全部环境 overlay + 自动生成的 .prod.docker.env
|
|
17
|
+
* (纯部署件,密钥不随包);install.sh 部署时播种部署位 APP_ROOT/config(config.yaml =
|
|
18
|
+
* 选定 overlay 副本,此后发布不覆盖,运维/开发维护该环境真值)、刷新 base、env 文件
|
|
19
|
+
* 移入 APP_ROOT 根(原件随移删除)。敏感值明文随包传输,服务器首部署选「开启加密」时
|
|
20
|
+
* 在部署机生成根密钥并就地加密(此后每次部署自动加密新明文,见 ops/lib-encrypt.sh)。
|
|
21
|
+
* 改部署位 config.yaml + 重启即轮换(三模式一致)。开发机只持 SSH 登录凭证、零密钥。
|
|
19
22
|
*
|
|
20
|
-
* 用法: pnpm publish [--skip-build]
|
|
23
|
+
* 用法: pnpm publish [--skip-build] [--env <名>]
|
|
24
|
+
* 多服务器:--env 选 server.config.cjs targets[名](各机独立实例);不传:配了 targets 交互列选,否则顶层默认目标。
|
|
21
25
|
*/
|
|
22
26
|
const fs = require('fs');
|
|
23
27
|
const path = require('path');
|
|
24
28
|
const { spawn } = require('child_process');
|
|
25
|
-
const { log, fatal, shq, connect, sshExec, sshInteractive } = require('./lib/ssh.cjs');
|
|
29
|
+
const { log, fatal, shq, connect, sshExec, sshInteractive, pickTarget } = require('./lib/ssh.cjs');
|
|
26
30
|
|
|
27
31
|
const BACKEND = path.resolve(__dirname, '..');
|
|
28
32
|
const cfg = require('./server.config.cjs');
|
|
@@ -85,17 +89,21 @@ async function main() {
|
|
|
85
89
|
const { dir: releaseDir, version, safeName } = getReleaseDir();
|
|
86
90
|
log.ok(`运维包: release/${safeName}-${version} (v${version})`);
|
|
87
91
|
|
|
92
|
+
// 多服务器目标:--env <名> > targets 交互列选 > 顶层默认(server.config.cjs)
|
|
93
|
+
const { name: targetName, target } = await pickTarget(cfg);
|
|
94
|
+
log.info(`目标: ${targetName || '顶层默认'} (${target.server.host})`);
|
|
95
|
+
|
|
88
96
|
// 2) tar
|
|
89
97
|
log.info('打包 tar ...');
|
|
90
98
|
const tarPath = await makeTar(releaseDir, safeName, version);
|
|
91
99
|
const tarName = path.basename(tarPath);
|
|
92
100
|
log.ok(`tar: ${tarName}`);
|
|
93
101
|
|
|
94
|
-
const appRoot =
|
|
102
|
+
const appRoot = target.backendServiceDir;
|
|
95
103
|
const remoteTar = `${appRoot}/${tarName}`;
|
|
96
104
|
|
|
97
105
|
// 3) SSH + 上传 + 触发
|
|
98
|
-
const ssh = await connect(
|
|
106
|
+
const ssh = await connect(target);
|
|
99
107
|
try {
|
|
100
108
|
await sshExec(ssh, `mkdir -p ${shq(appRoot)}`);
|
|
101
109
|
log.info(`上传 ${tarName} → ${appRoot} ...`);
|