From 59dd6ea26501724e5810e343a146ea57897c9e99 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:01 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(bootstrap):=20RELEASE.json=20=E5=9C=A8?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E6=A0=B9=E7=9A=84=E7=AC=AC=E4=B8=89=E5=B1=82?= =?UTF-8?q?=EF=BC=8CtargetVersion=20=E4=B8=8D=E5=86=8D=E6=81=92=E4=B8=BA?= =?UTF-8?q?=20unknown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/bootstrap/release-version.ts | 51 ++++++++++++++ .../unit/bootstrap-release-version.test.ts | 68 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/api/src/bootstrap/release-version.ts create mode 100644 apps/api/tests/unit/bootstrap-release-version.test.ts diff --git a/apps/api/src/bootstrap/release-version.ts b/apps/api/src/bootstrap/release-version.ts new file mode 100644 index 00000000..57259c84 --- /dev/null +++ b/apps/api/src/bootstrap/release-version.ts @@ -0,0 +1,51 @@ +/** + * 读取内核产物的目标版本(来自产物根目录的 `RELEASE.json`)。 + * + * 为什么单独成文件而不是留在 `server.ts` 里:`server.ts` 是带顶层副作用的 + * 进程入口(`await main()`),任何 import 都会真的去启动进程,因此无法直接做 + * 单元测试。这段路径推导又恰好是最容易写错的地方——产物布局是 + * `releases//apps/api/dist/server.js`,而 `RELEASE.json` 在 + * `releases//`,即 `dist` 的**上三层**。少一层就会得到 `unknown`, + * 表现为自举时 runner 报「任务未记录目标版本,无法定位迁移产物」。 + */ + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * 从入口模块 URL 向上查找 `RELEASE.json`。 + * + * 依次尝试 dist 的 1/2/3 层父目录,覆盖: + * - 产物布局 `/apps/api/dist/server.js` → 3 层; + * - 源码构建 `apps/api/dist/server.js` → 2 层(仓库根有时也有 RELEASE.json); + * - 单层打包布局 `dist/server.js` → 1 层。 + * + * 找不到或内容不可解析时返回 `'unknown'`;调用方必须显式拒绝该值, + * 不能把它当成合法版本继续执行。 + */ +export function resolveTargetVersion(moduleUrl: string | URL): string { + const here = path.dirname(moduleUrl instanceof URL ? moduleUrl.pathname : fileUrlPath(moduleUrl)); + const candidates = [ + path.resolve(here, '..', 'RELEASE.json'), + path.resolve(here, '..', '..', 'RELEASE.json'), + path.resolve(here, '..', '..', '..', 'RELEASE.json'), + ]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: unknown }; + if (typeof parsed.version === 'string' && parsed.version.length > 0) return parsed.version; + } catch { + // 读取或解析失败时继续尝试下一个候选。 + } + } + return 'unknown'; +} + +function fileUrlPath(moduleUrl: string): string { + try { + return new URL(moduleUrl).pathname; + } catch { + return moduleUrl; + } +} diff --git a/apps/api/tests/unit/bootstrap-release-version.test.ts b/apps/api/tests/unit/bootstrap-release-version.test.ts new file mode 100644 index 00000000..98090c24 --- /dev/null +++ b/apps/api/tests/unit/bootstrap-release-version.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveTargetVersion } from '../../src/bootstrap/release-version.ts'; + +/** + * 产物布局回归:`RELEASE.json` 在 `releases//`,入口在 + * `releases//apps/api/dist/server.js`——距 RELEASE.json 三层。 + * + * 少认一层会让 targetVersion 恒为 `unknown`,自举时 runner 直接失败并报 + * 「任务未记录目标版本,无法定位迁移产物」,而安装器看起来完全成功。 + */ +describe('resolveTargetVersion', () => { + const created: string[] = []; + + afterEach(() => { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function makeRelease(version: string, entryRel: string): string { + const root = mkdtempSync(path.join(tmpdir(), 'sp-release-')); + created.push(root); + writeFileSync(path.join(root, 'RELEASE.json'), JSON.stringify({ version }), 'utf8'); + const entryDir = path.join(root, path.dirname(entryRel)); + mkdirSync(entryDir, { recursive: true }); + const entry = path.join(root, entryRel); + writeFileSync(entry, '// entry\n', 'utf8'); + return entry; + } + + it('产物布局(apps/api/dist/server.js)读到版本', () => { + const entry = makeRelease('0.2.0', 'apps/api/dist/server.js'); + expect(resolveTargetVersion(entry)).toBe('0.2.0'); + }); + + it('接受 file:// URL 形式的入口', () => { + const entry = makeRelease('1.4.2', 'apps/api/dist/server.js'); + expect(resolveTargetVersion(new URL(`file://${entry}`))).toBe('1.4.2'); + }); + + it('单层布局(dist/server.js)也读到版本', () => { + const entry = makeRelease('9.9.9', 'dist/server.js'); + expect(resolveTargetVersion(entry)).toBe('9.9.9'); + }); + + it('找不到 RELEASE.json 时返回 unknown,而不是猜一个版本', () => { + const root = mkdtempSync(path.join(tmpdir(), 'sp-release-none-')); + created.push(root); + const entryDir = path.join(root, 'apps/api/dist'); + mkdirSync(entryDir, { recursive: true }); + const entry = path.join(entryDir, 'server.js'); + writeFileSync(entry, '// entry\n', 'utf8'); + expect(resolveTargetVersion(entry)).toBe('unknown'); + }); + + it('RELEASE.json 缺少 version 时返回 unknown', () => { + const root = mkdtempSync(path.join(tmpdir(), 'sp-release-bad-')); + created.push(root); + writeFileSync(path.join(root, 'RELEASE.json'), JSON.stringify({ role: 'kernel' }), 'utf8'); + const entryDir = path.join(root, 'apps/api/dist'); + mkdirSync(entryDir, { recursive: true }); + const entry = path.join(entryDir, 'server.js'); + writeFileSync(entry, '// entry\n', 'utf8'); + expect(resolveTargetVersion(entry)).toBe('unknown'); + }); +}); From 00bff2d8cea0050ac77d59a719256652eedfb622 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:04 +0800 Subject: [PATCH 2/6] =?UTF-8?q?refactor(bootstrap):=20server=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20release-version=20=E7=9A=84=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/server.ts | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b3a3d0f5..ef42a5a6 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -15,9 +15,10 @@ * 数据库故障绝不能让生产实例回落到可写配置的页面。 */ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import { BootstrapStateStore, defaultBootstrapStateDir, initialState } from './bootstrap/state.ts'; +import { resolveTargetVersion as readReleaseVersion } from './bootstrap/release-version.ts'; /** * 解析状态根目录与自举状态目录。 @@ -46,22 +47,7 @@ function resolveStateRoots(): { root: string; bootstrapDir: string } { /** 读取目标内核版本;来自产物内的 `RELEASE.json`。 */ function resolveTargetVersion(moduleUrl: string): string { - const here = path.dirname(new URL(moduleUrl).pathname); - // dist/server.js → 产物根是 dist 的上一级 - const candidates = [ - path.resolve(here, '..', 'RELEASE.json'), - path.resolve(here, '..', '..', 'RELEASE.json'), - ]; - for (const candidate of candidates) { - if (!existsSync(candidate)) continue; - try { - const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: string }; - if (typeof parsed.version === 'string') return parsed.version; - } catch { - // 读取失败时继续尝试下一个候选。 - } - } - return 'unknown'; + return readReleaseVersion(moduleUrl); } /** From eeec46f1ef9039a8a7bca9188d069ca27a7220d0 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:05 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(plugins):=20=E6=95=B0=E6=8D=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=A1=A5=E6=8E=A5=20node=5Fmodules=EF=BC=8C=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E5=86=8D=20Cannot=20find=20package=20'zod'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/lib/plugins.ts | 50 +++++++++- apps/api/src/plugin-host.ts | 8 +- .../unit/plugin-module-resolution.test.ts | 99 +++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 apps/api/tests/unit/plugin-module-resolution.test.ts diff --git a/apps/api/src/lib/plugins.ts b/apps/api/src/lib/plugins.ts index 2dc0f56a..fa3f69d1 100644 --- a/apps/api/src/lib/plugins.ts +++ b/apps/api/src/lib/plugins.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; -import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { cp, mkdir, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import type { PluginDefinition, PluginDependency, PluginExtensionConsumer } from '@stackpanel/sdk'; @@ -178,6 +178,54 @@ function pluginsDir(): string { return path.join(resolveStackPanelDataDir(), 'plugins'); } +/** + * 让「数据目录里的插件」能解析到内核自带的依赖。 + * + * 插件包被装在 `/plugins//`,而它 import 的 `@stackpanel/sdk`、`zod` + * 等裸包位于版本目录的 `node_modules`(`/current/node_modules`)。 + * Node 的 ESM 解析只会从插件目录**向上**找 `node_modules`,数据目录通常在 + * 版本目录之外(`/var/lib/stackpanel` vs `/opt/stackpanel`),这条路径永远 + * 到不了内核依赖——结果是每个内置插件都被判为 `Skipping invalid plugin`, + * 市场与主题首页全空,而进程本身看起来完全正常。 + * + * 做法是在数据目录放一个**符号链接** `node_modules -> /current/node_modules`: + * - 不复制依赖(否则会出现第二份 zod 实例、`instanceof` 失效); + * - 升级切换 `current` 后链接自动指向新版本,不需要重写; + * - 只在目标不存在时创建,开发布局下数据目录本来就有真实 node_modules 时不介入。 + * + * 失败只记日志:插件加载会因为找不到依赖而各自报错,这里再抛异常只会让 + * 整个进程起不来,反而更难定位。 + */ +export async function ensurePluginModuleResolution( + log: (message: string) => void = () => {}, +): Promise { + const dataDir = resolveStackPanelDataDir(); + const link = path.join(dataDir, 'node_modules'); + if (existsSync(link)) return; + // 内核进程的工作目录是同版本目录下的 `apps/api`(见 ecosystem.config.cjs), + // 因此 `/../../node_modules` 就是该版本目录的 node_modules;再兜底 + // `/../current/node_modules`(数据目录与安装根相邻的默认布局)。 + const cwd = process.cwd(); + const candidates = [ + path.resolve(cwd, '..', '..', 'node_modules'), + path.resolve(cwd, 'node_modules'), + path.join(path.dirname(dataDir), 'current', 'node_modules'), + ]; + const target = candidates.find((candidate) => existsSync(candidate)); + if (!target) return; + try { + await mkdir(dataDir, { recursive: true }); + await symlink(target, link, 'dir'); + log(`[plugins] 已建立模块解析桥接:${link} -> ${target}`); + } catch (error) { + log( + `[plugins] 无法建立模块解析桥接(${link} -> ${target}):${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + export function pluginPackageDir(id: string): string { return path.join(pluginsDir(), id); } diff --git a/apps/api/src/plugin-host.ts b/apps/api/src/plugin-host.ts index 90b50620..67fb067d 100644 --- a/apps/api/src/plugin-host.ts +++ b/apps/api/src/plugin-host.ts @@ -17,7 +17,12 @@ import type { NotificationChannel, NotificationTypeDefinition } from '@stackpane import type { PrismaClient } from '@stackpanel/db'; import { peekRedis } from '@stackpanel/db'; import { EXTENSION_POINTS } from '@stackpanel/sdk'; -import { loadPluginDefinition, scanPlugins, seedBuiltinPlugins } from './lib/plugins.ts'; +import { + ensurePluginModuleResolution, + loadPluginDefinition, + scanPlugins, + seedBuiltinPlugins, +} from './lib/plugins.ts'; import { packageManager } from './packages/manager.ts'; import { MarketService } from './market/service.ts'; import { getCatalogRefresh } from './distribution/runtime.ts'; @@ -257,6 +262,7 @@ export function createPluginHost(options: PluginHostOptions): PluginHost { media, coherence, async registerPlugins(): Promise { + await ensurePluginModuleResolution((message) => console.info(message)); await seedBuiltinPlugins(); // 回收上次崩溃遗留的未引用暂存/备份产物(§7.6/§7.8);活动目录不受影响。 await packageManager.gcAll().catch(() => undefined); diff --git a/apps/api/tests/unit/plugin-module-resolution.test.ts b/apps/api/tests/unit/plugin-module-resolution.test.ts new file mode 100644 index 00000000..86ae7df3 --- /dev/null +++ b/apps/api/tests/unit/plugin-module-resolution.test.ts @@ -0,0 +1,99 @@ +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readlinkSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** + * 插件装在数据目录(`/var/lib/stackpanel/data/plugins/`),但它们的裸导入 + * (`@stackpanel/sdk`、`zod`)只存在于版本目录的 `node_modules` + * (`/opt/stackpanel/releases//node_modules`)。Node 的解析只会从插件目录 + * **向上**找 `node_modules`,因此数据目录与安装根不在同一棵树时,每个内置插件 + * 都会变成 `Skipping invalid plugin: Cannot find package 'zod'`——进程正常、 + * 首页 404、市场为空,安装与自举却都报成功。 + * + * `ensurePluginModuleResolution` 在数据目录放一个指向版本目录的符号链接, + * 升级切换 `current` 后自动跟随新版本。这里钉死它的判定顺序与幂等性。 + */ +import { ensurePluginModuleResolution } from '../../src/lib/plugins.ts'; + +describe('ensurePluginModuleResolution', () => { + const created: string[] = []; + const savedDataDir = process.env['STACKPANEL_DATA_DIR']; + + afterEach(() => { + if (savedDataDir === undefined) delete process.env['STACKPANEL_DATA_DIR']; + else process.env['STACKPANEL_DATA_DIR'] = savedDataDir; + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function tree(): { root: string; releaseDir: string; dataDir: string; cwd: string } { + const root = mkdtempSync(path.join(tmpdir(), 'sp-modres-')); + created.push(root); + // 产物布局:releases//{apps/api,node_modules} + const releaseDir = path.join(root, 'releases', '0.2.0'); + const cwd = path.join(releaseDir, 'apps', 'api'); + const dataDir = path.join(root, 'state', 'data'); + mkdirSync(path.join(releaseDir, 'node_modules'), { recursive: true }); + mkdirSync(cwd, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(path.join(releaseDir, 'node_modules', '.keep'), ''); + return { root, releaseDir, dataDir, cwd }; + } + + it('在数据目录建立指向当前版本 node_modules 的符号链接', async () => { + const { releaseDir, dataDir, cwd } = tree(); + process.env['STACKPANEL_DATA_DIR'] = dataDir; + const previous = process.cwd(); + process.chdir(cwd); + try { + await ensurePluginModuleResolution(); + } finally { + process.chdir(previous); + } + const link = path.join(dataDir, 'node_modules'); + // macOS 的 tmpdir 是 /var → /private/var 符号链接,比较前统一取真实路径。 + expect(realpathSync(readlinkSync(link))).toBe( + realpathSync(path.join(releaseDir, 'node_modules')), + ); + }); + + it('已存在 node_modules 时不覆盖(开发布局与重复启动都走这条)', async () => { + const { dataDir } = tree(); + process.env['STACKPANEL_DATA_DIR'] = dataDir; + const existing = path.join(dataDir, 'node_modules'); + mkdirSync(existing, { recursive: true }); + writeFileSync(path.join(existing, 'sentinel'), ''); + const logs: string[] = []; + await ensurePluginModuleResolution((message) => logs.push(message)); + // 保持既有内容:开发布局下数据目录自带真实 node_modules,不该被符号链接替换。 + expect(existsSync(path.join(existing, 'sentinel'))).toBe(true); + expect(lstatSync(existing).isSymbolicLink()).toBe(false); + expect(logs).toHaveLength(0); + }); + + it('找不到版本目录时不创建悬空链接,只记一条日志', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'sp-modres-none-')); + created.push(root); + const dataDir = path.join(root, 'data'); + mkdirSync(dataDir, { recursive: true }); + process.env['STACKPANEL_DATA_DIR'] = dataDir; + const logs: string[] = []; + const previous = process.cwd(); + process.chdir(root); + try { + await ensurePluginModuleResolution((message) => logs.push(message)); + } finally { + process.chdir(previous); + } + expect(() => readlinkSync(path.join(dataDir, 'node_modules'))).toThrow(); + }); +}); From 1a1a46d0c22664d34f8c886953899a57503e3d09 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:07 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(release):=20=E4=BA=A7=E7=89=A9=E5=B8=A6?= =?UTF-8?q?=E4=B8=8A=E5=86=85=E7=BD=AE=E6=8F=92=E4=BB=B6=E4=B8=8E=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=B8=BB=E9=A2=98=EF=BC=8C=E9=97=AD=E5=8C=85=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=A4=8D=E5=88=B6=E5=8C=85=E5=86=85=20node=5Fmodules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release/build-kernel-artifact.mjs | 122 +++++++++++++++++++++- scripts/release/collect-runtime-deps.mjs | 11 +- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/scripts/release/build-kernel-artifact.mjs b/scripts/release/build-kernel-artifact.mjs index 2a819b95..34cf595b 100644 --- a/scripts/release/build-kernel-artifact.mjs +++ b/scripts/release/build-kernel-artifact.mjs @@ -486,6 +486,114 @@ function assertRelocatable(root, label) { } } +/** + * 把随内核发布的内置插件与默认主题拷进产物,并返回它们需要的运行时裸导入包。 + * + * 内核启动时会把 `packages/plugins/`(按 `package.json` 的 + * `stackpanel.builtin === true` 判定)与 `packages/themes/default` 拷到数据目录, + * 作为「随内核发布的本地来源」。产物缺这两棵目录时: + * - 首页 404(默认主题没被播种); + * - 市场里一个内置插件都没有。 + * + * 只拷运行时需要的东西:`dist/`、`frontend/`、`assets/`、`package.json`、 + * `theme.css`/`theme.json`。**不**拷 `src/`、`node_modules`、`dist-pack`: + * 源码与开发依赖在目标机上没有用处,而 node_modules 由依赖闭包统一提供 + * ——两份副本会各自带一个 zod 实例,`instanceof` 与 schema 复用都会失效。 + * + * 插件与主题的前端产物保留裸导入(`@stackpanel/sdk`、`zod`),它们的加载器 + * 是 `data/plugins|themes/...` 下的真实 ESM 导入,所以这些包必须出现在产物的 + * `node_modules` 里;这里把它们作为闭包的入口返回。 + */ +function copyBuiltinPackages(staging) { + const pluginsRoot = path.join(REPO_ROOT, 'packages/plugins'); + const themesRoot = path.join(REPO_ROOT, 'packages/themes'); + const runtimePackages = new Set(); + const copiedPlugins = []; + let themes = 0; + + const RUNTIME_DIRS = new Set(['dist', 'frontend', 'assets']); + const RUNTIME_FILES = new Set([ + 'package.json', + 'theme.css', + 'theme.json', + 'README.md', + 'LICENSE', + ]); + + /** 只拷贝运行期需要的条目;返回是否复制了任何内容。 */ + const copyRuntimeTree = (source, target) => { + if (!existsSync(source)) return false; + let copied = false; + for (const entry of readdirSync(source, { withFileTypes: true })) { + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + if (entry.isDirectory()) { + if (!RUNTIME_DIRS.has(entry.name)) continue; + cpSync(from, to, { recursive: true, dereference: true }); + copied = true; + continue; + } + if (!RUNTIME_FILES.has(entry.name)) continue; + ensureDir(target); + cpSync(from, to); + copied = true; + } + return copied; + }; + + if (existsSync(pluginsRoot)) { + for (const id of readdirSync(pluginsRoot).sort()) { + if (id.startsWith('.')) continue; + const source = path.join(pluginsRoot, id); + const pkg = readPkgOrNull(path.join(source, 'package.json')); + if (!pkg || pkg.stackpanel?.builtin !== true) continue; + if (!existsSync(path.join(source, 'dist/index.js'))) { + throw new Error( + `内置插件 ${id} 缺少构建产物 dist/index.js:请先构建 @stackpanel/plugins-${id}`, + ); + } + const target = path.join(staging, 'packages/plugins', id); + ensureDir(target); + copyRuntimeTree(source, target); + // 插件的运行时裸导入:自己声明的依赖 + workspace 内部的 @stackpanel/*。 + for (const dep of Object.keys(pkg.dependencies ?? {})) runtimePackages.add(dep); + copiedPlugins.push(id); + } + } + + if (existsSync(themesRoot)) { + for (const id of readdirSync(themesRoot).sort()) { + if (id.startsWith('.')) continue; + const source = path.join(themesRoot, id); + if (!existsSync(path.join(source, 'theme.json'))) continue; + const target = path.join(staging, 'packages/themes', id); + ensureDir(target); + copyRuntimeTree(source, target); + // 主题前端同样保留裸导入。 + const pkg = readPkgOrNull(path.join(source, 'frontend/package.json')); + for (const dep of Object.keys(pkg?.dependencies ?? {})) runtimePackages.add(dep); + themes += 1; + } + } + + if (copiedPlugins.length === 0) { + // 空市场在目标机上表现为「内核装好了但什么都没有」,属于必须提前失败的构建缺陷。 + throw new Error( + '产物中没有任何内置插件:请确认 packages/plugins/*/package.json 已声明 stackpanel.builtin', + ); + } + + return { plugins: copiedPlugins, themes, runtimePackages: [...runtimePackages].sort() }; +} + +function readPkgOrNull(file) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return null; + } +} + /** * 原生依赖必须与目标平台一致。 * @@ -599,11 +707,23 @@ async function buildKernel(spec, args, platformInfo) { recursive: true, }); + // 3b) 随内核发布的内置插件与主题。 + // + // 内核在启动时把 `packages/plugins/`(dist + frontend + assets)与 + // `packages/themes/default` 拷进数据目录作为可安装来源。产物里没有这两棵 + // 目录时,安装器「成功」、进程也起来了,但首页 404、市场空空如也, + // 而这在构建机上完全看不出来(构建机有完整源码树)。 + // 只带运行时需要的部分:不要 `src/`、`node_modules`(由下面的依赖闭包提供)、 + // 也不要 `dist-pack` 之类的中间产物。 + const builtinSource = copyBuiltinPackages(staging); + // 4) 不可打包依赖闭包,合并进 standalone 的 node_modules。 const missing = []; const closure = collectClosure( // Single source of truth, shared with apps/api/tsup.config.ts. - [...EXTERNAL_PACKAGES, '@prisma/adapter-pg'], + // 内置插件与主题的前端产物保持裸导入(`@stackpanel/sdk`、`zod`), + // 它们由插件加载器在运行时解析,所以这些包必须真的在产物里。 + [...EXTERNAL_PACKAGES, '@prisma/adapter-pg', ...builtinSource.runtimePackages], [path.join(REPO_ROOT, 'apps/api'), path.join(REPO_ROOT, 'packages/db'), REPO_ROOT], (name, from) => { if (name.startsWith('@types/') || /^@img\/sharp-/.test(name)) return; diff --git a/scripts/release/collect-runtime-deps.mjs b/scripts/release/collect-runtime-deps.mjs index ffab616e..592a0951 100644 --- a/scripts/release/collect-runtime-deps.mjs +++ b/scripts/release/collect-runtime-deps.mjs @@ -171,7 +171,16 @@ export function collectClosure(entries, fromDirs, onMissing, target) { function copyPackage(source, target) { mkdirSync(path.dirname(target), { recursive: true }); - cpSync(source, target, { recursive: true, dereference: true, force: true }); + // 跳过包自带的 `node_modules`:那是构建机的 pnpm 布局,里面的链接指向 + // `.pnpm/...` 的**绝对路径**,解包到目标机后全部失效;而且它会把 typescript、 + // vitest 这类开发依赖一起拖进产物。真正的运行期依赖由 `place()` 按 `edges` + // 显式铺到正确层级。 + cpSync(source, target, { + recursive: true, + dereference: true, + force: true, + filter: (src) => path.basename(src) !== 'node_modules' && path.basename(src) !== '.bin', + }); } /** From 01fb4a2f32db38cb4fe34a8b99f0e75084f82035 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(install):=20=E5=B9=B3=E5=8F=B0=E5=90=8D?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=86=99=E6=B3=95=E9=83=BD=E6=8E=A5=E5=8F=97?= =?UTF-8?q?=EF=BC=9BPM2=20=E5=9C=A8=E7=89=88=E6=9C=AC=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=86=85=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/install-artifact.sh | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/scripts/install-artifact.sh b/scripts/install-artifact.sh index 7ba1bd66..1d76b2a4 100644 --- a/scripts/install-artifact.sh +++ b/scripts/install-artifact.sh @@ -178,9 +178,12 @@ PKG_NODE_MAJOR="$(node -e 'const r=require(process.argv[1]);process.stdout.write PKG_PLATFORM="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.platform||""))' "${STAGING}/RELEASE.json")" [[ "${PKG_NODE_MAJOR}" == "${NODE_MAJOR}" ]] || fail "产物要求 Node ${PKG_NODE_MAJOR},当前为 ${NODE_MAJOR}" # 产物的 platform 必须与实际一致:交叉构建会产出坏的原生模块(sharp/prisma/.node)。 -if [[ -n "${PKG_PLATFORM}" && "${PKG_PLATFORM}" != "linux-x64" ]]; then - fail "产物平台为 ${PKG_PLATFORM},与目标机 linux-x64 不一致" -fi +# RELEASE.json 写的是**契约名**(linux/amd64),tar 命名用的是**产物名**(linux-x64), +# 两者必须都接受——只收其中一种会让每一份合法产物都装不上。 +case "${PKG_PLATFORM}" in + ''|linux-x64|linux/amd64) ;; + *) fail "产物平台为 ${PKG_PLATFORM},与目标机 linux-x64 不一致" ;; +esac info "内核版本 ${VERSION}(Node ${NODE_MAJOR})" # ---- 2. 落版本目录并原子切换 current ----------------------------------------- @@ -455,12 +458,18 @@ chmod 0755 "${INSTALL_ROOT}/bin/stackpanel-upgrade" PM2_HOME_DIR="${STACKPANEL_PM2_HOME:-$(getent passwd "${SERVICE_USER}" | cut -d: -f6)/.pm2}" if [[ "${SKIP_PM2}" -eq 0 ]]; then info "启动 API 与 Web(PM2_HOME=${PM2_HOME_DIR})…" - sudo -u "${SERVICE_USER}" env \ - PM2_HOME="${PM2_HOME_DIR}" \ - STACKPANEL_STATE_DIR="${STATE_DIR}" \ - PATH="/usr/local/bin:/usr/bin:/bin:$PATH" \ - pm2 start "${RELEASE_DIR}/ecosystem.config.cjs" --update-env - sudo -u "${SERVICE_USER}" env PM2_HOME="${PM2_HOME_DIR}" PATH="/usr/local/bin:/usr/bin:/bin:$PATH" pm2 save + # 必须在业务用户**可进入**的目录下启动:PM2 守护进程会继承当前工作目录, + # 操作员若在 /root 里执行安装,守护进程之后再 spawn 子进程时只会得到 + # `spawn /usr/local/bin/node EACCES`,进程表看起来建好了、实际一个都没起来。 + ( + cd "${RELEASE_DIR}" + sudo -u "${SERVICE_USER}" env \ + PM2_HOME="${PM2_HOME_DIR}" \ + STACKPANEL_STATE_DIR="${STATE_DIR}" \ + PATH="/usr/local/bin:/usr/bin:/bin:$PATH" \ + pm2 start "${RELEASE_DIR}/ecosystem.config.cjs" --update-env + sudo -u "${SERVICE_USER}" env PM2_HOME="${PM2_HOME_DIR}" PATH="/usr/local/bin:/usr/bin:/bin:$PATH" pm2 save + ) else warn "跳过 PM2 启动;请以 ${SERVICE_USER} 用户、PM2_HOME=${PM2_HOME_DIR} 执行:" warn " pm2 start ${RELEASE_DIR}/ecosystem.config.cjs --update-env && pm2 save" From 75c791f68a7d63977a8e3bce52c7c68c8166ceb8 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 17:11:08 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(artifact):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E5=86=85=E5=AE=B9=E3=80=81=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E4=B8=8E=20PM2=20=E5=B7=A5=E4=BD=9C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARTIFACT-UPGRADES.md | 70 +++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/ARTIFACT-UPGRADES.md b/docs/ARTIFACT-UPGRADES.md index e5bafa3b..372c80b1 100644 --- a/docs/ARTIFACT-UPGRADES.md +++ b/docs/ARTIFACT-UPGRADES.md @@ -58,6 +58,15 @@ sudo bash scripts/install-artifact.sh \ 已有实例默认**拒绝**重新初始化(防止覆盖 `JWT_SECRET` / `SETTINGS_ENCRYPTION_KEY`); 确实要覆盖需显式 `--allow-reinstall`。密钥只在缺失时生成,重复安装不轮换。 +安装脚本用 `sudo -u … env … pm2 start` 拉起进程,并且**先 `cd` 到版本目录**再执行: +PM2 守护进程会继承当时的工作目录,若在 `/root` 里执行安装,守护进程随后 spawn +子进程只会得到 `spawn /usr/local/bin/node EACCES`——进程表看起来建好了,实际一个 +都没起来。 + +产物平台名有两个来源:`RELEASE.json` 写的是**契约名**(`linux/amd64`),tar 与清单 +命名用的是**产物名**(`linux-x64`)。安装器两者都接受,只认其一会让每一份合法产物 +都装不上。 + ## 首次自举(零配置文件) 1. 用 HTTPS 或 SSH 隧道打开 `/setup`(例如 `ssh -L 3000:127.0.0.1:3000 @`), @@ -73,6 +82,11 @@ sudo bash scripts/install-artifact.sh \ 阶段:`needs_infra → validating → queued → migrating → starting → needs_admin → complete`, 失败停在 `migration_failed`(可直接修正候选)或 `runtime_failed`(**不**重开配置写接口)。 +任务里的 `targetVersion` 由 API 从产物根的 `RELEASE.json` 读取(`dist/server.js` +向上三层到 `/RELEASE.json`)。读不到时只能退化成 `unknown`,runner 就 +找不到对应的迁移包——因此解析逻辑集中在 `apps/api/src/bootstrap/release-version.ts` +并有单测钉住层级。 + `GET /setup/status` 是未认证的公开投影,只给阶段与失败分类。查看详细诊断(最近一次 runner 结果、三个独立结果)需要带上同一个令牌: @@ -93,6 +107,39 @@ curl -s -H "X-Setup-Token: ${TOKEN}" http://127.0.0.1:3001/setup/diagnostics `releases//deploy/systemd` 取单元文件。`/etc/stackpanel/upgrade.env` 只在不存在时创建,升级不会覆盖运维配置。 +### 产物必须自带什么 + +`build-kernel-artifact.mjs` 除了 `apps/` 与依赖闭包,还会把**内置插件与默认主题** +打进 `releases//packages/{plugins,themes}`(只带 `dist/`、`frontend/`、 +`assets/`、`package.json`、`theme.css|json`,不带 `src/`、`node_modules`、 +`dist-pack/`)。原因是内核把这两棵目录当作「随内核发布的本地来源」: + +- 缺 `packages/themes/default` → 默认主题没被播种,`GET /` 直接 404; +- 缺 `packages/plugins/*` → 市场里没有任何内置插件(`plugins: []`)。 + +这两种情况在构建机上完全看不出来(构建机有完整源码树),只会在目标机上暴露, +所以构建脚本在没找到任何 `stackpanel.builtin === true` 的插件时直接失败。 +插件与主题的前端产物保留裸导入(`@stackpanel/sdk`、`zod`),它们的依赖因此被 +并入依赖闭包。 + +闭包复制会跳过包自带的 `node_modules`/`.bin`:那是构建机的 pnpm 布局,里面的 +链接是 `.pnpm/...` 的绝对路径,解包到目标机后全部失效,还会把 typescript、vitest +这类开发依赖一起拖进产物。 + +### 插件如何解析到内核依赖 + +插件装在 `/data/plugins//`,而 `@stackpanel/sdk`、`zod` 在 +`/current/node_modules`;数据目录通常在安装根之外(`/var/lib/stackpanel` +vs `/opt/stackpanel`),Node 的 ESM 解析只会**向上**找 `node_modules`,这条路径 +永远到不了内核依赖。表现极具误导性:进程正常、安装与自举都报成功,但每个插件 +都是 `Skipping invalid plugin: Cannot find package 'zod'`,首页 404、市场为空。 + +API 启动时会建立**符号链接桥接** `/node_modules -> /current/node_modules`: + +- 不复制依赖,因此不会出现第二份 zod 实例(`instanceof` 与 schema 复用都会失效); +- 目标指向 `current`,升级切换后自动跟随新版本,无需重写; +- 已存在时不介入(开发布局下数据目录本来就有真实 `node_modules`)。 + ## 升级流程 1. 更新中心(API)求解计划,把声明式任务写到 `inbox//.json`。 @@ -130,17 +177,17 @@ sudo systemctl start stackpanel-upgrade.service runner 读取 `/etc/stackpanel/upgrade.env`(`deploy/systemd/upgrade.env.example`): -| 变量 | 说明 | -| ------------------------------------------------------- | ------------------------------------------------------------- | -| `STACKPANEL_ROOT` | 安装根,默认 `/opt/stackpanel` | -| `STACKPANEL_STATE_DIR` | 状态根,默认 `/var/lib/stackpanel` | -| `STACKPANEL_UPDATE_INSTANCE_ID` | 实例 ID;必须与 API 的 `DISTRIBUTION_UPDATE_INSTANCE_ID` 一致 | -| `STACKPANEL_PM2_HOME` | 必须与业务进程同一用户、同一 PM2_HOME | +| 变量 | 说明 | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `STACKPANEL_ROOT` | 安装根,默认 `/opt/stackpanel` | +| `STACKPANEL_STATE_DIR` | 状态根,默认 `/var/lib/stackpanel` | +| `STACKPANEL_UPDATE_INSTANCE_ID` | 实例 ID;必须与 API 的 `DISTRIBUTION_UPDATE_INSTANCE_ID` 一致 | +| `STACKPANEL_PM2_HOME` | 必须与业务进程同一用户、同一 PM2_HOME | | `DATABASE_URL` | **不在此文件配置**:唯一来源是 `/var/lib/stackpanel/stackpanel.env`(systemd 单元把它放在最后加载;同名键后者覆盖前者) | -| `STACKPANEL_RELEASE_RETENTION` | 保留 release 数,默认 2 | -| `STACKPANEL_HEALTH_URL` / `_READINESS_URL` / `_WEB_URL` | 维护态探针地址 | -| `STACKPANEL_DATABASE_BACKUP=off` | 备份由外部系统负责时跳过 `pg_dump` | -| `STACKPANEL_ARTIFACT_ALLOW_HTTP=1` | 仅离线镜像源/联调时放宽 https 要求 | +| `STACKPANEL_RELEASE_RETENTION` | 保留 release 数,默认 2 | +| `STACKPANEL_HEALTH_URL` / `_READINESS_URL` / `_WEB_URL` | 维护态探针地址 | +| `STACKPANEL_DATABASE_BACKUP=off` | 备份由外部系统负责时跳过 `pg_dump` | +| `STACKPANEL_ARTIFACT_ALLOW_HTTP=1` | 仅离线镜像源/联调时放宽 https 要求 | API 侧需要设置: @@ -182,3 +229,6 @@ DISTRIBUTION_UPDATER_STATUS_FILE=/var/lib/stackpanel/update/status.json - systemd 单元刻意不设 `ProtectHome`:runner 必须与业务进程共用同一 `PM2_HOME` (默认 `~/.pm2`)。若要求收紧,请把 `PM2_HOME` 与业务进程一并迁到 `/var/lib/stackpanel/.pm2` 并加入 `ReadWritePaths`。 +- 在 QEMU 模拟的 amd64 环境里做长时间压测时,`ecosystem.config.cjs` 的 + `max_memory_restart`(512M)可能因慢速模拟下的内存峰值反复重启 API。这是模拟 + 环境的性能特征,不是安装链路缺陷;真实 amd64 机器上不出现。