GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-25-windows-installer.md

839 lines
29 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 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` 或数据目录。