839 lines
29 KiB
Markdown
839 lines
29 KiB
Markdown
# 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
|
||
```
|
||
预期: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,因为当前仍然走 `<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` 或数据目录。
|