GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-25-windows-installer.md
2026-05-20 21:39:12 +08:00

839 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<ExternalAppConfig, 'data'>;
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
```
预期PASS2 个测试文件全部通过。
- [ ] **步骤 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因为当前仍然走 `<cwd>/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` 或数据目录。