@yufengtadian/freedom-cli 1.13.2 → 1.14.0-preview

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.
Files changed (58) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +74 -21
  3. package/lib/agents.js +182 -52
  4. package/lib/build.js +119 -42
  5. package/lib/cli.js +106 -9
  6. package/lib/release.js +22 -6
  7. package/lib/security.js +525 -45
  8. package/lib/shell.js +288 -64
  9. package/lib/toolchain.js +339 -0
  10. package/lib/utils.js +51 -0
  11. package/lib/verify.js +85 -30
  12. package/lib/webkit.js +45 -0
  13. package/package.json +4 -3
  14. package/shell/darwin-arm64/freedom-shell +0 -0
  15. package/shell/linux-x64/freedom-shell +0 -0
  16. package/shell/win-x64/freedom-shell.exe +0 -0
  17. package/skill/freedom/SKILL.md +3 -3
  18. package/templates/desktop/backend/desktop.mjs +16 -6
  19. package/templates/desktop/freedom.config.js +4 -1
  20. package/templates/go/go.mod +3 -0
  21. package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
  22. package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +136 -12
  24. package/templates/go/pkg/freedom/assets_embed.go +1 -0
  25. package/templates/go/pkg/freedom/backend_proc.go +5 -5
  26. package/templates/go/pkg/freedom/events_windows.go +5 -5
  27. package/templates/go/pkg/freedom/freedom.go +17 -3
  28. package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
  29. package/templates/go/pkg/freedom/resources.go +59 -1
  30. package/templates/go/pkg/freedom/security.go +475 -41
  31. package/templates/go/pkg/freedom/shutdown.go +55 -0
  32. package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
  33. package/templates/go/pkg/freedom/store.go +3 -3
  34. package/templates/go/pkg/freedom/syscap_windows.go +33 -33
  35. package/templates/go/pkg/freedom/sysint_windows.go +2 -2
  36. package/templates/go/pkg/freedom/tray_windows.go +18 -18
  37. package/templates/go/pkg/freedom/updater.go +3 -3
  38. package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
  39. package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
  40. package/templates/go/third_party/webview_go/LICENSE +22 -0
  41. package/templates/go/third_party/webview_go/README.md +50 -0
  42. package/templates/go/third_party/webview_go/glue.c +36 -0
  43. package/templates/go/third_party/webview_go/go.mod +3 -0
  44. package/templates/go/third_party/webview_go/go.sum +0 -0
  45. package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
  46. package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
  47. package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
  48. package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
  49. package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
  50. package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
  51. package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
  52. package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
  53. package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
  54. package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
  55. package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
  56. package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
  57. package/templates/go/third_party/webview_go/webview.cc +1 -0
  58. package/templates/go/third_party/webview_go/webview.go +332 -0
package/lib/build.js CHANGED
@@ -23,6 +23,7 @@
23
23
  const fs = require('fs');
24
24
  const fsp = fs.promises;
25
25
  const path = require('path');
26
+ const crypto = require('crypto');
26
27
  const { spawnSync } = require('child_process');
27
28
  const { copyDir } = require('./utils');
28
29
  const {
@@ -32,15 +33,22 @@ const {
32
33
  isMacPlat,
33
34
  platformExeName,
34
35
  nativePlatform,
36
+ normalizePlatform,
35
37
  localShellPath,
36
38
  } = require('./utils');
37
- const { hasShell, downloadShell, validateLocalShell } = require('./shell');
39
+ const { buildShell, downloadShell, validateLocalShell } = require('./shell');
38
40
  const {
39
41
  SECURITY_MODES,
40
42
  resolveSecurity,
41
- encryptApp,
42
- buildIntegrity,
43
- renderIntegrity,
43
+ encryptAppV3,
44
+ signIntegrityV3,
45
+ renderManifestV3,
46
+ shellInject,
47
+ keySlotForBuild,
48
+ loadProductKey,
49
+ signingKeyPath,
50
+ rawPubHexFromKey,
51
+ sha256hex,
44
52
  } = require('./security');
45
53
 
46
54
  function run(cmd, args, opts = {}) {
@@ -60,12 +68,12 @@ function run(cmd, args, opts = {}) {
60
68
  return res;
61
69
  }
62
70
 
63
- // --platform 解析:支持逗号 / 中英文逗号 / 空白分隔多平台;win|mac|linux|all 或平台 key(win-x64 等)。
71
+ // --platform 解析:支持逗号 / 中英文逗号 / 空白分隔多平台;win|mac|linux|all 或平台 key
72
+ // (win-x64 等,别名口径与 freedom shell 子命令统一走 utils.normalizePlatform)。
64
73
  // 多平台去重保留顺序。all 仅取可分发平台(DIST_PLATFORMS):linux-arm64 无 CI 资产,
65
74
  // 若列入 all 会在 build 时 404 拖垮整个全量构建(历史 bug B41)。
66
75
  function parsePlatforms(raw) {
67
76
  if (!raw) return [nativePlatform()];
68
- const keyMap = { win: 'win-x64', mac: 'darwin-arm64', linux: 'linux-x64' };
69
77
  const seen = [];
70
78
  const push = (p) => {
71
79
  if (!ALL_PLATFORMS.includes(p)) {
@@ -81,7 +89,7 @@ function parsePlatforms(raw) {
81
89
  if (v === 'all') {
82
90
  for (const p of DIST_PLATFORMS) push(p);
83
91
  } else {
84
- push(keyMap[v] || v);
92
+ push(normalizePlatform(v) || v);
85
93
  }
86
94
  }
87
95
  if (seen.length === 0) {
@@ -106,6 +114,14 @@ async function build(projectDir, opts = {}) {
106
114
  const platforms = parsePlatforms(opts.platform);
107
115
  const autoDownload = opts.autoDownload !== false;
108
116
 
117
+ // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
118
+ // none = 明文资源(默认,兼容历史产物)
119
+ // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆)
120
+ // high = FRDM3:resources 加密为 app.bin + 发布方私钥签名的 .integrity + 本应用专属壳
121
+ // 判定与发布方资产门都前置于前端打包:high 缺密钥属"注定失败的构建",不该先跑几分钟 vite。
122
+ const security = resolveSecurity(opts.security, cfg.security);
123
+ const secrets = security === 'high' ? await prepareHighSecrets(dir, name) : null;
124
+
109
125
  // 1) 前端打包(带缓存:源码未变更时复用上次 vite 产物,跳过 npm run build)
110
126
  // cfg.staticHtml:静态单文件页面直通——不跑 npm install / vite,用于 freedom 自己的
111
127
  // Desktop 界面自举打包(以及纯静态页项目),零网络、零依赖。
@@ -140,12 +156,7 @@ async function build(projectDir, opts = {}) {
140
156
  html = fs.readFileSync(distHtml, 'utf8');
141
157
  warnIfNotSingleFile(html, distHtml);
142
158
  }
143
- const configJSON = renderConfigJSON(cfg, name);
144
- // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
145
- // none = 明文资源(默认,兼容历史产物)
146
- // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆,后续可用高模式加密)
147
- // high = resources 整体加密为 app.bin(AES-256-CTR+HMAC)+ .integrity 完整性清单,磁盘无明文
148
- const security = resolveSecurity(opts.security, cfg.security);
159
+ const configJSON = renderConfigJSON(cfg, name, version);
149
160
 
150
161
  // 2) 后端目录(若配置了 backend 进程)
151
162
  const backendDir = path.join(dir, cfg.backendDir || 'backend');
@@ -161,7 +172,7 @@ async function build(projectDir, opts = {}) {
161
172
  const results = await Promise.all(
162
173
  platforms.map(async (plat) => {
163
174
  const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
164
- const emitted = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer: opts.installer === true });
175
+ const emitted = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer: opts.installer === true, secrets });
165
176
  return { plat, ...emitted };
166
177
  })
167
178
  );
@@ -196,34 +207,73 @@ async function build(projectDir, opts = {}) {
196
207
  return { results };
197
208
  }
198
209
 
199
- async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer }) {
200
- // 取预编译壳二进制
201
- const shell = localShellPath(plat);
202
- if (!fs.existsSync(shell)) {
203
- if (!autoDownload) {
210
+ // prepareHighSecrets —— high 模式(Tier B)的发布方资产门。
211
+ //
212
+ // 两样东西缺一不可:
213
+ // 每产物主密钥 .freedom/keys/<app>.key —— 编译进本应用专属壳,容器 KEK 的输入;
214
+ // 发布方私钥 .freedom/keys/update_ed25519 —— 签 resources/.integrity(与自更新清单同一把钥匙)。
215
+ // 缺失一律拒绝构建:退回 FRDM2 那把随 npm 包公开的全域主密钥,等于签出一份谁都能伪造的清单
216
+ // (台账 B-20260925-054)。私钥只用于签名,公钥才是注入壳的信任锚,私钥本身永不进产物。
217
+ async function prepareHighSecrets(dir, name) {
218
+ const master = loadProductKey(dir, name);
219
+ const privPath = signingKeyPath(dir);
220
+ if (!fs.existsSync(privPath)) {
221
+ throw new Error(`缺少发布方签名私钥:${privPath}\n请先运行 freedom keygen(私钥是发布方资产,勿入库、勿分发)`);
222
+ }
223
+ const privateKey = crypto.createPrivateKey(await fsp.readFile(privPath));
224
+ return { master, anchorPubHex: rawPubHexFromKey(privateKey), privateKey };
225
+ }
226
+
227
+ async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer, secrets }) {
228
+ const exeName = platformExeName(plat, name);
229
+ const outFile = path.join(targetDir, exeName);
230
+ await fsp.mkdir(targetDir, { recursive: true });
231
+
232
+ // 取壳:high 模式(Tier B)编译本应用专属壳,把每产物主密钥与信任锚编进 exe;
233
+ // 其余模式沿用包内/下载的通用预编译壳(明文资源,无需任何注入)。
234
+ if (security === 'high') {
235
+ if (!secrets) throw new Error('internal: high 模式缺少 secrets');
236
+ if (plat !== nativePlatform()) {
204
237
  throw new Error(
205
- `缺少平台 ${plat} 的壳二进制:${shell}\n` +
206
- `可运行 freedom shell download ${plat} 下载,或 freedom shell build ${plat} 本地编译。`
238
+ `high 模式需要在本机编译 ${plat} 的专属壳(webview_go 依赖目标平台系统 WebView,无法交叉编译)。` +
239
+ `当前平台是 ${nativePlatform()}:请在 ${plat} 机器上执行 freedom build --security high,` +
240
+ `或该平台改用 --security basic。`
207
241
  );
208
242
  }
209
- process.stdout.write(`[freedom] 本地无 ${plat} 壳,尝试自动下载...\n`);
210
- await downloadShell(plat);
243
+ process.stdout.write(
244
+ `[freedom] high 模式(Tier B):编译 ${name} 的专属壳,注入信任锚公钥 + 本次构建专属的主密钥装配码(需 Go 工具链)...\n`
245
+ );
246
+ // 主密钥不再有 -X 通道(固定掩码 = 一份脱壳器通吃所有产物,台账 B-20260925-060):
247
+ // 每次构建现场生成一份只属于本产物的装配码,经 overlay 虚拟进壳的包目录,编完即弃。
248
+ const keySlot = keySlotForBuild(secrets.master);
249
+ buildShell(plat, {
250
+ dest: outFile,
251
+ inject: shellInject(secrets.anchorPubHex),
252
+ stageGoFiles: { [keySlot.fileName]: keySlot.source },
253
+ });
254
+ if (!isWinPlat(plat)) await fsp.chmod(outFile, 0o755);
211
255
  } else {
212
- // 壳格式校验:防止 mac/linux 平台误用 Windows 假壳被静默分发(历史缺陷:shell/<darwin-*>/<linux-*> 曾误填 Windows PE 副本)。
213
- const formatIssue = validateLocalShell(plat);
214
- if (formatIssue) {
215
- throw new Error(formatIssue);
256
+ const shell = localShellPath(plat);
257
+ if (!fs.existsSync(shell)) {
258
+ if (!autoDownload) {
259
+ throw new Error(
260
+ `缺少平台 ${plat} 的壳二进制:${shell}\n` +
261
+ `可运行 freedom shell download ${plat} 下载,或 freedom shell build ${plat} 本地编译。`
262
+ );
263
+ }
264
+ process.stdout.write(`[freedom] 本地无 ${plat} 壳,尝试自动下载...\n`);
265
+ await downloadShell(plat);
266
+ } else {
267
+ // 壳格式校验:防止 mac/linux 平台误用 Windows 假壳被静默分发(历史缺陷:shell/<darwin-*>/<linux-*> 曾误填 Windows PE 副本)。
268
+ const formatIssue = validateLocalShell(plat);
269
+ if (formatIssue) {
270
+ throw new Error(formatIssue);
271
+ }
272
+ }
273
+ await fsp.copyFile(shell, outFile);
274
+ if (!isWinPlat(plat)) {
275
+ await fsp.chmod(outFile, 0o755);
216
276
  }
217
- }
218
-
219
- await fsp.mkdir(targetDir, { recursive: true });
220
-
221
- // 壳二进制 -> 应用可执行文件
222
- const exeName = platformExeName(plat, name);
223
- const outFile = path.join(targetDir, exeName);
224
- await fsp.copyFile(shell, outFile);
225
- if (!isWinPlat(plat)) {
226
- await fsp.chmod(outFile, 0o755);
227
277
  }
228
278
 
229
279
  // 自定义 exe 图标(仅 Windows PE 支持嵌入 .ico 资源;mac 用 .icns 走 .app 分支)
@@ -261,8 +311,21 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
261
311
  if (stripped > 0) {
262
312
  process.stdout.write(`[freedom] high 模式:已抹去前端页内 ${stripped} 处 source map 引用(可能含原始源码)。\n`);
263
313
  }
264
- const appBin = encryptApp(name, secureHtml, configJSON, backendFiles); await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
265
- await fsp.writeFile(path.join(resDir, '.integrity'), renderIntegrity(buildIntegrity(name, appBin)), 'utf8');
314
+ // FRDM3 容器:KEK 由「每产物主密钥 + 应用标识 + 容器随机盐」派生,主密钥只在发布方
315
+ // 仓库与本应用专属壳里,公开 npm 源上没有任何一把能解开它。
316
+ const appBin = encryptAppV3(secrets.master, name, secureHtml, configJSON, backendFiles);
317
+ await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
318
+ // self 绑定 exe 此刻的字节,故哈希必须在图标注入之后、安装包/.app 组装之前取
319
+ //(后续步骤不再改写 exe 本体)。事后改写 exe(尤其补做 Authenticode 签名)会让壳拒启动。
320
+ const selfHash = sha256hex(await fsp.readFile(outFile));
321
+ const manifest = signIntegrityV3({
322
+ name,
323
+ appBin,
324
+ signingKey: secrets.privateKey,
325
+ selfHash,
326
+ built: new Date().toISOString(),
327
+ });
328
+ await fsp.writeFile(path.join(resDir, '.integrity'), renderManifestV3(manifest), 'utf8');
266
329
  } else {
267
330
  for (const legacy of ['app.bin', '.integrity']) {
268
331
  const p = path.join(resDir, legacy);
@@ -470,7 +533,10 @@ async function buildInstaller({ plat, name, version, targetDir, exeName }) {
470
533
  const artifacts = [];
471
534
 
472
535
  const zipPath = path.join(targetDir, `${name}-${plat}-portable.zip`);
473
- const tmpPath = path.join(path.dirname(targetDir), `.freedom-portable-${process.pid}.zip`);
536
+ // 中间 zip 必须带平台后缀:多平台打包是并发的(emitPlatform 跑在 Promise.all 里),
537
+ // 而各 plat 的 targetDir 同级,只按 pid 命名会让 A 平台的 zipDir 覆写 B 平台正在 rename 的
538
+ // 临时文件——结果是 A 的 portable.zip 里装着 B 的二进制(静默错平台产物)或 ENOENT 崩在成功提示之后。
539
+ const tmpPath = path.join(path.dirname(targetDir), `.freedom-portable-${plat}-${process.pid}.zip`);
474
540
  zipDir(tmpPath, targetDir);
475
541
  await fsp.rename(tmpPath, zipPath);
476
542
  artifacts.push(zipPath);
@@ -530,9 +596,12 @@ function appVersionFromPkg(dir) {
530
596
  }
531
597
 
532
598
  // 渲染 resources/config.json(与壳 resources.go 的 runtimeConfigFile 字段对齐)
533
- function renderConfigJSON(cfg, name) {
599
+ function renderConfigJSON(cfg, name, version) {
534
600
  const obj = {
535
601
  name,
602
+ // 应用自己的版本必须随产物落盘:通用壳编译期注入的 freedom.Version 是「壳」的版本域,
603
+ // 自更新拿它跟应用清单比,结果是「永远 uncomparable」或「下完更新还是同一个壳」。
604
+ version: version || undefined,
536
605
  title: cfg.title || cfg.name || name,
537
606
  titlebar: cfg.titlebar || 'frameless',
538
607
  width: intVal(cfg.width, 1024),
@@ -562,6 +631,14 @@ function renderConfigJSON(cfg, name) {
562
631
  requireSignature: cfg.updater.requireSignature === true ? true : undefined,
563
632
  };
564
633
  }
634
+ // 能力收口透传:壳在 Config.Capabilities 未编译设置时才采用这里的白/黑名单
635
+ // (capability.go 在 sys/tray/window 三桥派发前判定,拒绝零副作用)。
636
+ const caps = cfg.capabilities;
637
+ if (caps && (Array.isArray(caps.allow) || Array.isArray(caps.deny))) {
638
+ const allow = (caps.allow || []).filter((s) => typeof s === 'string' && s);
639
+ const deny = (caps.deny || []).filter((s) => typeof s === 'string' && s);
640
+ if (allow.length || deny.length) obj.capabilities = { allow, deny };
641
+ }
565
642
  return JSON.stringify(obj, null, 2);
566
643
  }
567
644
 
@@ -721,4 +798,4 @@ function stripSourceMapRefs(html) {
721
798
  return { html: out, stripped };
722
799
  }
723
800
 
724
- module.exports = { build, parsePlatforms };
801
+ module.exports = { build, parsePlatforms, renderConfigJSON, prepareHighSecrets };
package/lib/cli.js CHANGED
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const { init } = require('./init');
6
6
  const { build } = require('./build');
7
7
  const { setConfig, showConfig } = require('./config');
8
- const { packageRoot, tutorialFile } = require('./utils');
8
+ const { packageRoot, tutorialFile, normalizePlatform } = require('./utils');
9
9
  const theme = require('./theme');
10
10
  const update = require('./update');
11
11
 
@@ -37,7 +37,7 @@ function help() {
37
37
  L.push(` ${paint('freedom dmg [--platform <plat>]', C.fg.cyan)} ${dim('将已构建的 .app 打包为 .dmg(需 macOS)')}`);
38
38
  L.push(section('开发与更新'));
39
39
  L.push(` ${paint('freedom dev [--port <n>|--url <u>] [--command <cmd>]', C.fg.cyan)} ${dim('dev server 联调壳窗口:HMR 热更,改码免重打包')}`);
40
- L.push(` ${paint('freedom keygen', C.fg.cyan)} ${dim('生成应用自更新 ed25519 密钥对(公钥进配置,私钥发布方保管)')}`);
40
+ L.push(` ${paint('freedom keygen [--dir <项目目录>]', C.fg.cyan)} ${dim('生成应用自更新 ed25519 密钥对 + 每产物主密钥(公钥进配置,私钥发布方保管)')}`);
41
41
  L.push(` ${paint('freedom manifest --artifact <产物> --url <下载地址> [--version x]', C.fg.cyan)} ${dim('产出签名的更新清单 latest.json')}`);
42
42
  L.push(section('外观'));
43
43
  L.push(` ${paint('freedom titlebar <native|frameless>', C.fg.cyan)} ${dim('一键切换标题栏策略')}`);
@@ -56,6 +56,11 @@ function help() {
56
56
  L.push(` ${paint('freedom skill install --agent <key|all> [--dry-run] [--skills-dir <path>]', C.fg.cyan)} ${dim('把 Freedom 使用技能装入 agent 的 skills 目录')}`);
57
57
  L.push(` ${paint('freedom mcp install --agent <key|all> [--dry-run] [--config <path> --format json|toml|yaml]', C.fg.cyan)} ${dim('把 Freedom MCP 服务写入 agent 的 MCP 配置(幂等合并、写前 .bak 备份)')}`);
58
58
  L.push(` ${paint('freedom mcp serve', C.fg.cyan)} ${dim('stdio MCP 服务本体(一般由 agent 自动拉起,无需手敲)')}`);
59
+ L.push(section('工具链'));
60
+ L.push(` ${paint('freedom toolchain', C.fg.cyan)} ${dim('探测 Go / Rust / C++ 工具链是否就位(已配置好则走缓存、不重复探测)')}`);
61
+ L.push(` ${paint('freedom toolchain install [go,rust,cpp|missing] [--apply]', C.fg.cyan)} ${dim('缺失时自动安装;缺省只打印命令,--apply 才真跑(联网 + 提权)')}`);
62
+ L.push(` ${paint('freedom toolchain optimize [--apply]', C.fg.cyan)} ${dim('自动优化配置:Go GOPROXY 镜像、cargo 源换 rsproxy(幂等 + .bak)')}`);
63
+ L.push(` ${paint('freedom toolchain status --refresh', C.fg.cyan)} ${dim('强制重新探测并刷新缓存(默认缓存 7 天,PATH 变更即失效)')}`);
59
64
  L.push(section('版本'));
60
65
  L.push(` ${paint('freedom version', C.fg.cyan)} ${dim('显示版本并检测最新版本')}`);
61
66
  L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并立即自动更新')}`);
@@ -154,7 +159,7 @@ async function run(argv) {
154
159
  if (home && !home.startsWith('--')) process.env.FREEDOM_AGENT_HOME = path.resolve(home.replace(/^--home=/, ''));
155
160
  if (!sub || sub === 'list') return require('./agents').printMatrix();
156
161
  if (sub !== 'install') {
157
- console.error(`${err(`未知 agents 子命令:${sub}`)} ${dim('可用:list / install --what <mcp|skill> --agent <key|all> [--dry-run]')}`);
162
+ console.error(`${err(`未知 agents 子命令:${sub}`)} ${dim('可用:list / install --what <mcp|skill> --agent <key|all> [--dry-run] [--force]')}`);
158
163
  return 1;
159
164
  }
160
165
  const wi = flags.indexOf('--what');
@@ -166,6 +171,10 @@ async function run(argv) {
166
171
  return await runAgents(what === 'skill' ? 'skill' : 'mcp', ['install', ...flags]);
167
172
  }
168
173
 
174
+ case 'toolchain':
175
+ case 'tc':
176
+ return toolchainCommand(rest);
177
+
169
178
  case 'skill':
170
179
  case 'mcp':
171
180
  return await runAgents(cmd, rest);
@@ -220,8 +229,12 @@ async function run(argv) {
220
229
 
221
230
  case 'keygen': {
222
231
  const { keygen } = require('./release');
232
+ // --dir 必须生效:忽略它会把密钥 mint 到当前目录,之后在项目里 build 只会在
233
+ // 一个从未存在的文件名下找主密钥(报"缺每产物主密钥",用户无从下手)。
234
+ const di = rest.indexOf('--dir');
235
+ const dir = di >= 0 && rest[di + 1] && !rest[di + 1].startsWith('--') ? rest[di + 1] : process.cwd();
223
236
  try {
224
- console.log(await keygen({ dir: process.cwd(), force: rest.includes('--force') }));
237
+ console.log(await keygen({ dir, force: rest.includes('--force') }));
225
238
  } catch (e) {
226
239
  console.error(`${err(e.message)}`);
227
240
  return 1;
@@ -462,21 +475,21 @@ async function runShell(rest) {
462
475
  case 'download': {
463
476
  const plat = rest[1];
464
477
  if (!plat) {
465
- console.error(`${err('用法:')}${paint('freedom shell download <win-x64|darwin-arm64|linux-x64|linux-arm64>', C.fg.cyan)}`);
478
+ console.error(`${err('用法:')}${paint('freedom shell download <win|mac|linux|all 或 win-x64 / darwin-arm64 / linux-x64 / linux-arm64>', C.fg.cyan)}`);
466
479
  return 1;
467
480
  }
468
481
  const dest = await downloadShell(plat);
469
- console.log(`${ok('已下载')} ${paint(plat, C.fg.magenta, C.bold)} ${dim('壳:')}${paint(dest, C.fg.white)}`);
482
+ console.log(`${ok('已下载')} ${paint(normalizePlatform(plat) || plat, C.fg.magenta, C.bold)} ${dim('壳:')}${paint(dest, C.fg.white)}`);
470
483
  return 0;
471
484
  }
472
485
  case 'build': {
473
486
  const plat = rest[1];
474
487
  if (!plat) {
475
- console.error(`${err('用法:')}${paint('freedom shell build <win-x64|darwin-arm64|linux-x64|linux-arm64>', C.fg.cyan)}`);
488
+ console.error(`${err('用法:')}${paint('freedom shell build <win|mac|linux 或 win-x64 / darwin-arm64 / linux-x64 / linux-arm64>', C.fg.cyan)}`);
476
489
  return 1;
477
490
  }
478
491
  const dest = buildShell(plat);
479
- console.log(`${ok('已编译')} ${paint(plat, C.fg.magenta, C.bold)} ${dim('壳:')}${paint(dest, C.fg.white)}`);
492
+ console.log(`${ok('已编译')} ${paint(normalizePlatform(plat) || plat, C.fg.magenta, C.bold)} ${dim('壳:')}${paint(dest, C.fg.white)}`);
480
493
  console.log(` ${dim('提示:壳已预编译随包分发,一般无需本地编译。')}`);
481
494
  return 0;
482
495
  }
@@ -499,7 +512,7 @@ async function runAgents(cmd, rest) {
499
512
  return await require('./mcp').serve();
500
513
  }
501
514
  if (sub !== 'install') {
502
- console.error(`${err('用法:')}${paint(`freedom ${cmd} install --agent <key|all> [--dry-run]`, C.fg.cyan)}`);
515
+ console.error(`${err('用法:')}${paint(`freedom ${cmd} install --agent <key|all> [--dry-run] [--force]`, C.fg.cyan)}`);
503
516
  if (cmd === 'mcp') {
504
517
  console.error(` ${paint('freedom mcp serve', C.fg.cyan)} ${dim('(stdio MCP 服务,通常由 agent 配置自动拉起)')}`);
505
518
  }
@@ -515,6 +528,7 @@ async function runAgents(cmd, rest) {
515
528
  config: optVal('config'),
516
529
  format: optVal('format'),
517
530
  skillsDir: optVal('skills-dir'),
531
+ force: rest.includes('--force'),
518
532
  home: optVal('home'),
519
533
  });
520
534
  return r.code;
@@ -563,6 +577,89 @@ async function runDmg(rest) {
563
577
  }
564
578
  }
565
579
 
580
+ // freedom toolchain —— 工具链自动装配:探测(带缓存)/ 安装(默认只打印)/ 配置优化。
581
+ function toolchainCommand(rest) {
582
+ const tc = require('./toolchain');
583
+ const flags = new Set();
584
+ const pos = [];
585
+ let homeArg;
586
+ for (let i = 0; i < rest.length; i++) {
587
+ const a = rest[i];
588
+ if (a === '--home') { homeArg = rest[++i]; continue; }
589
+ if (a.startsWith('--home=')) { homeArg = a.slice('--home='.length); continue; }
590
+ if (a.startsWith('--')) { flags.add(a); continue; }
591
+ pos.push(a);
592
+ }
593
+ const sub = pos[0];
594
+ const opts = {
595
+ refresh: flags.has('--refresh'),
596
+ apply: flags.has('--apply'),
597
+ home: homeArg ? path.resolve(homeArg) : undefined,
598
+ };
599
+ const out = [];
600
+ switch (sub) {
601
+ case undefined:
602
+ case 'status': {
603
+ const { rows, skipped } = tc.status(opts);
604
+ for (const r of rows) {
605
+ if (r.ok) out.push(` ${ok('已就位')} ${paint(r.key, C.fg.cyan, C.bold)} ${dim(r.version + ' ' + r.exe)} ${r.cached ? dim('[缓存]') : dim('[探测]')}`);
606
+ else out.push(` ${err('缺失')} ${paint(r.key, C.fg.cyan, C.bold)} ${dim(r.reason || '未检测到')} ${dim('· ' + r.why)}`);
607
+ }
608
+ out.push('');
609
+ const cacheNote = skipped ? `本轮跳过 ${skipped} 次探测(缓存命中,PATH 变更或 --refresh 即失效)` : '本轮全部实探';
610
+ out.push([' ', dim(cacheNote) + ';', dim('安装:'), paint('freedom toolchain install missing', C.fg.cyan),
611
+ dim('(默认只打印命令,加 --apply 才执行)')].join(' '));
612
+ console.log(out.join('\n'));
613
+ return 0;
614
+ }
615
+ case 'install': {
616
+ const res = tc.install(pos[1] || 'missing', opts);
617
+ for (const r of res.rows) {
618
+ if (r.action === 'skip') console.log(` ${ok(`[${r.key}] 已就位`)} ${dim(r.version || '')}`);
619
+ else if (r.action === 'manual') console.log(` ${warn(`[${r.key}] 本平台无自动安装方式`)} ${dim(r.note)}`);
620
+ else if (r.action === 'plan') {
621
+ console.log(` ${warn(`[${r.key}] 待安装`)} ${dim('以下为将执行的命令:')}`);
622
+ for (const c of r.commands) console.log(` ${paint(c, C.fg.white)}`);
623
+ } else if (r.action === 'applied') {
624
+ for (const s of r.ran) {
625
+ console.log(` ${s.status === 0 ? ok('[完成]') : err('[失败]')} ${dim(s.command)} ${s.status === 0 ? '' : err(`退出码 ${s.status}`)}`);
626
+ }
627
+ }
628
+ }
629
+ if (res.dryRun) {
630
+ console.log('');
631
+ console.log(` ${tip('确认无误后加 --apply 真正执行安装(联网、可能需要管理员权限)。')}`);
632
+ } else {
633
+ console.log(` ${dim('安装后若 PATH 未刷新,重新打开终端或执行 freedom toolchain status --refresh。')}`);
634
+ }
635
+ return res.rows.some((r) => r.action === 'applied' && r.ran.some((s) => s.status !== 0)) ? 1 : 0;
636
+ }
637
+ case 'optimize': {
638
+ const res = tc.optimize(opts);
639
+ for (const r of res.rows) {
640
+ if (r.action === 'skip') console.log(` ${ok(`[${r.key}] 无需优化`)} ${dim(r.note)}`);
641
+ else if (r.action === 'blocked') console.log(` ${warn(`[${r.key}] 跳过`)} ${dim(r.note)}`);
642
+ else if (r.action === 'report') console.log(` ${r.ok ? ok(`[${r.key}] 已就位`) : warn(`[${r.key}] 未就位`)} ${dim(r.note)}`);
643
+ else if (r.action === 'plan') {
644
+ console.log(` ${warn(`[${r.key}] 将优化`)} ${dim(r.from ? `当前:${r.from}` : '')}`);
645
+ for (const c of (r.commands || [])) console.log(` ${paint(c, C.fg.white)}`);
646
+ if (r.file) console.log(` ${dim(r.file)} ${dim('(幂等合并,写前 .bak)')}`);
647
+ } else if (r.action === 'applied') {
648
+ console.log(` ${ok(`[${r.key}] 已优化`)} ${dim(r.to || r.file || '')}${r.backup ? dim(' 备份 ' + r.backup) : ''}${r.status ? err(` 退出码 ${r.status}`) : ''}`);
649
+ }
650
+ }
651
+ if (res.dryRun) console.log(`\n ${tip('以上为拟写入内容,未改动任何文件;加 --apply 生效(写入前自动 .bak 备份)。')}`);
652
+ return 0;
653
+ }
654
+ case 'clear':
655
+ console.log(tc.clearCache(opts) ? `${ok('缓存已清除,下一次 status 将重新探测。')}` : `${dim('无缓存可清除。')}`);
656
+ return 0;
657
+ default:
658
+ console.error(`${err(`未知 toolchain 子命令:${sub}`)} ${dim('可用:status / install [go,rust,cpp|missing] / optimize / clear')}`);
659
+ return 1;
660
+ }
661
+ }
662
+
566
663
  function openBrowser(file) {
567
664
  const { spawn } = require('child_process');
568
665
  const plat = process.platform;
package/lib/release.js CHANGED
@@ -15,29 +15,45 @@ const fsp = fs.promises;
15
15
  const path = require('path');
16
16
 
17
17
  const { loadConfig } = require('./utils');
18
+ const { ensureKeysIgnored, signingKeyPath, productKeyPath, createProductKey } = require('./security');
18
19
 
19
20
  function manifestPayload(version, url, sha256hex) {
20
21
  return Buffer.from(`freedom-update-v1\n${version}\n${url}\n${String(sha256hex).toLowerCase()}`, 'utf8');
21
22
  }
22
23
 
23
- function keysPath(dir) {
24
- return path.join(dir, '.freedom', 'keys', 'update_ed25519');
25
- }
26
-
24
+ // keygen 一次性 mint 发布方的两把资产:
25
+ // 1) ed25519 签名私钥 —— 签自更新清单(latest.json)与产物完整性清单(.integrity v3);
26
+ // 2) 每产物主密钥 .freedom/keys/<app>.key —— high 模式容器 KEK 的输入(FRDM3)。
27
+ // 两把都落在 .freedom/keys/(ensureKeysIgnored 保证不入库),都属于"丢了就没法再为该应用
28
+ // 重签产物"的发布方资产,故 keygen 的输出把两个路径都念给用户。
27
29
  async function keygen(opts = {}) {
28
30
  const dir = path.resolve(opts.dir || '.');
29
- const privPath = opts.out ? path.resolve(opts.out) : keysPath(dir);
31
+ const privPath = opts.out ? path.resolve(opts.out) : signingKeyPath(dir);
30
32
  if (fs.existsSync(privPath) && !opts.force) {
31
33
  throw new Error(`私钥已存在:${privPath}(重新生成请 --force,注意旧公钥签发的客户端将收不到新签名)`);
32
34
  }
33
35
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
34
36
  await fsp.mkdir(path.dirname(privPath), { recursive: true });
37
+ ensureKeysIgnored(dir); // 只写提示语不算保护:一次 git add -A 就能把私钥推上远端
35
38
  await fsp.writeFile(privPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
36
39
  const rawPub = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); // SPKI 尾部 32 字节即原始公钥
37
40
  const pubB64 = rawPub.toString('base64');
41
+ // 应用标识必须与 build 同源(cfg.name),否则 high 构建按名字找主密钥会找不到。
42
+ let appName = path.basename(dir);
43
+ try {
44
+ appName = (await loadConfig(dir)).name || appName;
45
+ } catch { /* 非项目目录:按目录名 mint;在项目内重跑 keygen 即可 */ }
46
+ const masterPath = productKeyPath(dir, appName);
47
+ // 已有主密钥就沿用:老项目升级 CLI 后重跑 keygen 只为补这把钥匙,不该被迫 --force 轮换签名私钥。
48
+ let minted = '沿用';
49
+ if (!fs.existsSync(masterPath)) {
50
+ createProductKey(dir, appName);
51
+ minted = '新生成';
52
+ }
38
53
  const lines = [
39
54
  `私钥已生成:${privPath}(发布方资产,勿入库/勿分发)`,
40
55
  `公钥(base64):${pubB64}`,
56
+ `每产物主密钥(${minted}):${masterPath}(high 模式产物加密所需,应用名 ${appName})`,
41
57
  '用法:freedom.config.js 中设置',
42
58
  ` updater: { manifestURL: 'https://your.host/latest.json', publicKey: '${pubB64}' }`,
43
59
  '发版时:freedom manifest 生成签名 latest.json。',
@@ -71,7 +87,7 @@ async function manifest(opts = {}) {
71
87
  || (() => { try { return require(path.join(dir, 'package.json')).version; } catch { return null; } })();
72
88
  if (!version) throw new Error('版本未定:--version / freedom.config.js 的 version / 项目 package.json 的 version 均缺失');
73
89
 
74
- const keyPath = opts.key ? path.resolve(opts.key) : keysPath(dir);
90
+ const keyPath = opts.key ? path.resolve(opts.key) : signingKeyPath(dir);
75
91
  if (!fs.existsSync(keyPath)) {
76
92
  throw new Error(`私钥不存在:${keyPath},先运行 freedom keygen`);
77
93
  }