# 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` 或数据目录。
