@yufengtadian/freedom-cli 1.13.1 → 1.13.2

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 (38) hide show
  1. package/README.md +87 -13
  2. package/lib/agents.js +318 -0
  3. package/lib/build.js +168 -39
  4. package/lib/cli.js +160 -4
  5. package/lib/config.js +20 -1
  6. package/lib/desktop.js +133 -0
  7. package/lib/dev.js +149 -0
  8. package/lib/mcp.js +217 -0
  9. package/lib/release.js +90 -0
  10. package/lib/security.js +113 -54
  11. package/lib/shell.js +5 -2
  12. package/lib/tui.js +37 -1
  13. package/lib/verify.js +43 -8
  14. package/package.json +2 -1
  15. package/shell/darwin-arm64/freedom-shell +0 -0
  16. package/shell/linux-x64/freedom-shell +0 -0
  17. package/shell/win-x64/freedom-shell.exe +0 -0
  18. package/skill/freedom/SKILL.md +159 -0
  19. package/templates/desktop/app.html +559 -0
  20. package/templates/desktop/backend/desktop.mjs +317 -0
  21. package/templates/desktop/freedom.config.js +29 -0
  22. package/templates/desktop/icon.ico +0 -0
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +114 -11
  24. package/templates/go/pkg/freedom/assets/freedom.js +3 -0
  25. package/templates/go/pkg/freedom/freedom.go +52 -9
  26. package/templates/go/pkg/freedom/resources.go +68 -14
  27. package/templates/go/pkg/freedom/securetemp.go +61 -0
  28. package/templates/go/pkg/freedom/securetemp_other.go +20 -0
  29. package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
  30. package/templates/go/pkg/freedom/security.go +240 -61
  31. package/templates/go/pkg/freedom/store.go +8 -0
  32. package/templates/go/pkg/freedom/window_other.go +7 -1
  33. package/templates/go/pkg/freedom/window_windows.go +185 -17
  34. package/templates/installer/app.nsi +43 -0
  35. package/templates/project/freedom.config.js +14 -0
  36. package/templates/project/freedom.d.ts +77 -0
  37. package/templates/project-minimal/freedom.config.js +14 -0
  38. package/templates/project-minimal/freedom.d.ts +77 -0
package/lib/build.js CHANGED
@@ -107,27 +107,39 @@ async function build(projectDir, opts = {}) {
107
107
  const autoDownload = opts.autoDownload !== false;
108
108
 
109
109
  // 1) 前端打包(带缓存:源码未变更时复用上次 vite 产物,跳过 npm run build)
110
- ensureNodeModules(dir);
111
- const useCache = opts.noCache !== true;
112
- if (useCache && !viteNeedsRebuild(dir)) {
113
- process.stdout.write('[freedom] 前端源码无改动,复用构建缓存...\n');
110
+ // cfg.staticHtml:静态单文件页面直通——不跑 npm install / vite,用于 freedom 自己的
111
+ // Desktop 界面自举打包(以及纯静态页项目),零网络、零依赖。
112
+ let html;
113
+ if (cfg.staticHtml) {
114
+ const staticPath = path.resolve(dir, String(cfg.staticHtml));
115
+ if (!fs.existsSync(staticPath)) {
116
+ throw new Error(`staticHtml 指向的文件不存在:${staticPath}(freedom.config.js 配置项)`);
117
+ }
118
+ html = fs.readFileSync(staticPath, 'utf8');
119
+ warnIfNotSingleFile(html, staticPath);
114
120
  } else {
115
- process.stdout.write('[freedom] 前端打包中(npm run build)...\n');
116
- // 全量构建时 npm/vite 输出必须实时透传(stdio: inherit),
117
- // 此前 pipe 缓冲会吞掉构建全程输出,Node 主线程被 spawnSync 同步阻塞,
118
- // 大前端全量构建时终端长时间零输出,表现如"未响应/卡死"。
119
- const vite = run('npm', ['run', 'build'], { cwd: dir, stdio: 'inherit' });
120
- if (vite.status !== 0) {
121
- throw new Error(`前端打包失败(退出码 ${vite.status}),详见上方构建输出。`);
121
+ ensureNodeModules(dir);
122
+ const useCache = opts.noCache !== true;
123
+ if (useCache && !viteNeedsRebuild(dir)) {
124
+ process.stdout.write('[freedom] 前端源码无改动,复用构建缓存...\n');
125
+ } else {
126
+ process.stdout.write('[freedom] 前端打包中(npm run build)...\n');
127
+ // 全量构建时 npm/vite 输出必须实时透传(stdio: inherit),
128
+ // 此前 pipe 缓冲会吞掉构建全程输出,Node 主线程被 spawnSync 同步阻塞,
129
+ // 大前端全量构建时终端长时间零输出,表现如"未响应/卡死"。
130
+ const vite = run('npm', ['run', 'build'], { cwd: dir, stdio: 'inherit' });
131
+ if (vite.status !== 0) {
132
+ throw new Error(`前端打包失败(退出码 ${vite.status}),详见上方构建输出。`);
133
+ }
134
+ writeViteCacheMarker(dir);
122
135
  }
123
- writeViteCacheMarker(dir);
124
- }
125
- const distHtml = path.join(dir, '.freedom', 'vite-dist', 'index.html');
126
- if (!fs.existsSync(distHtml)) {
127
- throw new Error(`前端打包完成但未找到 ${distHtml},请检查 vite 配置(vite-plugin-singlefile)。`);
136
+ const distHtml = path.join(dir, '.freedom', 'vite-dist', 'index.html');
137
+ if (!fs.existsSync(distHtml)) {
138
+ throw new Error(`前端打包完成但未找到 ${distHtml},请检查 vite 配置(vite-plugin-singlefile)。`);
139
+ }
140
+ html = fs.readFileSync(distHtml, 'utf8');
141
+ warnIfNotSingleFile(html, distHtml);
128
142
  }
129
- const html = fs.readFileSync(distHtml, 'utf8');
130
- warnIfNotSingleFile(html, distHtml);
131
143
  const configJSON = renderConfigJSON(cfg, name);
132
144
  // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
133
145
  // none = 明文资源(默认,兼容历史产物)
@@ -149,8 +161,8 @@ async function build(projectDir, opts = {}) {
149
161
  const results = await Promise.all(
150
162
  platforms.map(async (plat) => {
151
163
  const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
152
- const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security });
153
- return { plat, outFile };
164
+ const emitted = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer: opts.installer === true });
165
+ return { plat, ...emitted };
154
166
  })
155
167
  );
156
168
 
@@ -184,7 +196,7 @@ async function build(projectDir, opts = {}) {
184
196
  return { results };
185
197
  }
186
198
 
187
- async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security }) {
199
+ async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer }) {
188
200
  // 取预编译壳二进制
189
201
  const shell = localShellPath(plat);
190
202
  if (!fs.existsSync(shell)) {
@@ -233,14 +245,24 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
233
245
  const p = path.join(resDir, legacy);
234
246
  if (fs.existsSync(p)) await fsp.rm(p, { force: true });
235
247
  }
236
- const appBin = encryptApp(name, html, configJSON);
237
- await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
238
- const backendRelMap = {};
248
+ // 后端源码同样进容器:high 模式磁盘上不留 resources/backend 明文目录,
249
+ // 壳启动时把容器内 backend/* 解密到一次性临时目录(退出即删)。
250
+ const legacyBackend = path.join(resDir, 'backend');
251
+ if (fs.existsSync(legacyBackend)) await fsp.rm(legacyBackend, { recursive: true, force: true });
252
+ const backendFiles = {};
239
253
  if (hasBackend && fs.existsSync(backendDir)) {
240
- collectDirFiles(backendDir, '', backendRelMap);
254
+ for (const [rel, buf] of Object.entries(collectDirFiles(backendDir, '', {}))) {
255
+ backendFiles['backend/' + rel] = { data: buf, mode: fileMode(path.join(backendDir, rel)) };
256
+ }
257
+ }
258
+ // 加密前先抹掉 source map 引用:内联 sourceMappingURL 里往往直接嵌着前端原始源码,
259
+ // 容器解密即还原,等于给 high 模式留了个明文后门。
260
+ const { html: secureHtml, stripped } = stripSourceMapRefs(html);
261
+ if (stripped > 0) {
262
+ process.stdout.write(`[freedom] high 模式:已抹去前端页内 ${stripped} 处 source map 引用(可能含原始源码)。\n`);
241
263
  }
242
- const integrityText = renderIntegrity(buildIntegrity(name, appBin, backendRelMap));
243
- await fsp.writeFile(path.join(resDir, '.integrity'), integrityText, 'utf8');
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');
244
266
  } else {
245
267
  for (const legacy of ['app.bin', '.integrity']) {
246
268
  const p = path.join(resDir, legacy);
@@ -256,10 +278,14 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
256
278
  );
257
279
  }
258
280
  }
259
- if (hasBackend) {
281
+ if (hasBackend && security !== 'high') {
260
282
  copyDir(backendDir, path.join(resDir, 'backend'));
261
283
  }
262
284
 
285
+ // --installer:便携 zip + Windows NSIS 安装器。先于 mac 的 .app 生成,
286
+ // 使 zip 内容只含「exe + resources」,不把 .app 与 .app.zip 二次打包进去。
287
+ const installers = installer ? await buildInstaller({ plat, name, version, targetDir, exeName }) : [];
288
+
263
289
  // macOS:额外生成 .app bundle + .app.zip(供 mac 用户解压即用)
264
290
  // 壳加载 exe 同目录 resources/,故把 resources 放进 Contents/MacOS/ 即可运行,无需改壳。
265
291
  if (isMacPlat(plat)) {
@@ -269,7 +295,7 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
269
295
  );
270
296
  }
271
297
 
272
- return outFile;
298
+ return { outFile, installers };
273
299
  }
274
300
 
275
301
  // 把 .ico 图标注入到 Windows exe 的 PE 资源(RT_ICON + RT_GROUP_ICON),
@@ -290,7 +316,16 @@ async function applyWindowsIcon(exePath, iconPath, name, version) {
290
316
  );
291
317
  return;
292
318
  }
293
- const rcedit = require('rcedit'); // 惰性加载,避免未配置 icon 时增加启动开销
319
+ let rcedit;
320
+ try {
321
+ rcedit = require('rcedit'); // 惰性加载,避免未配置 icon 时增加启动开销
322
+ } catch (e) {
323
+ throw new Error(
324
+ 'exe 图标注入需要依赖 rcedit,但未能加载(npm 安装不完整)。'
325
+ + '请在 freedom-cli 安装目录执行 npm install 后重试;'
326
+ + '或临时移除 freedom.config.js 的 icon 配置以跳过注入。'
327
+ );
328
+ }
294
329
  const info = { icon: iconPath };
295
330
  // rcedit 4.x 参数结构:FileDescription/ProductName 等字符串必须走
296
331
  // version-string(--set-version-string 键值对),file-version/product-version
@@ -402,21 +437,25 @@ ${iconEntry} <key>CFBundleExecutable</key>
402
437
  }
403
438
 
404
439
  // 把目录压成 .zip:跨平台零额外依赖(历史 bug B27)
405
- // - Windows:系统 tar 为 bsdtar,`-a` 按扩展名自动选 zip 压缩器,可用;
406
- // - Linux/macOS:GNU tar 的 `-a` 不支持 .zip(仅 gzip/bzip2/xz 等),
407
- // 改用系统 zip 命令(Linux/macOS 自带或常见,缺失时给出安装提示)。
440
+ // - Windows:bsdtar 的 `-a` 按扩展名自动选 zip 压缩器,可用;但必须绝对路径锁定系统
441
+ // tar.exe —— Git Bash / MSYS 会把 GNU tar 塞进 PATH 前面,GNU tar 不认 `-a` 且把
442
+ // `D:\...` 当远程主机(`Cannot connect to D: resolve failed`)。
443
+ // - Linux/macOS:GNU tar 的 `-a` 不支持 .zip,改用系统 zip 命令(缺失时给出安装提示)。
408
444
  function zipDir(zipPath, dir) {
409
445
  const parent = path.dirname(dir);
410
446
  const base = path.basename(dir);
411
447
  if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
412
- let res;
448
+ let cmd = 'zip';
449
+ let args = ['-r', '-q', zipPath, base];
413
450
  if (process.platform === 'win32') {
414
- res = spawnSync('tar', ['-a', '-c', '-f', zipPath, base], { cwd: parent, encoding: 'utf8' });
415
- } else {
416
- res = spawnSync('zip', ['-r', '-q', zipPath, base], { cwd: parent, encoding: 'utf8' });
451
+ const sysRoot = process.env.SystemRoot || 'C:\\Windows';
452
+ const bsdtar = path.join(sysRoot, 'System32', 'tar.exe');
453
+ cmd = fs.existsSync(bsdtar) ? bsdtar : 'tar';
454
+ args = ['-a', '-c', '-f', zipPath, base];
417
455
  }
456
+ const res = spawnSync(cmd, args, { cwd: parent, encoding: 'utf8', windowsHide: true });
418
457
  if (res.error || res.status !== 0) {
419
- const detail = (res.stderr || res.stdout || '').trim() || res.error.message;
458
+ const detail = (res.stderr || res.stdout || (res.error && res.error.message) || '').trim();
420
459
  const hint = process.platform !== 'win32'
421
460
  ? '\n(Linux/macOS 打包 zip 需要 zip 命令:Ubuntu: sudo apt install zip / macOS: brew install zip)'
422
461
  : '';
@@ -424,6 +463,62 @@ function zipDir(zipPath, dir) {
424
463
  }
425
464
  }
426
465
 
466
+ // --installer 产物:便携 zip 恒产出;Windows 额外填充 NSIS 模板,本机有 makensis
467
+ // 时直接编译 setup.exe,无则产出已填充的 .nsi(缺 makensis 属环境能力而非代码缺陷)。
468
+ // 顺序即排除策略:先压 zip 再写 .nsi/setup.exe,免维护"排除安装器中间物"清单。
469
+ async function buildInstaller({ plat, name, version, targetDir, exeName }) {
470
+ const artifacts = [];
471
+
472
+ const zipPath = path.join(targetDir, `${name}-${plat}-portable.zip`);
473
+ const tmpPath = path.join(path.dirname(targetDir), `.freedom-portable-${process.pid}.zip`);
474
+ zipDir(tmpPath, targetDir);
475
+ await fsp.rename(tmpPath, zipPath);
476
+ artifacts.push(zipPath);
477
+
478
+ if (!isWinPlat(plat)) return artifacts;
479
+
480
+ const tplPath = path.join(__dirname, '..', 'templates', 'installer', 'app.nsi');
481
+ if (!fs.existsSync(tplPath)) {
482
+ process.stdout.write(`[freedom] 提示:缺少安装包模板 ${tplPath},已跳过 NSIS。\n`);
483
+ return artifacts;
484
+ }
485
+ const fill = (s) =>
486
+ s.replace(/@NAME@/g, name)
487
+ .replace(/@VERSION@/g, version)
488
+ .replace(/@SRC@/g, targetDir)
489
+ .replace(/@OUT@/g, targetDir)
490
+ .replace(/@EXE@/g, exeName);
491
+ // NSIS 3 对无 BOM 的 UTF-8 脚本按代码页解释,模板含中文注释与 SimpChinese 文案,
492
+ // 故补 BOM(与 build.ps1 的 [Text.Encoding]::UTF8 写出行为一致)。
493
+ const body = fill(fs.readFileSync(tplPath, 'utf8'));
494
+ const nsiPath = path.join(targetDir, `${name}-setup-${version}.nsi`);
495
+ await fsp.writeFile(nsiPath, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]));
496
+ artifacts.push(nsiPath);
497
+
498
+ if (!hasTool('makensis')) {
499
+ process.stdout.write(
500
+ '[freedom] 未检测到 makensis(NSIS):已产出填充好的 .nsi,在装有 NSIS 的机器上执行\n' +
501
+ ` makensis "${nsiPath}" 即可编译安装器。\n`
502
+ );
503
+ return artifacts;
504
+ }
505
+ const res = spawnSync('makensis', ['-V2', nsiPath], { encoding: 'utf8' });
506
+ if (res.status !== 0) {
507
+ const detail = (res.stderr || res.stdout || '').trim().slice(0, 600);
508
+ throw new Error(`makensis 编译失败(退出码 ${res.status}):${detail}`);
509
+ }
510
+ const setupPath = path.join(targetDir, `${name}-setup-${version}.exe`);
511
+ process.stdout.write(`[freedom] NSIS 安装器已生成:${setupPath}\n`);
512
+ artifacts.push(setupPath);
513
+ return artifacts;
514
+ }
515
+
516
+ // 外部命令可用性探测(不影响主流程,仅决定是否尝试调用)。
517
+ function hasTool(cmd) {
518
+ const res = spawnSync(cmd, ['-VERSION'], { encoding: 'utf8', windowsHide: true });
519
+ return !res.error;
520
+ }
521
+
427
522
  // 从项目 package.json 取版本号(cfg.version 优先)
428
523
  function appVersionFromPkg(dir) {
429
524
  try {
@@ -456,6 +551,17 @@ function renderConfigJSON(cfg, name) {
456
551
  args: Array.isArray(cfg.backend.args) ? cfg.backend.args : [],
457
552
  };
458
553
  }
554
+ // 能力透传(与壳 resources.go 对齐):URL 直载 / 单实例 / 自动更新。
555
+ if (typeof cfg.url === 'string' && cfg.url) obj.url = cfg.url;
556
+ if (cfg.singleInstance === true) obj.singleInstance = true;
557
+ if (cfg.updater && typeof cfg.updater.manifestURL === 'string' && typeof cfg.updater.publicKey === 'string'
558
+ && cfg.updater.manifestURL && cfg.updater.publicKey) {
559
+ obj.updater = {
560
+ manifestURL: cfg.updater.manifestURL,
561
+ publicKey: cfg.updater.publicKey,
562
+ requireSignature: cfg.updater.requireSignature === true ? true : undefined,
563
+ };
564
+ }
459
565
  return JSON.stringify(obj, null, 2);
460
566
  }
461
567
 
@@ -579,7 +685,7 @@ function warnIfNotSingleFile(html, distHtml) {
579
685
  }
580
686
  }
581
687
 
582
- // 递归收集目录内所有文件:relPath(POSIX 相对路径)-> Buffer 内容,供 .integrity 完整性清单使用。
688
+ // 递归收集目录内所有文件:relPath(POSIX 相对路径)-> Buffer 内容,供 high 模式入容器。
583
689
  function collectDirFiles(dir, prefix, out) {
584
690
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
585
691
  const abs = path.join(dir, entry.name);
@@ -592,4 +698,27 @@ function collectDirFiles(dir, prefix, out) {
592
698
  return out;
593
699
  }
594
700
 
701
+ // 文件权限位(POSIX mode 低 12 位):进容器后由壳在临时目录还原(执行位决定后端能否直接跑)。
702
+ function fileMode(abs) {
703
+ try {
704
+ return fs.statSync(abs).mode & 0o7777;
705
+ } catch (e) {
706
+ return 0o644;
707
+ }
708
+ }
709
+
710
+ // 抹掉前端页内的 source map 引用(JS 的 //# sourceMappingURL=…、CSS 的 /*# … */)。
711
+ // 返回 { html, stripped }。
712
+ function stripSourceMapRefs(html) {
713
+ let stripped = 0;
714
+ const out = String(html).replace(
715
+ /\/\/[#@]\s*sourceMappingURL=[^\r\n]*|\/\*#\s*sourceMappingURL=[\s\S]*?\*\//g,
716
+ () => {
717
+ stripped += 1;
718
+ return '';
719
+ },
720
+ );
721
+ return { html: out, stripped };
722
+ }
723
+
595
724
  module.exports = { build, parsePlatforms };
package/lib/cli.js CHANGED
@@ -19,8 +19,11 @@ function help() {
19
19
  L.push(` ${paint('用法:', C.bold, C.fg.white)} ${paint('freedom <command> [options]', C.fg.cyan, C.bold)}`);
20
20
  L.push('');
21
21
  L.push(section('交互式界面'));
22
- L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('进入完整 CLI 模式(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
23
- L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('同上(兼容写法,显式进入交互式模式)')}`);
22
+ L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('选择显示方式:终端 TUI / Freedom Desktop(图形界面)')}`);
23
+ L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('直达终端 TUI(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
24
+ L.push(` ${paint('freedom desktop', C.fg.cyan, C.bold)} ${dim('直达 Freedom Desktop:图形界面由 freedom 自己打包产出,模板或版本变更时自动重打包')}`);
25
+ L.push(` ${dim('--rebuild')} ${dim('强制重新打包 Desktop 并拉起(需先关闭已开窗口)')}`);
26
+ L.push(` ${dim('--no-launch')} ${dim('只准备 / 重打包产物,不拉起窗口')}`);
24
27
  L.push(section('项目'));
25
28
  L.push(` ${paint('freedom init [目录] [--force] [--template full|minimal]', C.fg.cyan)} ${dim('新建项目模板(默认 full:含自绘标题栏示例;minimal:极简无自绘标题栏)')}`);
26
29
  L.push(` ${paint('freedom tutorial', C.fg.cyan)} ${dim('再次打开安装教程')}`);
@@ -28,8 +31,14 @@ function help() {
28
31
  L.push(` ${paint('freedom build [--platform <p>]', C.fg.cyan)} ${dim('前端打包并分发桌面应用(默认当前平台)')}`);
29
32
  L.push(` ${dim('--platform win|mac|linux|all')} ${dim('指定目标平台(all = 三平台全量)')}`);
30
33
  L.push(` ${dim('--no-cache')} ${dim('忽略前端构建缓存,强制重新打包')}`);
34
+ L.push(` ${dim('--security <none|basic|high>')} ${dim('资源安全模式(high = resources 加密为 app.bin)')}`);
35
+ L.push(` ${dim('--installer')} ${dim('附带安装包产物:便携 zip + Windows NSIS 安装器(有 makensis 时编译 setup.exe,否则产出已填充的 .nsi)')}`);
31
36
  L.push(` ${paint('freedom verify [--platform <p>]', C.fg.cyan)} ${dim('校验已构建产物完整性并打印产物结构(CI 可依赖退出码)')}`);
32
37
  L.push(` ${paint('freedom dmg [--platform <plat>]', C.fg.cyan)} ${dim('将已构建的 .app 打包为 .dmg(需 macOS)')}`);
38
+ L.push(section('开发与更新'));
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 密钥对(公钥进配置,私钥发布方保管)')}`);
41
+ L.push(` ${paint('freedom manifest --artifact <产物> --url <下载地址> [--version x]', C.fg.cyan)} ${dim('产出签名的更新清单 latest.json')}`);
33
42
  L.push(section('外观'));
34
43
  L.push(` ${paint('freedom titlebar <native|frameless>', C.fg.cyan)} ${dim('一键切换标题栏策略')}`);
35
44
  L.push(` ${paint('freedom icon <path>', C.fg.cyan)} ${dim('设置应用图标(Win .ico / mac .icns)')}`);
@@ -41,6 +50,12 @@ function help() {
41
50
  L.push(` ${paint('freedom config', C.fg.cyan)} ${dim('查看当前配置')}`);
42
51
  L.push(` ${paint('freedom config get <key>', C.fg.cyan)} ${dim('读取单个配置项')}`);
43
52
  L.push(` ${paint('freedom config set <key> <value>', C.fg.cyan)} ${dim('修改单个配置项')}`);
53
+ L.push(section('Agent 集成'));
54
+ L.push(` ${paint('freedom agents [--home <dir>]', C.fg.cyan)} ${dim('打印 Agent 支持矩阵(按本机磁盘证据判定 ready / convention / unknown)')}`);
55
+ L.push(` ${paint('freedom agents install --what <mcp|skill> --agent <key|all>', C.fg.cyan)} ${dim('等价于下面两条的合并入口')}`);
56
+ L.push(` ${paint('freedom skill install --agent <key|all> [--dry-run] [--skills-dir <path>]', C.fg.cyan)} ${dim('把 Freedom 使用技能装入 agent 的 skills 目录')}`);
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
+ L.push(` ${paint('freedom mcp serve', C.fg.cyan)} ${dim('stdio MCP 服务本体(一般由 agent 自动拉起,无需手敲)')}`);
44
59
  L.push(section('版本'));
45
60
  L.push(` ${paint('freedom version', C.fg.cyan)} ${dim('显示版本并检测最新版本')}`);
46
61
  L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并立即自动更新')}`);
@@ -66,9 +81,10 @@ async function run(argv) {
66
81
 
67
82
  switch (cmd) {
68
83
  case undefined: {
69
- // 无参数:交互式终端进入完整 CLI 模式(TUI);管道 / 脚本环境打印帮助,避免卡死
84
+ // 无参数:交互式终端先选择显示方式(终端 TUI / Freedom Desktop);
85
+ // 管道 / 脚本环境打印帮助,避免卡死
70
86
  if (process.stdin.isTTY && process.stdout.isTTY) {
71
- return await require('./tui').tui(process.cwd());
87
+ return await require('./tui').pickDisplay(process.cwd());
72
88
  }
73
89
  console.log(help());
74
90
  return 0;
@@ -112,6 +128,48 @@ async function run(argv) {
112
128
  case 'tui':
113
129
  return await require('./tui').tui(process.cwd());
114
130
 
131
+ case 'desktop': {
132
+ const { desktop } = require('./desktop');
133
+ try {
134
+ const r = await desktop({
135
+ rebuild: rest.includes('--rebuild'),
136
+ noLaunch: rest.includes('--no-launch'),
137
+ });
138
+ console.log(`${ok(r.rebuilt ? '已重新打包 Freedom Desktop:' : '复用已有产物:')}${paint(r.exe, C.fg.cyan, C.bold)}`);
139
+ console.log(` ${dim(`项目目录:${r.dir} · CLI v${r.version}`)}`);
140
+ if (r.launched) console.log(` ${ok(`窗口已拉起(pid ${r.pid})`)}`);
141
+ else console.log(` ${dim('未拉起窗口(--no-launch);执行 freedom desktop 即可打开。')}`);
142
+ return 0;
143
+ } catch (e) {
144
+ console.error(`${err(e.message)}`);
145
+ return 1;
146
+ }
147
+ }
148
+
149
+ case 'agents': {
150
+ const sub = rest[0] && !rest[0].startsWith('--') ? rest[0] : undefined;
151
+ const flags = sub ? rest.slice(1) : rest;
152
+ const hi = flags.indexOf('--home');
153
+ const home = flags.find((a) => a.startsWith('--home=')) || (hi >= 0 ? flags[hi + 1] : undefined);
154
+ if (home && !home.startsWith('--')) process.env.FREEDOM_AGENT_HOME = path.resolve(home.replace(/^--home=/, ''));
155
+ if (!sub || sub === 'list') return require('./agents').printMatrix();
156
+ if (sub !== 'install') {
157
+ console.error(`${err(`未知 agents 子命令:${sub}`)} ${dim('可用:list / install --what <mcp|skill> --agent <key|all> [--dry-run]')}`);
158
+ return 1;
159
+ }
160
+ const wi = flags.indexOf('--what');
161
+ const what = (flags.find((a) => a.startsWith('--what=')) || '').split('=')[1] || flags[wi + 1];
162
+ if (what && what !== 'mcp' && what !== 'skill') {
163
+ console.error(`${err(`不支持的 --what:${what}`)} ${dim('(可选 mcp|skill)')}`);
164
+ return 1;
165
+ }
166
+ return await runAgents(what === 'skill' ? 'skill' : 'mcp', ['install', ...flags]);
167
+ }
168
+
169
+ case 'skill':
170
+ case 'mcp':
171
+ return await runAgents(cmd, rest);
172
+
115
173
  case 'init': {
116
174
  const force = rest.includes('--force');
117
175
  const tmplArg = rest.find((a) => a.startsWith('--template'));
@@ -140,6 +198,62 @@ async function run(argv) {
140
198
  return 0;
141
199
  }
142
200
 
201
+ case 'dev': {
202
+ const optVal = (name) => {
203
+ const a = rest.find((x) => x.startsWith(`--${name}=`));
204
+ if (a) return a.split('=')[1];
205
+ const i = rest.indexOf(`--${name}`);
206
+ return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : undefined;
207
+ };
208
+ const url = optVal('url');
209
+ const port = optVal('port');
210
+ const { dev: runDev } = require('./dev');
211
+ try {
212
+ const done = await runDev({ dir: process.cwd(), url, port: port ? Number(port) : undefined, command: optVal('command') });
213
+ console.log(`${ok(String(done))}`);
214
+ } catch (e) {
215
+ console.error(`${err(e.message)}`);
216
+ return 1;
217
+ }
218
+ return 0;
219
+ }
220
+
221
+ case 'keygen': {
222
+ const { keygen } = require('./release');
223
+ try {
224
+ console.log(await keygen({ dir: process.cwd(), force: rest.includes('--force') }));
225
+ } catch (e) {
226
+ console.error(`${err(e.message)}`);
227
+ return 1;
228
+ }
229
+ return 0;
230
+ }
231
+
232
+ case 'manifest': {
233
+ const optVal = (name) => {
234
+ const a = rest.find((x) => x.startsWith(`--${name}=`));
235
+ if (a) return a.split('=')[1];
236
+ const i = rest.indexOf(`--${name}`);
237
+ return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : undefined;
238
+ };
239
+ const { manifest } = require('./release');
240
+ try {
241
+ console.log(await manifest({
242
+ dir: process.cwd(),
243
+ artifact: optVal('artifact'),
244
+ url: optVal('url'),
245
+ version: optVal('version'),
246
+ notes: optVal('notes'),
247
+ key: optVal('key'),
248
+ out: optVal('out'),
249
+ }));
250
+ } catch (e) {
251
+ console.error(`${err(e.message)}`);
252
+ return 1;
253
+ }
254
+ return 0;
255
+ }
256
+
143
257
  case 'build': {
144
258
  const platArg = rest.find((a) => a.startsWith('--platform') || a.startsWith('-p'));
145
259
  let platform;
@@ -171,9 +285,13 @@ async function run(argv) {
171
285
  platform,
172
286
  noCache: rest.includes('--no-cache'),
173
287
  security,
288
+ installer: rest.includes('--installer'),
174
289
  });
175
290
  for (const r of results) {
176
291
  console.log(`${ok('构建完成')} ${paint(`[${r.plat}]`, C.fg.magenta, C.bold)} ${paint(r.outFile, C.fg.white)}`);
292
+ for (const p of r.installers || []) {
293
+ console.log(` ${dim('安装包产物:')}${paint(p, C.fg.white)}`);
294
+ }
177
295
  }
178
296
  return 0;
179
297
  }
@@ -368,6 +486,44 @@ async function runShell(rest) {
368
486
  }
369
487
  }
370
488
 
489
+ // freedom skill / mcp —— Agent 集成安装与 MCP 服务
490
+ async function runAgents(cmd, rest) {
491
+ const sub = rest[0];
492
+ const optVal = (name) => {
493
+ const a = rest.find((x) => x.startsWith(`--${name}=`));
494
+ if (a) return a.split('=')[1];
495
+ const i = rest.indexOf(`--${name}`);
496
+ return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : undefined;
497
+ };
498
+ if (cmd === 'mcp' && sub === 'serve') {
499
+ return await require('./mcp').serve();
500
+ }
501
+ if (sub !== 'install') {
502
+ console.error(`${err('用法:')}${paint(`freedom ${cmd} install --agent <key|all> [--dry-run]`, C.fg.cyan)}`);
503
+ if (cmd === 'mcp') {
504
+ console.error(` ${paint('freedom mcp serve', C.fg.cyan)} ${dim('(stdio MCP 服务,通常由 agent 配置自动拉起)')}`);
505
+ }
506
+ console.error(` ${dim('可选 agent 与本机取证状态见:')}${paint('freedom agents', C.fg.white)}`);
507
+ return 1;
508
+ }
509
+ const { install } = require('./agents');
510
+ try {
511
+ const r = await install({
512
+ what: cmd === 'skill' ? 'skill' : 'mcp',
513
+ agent: optVal('agent') || 'all',
514
+ dryRun: rest.includes('--dry-run'),
515
+ config: optVal('config'),
516
+ format: optVal('format'),
517
+ skillsDir: optVal('skills-dir'),
518
+ home: optVal('home'),
519
+ });
520
+ return r.code;
521
+ } catch (e) {
522
+ console.error(`${err(e.message)}`);
523
+ return 1;
524
+ }
525
+ }
526
+
371
527
  // freedom dmg:在 macOS 上把已构建的 .app 打包为 .dmg(依赖系统 hdiutil)
372
528
  async function runDmg(rest) {
373
529
  const { loadConfig, nativePlatform } = require('./utils');
package/lib/config.js CHANGED
@@ -17,6 +17,8 @@ const KEY_TYPES = {
17
17
  security: 'security',
18
18
  icon: 'string',
19
19
  outDir: 'string',
20
+ url: 'string',
21
+ singleInstance: 'bool',
20
22
  };
21
23
 
22
24
  // 把 CLI/TUI 传入的值规范化为配置语义上的目标值;非法即抛错。
@@ -115,7 +117,21 @@ function setConfig(dir, key, value) {
115
117
  break;
116
118
  }
117
119
  if (!replaced) {
118
- throw new Error(`配置项 ${key} 未在 freedom.config.js 中找到(注释行不参与修改),请手动添加。`);
120
+ // 模板里的新能力键(url / singleInstance)默认是注释示例,用户不必先手动取消注释:
121
+ // 找不到未注释的键就在结尾 `};` 前插入一行,仍保持 `freedom config set` 一条命令可用。
122
+ let endIdx = -1;
123
+ for (let i = lines.length - 1; i >= 0; i--) {
124
+ if (/^\s*\}\s*;?\s*$/.test(lines[i])) {
125
+ endIdx = i;
126
+ break;
127
+ }
128
+ }
129
+ if (endIdx < 0) {
130
+ throw new Error(`配置项 ${key} 未在 freedom.config.js 中找到(注释行不参与修改),请手动添加。`);
131
+ }
132
+ lines.splice(endIdx, 0, ` ${key}: ${rendered},`);
133
+ fs.writeFileSync(cfgPath, lines.join('\n'), 'utf8');
134
+ return norm;
119
135
  }
120
136
  fs.writeFileSync(cfgPath, lines.join('\n'), 'utf8');
121
137
  return norm;
@@ -143,6 +159,8 @@ const KNOWN_KEYS = {
143
159
  security: 'none',
144
160
  icon: undefined,
145
161
  outDir: 'dist',
162
+ url: undefined,
163
+ singleInstance: false,
146
164
  };
147
165
 
148
166
  async function showConfig(dir) {
@@ -152,6 +170,7 @@ async function showConfig(dir) {
152
170
  return ` ${k}: ${JSON.stringify(v)}`;
153
171
  });
154
172
  lines.push(` backend: ${cfg.backend ? JSON.stringify(cfg.backend) : 'undefined'}`);
173
+ lines.push(` updater: ${cfg.updater ? JSON.stringify(cfg.updater) : 'undefined'}`);
155
174
  return lines.join('\n');
156
175
  }
157
176