@zhipu/zp-cli 0.0.1 → 0.0.3

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/dist/cli.mjs CHANGED
@@ -1,12 +1,14 @@
1
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";
2
2
  import { createRequire } from "node:module";
3
- import fs from "node:fs";
3
+ import fs, { mkdtempSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { Command } from "@commander-js/extra-typings";
6
6
  import { spawn } from "node:child_process";
7
+ import os, { tmpdir } from "node:os";
8
+ import chalk from "chalk";
7
9
  import zlib from "node:zlib";
8
10
  //#region package.json
9
- var version = "0.0.1";
11
+ var version = "0.0.3";
10
12
  var description = "内部单命令部署工具";
11
13
  //#endregion
12
14
  //#region src/utils/deploy-paths.ts
@@ -33,7 +35,82 @@ function shouldSkipDeployRuntime() {
33
35
  return process.env.ZPC_DEPLOY_SKIP_RUNTIME === "1";
34
36
  }
35
37
  //#endregion
38
+ //#region src/utils/tty-shim.ts
39
+ /** 子进程预加载脚本:让 piped stdout/stderr 表现为 TTY,以启用 Vite 等工具的进度覆写。 */
40
+ const TTY_SHIM_CJS = String.raw`'use strict';
41
+
42
+ function patchStdStream(stream) {
43
+ if (!stream || stream.__zpcTtyPatched) return;
44
+ stream.__zpcTtyPatched = true;
45
+ Object.defineProperty(stream, 'isTTY', {
46
+ value: true,
47
+ enumerable: true,
48
+ configurable: true,
49
+ });
50
+ if (stream.columns == null) stream.columns = 120;
51
+ if (stream.rows == null) stream.rows = 30;
52
+ if (typeof stream.clearLine !== 'function') {
53
+ stream.clearLine = function clearLine(dir, cb) {
54
+ if (typeof dir === 'function') {
55
+ cb = dir;
56
+ dir = 0;
57
+ }
58
+ if (dir === 0 || dir === undefined) stream.write('\x1b[2K\r');
59
+ cb?.();
60
+ return true;
61
+ };
62
+ }
63
+ if (typeof stream.cursorTo !== 'function') {
64
+ stream.cursorTo = function cursorTo(x, y, cb) {
65
+ if (typeof y === 'function') {
66
+ cb = y;
67
+ y = undefined;
68
+ }
69
+ if (typeof x === 'function') {
70
+ cb = x;
71
+ x = 0;
72
+ y = undefined;
73
+ }
74
+ if (y === undefined) stream.write('\x1b[' + (x + 1) + 'G');
75
+ else stream.write('\x1b[' + (y + 1) + ';' + (x + 1) + 'H');
76
+ cb?.();
77
+ return true;
78
+ };
79
+ }
80
+ }
81
+
82
+ patchStdStream(process.stdout);
83
+ patchStdStream(process.stderr);
84
+ `;
85
+ //#endregion
86
+ //#region src/utils/tty-shim-path.ts
87
+ let cachedShimPath;
88
+ /** 将 tty shim 写入临时目录并缓存路径,供 NODE_OPTIONS --require 使用。 */
89
+ function ensureTtyShimPath() {
90
+ if (cachedShimPath) return cachedShimPath;
91
+ const dir = mkdtempSync(path.join(tmpdir(), "zpc-tty-"));
92
+ cachedShimPath = path.join(dir, "tty-shim.cjs");
93
+ writeFileSync(cachedShimPath, TTY_SHIM_CJS, "utf8");
94
+ return cachedShimPath;
95
+ }
96
+ //#endregion
36
97
  //#region src/utils/run.ts
98
+ function killProcessTree(child) {
99
+ if (!child.pid || child.killed) return;
100
+ if (process.platform === "win32") {
101
+ spawn("taskkill", [
102
+ "/pid",
103
+ String(child.pid),
104
+ "/f",
105
+ "/t"
106
+ ], {
107
+ stdio: "ignore",
108
+ windowsHide: true
109
+ });
110
+ return;
111
+ }
112
+ child.kill("SIGTERM");
113
+ }
37
114
  function runCommand(command, args, opts = {}) {
38
115
  return new Promise((resolve, reject) => {
39
116
  const useCmd = opts.windowsCmd && process.platform === "win32";
@@ -64,6 +141,63 @@ function runCommand(command, args, opts = {}) {
64
141
  });
65
142
  });
66
143
  }
144
+ function decodeChunk(chunk) {
145
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
146
+ }
147
+ function quoteNodeOption(value) {
148
+ if (!/[ \t"]/u.test(value)) return value;
149
+ return `"${value.replace(/"/g, "\\\"")}"`;
150
+ }
151
+ function buildCapturedEnv(opts) {
152
+ const env = opts.env ? {
153
+ ...process.env,
154
+ ...opts.env
155
+ } : { ...process.env };
156
+ if (!opts.fakeTty) return env;
157
+ const requireFlag = `--require ${quoteNodeOption(ensureTtyShimPath())}`;
158
+ env.NODE_OPTIONS = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${requireFlag}` : requireFlag;
159
+ delete env.CI;
160
+ return env;
161
+ }
162
+ function runCommandCaptured(command, args, opts = {}) {
163
+ const useCmd = opts.windowsCmd && process.platform === "win32";
164
+ const child = spawn(useCmd ? process.env.ComSpec || "cmd.exe" : command, useCmd ? [
165
+ "/d",
166
+ "/s",
167
+ "/c",
168
+ command,
169
+ ...args
170
+ ] : args, {
171
+ cwd: opts.cwd,
172
+ stdio: [
173
+ "ignore",
174
+ "pipe",
175
+ "pipe"
176
+ ],
177
+ windowsHide: true,
178
+ env: buildCapturedEnv(opts)
179
+ });
180
+ child.stdout?.on("data", (chunk) => {
181
+ opts.onStdout?.(decodeChunk(chunk));
182
+ });
183
+ child.stderr?.on("data", (chunk) => {
184
+ opts.onStderr?.(decodeChunk(chunk));
185
+ });
186
+ return {
187
+ promise: new Promise((resolve, reject) => {
188
+ child.on("error", (err) => {
189
+ reject(/* @__PURE__ */ new Error(`无法启动 ${command}: ${err.message}`));
190
+ });
191
+ child.on("exit", (code, signal) => {
192
+ resolve({
193
+ exitCode: code,
194
+ signal
195
+ });
196
+ });
197
+ }),
198
+ kill: () => killProcessTree(child)
199
+ };
200
+ }
67
201
  //#endregion
68
202
  //#region src/utils/templates.ts
69
203
  const require = createRequire(import.meta.url);
@@ -187,17 +321,20 @@ function listNamedDirs(parent) {
187
321
  if (!fs.existsSync(parent)) return [];
188
322
  return fs.readdirSync(parent, { withFileTypes: true }).filter((d) => d.isDirectory() && isSafeDirName(d.name) && !d.name.startsWith(".")).map((d) => d.name);
189
323
  }
190
- /** 原子风格替换目录:删旧 `.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
+ */
191
333
  function deployDir(src, dest, log) {
192
334
  fs.mkdirSync(path.dirname(dest), { recursive: true });
193
- const bak = `${dest}.bak`;
194
- if (fs.existsSync(bak)) {
195
- log(`删除旧备份 ${bak}`);
196
- fs.rmSync(bak, {
197
- recursive: true,
198
- force: true
199
- });
200
- }
335
+ const stamp = backupTimestamp();
336
+ let bak = `${dest}.bak-${stamp}`;
337
+ for (let i = 1; fs.existsSync(bak); i++) bak = `${dest}.bak-${stamp}-${i}`;
201
338
  if (fs.existsSync(dest)) {
202
339
  log(`备份 ${dest} -> ${bak}`);
203
340
  fs.renameSync(dest, bak);
@@ -304,6 +441,273 @@ async function configureNginxProxies(projectName, apis, force, log) {
304
441
  log("nginx 已 reload");
305
442
  }
306
443
  //#endregion
444
+ //#region src/utils/deploy-state.ts
445
+ const DEPLOY_STATE_VERSION = 1;
446
+ const DEPLOY_STATE_FILE = "deploy-state.json";
447
+ function getZpcHome() {
448
+ return process.env.ZPC_HOME ?? path.join(os.homedir(), ".zpc");
449
+ }
450
+ function getDeployStatePath() {
451
+ return path.join(getZpcHome(), DEPLOY_STATE_FILE);
452
+ }
453
+ /** 以项目根目录绝对路径为键(统一分隔符,便于跨平台比对)。 */
454
+ function deployStateProjectKey(root) {
455
+ return path.resolve(root).replace(/\\/g, "/");
456
+ }
457
+ function emptyDeployState() {
458
+ return {
459
+ version: DEPLOY_STATE_VERSION,
460
+ projects: {}
461
+ };
462
+ }
463
+ function readDeployStateFile() {
464
+ const filePath = getDeployStatePath();
465
+ if (!fs.existsSync(filePath)) return emptyDeployState();
466
+ let parsed;
467
+ try {
468
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
469
+ } catch {
470
+ throw new Error(`[deploy] 无法解析 ${filePath},请检查 JSON 格式或删除后重试`);
471
+ }
472
+ if (typeof parsed !== "object" || parsed == null || parsed.version !== DEPLOY_STATE_VERSION || typeof parsed.projects !== "object" || parsed.projects == null) throw new Error(`[deploy] ${filePath} 格式无效,请删除后重试`);
473
+ return parsed;
474
+ }
475
+ function writeDeployStateFile(state) {
476
+ const home = getZpcHome();
477
+ fs.mkdirSync(home, { recursive: true });
478
+ fs.writeFileSync(getDeployStatePath(), `${JSON.stringify(state, null, 2)}\n`, "utf8");
479
+ }
480
+ function hasFullDeployRecord(root) {
481
+ const state = readDeployStateFile();
482
+ return deployStateProjectKey(root) in state.projects;
483
+ }
484
+ function markProjectFullDeployed(root, projectName) {
485
+ const state = readDeployStateFile();
486
+ const key = deployStateProjectKey(root);
487
+ state.projects[key] = {
488
+ projectName,
489
+ root: key,
490
+ fullDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
491
+ };
492
+ writeDeployStateFile(state);
493
+ }
494
+ /** 返回 ~/.zpc/deploy-state.json 中已记录的所有 projectName(去重、排序)。 */
495
+ function listRecordedProjectNames() {
496
+ const state = readDeployStateFile();
497
+ return [...new Set(Object.values(state.projects).map((p) => p.projectName))].sort();
498
+ }
499
+ //#endregion
500
+ //#region src/utils/check-state.ts
501
+ /** 从 nginx 配置的 `owned-by: projectName/name` 头解析出 projectName。 */
502
+ function nginxProjectName(text) {
503
+ const owned = parseNginxOwnedBy(text);
504
+ if (!owned) return null;
505
+ const idx = owned.indexOf("/");
506
+ return idx >= 0 ? owned.slice(0, idx) : owned;
507
+ }
508
+ /** 盘点部署痕迹(api/web 目录 + nginx 配置)与部署记录,合并成按系统分组的结果。 */
509
+ function collectSystemChecks() {
510
+ const recorded = new Set(listRecordedProjectNames());
511
+ const apiByProject = /* @__PURE__ */ new Map();
512
+ for (const projectName of listNamedDirs(getApiRoot())) apiByProject.set(projectName, listNamedDirs(path.join(getApiRoot(), projectName)));
513
+ const webByProject = /* @__PURE__ */ new Map();
514
+ for (const projectName of listNamedDirs(getWebRoot())) webByProject.set(projectName, listNamedDirs(path.join(getWebRoot(), projectName)));
515
+ const nginxByProject = /* @__PURE__ */ new Map();
516
+ const unownedNginxConfs = [];
517
+ const nginxDir = getNginxDefaultD();
518
+ if (fs.existsSync(nginxDir)) for (const name of fs.readdirSync(nginxDir)) {
519
+ if (!name.endsWith(".conf")) continue;
520
+ const full = path.join(nginxDir, name);
521
+ let text = "";
522
+ try {
523
+ text = fs.readFileSync(full, "utf8");
524
+ } catch {}
525
+ const projectName = nginxProjectName(text);
526
+ if (projectName) {
527
+ const arr = nginxByProject.get(projectName) ?? [];
528
+ arr.push(full);
529
+ nginxByProject.set(projectName, arr);
530
+ } else unownedNginxConfs.push(full);
531
+ }
532
+ return {
533
+ systems: [.../* @__PURE__ */ new Set([
534
+ ...recorded,
535
+ ...apiByProject.keys(),
536
+ ...webByProject.keys(),
537
+ ...nginxByProject.keys()
538
+ ])].sort().map((projectName) => ({
539
+ projectName,
540
+ recorded: recorded.has(projectName),
541
+ apiDirs: apiByProject.get(projectName) ?? [],
542
+ webDirs: webByProject.get(projectName) ?? [],
543
+ nginxConfs: nginxByProject.get(projectName) ?? []
544
+ })),
545
+ unownedNginxConfs
546
+ };
547
+ }
548
+ //#endregion
549
+ //#region src/utils/log.ts
550
+ function tagged(tag, message) {
551
+ return `${chalk.cyan(`[${tag}]`)} ${message}`;
552
+ }
553
+ /** 带命令前缀的彩色日志:info 默认、success 绿、warn 黄。 */
554
+ function createLogger(tag) {
555
+ return {
556
+ info: (message) => {
557
+ console.log(tagged(tag, message));
558
+ },
559
+ success: (message) => {
560
+ console.log(tagged(tag, chalk.green(message)));
561
+ },
562
+ warn: (message) => {
563
+ console.log(tagged(tag, chalk.yellow(message)));
564
+ }
565
+ };
566
+ }
567
+ function logError(message) {
568
+ console.error(chalk.red(message));
569
+ }
570
+ //#endregion
571
+ //#region src/commands/check.ts
572
+ const log$3 = createLogger("check");
573
+ function systemStatus(system) {
574
+ if (!system.recorded) return "未记录";
575
+ return system.apiDirs.length > 0 || system.webDirs.length > 0 || system.nginxConfs.length > 0 ? "已记录" : "记录但缺失";
576
+ }
577
+ function runCheck() {
578
+ const { systems, unownedNginxConfs } = collectSystemChecks();
579
+ if (systems.length === 0 && unownedNginxConfs.length === 0) {
580
+ log$3.info("未发现任何部署记录或部署痕迹");
581
+ return;
582
+ }
583
+ for (const system of systems) {
584
+ const status = systemStatus(system);
585
+ const head = `[${status}] ${system.projectName}`;
586
+ if (status === "已记录") log$3.info(head);
587
+ else log$3.warn(head);
588
+ if (!(system.apiDirs.length > 0 || system.webDirs.length > 0 || system.nginxConfs.length > 0)) continue;
589
+ if (system.apiDirs.length > 0) log$3.info(` api: ${system.apiDirs.join(", ")}`);
590
+ if (system.webDirs.length > 0) log$3.info(` web: ${system.webDirs.join(", ")}`);
591
+ if (system.nginxConfs.length > 0) log$3.info(` nginx: ${system.nginxConfs.join(", ")}`);
592
+ }
593
+ if (unownedNginxConfs.length > 0) {
594
+ log$3.warn(`[未归属 nginx 配置] ${unownedNginxConfs.length} 个`);
595
+ for (const conf of unownedNginxConfs) log$3.warn(` ${conf}`);
596
+ }
597
+ }
598
+ function createCheckCommand() {
599
+ return new Command("check").description("盘点部署目录与 nginx 配置,标注已记录 / 未记录 / 记录但缺失").action(async () => {
600
+ runCheck();
601
+ });
602
+ }
603
+ //#endregion
604
+ //#region src/utils/dist-zip.ts
605
+ const ZPC_GITIGNORE_HEADER = "# zpc files";
606
+ const ZPC_DIST_ZIP_IGNORE = "*-dist.zip";
607
+ function distZipFileName(projectName) {
608
+ return `${projectName}-dist.zip`;
609
+ }
610
+ function distZipPath(projectRoot, projectName) {
611
+ return path.join(projectRoot, distZipFileName(projectName));
612
+ }
613
+ function gitignoreHasDistZipRule(content) {
614
+ return content.split(/\r?\n/u).some((line) => line.trim() === ZPC_DIST_ZIP_IGNORE);
615
+ }
616
+ /** 若项目 .gitignore 缺少 *-dist.zip 规则则追加,返回是否新写入。 */
617
+ function ensureZpcGitignore(projectRoot) {
618
+ const gitignorePath = path.join(projectRoot, ".gitignore");
619
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
620
+ if (gitignoreHasDistZipRule(existing)) return false;
621
+ const block = `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}\n${ZPC_GITIGNORE_HEADER}\n${ZPC_DIST_ZIP_IGNORE}\n`;
622
+ fs.writeFileSync(gitignorePath, `${existing}${block}`, "utf8");
623
+ return true;
624
+ }
625
+ //#endregion
626
+ //#region src/utils/deploy-conflict.ts
627
+ function isDirectoryNonEmpty$1(dir) {
628
+ if (!fs.existsSync(dir)) return false;
629
+ try {
630
+ return fs.readdirSync(dir).length > 0;
631
+ } catch {
632
+ return false;
633
+ }
634
+ }
635
+ /** 收集「无 ~/.zpc 记录」时应提示用户的部署痕迹:api/web 目录非空、已存在/冲突的 nginx 配置。 */
636
+ function collectDeployConflicts(config) {
637
+ const projectName = config.projectName;
638
+ const conflicts = [];
639
+ if (!projectName) return conflicts;
640
+ for (const item of config.api ?? []) {
641
+ const dir = path.join(getApiRoot(), projectName, item.name);
642
+ if (isDirectoryNonEmpty$1(dir)) conflicts.push({
643
+ kind: "api-dir",
644
+ message: `api 部署目录非空: ${dir}`
645
+ });
646
+ }
647
+ for (const item of config.web ?? []) {
648
+ const dir = path.join(getWebRoot(), projectName, item.name);
649
+ if (isDirectoryNonEmpty$1(dir)) conflicts.push({
650
+ kind: "web-dir",
651
+ message: `web 部署目录非空: ${dir}`
652
+ });
653
+ }
654
+ const defaultD = getNginxDefaultD();
655
+ if (!fs.existsSync(defaultD)) return conflicts;
656
+ const proxies = (config.api ?? []).map((item) => item.proxy).filter((p) => !!p);
657
+ for (const name of fs.readdirSync(defaultD)) {
658
+ if (!name.endsWith(".conf")) continue;
659
+ const full = path.join(defaultD, name);
660
+ let text;
661
+ try {
662
+ text = fs.readFileSync(full, "utf8");
663
+ } catch {
664
+ continue;
665
+ }
666
+ const owned = parseNginxOwnedBy(text);
667
+ if (owned?.startsWith(`${projectName}/`) || name.startsWith(`${projectName}-`)) {
668
+ conflicts.push({
669
+ kind: "nginx",
670
+ message: `已存在本项目 nginx 配置: ${full}`
671
+ });
672
+ continue;
673
+ }
674
+ for (const proxy of proxies) if (confDefinesProxy(text, proxy)) {
675
+ conflicts.push({
676
+ kind: "nginx",
677
+ message: `nginx 配置 ${full} 已定义 proxy ${nginxLocation(proxy)}(归属 ${owned ?? "未知"})`
678
+ });
679
+ break;
680
+ }
681
+ }
682
+ return conflicts;
683
+ }
684
+ function isInteractive() {
685
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
686
+ }
687
+ /** 交互询问部署方式;非 TTY 环境直接返回取消(默认)。 */
688
+ async function promptDeployResolution() {
689
+ if (!isInteractive()) return "cancel";
690
+ const { select } = await import("@inquirer/prompts");
691
+ return await select({
692
+ message: "检测到已有部署痕迹(无 ~/.zpc 记录),请选择部署方式:",
693
+ choices: [
694
+ {
695
+ name: "清空目标文件夹并覆盖(全量,旧目录将备份)",
696
+ value: "full"
697
+ },
698
+ {
699
+ name: "使用增量部署(api 保留 public,跳过 web)",
700
+ value: "incremental"
701
+ },
702
+ {
703
+ name: "取消部署",
704
+ value: "cancel"
705
+ }
706
+ ],
707
+ default: "cancel"
708
+ });
709
+ }
710
+ //#endregion
307
711
  //#region src/utils/zip.ts
308
712
  /** ZIP End of Central Directory 签名 */
309
713
  const SIG_EOCD = 101010256;
@@ -419,27 +823,7 @@ async function zipDirectory(stagingDir, zipPath, cwd) {
419
823
  }
420
824
  //#endregion
421
825
  //#region src/commands/deploy.ts
422
- function log$1(message) {
423
- console.log(`[deploy] ${message}`);
424
- }
425
- function envFlag(name) {
426
- const value = process.env[name];
427
- return value === "1" || value === "true";
428
- }
429
- function resolveFlags(options) {
430
- const forceFull = Boolean(options.full);
431
- const force = Boolean(options.force) || envFlag("DEPLOY_FORCE");
432
- let wantIncremental = Boolean(options.incremental) || envFlag("DEPLOY_INCREMENTAL");
433
- if (forceFull) wantIncremental = false;
434
- return {
435
- wantIncremental,
436
- forceFull,
437
- force
438
- };
439
- }
440
- function hasProjectFiles(dir) {
441
- return fs.existsSync(path.join(dir, "dist.zip")) && Boolean(findConfigFile(dir));
442
- }
826
+ const log$2 = createLogger("deploy");
443
827
  function resolveProjectDir(dirArg) {
444
828
  let dir;
445
829
  if (dirArg) {
@@ -448,39 +832,40 @@ function resolveProjectDir(dirArg) {
448
832
  dir = fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
449
833
  } else if (process.env.DEPLOY_DIR) dir = path.resolve(process.env.DEPLOY_DIR);
450
834
  else dir = process.cwd();
451
- if (!hasProjectFiles(dir)) throw new Error(`[deploy] ${dir} 中需要同时有 dist.zip 和 zpc.config.*(配置请单独上传,不要打进压缩包)`);
835
+ if (!findConfigFile(dir)) throw new Error(`[deploy] ${dir} 中需要 zpc.config.*(配置请单独上传,不要打进压缩包)`);
452
836
  return dir;
453
837
  }
454
838
  async function deployKind(stagingDir, items, kind, destRoot, projectName, incremental) {
455
839
  const packed = new Set(listNamedDirs(path.join(stagingDir, kind)));
456
840
  const configured = items ?? [];
457
- for (const extra of packed) if (!configured.some((item) => item.name === extra)) log$1(`跳过压缩包中的 ${kind}/${extra}(未在配置中列出)`);
841
+ for (const extra of packed) if (!configured.some((item) => item.name === extra)) log$2.warn(`跳过压缩包中的 ${kind}/${extra}(未在配置中列出)`);
458
842
  for (const item of configured) {
459
843
  const src = path.join(stagingDir, kind, item.name);
460
844
  if (!fs.existsSync(src)) throw new Error(`[deploy] 配置中有 ${kind}/${item.name},但压缩包中没有对应目录`);
461
845
  const dest = path.join(destRoot, projectName, item.name);
462
846
  const pm2Name = `${projectName}-${item.name}`;
463
- if (kind === "api" && incremental && fs.existsSync(dest)) deployApiIncremental(src, dest, log$1);
847
+ if (kind === "api" && incremental && fs.existsSync(dest)) deployApiIncremental(src, dest, log$2.info);
464
848
  else {
465
- if (kind === "api" && incremental && !fs.existsSync(dest)) log$1(`${dest} 不存在,增量改为全量部署`);
466
- deployDir(src, dest, log$1);
849
+ if (kind === "api" && incremental && !fs.existsSync(dest)) log$2.warn(`${dest} 不存在,增量改为全量部署`);
850
+ deployDir(src, dest, log$2.info);
467
851
  }
468
- if (kind === "api" && !shouldSkipDeployRuntime()) await installAndReloadApi(pm2Name, dest, item, log$1);
852
+ if (kind === "api" && !shouldSkipDeployRuntime()) await installAndReloadApi(pm2Name, dest, item, log$2.info);
469
853
  }
470
854
  }
471
- async function runDeploy(dir, options) {
472
- const { wantIncremental, forceFull, force } = resolveFlags(options);
855
+ async function runDeploy(dir, options, prompt = promptDeployResolution) {
856
+ const force = Boolean(options.force) || process.env.DEPLOY_FORCE === "1" || process.env.DEPLOY_FORCE === "true";
473
857
  const projectDir = resolveProjectDir(dir);
474
858
  const stagingDir = path.join(projectDir, ".deploy-staging");
475
- const zipPath = path.join(projectDir, "dist.zip");
476
859
  const { file, config } = await loadConfig(projectDir);
477
860
  assertDeployConfig(config);
478
- log$1(`项目目录: ${projectDir}`);
479
- log$1(`压缩包: ${zipPath}`);
480
- log$1(`配置: ${file}`);
481
- log$1(`项目: ${config.projectName}`);
861
+ const zipPath = distZipPath(projectDir, config.projectName);
862
+ if (!fs.existsSync(zipPath)) throw new Error(`[deploy] 找不到 ${zipPath},请先在该目录执行 zpc pack(配置请单独上传,不要打进压缩包)`);
863
+ log$2.info(`项目目录: ${projectDir}`);
864
+ log$2.info(`压缩包: ${zipPath}`);
865
+ log$2.info(`配置: ${file}`);
866
+ log$2.info(`项目: ${config.projectName}`);
482
867
  try {
483
- log$1(`解压 ${zipPath}`);
868
+ log$2.info(`解压 ${zipPath}`);
484
869
  await extractArchive(zipPath, stagingDir);
485
870
  } catch (err) {
486
871
  fs.rmSync(stagingDir, {
@@ -490,17 +875,35 @@ async function runDeploy(dir, options) {
490
875
  const message = err instanceof Error ? err.message : String(err);
491
876
  throw new Error(`[deploy] 解压失败: ${message}`);
492
877
  }
493
- const packIsIncremental = fs.existsSync(path.join(stagingDir, INCREMENTAL_MARKER));
494
- let incremental = wantIncremental || packIsIncremental;
878
+ const zipIsIncremental = fs.existsSync(path.join(stagingDir, INCREMENTAL_MARKER));
879
+ const hadRecord = hasFullDeployRecord(projectDir);
880
+ let incremental;
881
+ let resolvedFromConflicts = false;
882
+ if (hadRecord) incremental = true;
883
+ else {
884
+ const conflicts = collectDeployConflicts(config);
885
+ if (conflicts.length > 0) {
886
+ for (const conflict of conflicts) log$2.warn(conflict.message);
887
+ const choice = await prompt();
888
+ if (choice === "cancel") {
889
+ log$2.warn("已取消部署");
890
+ fs.rmSync(stagingDir, {
891
+ recursive: true,
892
+ force: true
893
+ });
894
+ return;
895
+ }
896
+ incremental = choice === "incremental";
897
+ resolvedFromConflicts = true;
898
+ } else incremental = zipIsIncremental;
899
+ }
495
900
  try {
496
- if (packIsIncremental && forceFull) throw new Error("[deploy] 当前 dist.zip 是增量包(含 .deploy-incremental),不能 --full,请使用 --incremental,或重新 pack 全量包");
497
- if (packIsIncremental && !wantIncremental) throw new Error("[deploy] 当前 dist.zip 是增量包(由 zpc pack -i 生成),请加上 --incremental / -i,以免全量覆盖清空 public");
498
- incremental = wantIncremental || packIsIncremental;
499
- log$1(`模式: ${incremental ? "增量(api 保留 public,跳过 web)" : "全量"}`);
500
- if (force) log$1("已启用 --force(允许抢占冲突的 nginx proxy)");
901
+ log$2.info(`模式: ${incremental ? "增量(api 保留 public,跳过 web)" : "全量"}`);
902
+ if (!incremental && !hadRecord) log$2.info("首次部署该项目,完成后将记录到 ~/.zpc/deploy-state.json");
903
+ if (force) log$2.warn("已启用 --force(允许抢占冲突的 nginx proxy)");
501
904
  await deployKind(stagingDir, config.api, "api", getApiRoot(), config.projectName, incremental);
502
905
  if (!incremental) await deployKind(stagingDir, config.web, "web", getWebRoot(), config.projectName, false);
503
- else if (config.web?.length) log$1("增量模式跳过 web 部署");
906
+ else if (config.web?.length) log$2.warn("增量模式跳过 web 部署");
504
907
  } finally {
505
908
  fs.rmSync(stagingDir, {
506
909
  recursive: true,
@@ -508,21 +911,28 @@ async function runDeploy(dir, options) {
508
911
  });
509
912
  }
510
913
  if (!shouldSkipDeployRuntime()) {
511
- await configureNginxProxies(config.projectName, config.api, force, log$1);
914
+ await configureNginxProxies(config.projectName, config.api, force, log$2.info);
512
915
  if (config.api?.length) await runCommand("pm2", ["save"], { ignoreExit: true });
513
916
  }
917
+ if (!hasFullDeployRecord(projectDir)) {
918
+ markProjectFullDeployed(projectDir, config.projectName);
919
+ if (!incremental) log$2.info("已记录全量部署状态到 ~/.zpc/deploy-state.json,下次 deploy 将默认增量");
920
+ else if (resolvedFromConflicts) log$2.info("已根据现有部署目录建立部署记录,下次 deploy 将默认增量");
921
+ else if (zipIsIncremental) log$2.info("已记录部署状态,下次 deploy 将默认增量");
922
+ }
514
923
  const apiCount = config.api?.length ?? 0;
515
924
  const webCount = incremental ? 0 : config.web?.length ?? 0;
516
925
  const prefix = `${config.projectName}/<name>`;
517
- log$1(`完成: api ${apiCount} 个 -> ${getApiRoot()}/${prefix},web ${webCount} 个 -> ${getWebRoot()}/${prefix}`);
926
+ log$2.success(`完成: api ${apiCount} 个 -> ${getApiRoot()}/${prefix},web ${webCount} 个 -> ${getWebRoot()}/${prefix}`);
518
927
  }
519
928
  function createDeployCommand() {
520
- return new Command("deploy").description("按 zpc.config 将 dist.zip 部署到服务器").argument("[dir]", "项目目录,默认为当前目录").option("-i, --incremental", "增量部署(api 保留 public,跳过 web)").option("--full", "全量部署").option("--force", "允许抢占冲突的 nginx proxy").action(async (dir, options) => {
929
+ return new Command("deploy").description("按 zpc.config 将 <projectName>-dist.zip 部署到服务器").argument("[dir]", "项目目录,默认为当前目录").option("--force", "允许抢占冲突的 nginx proxy").action(async (dir, options) => {
521
930
  await runDeploy(dir, options);
522
931
  });
523
932
  }
524
933
  //#endregion
525
934
  //#region src/commands/init.ts
935
+ const log$1 = createLogger("init");
526
936
  async function runInit(dir, options) {
527
937
  const format = resolveFormat(options.template);
528
938
  const root = path.resolve(dir ?? process.cwd());
@@ -536,10 +946,10 @@ async function runInit(dir, options) {
536
946
  fs.mkdirSync(root, { recursive: true });
537
947
  for (const file of existing) if (file !== dest) fs.rmSync(file);
538
948
  fs.copyFileSync(resolveTemplateFile(format), dest);
539
- console.log(`[init] 已写入 ${dest}`);
949
+ log$1.success(`已写入 ${dest}`);
540
950
  if (format === "json") {
541
951
  fs.copyFileSync(resolveSchemaFile(), schemaDest);
542
- console.log(`[init] 已写入 ${schemaDest}`);
952
+ log$1.success(`已写入 ${schemaDest}`);
543
953
  } else if (fs.existsSync(schemaDest)) fs.rmSync(schemaDest);
544
954
  }
545
955
  function resolveFormat(template) {
@@ -553,14 +963,59 @@ function createInitCommand() {
553
963
  });
554
964
  }
555
965
  //#endregion
556
- //#region src/commands/pack.ts
557
- function log(message) {
558
- console.log(`[pack] ${message}`);
966
+ //#region src/utils/incremental-mode.ts
967
+ function isDirectoryNonEmpty(dir) {
968
+ if (!fs.existsSync(dir)) return false;
969
+ try {
970
+ return fs.readdirSync(dir).length > 0;
971
+ } catch {
972
+ return false;
973
+ }
974
+ }
975
+ /** 配置中所有部署目录(api + web)里已存在且非空的目录。 */
976
+ function findNonemptyDeployDirs(config) {
977
+ const projectName = config.projectName;
978
+ if (!projectName) return [];
979
+ const found = [];
980
+ for (const item of config.api ?? []) {
981
+ const deployDir = path.join(getApiRoot(), projectName, item.name);
982
+ if (isDirectoryNonEmpty(deployDir)) found.push(deployDir);
983
+ }
984
+ for (const item of config.web ?? []) {
985
+ const deployDir = path.join(getWebRoot(), projectName, item.name);
986
+ if (isDirectoryNonEmpty(deployDir)) found.push(deployDir);
987
+ }
988
+ return found;
559
989
  }
560
- function resolveIncremental(options) {
561
- if (options.full) return false;
562
- return Boolean(options.incremental);
990
+ /**
991
+ * 全量 / 增量判定(pack 只读、deploy 读后写入):
992
+ * 1. ~/.zpc/deploy-state.json 有记录 → 增量
993
+ * 2. 无记录但 api/web 部署目录非空 → 交由调用方决策(不直接判定)
994
+ * 3. 无记录但 zip 含增量标记 → 增量
995
+ * 4. 否则全量
996
+ */
997
+ function resolveIncrementalMode(projectRoot, config, options = {}) {
998
+ if (hasFullDeployRecord(projectRoot)) return {
999
+ incremental: true,
1000
+ existingDeployDirs: []
1001
+ };
1002
+ const existingDeployDirs = findNonemptyDeployDirs(config);
1003
+ if (existingDeployDirs.length > 0) return {
1004
+ incremental: false,
1005
+ existingDeployDirs
1006
+ };
1007
+ if (options.zipIsIncremental) return {
1008
+ incremental: true,
1009
+ existingDeployDirs: []
1010
+ };
1011
+ return {
1012
+ incremental: false,
1013
+ existingDeployDirs: []
1014
+ };
563
1015
  }
1016
+ //#endregion
1017
+ //#region src/commands/pack.ts
1018
+ const log = createLogger("pack");
564
1019
  function resolveProjects(root, config, incremental) {
565
1020
  const projects = [];
566
1021
  const kinds = incremental ? ["api"] : ["api", "web"];
@@ -596,18 +1051,45 @@ function resolveBuildEnv(project, config) {
596
1051
  }
597
1052
  return env;
598
1053
  }
599
- async function buildAll(projects, config) {
600
- log(`并行构建 ${projects.length} 个项目`);
601
- await Promise.all(projects.map(async (project) => {
1054
+ function shouldUseBuildTui(options) {
1055
+ return Boolean(process.stdout.isTTY) && options.tui !== false;
1056
+ }
1057
+ function toBuildJobSpecs(projects, config) {
1058
+ return projects.map((project) => ({
1059
+ id: `${project.kind}/${project.name}`,
1060
+ label: `${project.kind}/${project.name}`,
1061
+ cwd: project.absPath,
1062
+ env: resolveBuildEnv(project, config)
1063
+ }));
1064
+ }
1065
+ async function buildAllLegacy(projects, config, concurrency) {
1066
+ const limitText = concurrency !== void 0 ? `,最多同时 ${concurrency} 个` : "";
1067
+ log.info(`构建 ${projects.length} 个项目${limitText}`);
1068
+ const { runWithConcurrency } = await import("./build-jobs-m5jbiNTx.mjs");
1069
+ await runWithConcurrency(projects, concurrency ?? 0, async (project) => {
602
1070
  const env = resolveBuildEnv(project, config);
603
- if (project.kind === "web" && env.VITE_API_PROXY) log(`build ${project.kind}/${project.name} VITE_API_PROXY=${env.VITE_API_PROXY}`);
604
- else log(`build ${project.kind}/${project.name} -> ${project.absPath}`);
1071
+ if (project.kind === "web" && env.VITE_API_PROXY) log.info(`build ${project.kind}/${project.name} VITE_API_PROXY=${env.VITE_API_PROXY}`);
1072
+ else log.info(`build ${project.kind}/${project.name} -> ${project.absPath}`);
605
1073
  await runCommand("npm", ["run", "build"], {
606
1074
  cwd: project.absPath,
607
1075
  env,
608
1076
  windowsCmd: true
609
1077
  });
610
- }));
1078
+ });
1079
+ }
1080
+ async function buildAllWithTui(projects, config, options) {
1081
+ const { runBuildDashboard } = await import("./build-dashboard-CqZVHPH7.mjs");
1082
+ await runBuildDashboard(toBuildJobSpecs(projects, config), {
1083
+ concurrency: options.concurrency,
1084
+ stayOnSuccess: options.stay
1085
+ });
1086
+ }
1087
+ async function buildAll(projects, config, options) {
1088
+ if (shouldUseBuildTui(options)) {
1089
+ await buildAllWithTui(projects, config, options);
1090
+ return;
1091
+ }
1092
+ await buildAllLegacy(projects, config, options.concurrency);
611
1093
  }
612
1094
  function copyRequiredFile(src, destDir, label) {
613
1095
  if (!fs.existsSync(src)) throw new Error(`[pack] 缺少 ${label}: ${src}`);
@@ -619,7 +1101,7 @@ function stageApi(project, dest, incremental) {
619
1101
  copyRequiredFile(path.join(project.absPath, "package.json"), dest, "package.json");
620
1102
  copyRequiredFile(path.join(project.absPath, "bootstrap.js"), dest, "bootstrap.js");
621
1103
  if (incremental) {
622
- log(`api/${project.name} 增量:跳过 public`);
1104
+ log.warn(`api/${project.name} 增量:跳过 public`);
623
1105
  return;
624
1106
  }
625
1107
  const publicDir = path.join(project.absPath, "public");
@@ -639,26 +1121,32 @@ function stageDist(stagingDir, root, projects, incremental) {
639
1121
  const dest = path.join(stagingDir, project.kind, project.name);
640
1122
  if (project.kind === "api") stageApi(project, dest, incremental);
641
1123
  else fs.cpSync(project.distPath, dest, { recursive: true });
642
- log(`收集 ${project.kind}/${project.name} -> ${path.relative(root, dest)}`);
1124
+ log.info(`收集 ${project.kind}/${project.name} -> ${path.relative(root, dest)}`);
643
1125
  }
644
1126
  }
645
1127
  async function runPack(dir, options) {
646
1128
  const root = path.resolve(dir ?? process.cwd());
647
1129
  if (!fs.existsSync(root)) throw new Error(`[pack] 找不到路径: ${root}`);
648
1130
  const projectRoot = fs.statSync(root).isDirectory() ? root : path.dirname(root);
649
- const incremental = resolveIncremental(options);
650
1131
  const stagingDir = path.join(projectRoot, ".packup-staging");
651
- const zipPath = path.join(projectRoot, "dist.zip");
652
1132
  const { file, config } = await loadConfig(projectRoot);
653
- log(`项目目录: ${projectRoot}`);
654
- log(`配置: ${file}`);
655
- log(`项目: ${config.projectName}`);
656
- log(`模式: ${incremental ? "增量(仅 api,不含 public)" : "全量"}`);
1133
+ const zipPath = distZipPath(projectRoot, config.projectName);
1134
+ const packMode = resolveIncrementalMode(projectRoot, config);
1135
+ const incremental = packMode.incremental || packMode.existingDeployDirs.length > 0;
1136
+ log.info(`项目目录: ${projectRoot}`);
1137
+ log.info(`配置: ${file}`);
1138
+ log.info(`项目: ${config.projectName}`);
1139
+ log.info(`模式: ${incremental ? "增量(仅 api,不含 public)" : "全量"}`);
1140
+ if (packMode.existingDeployDirs.length > 0) {
1141
+ const listed = packMode.existingDeployDirs.map((dir) => path.normalize(dir)).join(", ");
1142
+ log.warn(`无 ~/.zpc 部署记录,但检测到部署目录非空:${listed}。将按增量处理(api 保留 public,跳过 web);若确需全量请清空部署目录并删除 deploy-state 中对应记录`);
1143
+ }
1144
+ if (options.concurrency !== void 0) log.info(`并行上限: ${options.concurrency}`);
657
1145
  const projects = resolveProjects(projectRoot, config, incremental);
658
- await buildAll(projects, config);
1146
+ await buildAll(projects, config, options);
659
1147
  stageDist(stagingDir, projectRoot, projects, incremental);
660
1148
  try {
661
- log(`压缩 ${path.relative(projectRoot, zipPath)}`);
1149
+ log.info(`压缩 ${path.relative(projectRoot, zipPath)}`);
662
1150
  await zipDirectory(stagingDir, zipPath, projectRoot);
663
1151
  } catch (err) {
664
1152
  const message = err instanceof Error ? err.message : String(err);
@@ -669,18 +1157,23 @@ async function runPack(dir, options) {
669
1157
  force: true
670
1158
  });
671
1159
  }
672
- log(`完成: ${zipPath}`);
673
- if (incremental) log("请使用: zpc deploy --incremental");
1160
+ log.success(`完成: ${zipPath}`);
1161
+ if (ensureZpcGitignore(projectRoot)) log.info("已更新 .gitignore,忽略 *-dist.zip");
1162
+ if (incremental) log.info("增量包已生成,部署时将自动识别为增量模式");
674
1163
  }
675
1164
  function createPackCommand() {
676
- return new Command("pack").description("按 zpc.config 构建并打包 dist.zip").argument("[dir]", "项目目录,默认为当前目录").option("-i, --incremental", "增量打包(仅 api,不含 public)").option("--full", "全量打包").action(async (dir, options) => {
1165
+ return new Command("pack").description("按 zpc.config 构建并打包 <projectName>-dist.zip").argument("[dir]", "项目目录,默认为当前目录").option("--no-tui", "禁用构建 TUI,使用传统输出").option("--stay", "构建成功后保留 TUI,手动退出").option("-c, --concurrency <n>", "最大同时构建数(默认不限制)", (value) => {
1166
+ const parsed = Number(value);
1167
+ if (!Number.isInteger(parsed) || parsed < 1) throw new Error("--concurrency 必须是大于 0 的整数");
1168
+ return parsed;
1169
+ }).action(async (dir, options) => {
677
1170
  await runPack(dir, options);
678
1171
  });
679
1172
  }
680
1173
  //#endregion
681
1174
  //#region src/utils/program.ts
682
1175
  function createProgram() {
683
- const program = new Command().name("zpc").description(description).version(version).showHelpAfterError().addCommand(createInitCommand()).addCommand(createPackCommand()).addCommand(createDeployCommand());
1176
+ const program = new Command().name("zpc").description(description).version(version).showHelpAfterError().addCommand(createInitCommand()).addCommand(createPackCommand()).addCommand(createDeployCommand()).addCommand(createCheckCommand());
684
1177
  program.action(() => {
685
1178
  program.outputHelp();
686
1179
  });
@@ -690,8 +1183,7 @@ async function runCli(argv = process.argv) {
690
1183
  try {
691
1184
  await createProgram().parseAsync(argv);
692
1185
  } catch (err) {
693
- const message = err instanceof Error ? err.message : String(err);
694
- console.error(message);
1186
+ logError(err instanceof Error ? err.message : String(err));
695
1187
  process.exitCode = 1;
696
1188
  }
697
1189
  }
@@ -699,4 +1191,4 @@ async function runCli(argv = process.argv) {
699
1191
  //#region src/cli.ts
700
1192
  runCli();
701
1193
  //#endregion
702
- export {};
1194
+ export { runCommandCaptured as t };