e10-data 0.1.3 → 0.1.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
@@ -47,5 +47,22 @@ e10-data conf resolve '{"connId":"#{CONN_EC_EDCAPP}"}' # 占位
47
47
  e10-data conf add CONN_EC_EDCAPP --value 10 --name "说明" --attrs '{"schema":"ec_edcapp"}'
48
48
  e10-data conf delete CONN_EC_EDCAPP
49
49
  e10-data exp status --expert data-assistant
50
- e10-data exp pull --expert data-assistant --dir <本地专家包目录> --dry-run
50
+ e10-data exp pull --expert data-assistant --dir <本地专家包目录> [--dry-run] [--force]
51
51
  ```
52
+
53
+ ## exp pull 智能拉取(v0.1.4 新增,防本地修改被覆盖)
54
+
55
+ `exp pull` 不再盲目覆盖。CLI 在专家包本地目录维护基线文件 `.sync-state.json`(记录上次同步的 commitId/headRevision 与每文件 sha256),拉取时逐文件三方对比:
56
+
57
+ | 场景 | 判定 | 处理 |
58
+ |---|---|---|
59
+ | 仅远端改(本地未动) | 本地 sha == 基线 sha ≠ 远端 sha | 直接下载覆盖(安全) |
60
+ | 仅本地改(远端未动) | 本地 sha ≠ 基线 sha == 远端 sha | **保留本地,不覆盖** |
61
+ | 双方都改 / 无基线可判 | 本地、基线、远端互不相同 | **先备份本地副本到 `<专家包目录>.pull-backup-<时间戳>/`,再下载覆盖** |
62
+ | 本地缺失 | manifest 有、本地无 | 直接下载(新增/恢复) |
63
+
64
+ - 拉取成功后将 `.sync-state.json` 刷新到新 head,后续判定持续准确;
65
+ - `--force`:一律以远端覆盖(放弃本地修改,不备份);
66
+ - `--dry-run`:只预览决策,不写盘不下载;
67
+ - `.sync-state.json` 为本地同步元文件,**永不随包提交**(`walkDir`/`collectPackage` 已排除);`exp commit` 成功后也会自动刷新该基线。
68
+
package/bin/e10-data.mjs CHANGED
@@ -705,6 +705,7 @@ function rawNumSeq(raw, key) {
705
705
  }
706
706
  const EXP_ALLOWED_DIRS = [".codebuddy-plugin", "agents", "avatars", "skills"];
707
707
  const EXP_SKIP_DIRS = [".git", ".workbuddy", "node_modules", "__pycache__"];
708
+ const EXP_SKIP_FILES = [".sync-state.json"]; // 本地同步状态元文件(含基线 sha),永不随包提交
708
709
  function sha256(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); }
709
710
  function walkDir(dir, root) {
710
711
  const out = [];
@@ -717,6 +718,7 @@ function walkDir(dir, root) {
717
718
  if (rel.includes("/") && !EXP_ALLOWED_DIRS.includes(top)) continue; // 仅允许根文件+四大目录
718
719
  walkDir(abs, root).forEach((x) => out.push(x));
719
720
  } else if (entry.isFile()) {
721
+ if (EXP_SKIP_FILES.includes(entry.name)) continue; // .sync-state.json 不入包
720
722
  const top = rel.split("/")[0];
721
723
  if (!rel.includes("/") || EXP_ALLOWED_DIRS.includes(top)) out.push({ rel, abs });
722
724
  }
@@ -803,37 +805,96 @@ async function expFile(args, json) {
803
805
  if (json) { console.log(JSON.stringify({ relativePath: rp, size: buf.length, base64: buf.toString("base64") })); return; }
804
806
  process.stdout.write(buf.toString("utf-8"));
805
807
  }
808
+ // ---- exp 智能拉取:基线状态文件(三方差异保护,2026-09-04 实现)----
809
+ // .sync-state.json 记录上次成功同步的 commitId/revision 与每文件 sha256(基线)。
810
+ // pull 逐文件三方对比:local(当前本地) vs base(上次同步基线) vs remote(远端 manifest),
811
+ // 决策:仅远端改→覆盖;仅本地改→保留本地;双方都改→备份本地副本后覆盖(备份目录保留本地版);
812
+ // 无基线文件/基线无记录→保守备份后覆盖。拉取成功后把 .sync-state.json 更新为新 head。
813
+ function syncStatePath(dir) { return path.join(dir, ".sync-state.json"); }
814
+ function readSyncState(dir) {
815
+ const p = syncStatePath(dir);
816
+ if (!fs.existsSync(p)) return null;
817
+ try {
818
+ const j = JSON.parse(fs.readFileSync(p, "utf-8"));
819
+ const files = {};
820
+ for (const [rel, v] of Object.entries(j.files || {})) files[rel] = (typeof v === "string") ? v : (v.sha256 || "");
821
+ return { commitId: String(j.commitId || ""), headRevision: j.headRevision, files };
822
+ } catch { return null; }
823
+ }
824
+ function writeSyncState(dir, commitId, headRevision, fileShaMap) {
825
+ const files = {};
826
+ for (const [rel, sha] of Object.entries(fileShaMap)) files[rel] = { sha256: sha };
827
+ const obj = { commitId: String(commitId), headRevision, files, updateTime: new Date().toISOString() };
828
+ fs.writeFileSync(syncStatePath(dir), JSON.stringify(obj, null, 1), "utf-8");
829
+ }
806
830
  async function expPull(args, json) {
807
831
  const expert = need("expert", args), dir = need("dir", args);
808
832
  const dry = !!args["dry-run"];
833
+ const force = !!args["force"]; // --force = 一律以远端覆盖(放弃本地修改)
809
834
  const st = await expGet("/api/dw/expert-repository/v1/repositories/status?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
810
835
  if (!expOk(st.j)) throw new Error(expFailMsg(st.j));
811
836
  const headId = rawNum(st.raw, "headCommitId") || String((st.j.data || {}).headCommitId);
837
+ const headRev = (st.j.data || {}).headRevision;
812
838
  const mf = await expGet("/api/dw/expert-repository/v1/repositories/manifest?expertCode=" + encodeURIComponent(expert) + "&branchName=" + (args.branch || "main"));
813
839
  if (!expOk(mf.j)) throw new Error(expFailMsg(mf.j));
814
840
  const items = (mf.j.data || []).filter((f) => f.relativePath && f.relativePath !== "DELETE");
815
841
  const local = fs.existsSync(dir) ? localShaMap(dir) : {};
816
- let downloaded = 0, unchanged = 0, changed = 0, missing = 0;
817
- const plan = [];
842
+ const base = readSyncState(dir); // 基线:上次同步后的文件 sha(无则 null)
843
+ const baseCommit = (base && base.commitId) || "";
844
+ // 基线有效判定:上次同步 commitId 须在提交历史上(取不到历史就保守按无基线处理)
845
+ let backupRoot = "";
846
+ let downloaded = 0, unchanged = 0, preserved = 0, backed = 0, missing = 0;
847
+ const plan = []; // {rel, action, reason}
848
+ const mkBackupRoot = () => {
849
+ if (backupRoot) return backupRoot;
850
+ const absDir = path.resolve(dir);
851
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
852
+ backupRoot = path.join(path.dirname(absDir), path.basename(absDir) + ".pull-backup-" + ts);
853
+ fs.mkdirSync(backupRoot, { recursive: true });
854
+ return backupRoot;
855
+ };
818
856
  for (const it of items) {
819
857
  const lp = path.join(dir, it.relativePath.split("/").join(path.sep));
820
858
  const ls = local[it.relativePath];
859
+ const baseSha = (base && base.files) ? base.files[it.relativePath] : undefined;
821
860
  if (ls === it.sha256) { unchanged++; continue; }
822
- changed++;
823
- if (ls === undefined) missing++;
824
- plan.push({ rel: it.relativePath, lp, sha: it.sha256, size: it.size });
861
+ if (ls === undefined) { // 本地缺失 → 直接下载(新增/恢复)
862
+ missing++; plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "本地缺失" });
863
+ } else if (!force && baseSha !== undefined && ls === baseSha && baseSha !== it.sha256) {
864
+ // 仅远端改(本地未动,与基线一致)→ 覆盖
865
+ plan.push({ rel: it.relativePath, action: dry ? "待下载" : "已下载", reason: "远端更新(本地未动)" });
866
+ } else if (!force && baseSha !== undefined && baseSha === it.sha256) {
867
+ // 仅本地改(远端=基线,本地≠基线)→ 保留本地,不覆盖
868
+ preserved++; plan.push({ rel: it.relativePath, action: "保留本地", reason: "本地修改(远端未动)" });
869
+ continue;
870
+ } else { // 双方都改 / 无基线可判 / --force → 先备份本地副本再覆盖
871
+ backed++;
872
+ if (!dry && !force) {
873
+ const bdir = path.join(mkBackupRoot(), it.relativePath.split("/").join(path.sep));
874
+ fs.mkdirSync(path.dirname(bdir), { recursive: true });
875
+ fs.copyFileSync(lp, bdir);
876
+ }
877
+ plan.push({ rel: it.relativePath, action: dry ? "待下载(将备份本地)" : (force ? "已下载(force覆盖)" : "已下载(本地已备份)"), reason: (force ? "force 覆盖" : (baseSha === undefined ? "无基线记录" : "双方都改")) });
878
+ }
825
879
  if (dry) continue;
826
880
  fs.mkdirSync(path.dirname(lp), { recursive: true });
827
881
  const buf = await rawGet("/api/dw/expert-repository/v1/repositories/file?expertCode=" + encodeURIComponent(expert) + "&commitId=" + encodeURIComponent(headId) + "&relativePath=" + encodeURIComponent(it.relativePath));
828
882
  fs.writeFileSync(lp, buf);
829
883
  downloaded++;
830
884
  }
831
- const out = { expert, headCommitId: headId, total: items.length, unchanged, changed, downloaded, missing, dryRun: !!dry };
885
+ // 拉取成功后更新基线状态文件(记录新 head + 本地最终文件 sha)
886
+ if (!dry) {
887
+ const finalLocal = localShaMap(dir);
888
+ writeSyncState(dir, headId, headRev, finalLocal);
889
+ }
890
+ const out = { expert, headCommitId: headId, headRevision: headRev, total: items.length, unchanged, downloaded, preserved, backed, missing, dryRun: !!dry, backupDir: backupRoot || undefined };
832
891
  if (json) { console.log(JSON.stringify(out)); return; }
833
892
  console.log("== pull " + expert + "@" + headId + (dry ? " (dry-run)" : "") + " ==");
834
- console.log("清单 " + out.total + " 项 | 未变 " + unchanged + " | 需更新 " + changed + "(缺失 " + missing + ")" + (dry ? "(dry-run 未下载)" : " | 已下载 " + downloaded));
835
- for (const p of plan.slice(0, 30)) console.log(" " + (dry ? "[待下载]" : "[已下载]") + " " + p.rel + " (" + p.sha.slice(0, 12) + "…)");
836
- if (plan.length > 30) console.log(" ... 共 " + plan.length + " 个文件待更新");
893
+ console.log("清单 " + out.total + " 项 | 未变 " + unchanged + " | 已下载 " + downloaded + " | 保留本地 " + preserved + " | 覆盖前备份 " + backed + " | 缺失 " + missing);
894
+ for (const p of plan.slice(0, 40)) console.log(" [" + p.action + "] " + p.rel + (p.reason ? "(" + p.reason + ")" : ""));
895
+ if (plan.length > 40) console.log(" ... 共 " + plan.length + " 个文件");
896
+ if (backupRoot && !dry) console.log("本地修改副本备份于: " + backupRoot);
897
+ if (preserved > 0 && !dry) console.log("⚠️ " + preserved + " 个文件因含本地修改被保留(远端未改);如需强制覆盖请加 --force");
837
898
  }
838
899
  async function expInit(args, json) {
839
900
  const expert = need("expert", args), dir = need("dir", args);
@@ -882,6 +943,12 @@ async function expCommit(args, json) {
882
943
  if (/基线|过期|revision|冲突/i.test(m)) throw new Error("提交冲突: " + m + " —— 请先 exp pull 拉取最新并合并后重试");
883
944
  throw new Error("提交失败: " + m);
884
945
  }
946
+ // 提交成功后把本地 .sync-state.json 基线刷新到新 head(与本地文件 sha 一致),保证后续 pull 三方判定准确
947
+ const rj = r.data || {};
948
+ const newHead = rawNum(JSON.stringify(r), "headCommitId") || rawNum(JSON.stringify(r), "commitId") || rj.headCommitId || rj.commitId || "";
949
+ if (newHead && fs.existsSync(dir)) {
950
+ try { writeSyncState(dir, newHead, rj.headRevision ?? rj.revision ?? 0, localShaMap(dir)); } catch {}
951
+ }
885
952
  console.log("== 提交成功 =="); console.log(JSON.stringify(r.data ?? {}, null, 1));
886
953
  }
887
954
  async function expLock(args, json) {
@@ -975,7 +1042,7 @@ exp 专家包版本管理(expert-repository/v1,响应以 data.actionMsg.
975
1042
  exp manifest --expert <code> [--commit <id>] # 文件清单(sha256)
976
1043
  exp commits --expert <code> # 提交历史
977
1044
  exp file --expert <code> --commit <id> --path <相对路径> [--out 本地文件]
978
- exp pull --expert <code> --dir <本地专家包目录> [--dry-run] # 按 manifest sha 差异下载缺失/不一致文件
1045
+ exp pull --expert <code> --dir <本地专家包目录> [--dry-run] [--force] # 智能拉取:基线三方对比(仅远端改→覆盖/仅本地改→保留/双方都改→备份后覆盖;--force 一律覆盖),成功后更新 .sync-state.json 基线
979
1046
  exp init --expert <code> --dir <本地目录> [--display-name X] [--message 初始化导入]
980
1047
  exp commit --expert <code> --dir <本地目录> --message "..." [--mode FULL_SNAPSHOT|FILE_PATCH]
981
1048
  exp lock --expert <code> --action query|acquire(默认)|heartbeat|release --component ROOT --ttl 30 --lock-token <token>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e10-data",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "泛微 E10 数据接口统一 CLI(data-assistant 专家配套):DSL 查询/数据源/文档/词库/关联字段翻译/环境参数配置中心(conf)/专家包版本管理,认证复用 weaver-e10-login 登录态",
5
5
  "author": "e10-dw",
6
6
  "license": "MIT",