From bea98fef30216e7ead61422e5a6114e91affbe8c Mon Sep 17 00:00:00 2001 From: Windsander Date: Mon, 14 Sep 2026 21:28:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20G6.5=20=E7=99=BC=E5=B8=83=20+=20ta?= =?UTF-8?q?rball=20=E5=86=92=E7=85=99=EF=BC=8C=E8=90=BD=E5=AF=A6=20D41=20?= =?UTF-8?q?=E4=BE=9D=E8=B3=B4=E6=94=B9=E5=AF=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(mcp): prepack/postpack 以 publish-manifest.mjs 將 @mebular/core 由 file:../.. 改 ^0.1.0 並於 pack 後還原 - build: .gitignore 納入 packages/*/package.json.packbak 與 *.tgz - test(publish): scripts/verify-mcp-publish.mjs:三包 npm pack、tarball 內容與依賴改寫斷言、乾淨安裝後跑 mebular status/mcp(真實 client)與 skill install.mjs - ci: build-test-lint job 增 Publish 步驟 對應 goal-v0.1.md G6.5 與 project-status.md D44。 --- .github/workflows/ci.yml | 3 + .gitignore | 4 + package.json | 1 + packages/mcp/package.json | 7 +- packages/mcp/scripts/publish-manifest.mjs | 41 +++++++ scripts/verify-mcp-publish.mjs | 132 ++++++++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/scripts/publish-manifest.mjs create mode 100644 scripts/verify-mcp-publish.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f020aa..9c57f70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Skill package + CLI (init/keygen/print-config/status) run: npm run verify:mcp:skill + - name: Publish (npm pack + tarball smoke, D41) + run: npm run verify:mcp:publish + semantic-real: name: semantic recall (real model) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9a2fff0..385c833 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,10 @@ docs/ design-notes/ docs.design/ +# npm pack 發布期備份(transient) +packages/*/package.json.packbak +*.tgz + # 示例執行時產物(本地留存) examples/quickstart/.data/ diff --git a/package.json b/package.json index 2b369f2..03728c3 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "verify:mcp:stdio": "node scripts/verify-mcp-stdio.mjs", "verify:mcp:http": "node scripts/verify-mcp-http.mjs", "verify:mcp:skill": "node scripts/verify-mcp-skill.mjs", + "verify:mcp:publish": "node scripts/verify-mcp-publish.mjs", "dev": "node --loader ts-node/esm src/index.ts" }, "dependencies": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a0bd9e8..04bdf2c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -10,8 +10,13 @@ }, "files": [ "bin", - "src" + "src", + "scripts" ], + "scripts": { + "prepack": "node scripts/publish-manifest.mjs set", + "postpack": "node scripts/publish-manifest.mjs restore" + }, "engines": { "node": ">=20" }, diff --git a/packages/mcp/scripts/publish-manifest.mjs b/packages/mcp/scripts/publish-manifest.mjs new file mode 100644 index 0000000..7a9da76 --- /dev/null +++ b/packages/mcp/scripts/publish-manifest.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// G6.5 / D41:發布期改寫 @mebular/core 依賴。 +// +// 開發期以 `file:../..` 本地連結(根包非 workspace 成員、本環境 npm 不支援 `workspace:`); +// `npm pack`/`npm publish` 的 prepack 階段改為 `^0.1.0`,postpack 還原。 +// +// 用法:node scripts/publish-manifest.mjs set|restore + +import { existsSync } from 'node:fs'; +import { readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkgFile = join(pkgDir, 'package.json'); +const backupFile = `${pkgFile}.packbak`; +const PUBLISH_RANGE = '^0.1.0'; + +const action = process.argv[2]; + +async function writeJson(file, data) { + await writeFile(file, `${JSON.stringify(data, null, 2)}\n`, 'utf-8'); +} + +if (action === 'set') { + const original = await readFile(pkgFile, 'utf-8'); + if (!existsSync(backupFile)) await writeFile(backupFile, original, 'utf-8'); + const pkg = JSON.parse(original); + pkg.dependencies = pkg.dependencies ?? {}; + pkg.dependencies['@mebular/core'] = PUBLISH_RANGE; + await writeJson(pkgFile, pkg); + console.error(`[publish-manifest] @mebular/core -> ${PUBLISH_RANGE}(備份 ${backupFile})`); +} else if (action === 'restore') { + if (existsSync(backupFile)) { + await rename(backupFile, pkgFile); + console.error('[publish-manifest] 已還原本地 file: 依賴'); + } +} else { + console.error('用法:node scripts/publish-manifest.mjs set|restore'); + process.exit(2); +} diff --git a/scripts/verify-mcp-publish.mjs b/scripts/verify-mcp-publish.mjs new file mode 100644 index 0000000..12c2965 --- /dev/null +++ b/scripts/verify-mcp-publish.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// G6.5 發布驗證(E1) +// +// 1) 對三個套件 npm pack;檢驗 tarball 內容與 @mebular/mcp 內 @mebular/core 已由 +// prepack 由 `file:../..` 改寫為 `^0.1.0`(D41),且 pack 後本地依賴已還原。 +// 2) 用產出的 tarball 在乾淨目錄安裝;以安裝後的 `mebular status`、`mebular mcp` +// (真實 MCP client)與 `@mebular/skill/scripts/install.mjs` 做冒煙。 +// 乾淨環境退出碼 0。前置:npm run build。 + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const mcpDir = join(rootDir, 'packages', 'mcp'); +const skillDir = join(rootDir, 'packages', 'skill'); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +let passed = true; +const check = (label, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${label}${detail ? `(${detail})` : ''}`); + if (!ok) passed = false; +}; + +const pack = (cwd, dest) => { + const out = execFileSync(npm, ['pack', '--json', '--pack-destination', dest], { + cwd, + encoding: 'utf-8', + env: process.env, + }); + const meta = JSON.parse(out)[0]; + return { file: join(dest, meta.filename), meta }; +}; +const listTarball = (tgz) => execFileSync('tar', ['-tzf', tgz], { encoding: 'utf-8' }).split('\n').filter(Boolean); +const readTarballFile = (tgz, inner) => execFileSync('tar', ['-xzf', tgz, '-O', inner], { encoding: 'utf-8' }); + +console.log('Mebular G6.5 發布驗證(npm pack + tarball 冒煙)'); +console.log('==============================================='); + +if (!existsSync(join(rootDir, 'dist', 'index.js'))) { + console.log(' · dist 不存在,先 build'); + execFileSync(npm, ['run', 'build'], { cwd: rootDir, stdio: 'inherit' }); +} + +const work = await mkdtemp(join(tmpdir(), 'mebular-publish-')); +const app = join(work, 'app'); +const home = join(app, 'home'); +let client = null; + +try { + // ---------- npm pack ---------- + const core = pack(rootDir, work); + const mcp = pack(mcpDir, work); + const skill = pack(skillDir, work); + check('三包 npm pack', true, [core.meta.name, mcp.meta.name, skill.meta.name].join(' / ')); + + // D41:mcp tarball 內為 ^0.1.0,pack 後本地 manifest 已還原 + const mcpManifest = JSON.parse(readTarballFile(mcp.file, 'package/package.json')); + check('mcp tarball 依賴 @mebular/core=^0.1.0', mcpManifest.dependencies?.['@mebular/core'] === '^0.1.0', mcpManifest.dependencies?.['@mebular/core']); + const localManifest = JSON.parse(await readFile(join(mcpDir, 'package.json'), 'utf-8')); + check('pack 後本地依賴還原為 file:../..', localManifest.dependencies?.['@mebular/core'] === 'file:../..', localManifest.dependencies?.['@mebular/core']); + check('pack 備份已清除', !existsSync(join(mcpDir, 'package.json.packbak'))); + + // tarball 內容 + const coreFiles = listTarball(core.file); + const mcpFiles = listTarball(mcp.file); + const skillFiles = listTarball(skill.file); + check('core tarball 含 dist/index.js', coreFiles.includes('package/dist/index.js')); + check('core tarball 含 package.json', coreFiles.includes('package/package.json')); + check('mcp tarball 含 bin/src', mcpFiles.includes('package/bin/mebular.mjs') && mcpFiles.includes('package/src/server.mjs')); + check( + 'skill tarball 含 SKILL/MEMORY_POLICY/install/mcp', + ['package/SKILL.md', 'package/MEMORY_POLICY.md', 'package/scripts/install.mjs', 'package/mcp/opencode.json'].every((f) => skillFiles.includes(f)), + ); + + // ---------- tarball 安裝冒煙 ---------- + await writeFile(join(work, 'package.json'), '{"name":"mebular-publish-smoke","private":true,"version":"0.0.0"}\n', 'utf-8'); + execFileSync(npm, ['install', core.file, mcp.file, skill.file, '--omit=optional', '--no-audit', '--no-fund', '--loglevel=error'], { + cwd: work, + stdio: ['ignore', 'ignore', 'inherit'], + env: process.env, + }); + const installedCore = JSON.parse(await readFile(join(work, 'node_modules', '@mebular', 'core', 'package.json'), 'utf-8')); + check('tarball 安裝:@mebular/core@0.1.0', installedCore.version === '0.1.0', installedCore.version); + check('tarball 安裝:@mebular/mcp 與 @mebular/skill', existsSync(join(work, 'node_modules', '@mebular', 'mcp')) && existsSync(join(work, 'node_modules', '@mebular', 'skill'))); + + const bin = join(work, 'node_modules', '.bin', 'mebular'); + const env = { ...process.env, MEBULAR_HOME: home, MEBULAR_STORAGE_PATH: join(home, 'store.jsonl'), MEBULAR_DEVICE_ID: 'device-publish-smoke' }; + + const statusOut = JSON.parse(execFileSync(process.execPath, [bin, 'status'], { cwd: work, encoding: 'utf-8', env })); + check('安裝後 mebular status', /^[0-9a-f]{64}$/.test(statusOut.stateHash ?? ''), `deviceId=${statusOut.deviceId}`); + + // 安裝後的 stdio MCP 冒煙(真實 MCP client) + client = new Client({ name: 'mebular-publish-smoke', version: '0.1.0' }); + const transport = new StdioClientTransport({ command: process.execPath, args: [bin, 'mcp'], env, stderr: 'pipe' }); + await client.connect(transport); + const { tools } = await client.listTools(); + check('安裝後 mebular mcp:tools/list=11', tools.length === 11, `count=${tools.length}`); + const written = await client.callTool({ name: 'memory_write', arguments: { items: [{ type: 'fact', content: 'publish-smoke' }] } }); + const parsed = written.structuredContent ?? JSON.parse(written.content?.find((c) => c.type === 'text')?.text ?? '{}'); + check('安裝後 memory_write 落圖', Array.isArray(parsed?.stored) && parsed.stored.length === 1); + const queried = await client.callTool({ name: 'memory_query', arguments: { query: 'publish-smoke' } }); + const q = queried.structuredContent ?? JSON.parse(queried.content?.find((c) => c.type === 'text')?.text ?? '{}'); + check('安裝後 memory_query 命中', (q?.totalMatches ?? 0) >= 1, `totalMatches=${q?.totalMatches}`); + await client.close(); + client = null; + + // 安裝後的 skill 安裝器 + const skillTarget = join(work, 'skills'); + execFileSync(process.execPath, [join(work, 'node_modules', '@mebular', 'skill', 'scripts', 'install.mjs'), '--target', skillTarget], { cwd: work, encoding: 'utf-8', env }); + check('安裝後 skill install.mjs', existsSync(join(skillTarget, 'mebular-memory', 'SKILL.md'))); +} catch (error) { + check('G6.5 發布驗證', false, String(error?.stderr ?? error?.message ?? error).substring(0, 500)); +} finally { + if (client) await client.close().catch(() => undefined); + await rm(work, { recursive: true, force: true }).catch(() => undefined); +} + +console.log('==============================================='); +if (passed) { + console.log('✓ G6.5 發布驗證通過(E1)'); + process.exit(0); +} else { + console.log('✗ G6.5 發布驗證失敗'); + process.exit(1); +}