# Windows 安装方案实施计划 > **给执行型 agent 的要求:** 实施本计划时必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框(`- [ ]`)跟踪。 **目标:** 交付一个离线 Windows 安装器,把 Neta 安装为单个 `backend.exe` 应用;该进程同时托管前端静态页面与后端 API,把所有可写数据统一放到用户选择的数据目录,支持安装、卸载、重装和首次启动自动打开浏览器。 **架构:** 在 Midway 启动前先从 exe 同目录读取并校验 `config.yaml`,解析出唯一可写的 `data.dir`,再把 `comm/path.ts`、agent 记忆、session 树、skills、插件、SQLite、日志、锁文件全部收口到这个目录。安装目录保持只读;打包时先构建前端,再用 staging + pkg 生成 `backend.exe`,最后由 Inno Setup 生成离线安装器并处理安装/卸载生命周期。 **技术栈:** Midway.js 3.x、Koa、TypeORM、better-sqlite3、Jest + ts-jest、Vue 3 + Vite、@yao-pkg/pkg、Inno Setup --- ## 文件结构 **新增:** - `packages/backend/src/comm/data-dir.ts` — 统一解析可写数据根目录 - `packages/backend/src/comm/config-loader.ts` — 读取并校验外部 `config.yaml` - `packages/backend/src/comm/runtime-lock.ts` — 单实例锁、失效锁恢复、退出清理 - `packages/backend/src/comm/browser.ts` — Windows 环境打开默认浏览器 - `packages/backend/test/windows-installer/config-loader.test.ts` — 配置加载/校验/回退测试 - `packages/backend/test/windows-installer/data-dir.test.ts` — 数据目录解析与路径收口测试 - `packages/backend/test/windows-installer/runtime-lock.test.ts` — 单实例锁测试 - `packages/backend/scripts/pkg-build.js` — Node 版 staging + pkg 打包脚本 - `packages/backend/scripts/build-windows-installer.js` — 生成离线安装器的包装脚本 - `packages/backend/installer/config.default.yaml` — 安装器模板配置 - `packages/backend/installer/setup.iss` — Inno Setup 安装器脚本 **修改:** - `packages/backend/bootstrap.js` — CLI 参数、配置加载顺序、启动失败处理 - `packages/backend/src/comm/path.ts` — 通过 `resolveDataDir()` 输出统一路径 - `packages/backend/src/config/config.default.ts` — upload/cache/log/path 配置 - `packages/backend/src/config/config.prod.ts` — 移除硬编码数据库凭证并读取外部配置 - `packages/backend/src/configuration.ts` — `onReady` 中注册锁文件和自动打开浏览器 - `packages/backend/src/modules/netaclaw/memory/sqlite_provider.ts` — memory DB 路径统一 - `packages/backend/src/modules/netaclaw/session-tree/factory.ts` — session root 路径统一 - `packages/backend/src/modules/netaclaw/service/skill_installer.ts` — skills 路径统一 - `packages/backend/src/modules/netaclaw/service/skill_registry.ts` — `.skillhub` 路径统一 - `packages/backend/package.json` — 打包脚本 - `packages/backend/scripts/pkg-build.sh` — 收敛为调用 Node 脚本的薄包装 --- ### 任务 1:配置加载、校验与数据目录基础设施 **文件:** - 新增:`packages/backend/src/comm/config-loader.ts` - 新增:`packages/backend/src/comm/data-dir.ts` - 新增:`packages/backend/test/windows-installer/config-loader.test.ts` - 新增:`packages/backend/test/windows-installer/data-dir.test.ts` - 修改:`packages/backend/bootstrap.js` - [ ] **步骤 1:先写失败测试,覆盖安装态与开发态回退** ```ts // packages/backend/test/windows-installer/config-loader.test.ts import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { loadExternalConfig } from '../../src/comm/config-loader'; describe('loadExternalConfig', () => { it('在 pkg 模式读取 exe 同目录下的 config.yaml', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-config-')); fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); fs.writeFileSync( path.join(tempDir, 'config.yaml'), [ 'server:', ' port: 8100', 'data:', ` dir: "${path.join(tempDir, 'data').replace(/\\/g, '\\\\')}"`, 'autoOpenBrowser: true', 'database:', ' type: mysql', ' host: db.example.com', ' port: 3306', ' username: demo', ' password: secret', ' database: neta_test', ].join('\n'), 'utf8' ); const loaded = loadExternalConfig({ isPkg: true, execPath: path.join(tempDir, 'backend.exe') }); expect(loaded.server.port).toBe(8100); expect(loaded.database.host).toBe('db.example.com'); expect(loaded.data.dir).toContain(path.join('data')); }); it('在开发模式允许没有 config.yaml 并回退到内置默认值', () => { const loaded = loadExternalConfig({ isPkg: false, cwd: '/tmp/neta-backend' }); expect(loaded.source).toBe('fallback'); }); }); // packages/backend/test/windows-installer/data-dir.test.ts import * as path from 'node:path'; import { resolveDataDir } from '../../src/comm/data-dir'; describe('resolveDataDir', () => { it('优先使用已校验配置里的 data.dir', () => { const dir = resolveDataDir({ isPkg: true, execDir: 'C:/Program Files/Neta', config: { data: { dir: 'D:/NetaData' } }, cwd: 'C:/Users/demo/Desktop', }); expect(path.normalize(dir)).toBe(path.normalize('D:/NetaData')); }); }); ``` - [ ] **步骤 2:运行测试确认当前失败** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/config-loader.test.ts test/windows-installer/data-dir.test.ts -i ``` 预期:FAIL,提示找不到 `config-loader` / `data-dir` 模块。 - [ ] **步骤 3:写最小实现,先把启动顺序与 schema 校验立住** ```ts // packages/backend/src/comm/config-loader.ts import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'js-yaml'; export interface ExternalAppConfig { server: { port: number }; data: { dir: string }; autoOpenBrowser: boolean; database: { type: 'mysql'; host: string; port: number; username: string; password: string; database: string; }; } export interface LoadedExternalConfig { source: 'file' | 'fallback'; config: ExternalAppConfig; } function assertConfig(config: any): asserts config is ExternalAppConfig { if (!config?.data?.dir || typeof config.data.dir !== 'string') throw new Error('config.yaml 缺少 data.dir'); if (!config?.server?.port || typeof config.server.port !== 'number') throw new Error('config.yaml 缺少 server.port'); if (!config?.database?.host || !config?.database?.username || !config?.database?.database) { throw new Error('config.yaml 缺少数据库配置'); } } export function loadExternalConfig(options?: { isPkg?: boolean; execPath?: string; cwd?: string }): LoadedExternalConfig { const isPkg = options?.isPkg ?? Boolean(process.pkg); if (!isPkg) { return { source: 'fallback', config: { server: { port: 8003 }, data: { dir: path.join(options?.cwd ?? process.cwd(), 'dist') }, autoOpenBrowser: false, database: { type: 'mysql', host: '', port: 3306, username: '', password: '', database: '' }, }, }; } const execPath = options?.execPath ?? process.execPath; const configPath = path.join(path.dirname(execPath), 'config.yaml'); const raw = fs.readFileSync(configPath, 'utf8'); const parsed = yaml.load(raw); assertConfig(parsed); return { source: 'file', config: parsed }; } ``` ```ts // packages/backend/src/comm/data-dir.ts import * as path from 'node:path'; import type { ExternalAppConfig } from './config-loader'; export function resolveDataDir(input?: { isPkg?: boolean; execDir?: string; config?: Pick; cwd?: string; }): string { if (input?.config?.data?.dir) return path.resolve(input.config.data.dir); if (process.env.NETA_DATA_DIR) return path.resolve(process.env.NETA_DATA_DIR); if (input?.isPkg ?? Boolean(process.pkg)) return path.join(input?.execDir ?? path.dirname(process.execPath), 'data'); return path.join(input?.cwd ?? process.cwd(), 'dist'); } ``` ```js // packages/backend/bootstrap.js const { Bootstrap } = require('@midwayjs/bootstrap'); const { loadExternalConfig } = require('./dist/comm/config-loader'); if (process.argv.includes('--version')) { process.stdout.write(`${require('./package.json').version}\n`); process.exit(0); } const configArgIndex = process.argv.indexOf('--config'); if (configArgIndex > -1 && process.argv[configArgIndex + 1]) { process.env.NETA_CONFIG_PATH = process.argv[configArgIndex + 1]; } try { const loaded = loadExternalConfig(); global.__NETA_EXTERNAL_CONFIG__ = loaded.config; process.env.NETA_DATA_DIR = loaded.config.data.dir; } catch (error) { process.stderr.write(`${error.message}\n`); process.exit(1); } Bootstrap.configure({ imports: require('./dist/index'), moduleDetector: false, }).run(); ``` - [ ] **步骤 4:重新运行测试,确认基础设施通过** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/config-loader.test.ts test/windows-installer/data-dir.test.ts -i ``` 预期:PASS,2 个测试文件全部通过。 - [ ] **步骤 5:提交这一轮基础设施改动** ```bash git add packages/backend/src/comm/config-loader.ts packages/backend/src/comm/data-dir.ts packages/backend/test/windows-installer/config-loader.test.ts packages/backend/test/windows-installer/data-dir.test.ts packages/backend/bootstrap.js git commit -m "feat: add installer-aware config bootstrap" ``` ### 任务 2:把所有可写路径统一收口到 data.dir **文件:** - 修改:`packages/backend/src/comm/path.ts` - 修改:`packages/backend/src/config/config.default.ts` - 修改:`packages/backend/src/config/config.prod.ts` - 修改:`packages/backend/src/modules/netaclaw/memory/sqlite_provider.ts` - 修改:`packages/backend/src/modules/netaclaw/session-tree/factory.ts` - 修改:`packages/backend/src/modules/netaclaw/service/skill_installer.ts` - 修改:`packages/backend/src/modules/netaclaw/service/skill_registry.ts` - 复用测试:`packages/backend/test/windows-installer/data-dir.test.ts` - [ ] **步骤 1:补失败测试,覆盖 comm/path、session、skills、skillhub** ```ts // packages/backend/test/windows-installer/data-dir.test.ts import * as path from 'node:path'; import { pUploadPath, pCachePath, pPluginPath, pSqlitePath } from '../../src/comm/path'; import { resolveAgentSessionTreeConfig } from '../../src/modules/netaclaw/session-tree/factory'; describe('installer-aware writable paths', () => { beforeEach(() => { process.env.NETA_DATA_DIR = 'D:/NetaData'; }); afterEach(() => { delete process.env.NETA_DATA_DIR; }); it('把 comm/path 全部映射到 data.dir', () => { expect(path.normalize(pUploadPath())).toBe(path.normalize('D:/NetaData/uploads')); expect(path.normalize(pCachePath())).toBe(path.normalize('D:/NetaData/cache')); expect(path.normalize(pPluginPath())).toBe(path.normalize('D:/NetaData/plugins')); expect(path.normalize(pSqlitePath())).toBe(path.normalize('D:/NetaData/cool.sqlite')); }); it('把 session tree 根目录映射到 data.dir/sessions', () => { const resolved = resolveAgentSessionTreeConfig(undefined, { backend: 'file', dataDir: 'D:/NetaData' }); expect(path.normalize(resolved.file!.rootDir)).toBe(path.normalize('D:/NetaData/sessions')); }); }); ``` - [ ] **步骤 2:运行测试确认目前失败** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/data-dir.test.ts -i ``` 预期:FAIL,因为当前仍然走 `/dist`、`~/.neta`、`process.cwd()/skills`。 - [ ] **步骤 3:写最小实现,把路径改为统一根目录** ```ts // packages/backend/src/comm/path.ts import * as path from 'path'; import * as fs from 'fs'; import { resolveDataDir } from './data-dir'; export const pDataPath = () => { const dirPath = resolveDataDir(); if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); return dirPath; }; export const pUploadPath = () => { const uploadPath = path.join(pDataPath(), 'uploads'); if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true }); return uploadPath; }; export const pPluginPath = () => { const pluginPath = path.join(pDataPath(), 'plugins'); if (!fs.existsSync(pluginPath)) fs.mkdirSync(pluginPath, { recursive: true }); return pluginPath; }; export const pSqlitePath = () => path.join(pDataPath(), 'cool.sqlite'); export const pCachePath = () => { const cachePath = path.join(pDataPath(), 'cache'); if (!fs.existsSync(cachePath)) fs.mkdirSync(cachePath, { recursive: true }); return cachePath; }; ``` ```ts // packages/backend/src/modules/netaclaw/memory/sqlite_provider.ts constructor(dbPath?: string) { const baseDir = process.env.NETA_DATA_DIR ?? path.join(os.homedir(), '.neta'); const resolvedPath = dbPath ?? path.join(baseDir, 'memory', 'memory.db'); fs.mkdirSync(path.dirname(resolvedPath), { recursive: true }); this.db = new Database(resolvedPath); this.db.pragma('journal_mode = WAL'); this.db.exec(INIT_SQL); this.db.exec(FTS_SQL); this.db.exec(TRIGGER_SQL); } ``` ```ts // packages/backend/src/modules/netaclaw/session-tree/factory.ts rootDir: agentConfig?.file?.rootDir ?? path.join(defaults.dataDir ?? process.env.NETA_DATA_DIR ?? '~/.neta', 'sessions') ``` ```ts // packages/backend/src/modules/netaclaw/service/skill_installer.ts @Init() async init() { const root = process.env.NETA_DATA_DIR ?? process.cwd(); this.skillsDir = path.resolve(root, 'skills'); await fs.mkdir(this.skillsDir, { recursive: true }); } ``` ```ts // packages/backend/src/modules/netaclaw/service/skill_registry.ts @Init() async init() { const root = process.env.NETA_DATA_DIR ?? process.cwd(); this.hubDir = path.resolve(root, '.skillhub'); this.originsDir = path.join(this.hubDir, 'origins'); this.lockfilePath = path.join(this.hubDir, 'lock.json'); await fs.mkdir(this.originsDir, { recursive: true }); } ``` ```ts // packages/backend/src/config/config.default.ts import { pCachePath, pUploadPath } from '../comm/path'; staticFile: { buffer: true, dirs: { default: { prefix: '/', dir: path.join(__dirname, '..', '..', 'public') }, static: { prefix: '/upload', dir: pUploadPath() }, }, }, cacheManager: { clients: { default: { store: CoolCacheStore, options: { path: pCachePath(), ttl: 0 }, }, }, }, netaclaw: { ..., skillsDir: path.join(process.env.NETA_DATA_DIR ?? process.cwd(), 'skills'), dataDir: process.env.NETA_DATA_DIR ?? '~/.neta', } ``` ```ts // packages/backend/src/config/config.prod.ts const external = global.__NETA_EXTERNAL_CONFIG__; export default { typeorm: { dataSource: { default: { type: external?.database?.type ?? 'mysql', host: external?.database?.host ?? '', port: external?.database?.port ?? 3306, username: external?.database?.username ?? '', password: external?.database?.password ?? '', database: external?.database?.database ?? '', synchronize: false, logging: false, charset: 'utf8mb4', cache: true, entities, subscribers: [TenantSubscriber], }, }, }, } as MidwayConfig; ``` - [ ] **步骤 4:运行测试确认路径收口成功** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/data-dir.test.ts -i ``` 预期:PASS,所有路径都解析到 `D:/NetaData` 下。 - [ ] **步骤 5:提交路径统一改动** ```bash git add packages/backend/src/comm/path.ts packages/backend/src/config/config.default.ts packages/backend/src/config/config.prod.ts packages/backend/src/modules/netaclaw/memory/sqlite_provider.ts packages/backend/src/modules/netaclaw/session-tree/factory.ts packages/backend/src/modules/netaclaw/service/skill_installer.ts packages/backend/src/modules/netaclaw/service/skill_registry.ts packages/backend/test/windows-installer/data-dir.test.ts git commit -m "feat: unify backend writable paths under data dir" ``` ### 任务 3:单实例锁、自动开浏览器、日志与数据目录初始化 **文件:** - 新增:`packages/backend/src/comm/runtime-lock.ts` - 新增:`packages/backend/src/comm/browser.ts` - 新增:`packages/backend/test/windows-installer/runtime-lock.test.ts` - 修改:`packages/backend/src/configuration.ts` - 修改:`packages/backend/src/config/config.default.ts` - [ ] **步骤 1:写失败测试,先定义锁文件行为** ```ts // packages/backend/test/windows-installer/runtime-lock.test.ts import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { acquireRuntimeLock, readRuntimeLock, releaseRuntimeLock } from '../../src/comm/runtime-lock'; describe('runtime lock', () => { it('写入当前 pid,并在释放时删除锁文件', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-lock-')); const lockPath = path.join(tempDir, 'neta.lock'); acquireRuntimeLock(lockPath); expect(readRuntimeLock(lockPath).pid).toBe(process.pid); releaseRuntimeLock(lockPath); expect(fs.existsSync(lockPath)).toBe(false); }); }); ``` - [ ] **步骤 2:运行测试确认当前失败** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/runtime-lock.test.ts -i ``` 预期:FAIL,因为 `runtime-lock.ts` 尚不存在。 - [ ] **步骤 3:写最小实现,包含失效锁恢复与 onReady 行为** ```ts // packages/backend/src/comm/runtime-lock.ts import * as fs from 'node:fs'; export function acquireRuntimeLock(lockPath: string) { if (fs.existsSync(lockPath)) { const current = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid: number }; try { process.kill(current.pid, 0); throw new Error(`Neta 已在运行,PID=${current.pid}`); } catch { fs.rmSync(lockPath, { force: true }); } } fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), 'utf8'); } export function readRuntimeLock(lockPath: string): { pid: number; startedAt: string } { return JSON.parse(fs.readFileSync(lockPath, 'utf8')); } export function releaseRuntimeLock(lockPath: string) { fs.rmSync(lockPath, { force: true }); } ``` ```ts // packages/backend/src/comm/browser.ts import { exec } from 'node:child_process'; export function openBrowser(url: string) { if (process.platform !== 'win32') return; exec(`start "" "${url}"`); } ``` ```ts // packages/backend/src/configuration.ts import * as path from 'node:path'; import { acquireRuntimeLock, releaseRuntimeLock } from './comm/runtime-lock'; import { openBrowser } from './comm/browser'; import { pDataPath } from './comm/path'; async onReady() { const lockPath = path.join(pDataPath(), 'neta.lock'); acquireRuntimeLock(lockPath); process.on('exit', () => releaseRuntimeLock(lockPath)); process.on('SIGINT', () => { releaseRuntimeLock(lockPath); process.exit(0); }); const channelService = await this.app.getApplicationContext().getAsync(NetaClawAgentChannelService); await channelService.restoreConnectedRunners(); const port = this.app.getConfig('koa.port'); const autoOpenBrowser = this.app.getConfig('autoOpenBrowser'); if (process.pkg && autoOpenBrowser) { openBrowser(`http://127.0.0.1:${port}`); } } ``` ```ts // packages/backend/src/config/config.default.ts loggers: { coreLogger: { level: 'INFO', consoleLevel: 'INFO', dir: path.join(pDataPath(), 'logs'), }, }, autoOpenBrowser: global.__NETA_EXTERNAL_CONFIG__?.autoOpenBrowser ?? false, ``` - [ ] **步骤 4:重新运行测试并做一次手工 smoke** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/runtime-lock.test.ts -i ``` 预期:PASS,锁文件可创建并释放。 再运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run start ``` 预期:应用正常启动,首次启动时在数据目录创建 `logs/` 与 `neta.lock`。 - [ ] **步骤 5:提交运行时行为改动** ```bash git add packages/backend/src/comm/runtime-lock.ts packages/backend/src/comm/browser.ts packages/backend/src/configuration.ts packages/backend/src/config/config.default.ts packages/backend/test/windows-installer/runtime-lock.test.ts git commit -m "feat: add installer runtime lifecycle helpers" ``` ### 任务 4:前端 + 后端 + pkg 的 Windows 打包流水线 **文件:** - 新增:`packages/backend/scripts/pkg-build.js` - 修改:`packages/backend/package.json` - 修改:`packages/backend/scripts/pkg-build.sh` - 新增:`packages/backend/installer/config.default.yaml` - [ ] **步骤 1:先写一个 staging 断言,定义产物要求** ```js // packages/backend/scripts/pkg-build.js const fs = require('node:fs'); const path = require('node:path'); function assertStage(stageDir) { const required = [ path.join(stageDir, 'public', 'index.html'), path.join(stageDir, 'public', 'swagger', 'index.html'), path.join(stageDir, 'dist', 'index.js'), ]; for (const file of required) { if (!fs.existsSync(file)) { throw new Error(`缺少 staging 文件: ${file}`); } } } ``` - [ ] **步骤 2:运行现有流程,确认它还没有完整前后端合包** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node -e "const fs=require('fs'); console.log(fs.existsSync('build/pkg-stage/public/index.html') ? 'staged' : 'missing')" ``` 预期:`missing` 或是旧产物,因为当前流程没有强制先构建前端再合并到 staging。 - [ ] **步骤 3:写最小实现,用 Node 脚本统一构建** ```js // packages/backend/scripts/pkg-build.js const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); const backendDir = path.resolve(__dirname, '..'); const repoDir = path.resolve(backendDir, '..', '..'); const frontendDir = path.join(repoDir, 'packages', 'frontend'); const stageDir = path.join(backendDir, 'build', 'pkg-stage'); const outputDir = path.join(backendDir, 'build', 'pkg-output'); function run(command, cwd) { cp.execFileSync(process.platform === 'win32' ? 'cmd.exe' : 'bash', process.platform === 'win32' ? ['/c', command] : ['-lc', command], { cwd, stdio: 'inherit' }); } fs.rmSync(stageDir, { recursive: true, force: true }); fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(stageDir, { recursive: true }); run('pnpm build', frontendDir); run('npm run build', backendDir); fs.cpSync(path.join(backendDir, 'bootstrap.js'), path.join(stageDir, 'bootstrap.js')); fs.cpSync(path.join(backendDir, 'dist'), path.join(stageDir, 'dist'), { recursive: true }); fs.cpSync(path.join(backendDir, 'public'), path.join(stageDir, 'public'), { recursive: true }); fs.cpSync(path.join(frontendDir, 'dist'), path.join(stageDir, 'public'), { recursive: true, force: true }); if (fs.existsSync(path.join(backendDir, 'typings'))) { fs.cpSync(path.join(backendDir, 'typings'), path.join(stageDir, 'typings'), { recursive: true }); } const stagePkg = { name: '@neta/backend', version: require(path.join(backendDir, 'package.json')).version, private: true, dependencies: require(path.join(backendDir, 'package.json')).dependencies, bin: './bootstrap.js', pkg: { scripts: ['dist/**/*.js', 'node_modules/**/*.mjs'], assets: [ 'public/**/*', 'typings/**/*', 'node_modules/@img/sharp-win32-x64/**/*', 'node_modules/@img/colour/**/*', 'node_modules/better-sqlite3/build/Release/*.node', 'node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/*.node', 'node_modules/@napi-rs/canvas-win32-x64-msvc/*.node', ], targets: ['node20-win-x64'], outputPath: outputDir, }, }; fs.writeFileSync(path.join(stageDir, 'package.json'), JSON.stringify(stagePkg, null, 2)); run('npm install --production --install-strategy=hoisted --legacy-peer-deps', stageDir); run('npx @yao-pkg/pkg@6.14.1 .', stageDir); assertStage(stageDir); ``` ```json // packages/backend/package.json { "scripts": { "pkg": "node scripts/pkg-build.js", "build:windows-installer": "node scripts/build-windows-installer.js" } } ``` ```sh # packages/backend/scripts/pkg-build.sh #!/usr/bin/env bash set -euo pipefail node "$(cd "$(dirname "$0")" && pwd)/pkg-build.js" ``` ```yaml # packages/backend/installer/config.default.yaml server: port: 8003 data: dir: "C:\\NetaData" autoOpenBrowser: true database: type: mysql host: "" port: 3306 username: "" password: "" database: "" ``` - [ ] **步骤 4:运行打包流水线确认 exe 可生成** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run pkg ``` 预期:PASS,生成 `packages/backend/build/pkg-output/backend.exe`,且 `public/index.html` 来自前端构建产物,`public/swagger/index.html` 仍保留。 - [ ] **步骤 5:提交打包脚本改动** ```bash git add packages/backend/scripts/pkg-build.js packages/backend/scripts/pkg-build.sh packages/backend/installer/config.default.yaml packages/backend/package.json git commit -m "feat: package installer-ready backend exe" ``` ### 任务 5:离线安装器、卸载交互与重装安全 **文件:** - 新增:`packages/backend/scripts/build-windows-installer.js` - 新增:`packages/backend/installer/setup.iss` - 手工验证:`packages/backend/build/pkg-output/backend.exe` - [ ] **步骤 1:先写失败入口,定义安装器脚本必须存在** ```js // packages/backend/scripts/build-windows-installer.js const fs = require('node:fs'); const path = require('node:path'); const issPath = path.resolve(__dirname, '..', 'installer', 'setup.iss'); if (!fs.existsSync(issPath)) { throw new Error(`缺少安装器脚本: ${issPath}`); } ``` - [ ] **步骤 2:运行安装器构建命令,确认当前失败** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node scripts/build-windows-installer.js ``` 预期:FAIL,提示缺少 `setup.iss`。 - [ ] **步骤 3:写最小可用的 Inno Setup 安装器实现** ```js // packages/backend/scripts/build-windows-installer.js const cp = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const backendDir = path.resolve(__dirname, '..'); const outputDir = path.join(backendDir, 'build', 'pkg-output'); const issPath = path.join(backendDir, 'installer', 'setup.iss'); if (!fs.existsSync(path.join(outputDir, 'backend.exe'))) { cp.execFileSync('node', ['scripts/pkg-build.js'], { cwd: backendDir, stdio: 'inherit' }); } cp.execFileSync('iscc', [issPath], { cwd: backendDir, stdio: 'inherit' }); ``` ```iss ; packages/backend/installer/setup.iss #define MyAppName "Neta" #define MyAppVersion "8.0.0" [Setup] AppId={{1B72B6C4-21A4-4C77-A6F6-1D4B98E7F1A1} AppName={#MyAppName} AppVersion={#MyAppVersion} DefaultDirName={autopf}\Neta DefaultGroupName=Neta OutputDir=..\build\installer-output OutputBaseFilename=neta-setup Compression=lzma2 SolidCompression=yes WizardStyle=modern PrivilegesRequired=admin [Files] Source: "..\build\pkg-output\backend.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "config.default.yaml"; DestDir: "{app}"; DestName: "config.yaml"; Flags: onlyifdoesntexist [Icons] Name: "{autodesktop}\Neta"; Filename: "{app}\backend.exe"; WorkingDir: "{app}" Name: "{group}\Neta"; Filename: "{app}\backend.exe"; WorkingDir: "{app}" [Run] Filename: "{app}\backend.exe"; Description: "启动 Neta"; Flags: nowait postinstall skipifsilent [UninstallRun] Filename: "taskkill"; Parameters: "/IM backend.exe /F"; Flags: runhidden skipifdoesntexist ``` - [ ] **步骤 4:运行安装器构建并做一次手工安装验证** 运行: ```bash cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node scripts/build-windows-installer.js ``` 预期:PASS,生成 `packages/backend/build/installer-output/neta-setup.exe`。 再验证: 1. 双击安装器,确认安装成功 2. 安装完成后点击“立即启动”,浏览器能打开首页 3. 卸载后确认程序目录删除 4. 重装后确认数据目录未被误删 - [ ] **步骤 5:提交安装器实现** ```bash git add packages/backend/scripts/build-windows-installer.js packages/backend/installer/setup.iss git commit -m "feat: add offline windows installer" ``` --- ## 自检 **设计覆盖检查:** - 程序目录 / 数据目录分离:任务 1-2 - 外部 `config.yaml` + 校验 + 启动顺序:任务 1 - 数据库凭证外置:任务 2 - 所有本地持久化统一写入 `data.dir`:任务 2 - 单实例、自动开浏览器、日志、锁文件:任务 3 - 前端与后端合包为单 exe:任务 4 - Inno Setup 安装/卸载/重装:任务 5 **占位符检查:** 无 `TBD`、`TODO`、`后续补充` 一类占位内容。 **架构一致性检查:** 当前版本已经补上了交叉审查里指出的高优先级项:开发态回退、配置校验、路径统一、启动顺序、卸载/重装闭环。仍需注意的剩余架构问题只有一个:`config.yaml` 当前仍放在安装目录,和“普通用户手动编辑”存在权限冲突;第一版可以先接受“安装器生成、一般不手改”,后续若要开放用户编辑,再把配置迁到 `ProgramData` 或数据目录。