@zhipu/zp-cli 0.0.2 → 0.0.4

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 CHANGED
@@ -16,10 +16,21 @@ zpc pack
16
16
  # 在服务器上:目录内需有 <projectName>-dist.zip + zpc.config.*(配置不要打进压缩包)
17
17
  zpc deploy
18
18
  zpc deploy --force # 允许抢占冲突的 nginx proxy
19
+
20
+ # 盘点部署目录与 nginx 配置,标注已记录 / 未记录 / 记录但缺失
21
+ zpc check
19
22
  ```
20
23
 
24
+ `zpc deploy` 若发现无 `~/.zpc` 部署记录、但 api/web 部署目录非空或已有本项目 nginx 配置,会交互询问:
25
+
26
+ 1. 清空目标文件夹并覆盖(全量,旧目录备份为带时间戳的 `.bak-*`)
27
+ 2. 使用增量部署(api 保留 `excludeOnIncremental` 指定项,默认 `public`;web 全量覆盖)
28
+ 3. 取消部署(默认;非交互环境同样默认取消)
29
+
21
30
  可选环境变量:`DEPLOY_DIR`、`DEPLOY_FORCE`;`ZPC_HOME` 覆盖 `~/.zpc`(部署状态记录);测试可用 `ZPC_API_ROOT` / `ZPC_WEB_ROOT` / `ZPC_NGINX_DEFAULT_D` / `ZPC_DEPLOY_SKIP_RUNTIME`。
22
31
 
32
+ `api.excludeOnIncremental`:增量部署时排除、不覆盖的目录或文件名列表,默认 `["public"]`;仅对 api 生效,指定项在增量部署后保留服务器上的既有内容。
33
+
23
34
  ## 部署约定
24
35
 
25
36
  `zpc.config` 中的 `projectName` 即下文 `<system-tag>`。
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as findConfigFile, c as loadConfig, l as templateFileName, n as SCHEMA_FILE_NAME, o as isConfigFormat, r as configFileName, s as listConfigFiles, t as CONFIG_FORMATS } from "./config-BZc9pg7J.mjs";
1
+ import { a as findConfigFile, c as loadConfig, l as templateFileName, n as SCHEMA_FILE_NAME, o as isConfigFormat, r as configFileName, s as listConfigFiles, t as CONFIG_FORMATS } from "./config-DzcldbBT.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import fs, { mkdtempSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
@@ -8,7 +8,7 @@ import os, { tmpdir } from "node:os";
8
8
  import chalk from "chalk";
9
9
  import zlib from "node:zlib";
10
10
  //#region package.json
11
- var version = "0.0.2";
11
+ var version = "0.0.4";
12
12
  var description = "内部单命令部署工具";
13
13
  //#endregion
14
14
  //#region src/utils/deploy-paths.ts
@@ -321,17 +321,20 @@ function listNamedDirs(parent) {
321
321
  if (!fs.existsSync(parent)) return [];
322
322
  return fs.readdirSync(parent, { withFileTypes: true }).filter((d) => d.isDirectory() && isSafeDirName(d.name) && !d.name.startsWith(".")).map((d) => d.name);
323
323
  }
324
- /** 原子风格替换目录:删旧 `.bak` → 现目录改名为 `.bak` → 拷入新内容。 */
324
+ function backupTimestamp() {
325
+ const d = /* @__PURE__ */ new Date();
326
+ const pad = (n) => String(n).padStart(2, "0");
327
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
328
+ }
329
+ /**
330
+ * 原子风格替换目录:现目录改名为带时间戳的唯一 `.bak-*` 备份 → 拷入新内容。
331
+ * 备份名含时间戳,多次覆盖不会互相覆盖,历史版本得以保留;失败从该备份恢复。
332
+ */
325
333
  function deployDir(src, dest, log) {
326
334
  fs.mkdirSync(path.dirname(dest), { recursive: true });
327
- const bak = `${dest}.bak`;
328
- if (fs.existsSync(bak)) {
329
- log(`删除旧备份 ${bak}`);
330
- fs.rmSync(bak, {
331
- recursive: true,
332
- force: true
333
- });
334
- }
335
+ const stamp = backupTimestamp();
336
+ let bak = `${dest}.bak-${stamp}`;
337
+ for (let i = 1; fs.existsSync(bak); i++) bak = `${dest}.bak-${stamp}-${i}`;
335
338
  if (fs.existsSync(dest)) {
336
339
  log(`备份 ${dest} -> ${bak}`);
337
340
  fs.renameSync(dest, bak);
@@ -348,8 +351,8 @@ function deployDir(src, dest, log) {
348
351
  }
349
352
  }
350
353
  const API_INCREMENTAL_FILES = ["package.json", "bootstrap.js"];
351
- /** api 增量部署:只替换 dist 与根部 package.json / bootstrap.js,不碰 public。 */
352
- function deployApiIncremental(src, dest, log) {
354
+ /** api 增量部署:只替换 dist 与根部 package.json / bootstrap.js,不覆盖 excludeOnIncremental 指定项。 */
355
+ function deployApiIncremental(src, dest, exclude, log) {
353
356
  const distSrc = path.join(src, "dist");
354
357
  if (!fs.existsSync(distSrc)) throw new Error(`[deploy] ${src} 缺少 dist,无法增量部署`);
355
358
  fs.mkdirSync(dest, { recursive: true });
@@ -375,8 +378,15 @@ function deployApiIncremental(src, dest, log) {
375
378
  if (!fs.existsSync(from)) throw new Error(`[deploy] ${src} 缺少 ${file},无法增量部署`);
376
379
  fs.cpSync(from, path.join(dest, file));
377
380
  }
378
- fs.mkdirSync(path.join(dest, "public"), { recursive: true });
379
- log(`增量部署 ${src} -> ${dest}(更新 dist、${API_INCREMENTAL_FILES.join("、")},保留 public)`);
381
+ for (const entry of exclude) {
382
+ const destEntry = path.join(dest, entry);
383
+ if (fs.existsSync(destEntry)) continue;
384
+ const srcEntry = path.join(src, entry);
385
+ if (fs.existsSync(srcEntry) && !fs.statSync(srcEntry).isDirectory()) continue;
386
+ fs.mkdirSync(destEntry, { recursive: true });
387
+ }
388
+ const skipText = exclude.length ? `,跳过 ${exclude.join("、")}` : "";
389
+ log(`增量部署 ${src} -> ${dest}(更新 dist、${API_INCREMENTAL_FILES.join("、")}${skipText})`);
380
390
  }
381
391
  async function installAndReloadApi(name, dest, item, log) {
382
392
  if (!fs.existsSync(path.join(dest, "package.json"))) throw new Error(`[deploy] ${dest} 缺少 package.json,无法 pnpm i`);
@@ -488,6 +498,115 @@ function markProjectFullDeployed(root, projectName) {
488
498
  };
489
499
  writeDeployStateFile(state);
490
500
  }
501
+ /** 返回 ~/.zpc/deploy-state.json 中已记录的所有 projectName(去重、排序)。 */
502
+ function listRecordedProjectNames() {
503
+ const state = readDeployStateFile();
504
+ return [...new Set(Object.values(state.projects).map((p) => p.projectName))].sort();
505
+ }
506
+ //#endregion
507
+ //#region src/utils/check-state.ts
508
+ /** 从 nginx 配置的 `owned-by: projectName/name` 头解析出 projectName。 */
509
+ function nginxProjectName(text) {
510
+ const owned = parseNginxOwnedBy(text);
511
+ if (!owned) return null;
512
+ const idx = owned.indexOf("/");
513
+ return idx >= 0 ? owned.slice(0, idx) : owned;
514
+ }
515
+ /** 盘点部署痕迹(api/web 目录 + nginx 配置)与部署记录,合并成按系统分组的结果。 */
516
+ function collectSystemChecks() {
517
+ const recorded = new Set(listRecordedProjectNames());
518
+ const apiByProject = /* @__PURE__ */ new Map();
519
+ for (const projectName of listNamedDirs(getApiRoot())) apiByProject.set(projectName, listNamedDirs(path.join(getApiRoot(), projectName)));
520
+ const webByProject = /* @__PURE__ */ new Map();
521
+ for (const projectName of listNamedDirs(getWebRoot())) webByProject.set(projectName, listNamedDirs(path.join(getWebRoot(), projectName)));
522
+ const nginxByProject = /* @__PURE__ */ new Map();
523
+ const unownedNginxConfs = [];
524
+ const nginxDir = getNginxDefaultD();
525
+ if (fs.existsSync(nginxDir)) for (const name of fs.readdirSync(nginxDir)) {
526
+ if (!name.endsWith(".conf")) continue;
527
+ const full = path.join(nginxDir, name);
528
+ let text = "";
529
+ try {
530
+ text = fs.readFileSync(full, "utf8");
531
+ } catch {}
532
+ const projectName = nginxProjectName(text);
533
+ if (projectName) {
534
+ const arr = nginxByProject.get(projectName) ?? [];
535
+ arr.push(full);
536
+ nginxByProject.set(projectName, arr);
537
+ } else unownedNginxConfs.push(full);
538
+ }
539
+ return {
540
+ systems: [.../* @__PURE__ */ new Set([
541
+ ...recorded,
542
+ ...apiByProject.keys(),
543
+ ...webByProject.keys(),
544
+ ...nginxByProject.keys()
545
+ ])].sort().map((projectName) => ({
546
+ projectName,
547
+ recorded: recorded.has(projectName),
548
+ apiDirs: apiByProject.get(projectName) ?? [],
549
+ webDirs: webByProject.get(projectName) ?? [],
550
+ nginxConfs: nginxByProject.get(projectName) ?? []
551
+ })),
552
+ unownedNginxConfs
553
+ };
554
+ }
555
+ //#endregion
556
+ //#region src/utils/log.ts
557
+ function tagged(tag, message) {
558
+ return `${chalk.cyan(`[${tag}]`)} ${message}`;
559
+ }
560
+ /** 带命令前缀的彩色日志:info 默认、success 绿、warn 黄。 */
561
+ function createLogger(tag) {
562
+ return {
563
+ info: (message) => {
564
+ console.log(tagged(tag, message));
565
+ },
566
+ success: (message) => {
567
+ console.log(tagged(tag, chalk.green(message)));
568
+ },
569
+ warn: (message) => {
570
+ console.log(tagged(tag, chalk.yellow(message)));
571
+ }
572
+ };
573
+ }
574
+ function logError(message) {
575
+ console.error(chalk.red(message));
576
+ }
577
+ //#endregion
578
+ //#region src/commands/check.ts
579
+ const log$3 = createLogger("check");
580
+ function systemStatus(system) {
581
+ if (!system.recorded) return "未记录";
582
+ return system.apiDirs.length > 0 || system.webDirs.length > 0 || system.nginxConfs.length > 0 ? "已记录" : "记录但缺失";
583
+ }
584
+ function runCheck() {
585
+ const { systems, unownedNginxConfs } = collectSystemChecks();
586
+ if (systems.length === 0 && unownedNginxConfs.length === 0) {
587
+ log$3.info("未发现任何部署记录或部署痕迹");
588
+ return;
589
+ }
590
+ for (const system of systems) {
591
+ const status = systemStatus(system);
592
+ const head = `[${status}] ${system.projectName}`;
593
+ if (status === "已记录") log$3.info(head);
594
+ else log$3.warn(head);
595
+ if (!(system.apiDirs.length > 0 || system.webDirs.length > 0 || system.nginxConfs.length > 0)) continue;
596
+ if (system.apiDirs.length > 0) log$3.info(` api: ${system.apiDirs.join(", ")}`);
597
+ if (system.webDirs.length > 0) log$3.info(` web: ${system.webDirs.join(", ")}`);
598
+ if (system.nginxConfs.length > 0) log$3.info(` nginx: ${system.nginxConfs.join(", ")}`);
599
+ }
600
+ if (unownedNginxConfs.length > 0) {
601
+ log$3.warn(`[未归属 nginx 配置] ${unownedNginxConfs.length} 个`);
602
+ for (const conf of unownedNginxConfs) log$3.warn(` ${conf}`);
603
+ }
604
+ }
605
+ function createCheckCommand() {
606
+ return new Command("check").description("盘点部署目录与 nginx 配置,标注已记录 / 未记录 / 记录但缺失").action(async () => {
607
+ runCheck();
608
+ });
609
+ }
491
610
  //#endregion
492
611
  //#region src/utils/dist-zip.ts
493
612
  const ZPC_GITIGNORE_HEADER = "# zpc files";
@@ -511,8 +630,8 @@ function ensureZpcGitignore(projectRoot) {
511
630
  return true;
512
631
  }
513
632
  //#endregion
514
- //#region src/utils/incremental-mode.ts
515
- function isDirectoryNonEmpty(dir) {
633
+ //#region src/utils/deploy-conflict.ts
634
+ function isDirectoryNonEmpty$1(dir) {
516
635
  if (!fs.existsSync(dir)) return false;
517
636
  try {
518
637
  return fs.readdirSync(dir).length > 0;
@@ -520,55 +639,80 @@ function isDirectoryNonEmpty(dir) {
520
639
  return false;
521
640
  }
522
641
  }
523
- /** 配置中 api 在部署根目录下已存在且非空的目录。 */
524
- function findNonemptyApiDeployDirs(config) {
642
+ /** 收集「无 ~/.zpc 记录」时应提示用户的部署痕迹:api/web 目录非空、已存在/冲突的 nginx 配置。 */
643
+ function collectDeployConflicts(config) {
525
644
  const projectName = config.projectName;
526
- if (!projectName) return [];
527
- const found = [];
645
+ const conflicts = [];
646
+ if (!projectName) return conflicts;
528
647
  for (const item of config.api ?? []) {
529
- const deployDir = path.join(getApiRoot(), projectName, item.name);
530
- if (isDirectoryNonEmpty(deployDir)) found.push(deployDir);
648
+ const dir = path.join(getApiRoot(), projectName, item.name);
649
+ if (isDirectoryNonEmpty$1(dir)) conflicts.push({
650
+ kind: "api-dir",
651
+ message: `api 部署目录非空: ${dir}`
652
+ });
531
653
  }
532
- return found;
533
- }
534
- /**
535
- * 全量 / 增量判定(pack 只读、deploy 读后写入):
536
- * 1. ~/.zpc/deploy-state.json 有记录 → 增量
537
- * 2. 无记录但 api 部署目录非空 → 增量并提示
538
- * 3. 无记录但 zip 含增量标记 → 增量
539
- * 4. 否则全量
540
- */
541
- function resolveIncrementalMode(projectRoot, config, options = {}) {
542
- if (hasFullDeployRecord(projectRoot)) return { incremental: true };
543
- const existingDeployDirs = findNonemptyApiDeployDirs(config);
544
- if (existingDeployDirs.length > 0) return {
545
- incremental: true,
546
- existingDeployHint: `无 ~/.zpc 部署记录,但检测到 api 部署目录非空:${existingDeployDirs.map((dir) => path.normalize(dir)).join(", ")}。将按增量处理(api 保留 public,跳过 web);若确需全量请清空部署目录并删除 deploy-state 中对应记录`
547
- };
548
- if (options.zipIsIncremental) return { incremental: true };
549
- return { incremental: false };
550
- }
551
- //#endregion
552
- //#region src/utils/log.ts
553
- function tagged(tag, message) {
554
- return `${chalk.cyan(`[${tag}]`)} ${message}`;
555
- }
556
- /** 带命令前缀的彩色日志:info 默认、success 绿、warn 黄。 */
557
- function createLogger(tag) {
558
- return {
559
- info: (message) => {
560
- console.log(tagged(tag, message));
561
- },
562
- success: (message) => {
563
- console.log(tagged(tag, chalk.green(message)));
564
- },
565
- warn: (message) => {
566
- console.log(tagged(tag, chalk.yellow(message)));
654
+ for (const item of config.web ?? []) {
655
+ const dir = path.join(getWebRoot(), projectName, item.name);
656
+ if (isDirectoryNonEmpty$1(dir)) conflicts.push({
657
+ kind: "web-dir",
658
+ message: `web 部署目录非空: ${dir}`
659
+ });
660
+ }
661
+ const defaultD = getNginxDefaultD();
662
+ if (!fs.existsSync(defaultD)) return conflicts;
663
+ const proxies = (config.api ?? []).map((item) => item.proxy).filter((p) => !!p);
664
+ for (const name of fs.readdirSync(defaultD)) {
665
+ if (!name.endsWith(".conf")) continue;
666
+ const full = path.join(defaultD, name);
667
+ let text;
668
+ try {
669
+ text = fs.readFileSync(full, "utf8");
670
+ } catch {
671
+ continue;
567
672
  }
568
- };
569
- }
570
- function logError(message) {
571
- console.error(chalk.red(message));
673
+ const owned = parseNginxOwnedBy(text);
674
+ if (owned?.startsWith(`${projectName}/`) || name.startsWith(`${projectName}-`)) {
675
+ conflicts.push({
676
+ kind: "nginx",
677
+ message: `已存在本项目 nginx 配置: ${full}`
678
+ });
679
+ continue;
680
+ }
681
+ for (const proxy of proxies) if (confDefinesProxy(text, proxy)) {
682
+ conflicts.push({
683
+ kind: "nginx",
684
+ message: `nginx 配置 ${full} 已定义 proxy ${nginxLocation(proxy)}(归属 ${owned ?? "未知"})`
685
+ });
686
+ break;
687
+ }
688
+ }
689
+ return conflicts;
690
+ }
691
+ function isInteractive() {
692
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
693
+ }
694
+ /** 交互询问部署方式;非 TTY 环境直接返回取消(默认)。 */
695
+ async function promptDeployResolution() {
696
+ if (!isInteractive()) return "cancel";
697
+ const { select } = await import("@inquirer/prompts");
698
+ return await select({
699
+ message: "检测到已有部署痕迹(无 ~/.zpc 记录),请选择部署方式:",
700
+ choices: [
701
+ {
702
+ name: "清空目标文件夹并覆盖(全量,旧目录将备份)",
703
+ value: "full"
704
+ },
705
+ {
706
+ name: "使用增量部署(api 保留排除项,web 全量覆盖)",
707
+ value: "incremental"
708
+ },
709
+ {
710
+ name: "取消部署",
711
+ value: "cancel"
712
+ }
713
+ ],
714
+ default: "cancel"
715
+ });
572
716
  }
573
717
  //#endregion
574
718
  //#region src/utils/zip.ts
@@ -707,15 +851,16 @@ async function deployKind(stagingDir, items, kind, destRoot, projectName, increm
707
851
  if (!fs.existsSync(src)) throw new Error(`[deploy] 配置中有 ${kind}/${item.name},但压缩包中没有对应目录`);
708
852
  const dest = path.join(destRoot, projectName, item.name);
709
853
  const pm2Name = `${projectName}-${item.name}`;
710
- if (kind === "api" && incremental && fs.existsSync(dest)) deployApiIncremental(src, dest, log$2.info);
854
+ const apiItem = kind === "api" ? item : null;
855
+ if (apiItem && incremental && fs.existsSync(dest)) deployApiIncremental(src, dest, apiItem.excludeOnIncremental ?? [], log$2.info);
711
856
  else {
712
- if (kind === "api" && incremental && !fs.existsSync(dest)) log$2.warn(`${dest} 不存在,增量改为全量部署`);
857
+ if (apiItem && incremental && !fs.existsSync(dest)) log$2.warn(`${dest} 不存在,增量改为全量部署`);
713
858
  deployDir(src, dest, log$2.info);
714
859
  }
715
- if (kind === "api" && !shouldSkipDeployRuntime()) await installAndReloadApi(pm2Name, dest, item, log$2.info);
860
+ if (apiItem && !shouldSkipDeployRuntime()) await installAndReloadApi(pm2Name, dest, apiItem, log$2.info);
716
861
  }
717
862
  }
718
- async function runDeploy(dir, options) {
863
+ async function runDeploy(dir, options, prompt = promptDeployResolution) {
719
864
  const force = Boolean(options.force) || process.env.DEPLOY_FORCE === "1" || process.env.DEPLOY_FORCE === "true";
720
865
  const projectDir = resolveProjectDir(dir);
721
866
  const stagingDir = path.join(projectDir, ".deploy-staging");
@@ -739,16 +884,33 @@ async function runDeploy(dir, options) {
739
884
  throw new Error(`[deploy] 解压失败: ${message}`);
740
885
  }
741
886
  const zipIsIncremental = fs.existsSync(path.join(stagingDir, INCREMENTAL_MARKER));
742
- const deployMode = resolveIncrementalMode(projectDir, config, { zipIsIncremental });
743
- const incremental = deployMode.incremental;
887
+ const hadRecord = hasFullDeployRecord(projectDir);
888
+ let incremental;
889
+ let resolvedFromConflicts = false;
890
+ if (hadRecord) incremental = true;
891
+ else {
892
+ const conflicts = collectDeployConflicts(config);
893
+ if (conflicts.length > 0) {
894
+ for (const conflict of conflicts) log$2.warn(conflict.message);
895
+ const choice = await prompt();
896
+ if (choice === "cancel") {
897
+ log$2.warn("已取消部署");
898
+ fs.rmSync(stagingDir, {
899
+ recursive: true,
900
+ force: true
901
+ });
902
+ return;
903
+ }
904
+ incremental = choice === "incremental";
905
+ resolvedFromConflicts = true;
906
+ } else incremental = zipIsIncremental;
907
+ }
744
908
  try {
745
- log$2.info(`模式: ${incremental ? "增量(api 保留 public,跳过 web)" : "全量"}`);
746
- if (deployMode.existingDeployHint) log$2.warn(deployMode.existingDeployHint);
747
- else if (!incremental) log$2.info("首次部署该项目,完成后将记录到 ~/.zpc/deploy-state.json");
909
+ log$2.info(`模式: ${incremental ? "增量(api 保留排除项,web 全量覆盖)" : "全量"}`);
910
+ if (!incremental && !hadRecord) log$2.info("首次部署该项目,完成后将记录到 ~/.zpc/deploy-state.json");
748
911
  if (force) log$2.warn("已启用 --force(允许抢占冲突的 nginx proxy)");
749
912
  await deployKind(stagingDir, config.api, "api", getApiRoot(), config.projectName, incremental);
750
- if (!incremental) await deployKind(stagingDir, config.web, "web", getWebRoot(), config.projectName, false);
751
- else if (config.web?.length) log$2.warn("增量模式跳过 web 部署");
913
+ await deployKind(stagingDir, config.web, "web", getWebRoot(), config.projectName, false);
752
914
  } finally {
753
915
  fs.rmSync(stagingDir, {
754
916
  recursive: true,
@@ -762,11 +924,11 @@ async function runDeploy(dir, options) {
762
924
  if (!hasFullDeployRecord(projectDir)) {
763
925
  markProjectFullDeployed(projectDir, config.projectName);
764
926
  if (!incremental) log$2.info("已记录全量部署状态到 ~/.zpc/deploy-state.json,下次 deploy 将默认增量");
765
- else if (deployMode.existingDeployHint) log$2.info("已根据现有部署目录建立部署记录,下次 deploy 将默认增量");
927
+ else if (resolvedFromConflicts) log$2.info("已根据现有部署目录建立部署记录,下次 deploy 将默认增量");
766
928
  else if (zipIsIncremental) log$2.info("已记录部署状态,下次 deploy 将默认增量");
767
929
  }
768
930
  const apiCount = config.api?.length ?? 0;
769
- const webCount = incremental ? 0 : config.web?.length ?? 0;
931
+ const webCount = config.web?.length ?? 0;
770
932
  const prefix = `${config.projectName}/<name>`;
771
933
  log$2.success(`完成: api ${apiCount} 个 -> ${getApiRoot()}/${prefix},web ${webCount} 个 -> ${getWebRoot()}/${prefix}`);
772
934
  }
@@ -808,12 +970,62 @@ function createInitCommand() {
808
970
  });
809
971
  }
810
972
  //#endregion
973
+ //#region src/utils/incremental-mode.ts
974
+ function isDirectoryNonEmpty(dir) {
975
+ if (!fs.existsSync(dir)) return false;
976
+ try {
977
+ return fs.readdirSync(dir).length > 0;
978
+ } catch {
979
+ return false;
980
+ }
981
+ }
982
+ /** 配置中所有部署目录(api + web)里已存在且非空的目录。 */
983
+ function findNonemptyDeployDirs(config) {
984
+ const projectName = config.projectName;
985
+ if (!projectName) return [];
986
+ const found = [];
987
+ for (const item of config.api ?? []) {
988
+ const deployDir = path.join(getApiRoot(), projectName, item.name);
989
+ if (isDirectoryNonEmpty(deployDir)) found.push(deployDir);
990
+ }
991
+ for (const item of config.web ?? []) {
992
+ const deployDir = path.join(getWebRoot(), projectName, item.name);
993
+ if (isDirectoryNonEmpty(deployDir)) found.push(deployDir);
994
+ }
995
+ return found;
996
+ }
997
+ /**
998
+ * 全量 / 增量判定(pack 只读、deploy 读后写入):
999
+ * 1. ~/.zpc/deploy-state.json 有记录 → 增量
1000
+ * 2. 无记录但 api/web 部署目录非空 → 交由调用方决策(不直接判定)
1001
+ * 3. 无记录但 zip 含增量标记 → 增量
1002
+ * 4. 否则全量
1003
+ */
1004
+ function resolveIncrementalMode(projectRoot, config, options = {}) {
1005
+ if (hasFullDeployRecord(projectRoot)) return {
1006
+ incremental: true,
1007
+ existingDeployDirs: []
1008
+ };
1009
+ const existingDeployDirs = findNonemptyDeployDirs(config);
1010
+ if (existingDeployDirs.length > 0) return {
1011
+ incremental: false,
1012
+ existingDeployDirs
1013
+ };
1014
+ if (options.zipIsIncremental) return {
1015
+ incremental: true,
1016
+ existingDeployDirs: []
1017
+ };
1018
+ return {
1019
+ incremental: false,
1020
+ existingDeployDirs: []
1021
+ };
1022
+ }
1023
+ //#endregion
811
1024
  //#region src/commands/pack.ts
812
1025
  const log = createLogger("pack");
813
- function resolveProjects(root, config, incremental) {
1026
+ function resolveProjects(root, config) {
814
1027
  const projects = [];
815
- const kinds = incremental ? ["api"] : ["api", "web"];
816
- for (const kind of kinds) {
1028
+ for (const kind of ["api", "web"]) {
817
1029
  const items = config[kind];
818
1030
  if (items == null) continue;
819
1031
  for (const item of items) {
@@ -829,7 +1041,7 @@ function resolveProjects(root, config, incremental) {
829
1041
  });
830
1042
  }
831
1043
  }
832
- if (projects.length === 0) throw new Error(incremental ? "[pack] 增量打包需要配置中至少有一个 api 项目" : "[pack] 配置中没有 api / web 项目");
1044
+ if (projects.length === 0) throw new Error("[pack] 配置中没有 api / web 项目");
833
1045
  return projects;
834
1046
  }
835
1047
  function resolveBuildEnv(project, config) {
@@ -895,7 +1107,8 @@ function stageApi(project, dest, incremental) {
895
1107
  copyRequiredFile(path.join(project.absPath, "package.json"), dest, "package.json");
896
1108
  copyRequiredFile(path.join(project.absPath, "bootstrap.js"), dest, "bootstrap.js");
897
1109
  if (incremental) {
898
- log.warn(`api/${project.name} 增量:跳过 public`);
1110
+ const excluded = project.excludeOnIncremental ?? [];
1111
+ log.warn(excluded.length ? `api/${project.name} 增量:跳过 ${excluded.join("、")}` : `api/${project.name} 增量:不排除任何目录/文件`);
899
1112
  return;
900
1113
  }
901
1114
  const publicDir = path.join(project.absPath, "public");
@@ -926,14 +1139,17 @@ async function runPack(dir, options) {
926
1139
  const { file, config } = await loadConfig(projectRoot);
927
1140
  const zipPath = distZipPath(projectRoot, config.projectName);
928
1141
  const packMode = resolveIncrementalMode(projectRoot, config);
929
- const incremental = packMode.incremental;
1142
+ const incremental = packMode.incremental || packMode.existingDeployDirs.length > 0;
930
1143
  log.info(`项目目录: ${projectRoot}`);
931
1144
  log.info(`配置: ${file}`);
932
1145
  log.info(`项目: ${config.projectName}`);
933
- log.info(`模式: ${incremental ? "增量(仅 api,不含 public)" : "全量"}`);
934
- if (packMode.existingDeployHint) log.warn(packMode.existingDeployHint);
1146
+ log.info(`模式: ${incremental ? "增量(api 不含排除项,web 全量)" : "全量"}`);
1147
+ if (packMode.existingDeployDirs.length > 0) {
1148
+ const listed = packMode.existingDeployDirs.map((dir) => path.normalize(dir)).join(", ");
1149
+ log.warn(`无 ~/.zpc 部署记录,但检测到部署目录非空:${listed}。将按增量处理(api 保留排除项,web 全量覆盖);若确需全量请清空部署目录并删除 deploy-state 中对应记录`);
1150
+ }
935
1151
  if (options.concurrency !== void 0) log.info(`并行上限: ${options.concurrency}`);
936
- const projects = resolveProjects(projectRoot, config, incremental);
1152
+ const projects = resolveProjects(projectRoot, config);
937
1153
  await buildAll(projects, config, options);
938
1154
  stageDist(stagingDir, projectRoot, projects, incremental);
939
1155
  try {
@@ -964,7 +1180,7 @@ function createPackCommand() {
964
1180
  //#endregion
965
1181
  //#region src/utils/program.ts
966
1182
  function createProgram() {
967
- const program = new Command().name("zpc").description(description).version(version).showHelpAfterError().addCommand(createInitCommand()).addCommand(createPackCommand()).addCommand(createDeployCommand());
1183
+ const program = new Command().name("zpc").description(description).version(version).showHelpAfterError().addCommand(createInitCommand()).addCommand(createPackCommand()).addCommand(createDeployCommand()).addCommand(createCheckCommand());
968
1184
  program.action(() => {
969
1185
  program.outputHelp();
970
1186
  });
@@ -13,6 +13,8 @@ const CONFIG_FORMATS = [
13
13
  "cjs",
14
14
  "json"
15
15
  ];
16
+ /** 增量部署默认排除、不覆盖的目录/文件名。 */
17
+ const DEFAULT_EXCLUDE_ON_INCREMENTAL = ["public"];
16
18
  function defineConfig(config) {
17
19
  return config;
18
20
  }
@@ -66,9 +68,9 @@ function validateConfig(config, source) {
66
68
  function optionalAppList(value, kind, source) {
67
69
  if (value == null) return [];
68
70
  if (!Array.isArray(value)) throw new Error(`[config] ${source} 中 ${kind} 必须是数组`);
69
- return value.map((item, index) => parseAppConfig(item, `${source} ${kind}[${index}]`));
71
+ return value.map((item, index) => parseAppConfig(item, kind, `${source} ${kind}[${index}]`));
70
72
  }
71
- function parseAppConfig(item, label) {
73
+ function parseAppConfig(item, kind, label) {
72
74
  if (!isPlainObject(item)) throw new Error(`[config] ${label} 必须是对象`);
73
75
  if (typeof item.path !== "string" || item.path.trim() === "") throw new Error(`[config] ${label} 缺少 path`);
74
76
  if (!isSafeName(item.name)) throw new Error(`[config] ${label} 缺少合法 name`);
@@ -81,8 +83,15 @@ function parseAppConfig(item, label) {
81
83
  parsed.proxy = item.proxy;
82
84
  }
83
85
  if (item.buildEnv != null) parsed.buildEnv = parseEnvMap(item.buildEnv, `${label} buildEnv`);
84
- if (item.env != null) parsed.env = parseEnvMap(item.env, `${label} env`);
85
- return parsed;
86
+ if (kind === "web") return parsed;
87
+ const api = { ...parsed };
88
+ if (item.env != null) api.env = parseEnvMap(item.env, `${label} env`);
89
+ api.excludeOnIncremental = item.excludeOnIncremental != null ? parseExcludeOnIncremental(item.excludeOnIncremental, `${label} excludeOnIncremental`) : [...DEFAULT_EXCLUDE_ON_INCREMENTAL];
90
+ return api;
91
+ }
92
+ function parseExcludeOnIncremental(value, label) {
93
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !isSafeName(entry))) throw new Error(`[config] ${label} 必须是字符串数组(目录/文件名,不含路径分隔符)`);
94
+ return value;
86
95
  }
87
96
  function parseEnvMap(value, label) {
88
97
  if (!isPlainObject(value)) throw new Error(`[config] ${label} 必须是对象`);
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@
2
2
  declare const CONFIG_FORMATS: readonly ["ts", "mts", "cts", "js", "mjs", "cjs", "json"];
3
3
  type ConfigFormat = (typeof CONFIG_FORMATS)[number];
4
4
  type ZpcEnvMap = Record<string, string | number | boolean>;
5
+ /** api / web 应用配置公共字段 */
5
6
  interface ZpcAppConfig {
6
7
  /** 相对项目根的源码目录 */
7
8
  path: string;
@@ -11,14 +12,21 @@ interface ZpcAppConfig {
11
12
  proxy?: string;
12
13
  /** 仅构建期注入 */
13
14
  buildEnv?: ZpcEnvMap;
15
+ }
16
+ /** api 应用配置(额外含运行时 env 与增量部署排除项) */
17
+ interface ZpcApiConfig extends ZpcAppConfig {
14
18
  /** 仅运行时注入(pm2) */
15
19
  env?: ZpcEnvMap;
20
+ /** 增量部署时排除、不覆盖的目录/文件名;默认 ["public"] */
21
+ excludeOnIncremental?: string[];
16
22
  }
23
+ /** web 应用配置(静态站点,仅构建期 buildEnv,无运行时 env) */
24
+ interface ZpcWebConfig extends ZpcAppConfig {}
17
25
  interface ZpcConfig {
18
26
  projectName: string;
19
- api?: ZpcAppConfig[];
20
- web?: ZpcAppConfig[];
27
+ api?: ZpcApiConfig[];
28
+ web?: ZpcWebConfig[];
21
29
  }
22
30
  declare function defineConfig(config: ZpcConfig): ZpcConfig;
23
31
  //#endregion
24
- export { type ConfigFormat, type ZpcAppConfig, type ZpcConfig, type ZpcEnvMap, defineConfig };
32
+ export { type ConfigFormat, type ZpcApiConfig, type ZpcAppConfig, type ZpcConfig, type ZpcEnvMap, type ZpcWebConfig, defineConfig };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { i as defineConfig } from "./config-BZc9pg7J.mjs";
1
+ import { i as defineConfig } from "./config-DzcldbBT.mjs";
2
2
  export { defineConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhipu/zp-cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "内部单命令部署工具",
5
5
  "bin": {
6
6
  "zpc": "./dist/cli.mjs"
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@commander-js/extra-typings": "^15.0.0",
22
+ "@inquirer/prompts": "^8.6.0",
22
23
  "@vue-tui/components": "^0.3.0",
23
24
  "@vue-tui/runtime": "^0.3.0",
24
25
  "chalk": "^6.0.0",
@@ -55,6 +56,21 @@
55
56
  "@alcalzone/ansi-tokenize": "0.3.0",
56
57
  "@babel/parser": "7.29.8",
57
58
  "@commander-js/extra-typings": "15.0.0",
59
+ "@inquirer/ansi": "2.0.7",
60
+ "@inquirer/checkbox": "5.2.2",
61
+ "@inquirer/confirm": "6.2.0",
62
+ "@inquirer/core": "12.0.0",
63
+ "@inquirer/editor": "5.3.0",
64
+ "@inquirer/expand": "5.1.2",
65
+ "@inquirer/external-editor": "3.0.4",
66
+ "@inquirer/figures": "2.0.8",
67
+ "@inquirer/input": "5.1.3",
68
+ "@inquirer/number": "4.2.0",
69
+ "@inquirer/password": "5.1.2",
70
+ "@inquirer/prompts": "8.6.0",
71
+ "@inquirer/rawlist": "5.3.2",
72
+ "@inquirer/search": "4.3.0",
73
+ "@inquirer/select": "5.2.2",
58
74
  "@vue-tui/components": "0.3.0",
59
75
  "@vue-tui/runtime": "0.3.0",
60
76
  "@vue/compiler-core": "3.5.41",
@@ -69,16 +85,24 @@
69
85
  "5.6.2",
70
86
  "6.0.0"
71
87
  ],
88
+ "chardet": "2.2.0",
72
89
  "cli-boxes": "3.0.0",
73
90
  "cli-truncate": "6.1.1",
91
+ "cli-width": "4.1.0",
74
92
  "commander": "15.0.0",
75
93
  "entities": "7.0.1",
76
94
  "environment": "1.1.0",
77
95
  "estree-walker": "2.0.2",
96
+ "fast-string-truncated-width": "3.0.3",
97
+ "fast-string-width": "3.0.2",
98
+ "fast-wrap-ansi": "0.2.2",
78
99
  "get-east-asian-width": "1.6.0",
100
+ "iconv-lite": "0.7.3",
79
101
  "is-fullwidth-code-point": "5.1.0",
80
102
  "jiti": "2.7.0",
103
+ "mute-stream": "3.0.0",
81
104
  "patch-console": "2.0.0",
105
+ "safer-buffer": "2.1.2",
82
106
  "signal-exit": "4.1.0",
83
107
  "slice-ansi": "9.0.0",
84
108
  "source-map-js": "1.2.1",
@@ -54,6 +54,15 @@
54
54
  "env": {
55
55
  "$ref": "#/definitions/envMap",
56
56
  "description": "仅运行时注入(pm2)"
57
+ },
58
+ "excludeOnIncremental": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "string",
62
+ "minLength": 1
63
+ },
64
+ "default": ["public"],
65
+ "description": "增量部署时排除、不覆盖的目录/文件名;仅 api 生效,默认 [\"public\"]"
57
66
  }
58
67
  }
59
68
  },