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

29 KiB
Raw Permalink Blame History

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.tsonReady 中注册锁文件和自动打开浏览器
  • 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先写失败测试覆盖安装态与开发态回退

// 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运行测试确认当前失败

运行:

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 校验立住
// 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 };
}
// 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');
}
// 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重新运行测试确认基础设施通过

运行:

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提交这一轮基础设施改动
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

// 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运行测试确认目前失败

运行:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/data-dir.test.ts -i

预期FAIL因为当前仍然走 <cwd>/dist~/.netaprocess.cwd()/skills

  • 步骤 3写最小实现把路径改为统一根目录
// 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;
};
// 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);
}
// packages/backend/src/modules/netaclaw/session-tree/factory.ts
rootDir: agentConfig?.file?.rootDir ?? path.join(defaults.dataDir ?? process.env.NETA_DATA_DIR ?? '~/.neta', 'sessions')
// 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 });
}
// 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 });
}
// 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',
}
// 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运行测试确认路径收口成功

运行:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/data-dir.test.ts -i

预期PASS所有路径都解析到 D:/NetaData 下。

  • 步骤 5提交路径统一改动
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写失败测试先定义锁文件行为

// 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运行测试确认当前失败

运行:

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 行为
// 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 });
}
// 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}"`);
}
// 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}`);
  }
}
// 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

运行:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/runtime-lock.test.ts -i

预期PASS锁文件可创建并释放。

再运行:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run start

预期:应用正常启动,首次启动时在数据目录创建 logs/neta.lock

  • 步骤 5提交运行时行为改动
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 断言,定义产物要求

// 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运行现有流程确认它还没有完整前后端合包

运行:

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 脚本统一构建
// 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);
// packages/backend/package.json
{
  "scripts": {
    "pkg": "node scripts/pkg-build.js",
    "build:windows-installer": "node scripts/build-windows-installer.js"
  }
}
# packages/backend/scripts/pkg-build.sh
#!/usr/bin/env bash
set -euo pipefail
node "$(cd "$(dirname "$0")" && pwd)/pkg-build.js"
# 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 可生成

运行:

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提交打包脚本改动
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先写失败入口定义安装器脚本必须存在

// 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运行安装器构建命令确认当前失败

运行:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node scripts/build-windows-installer.js

预期FAIL提示缺少 setup.iss

  • 步骤 3写最小可用的 Inno Setup 安装器实现
// 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' });
; 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运行安装器构建并做一次手工安装验证

运行:

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提交安装器实现
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

占位符检查:TBDTODO后续补充 一类占位内容。

架构一致性检查: 当前版本已经补上了交叉审查里指出的高优先级项:开发态回退、配置校验、路径统一、启动顺序、卸载/重装闭环。仍需注意的剩余架构问题只有一个:config.yaml 当前仍放在安装目录,和“普通用户手动编辑”存在权限冲突;第一版可以先接受“安装器生成、一般不手改”,后续若要开放用户编辑,再把配置迁到 ProgramData 或数据目录。