From a46a741b239b8785ff7744e5dfe7455404896d45 Mon Sep 17 00:00:00 2001 From: 3494036618-eng <252820799+3494036618-eng@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:29:39 +0800 Subject: [PATCH] release(demohouse/personal-investment-assistant): sync v0.3.1 --- .../.github/workflows/ci.yml | 26 +++ .../personal-investment-assistant/README.md | 32 ++- .../UPSTREAM.json | 6 + .../app/package-lock.json | 64 +++--- .../app/package.json | 2 +- .../app/tests/integration/api.test.js | 11 +- .../app/tests/unit/database.test.js | 48 +++- .../docs/release-checklist.md | 60 +++++ .../package.json | 9 +- .../scripts/print-public-skill-command.mjs | 130 +++++++++++ .../scripts/test-initializer.mjs | 103 ++++++++- .../scripts/test-release-checkout.mjs | 115 ++++++++++ .../scripts/validate-public-release.mjs | 180 +++++++++++++++ .../scripts/validate-release-checkout.mjs | 131 +++++++++++ .../scripts/validate-skill.mjs | 31 +++ .../scripts/verify-public-install.mjs | 206 ++++++++++++++++++ .../skills/investment-assistant/SKILL.md | 90 +++++++- .../investment-assistant/scripts/lib.mjs | 18 +- 18 files changed, 1194 insertions(+), 68 deletions(-) create mode 100644 demohouse/personal-investment-assistant/.github/workflows/ci.yml create mode 100644 demohouse/personal-investment-assistant/UPSTREAM.json create mode 100644 demohouse/personal-investment-assistant/docs/release-checklist.md create mode 100644 demohouse/personal-investment-assistant/scripts/print-public-skill-command.mjs create mode 100644 demohouse/personal-investment-assistant/scripts/test-release-checkout.mjs create mode 100644 demohouse/personal-investment-assistant/scripts/validate-public-release.mjs create mode 100644 demohouse/personal-investment-assistant/scripts/validate-release-checkout.mjs create mode 100644 demohouse/personal-investment-assistant/scripts/verify-public-install.mjs diff --git a/demohouse/personal-investment-assistant/.github/workflows/ci.yml b/demohouse/personal-investment-assistant/.github/workflows/ci.yml new file mode 100644 index 00000000..99047449 --- /dev/null +++ b/demohouse/personal-investment-assistant/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.13' + cache: npm + cache-dependency-path: app/package-lock.json + - run: npm ci + working-directory: app + - run: npm run verify diff --git a/demohouse/personal-investment-assistant/README.md b/demohouse/personal-investment-assistant/README.md index 4bdb8013..bf66d31f 100644 --- a/demohouse/personal-investment-assistant/README.md +++ b/demohouse/personal-investment-assistant/README.md @@ -18,14 +18,40 @@ 帮我初始化个人投资助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md ``` -该地址直接指向中文 Skill 入口。Codex 或 Claude Code 会获取完整仓库,安装与当前客户端匹配的 Skill,并进入同一套初始化流程。 +该地址是 AI App Lab 的官方入口。日常可以使用 `main` 获取官方最新版本;重大宣传应在 +AI App Lab 更新 PR 合并后,把 `main` 替换为该次合并的完整 40 位 commit SHA,避免入口漂移。 -需要固定版本时,也可以使用独立仓库中的 `v0.3.0` 入口: +官方 URL 只负责让 Agent 找到 Skill。Skill 会固定下载并验证以下独立发行版本,不会下载 +整个 AI App Lab,也不会使用独立仓库的 `main`: ```text -帮我初始化个人投资助手:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.0/skills/investment-assistant/SKILL.md +固定发行仓库:https://github.com/3494036618-eng/personal-investment-assistant/tree/v0.3.1 +固定发行 Skill:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.1/skills/investment-assistant/SKILL.md ``` +维护者可以生成日常官方口令或宣传固定口令: + +```bash +npm run skill:command -- --official +npm run skill:command -- --official-ref +``` + +发布 `v0.3.1` 前必须完成 Ubuntu、macOS、Windows CI;正式 GitHub Release 必须启用 +immutable。发布后先从公开 GitHub 全新下载并验收: + +```bash +npm run release:verify:public +``` + +AI App Lab 更新合并后,再校验固定 commit、完整项目镜像和 `UPSTREAM.json`: + +```bash +npm run release:verify:public -- --official-ref +``` + +这两次验证不会调用 Agent Plan、DataPro 或豆包搜索,也不会产生 AFP。只有在用户确认投资 +配置和真实调用后,初始化流程才会探测 Provider 并生成报告。 + 已经安装中文 Skill 后: ```text diff --git a/demohouse/personal-investment-assistant/UPSTREAM.json b/demohouse/personal-investment-assistant/UPSTREAM.json new file mode 100644 index 00000000..707fe692 --- /dev/null +++ b/demohouse/personal-investment-assistant/UPSTREAM.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/3494036618-eng/personal-investment-assistant", + "commit": "ddb626703187baeae067d61f781f46e16e1503c3", + "version": "0.3.1", + "synced_at": "2026-09-03" +} diff --git a/demohouse/personal-investment-assistant/app/package-lock.json b/demohouse/personal-investment-assistant/app/package-lock.json index 2cb1500c..72d70597 100644 --- a/demohouse/personal-investment-assistant/app/package-lock.json +++ b/demohouse/personal-investment-assistant/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "@investment-assistant/app", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@investment-assistant/app", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "dotenv": "^16.5.0", @@ -1334,8 +1334,8 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.44", - "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", + "version": "2.11.20", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1381,8 +1381,8 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.8", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1400,11 +1400,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1449,8 +1449,8 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -1639,8 +1639,8 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.394", - "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==", + "version": "1.5.420", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -1844,8 +1844,8 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.7", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -2091,8 +2091,8 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.5", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2138,8 +2138,8 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.0", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2314,8 +2314,8 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2340,8 +2340,8 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.54", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -2438,8 +2438,8 @@ } }, "node_modules/postcss": { - "version": "8.5.20", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "version": "8.5.27", + "integrity": "sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==", "dev": true, "funding": [ { @@ -2457,7 +2457,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2478,8 +2478,8 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -2883,8 +2883,8 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/demohouse/personal-investment-assistant/app/package.json b/demohouse/personal-investment-assistant/app/package.json index 63e2eafe..c17e4213 100644 --- a/demohouse/personal-investment-assistant/app/package.json +++ b/demohouse/personal-investment-assistant/app/package.json @@ -1,6 +1,6 @@ { "name": "@investment-assistant/app", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "engines": { diff --git a/demohouse/personal-investment-assistant/app/tests/integration/api.test.js b/demohouse/personal-investment-assistant/app/tests/integration/api.test.js index 1ec93b71..1c4ddb03 100644 --- a/demohouse/personal-investment-assistant/app/tests/integration/api.test.js +++ b/demohouse/personal-investment-assistant/app/tests/integration/api.test.js @@ -55,6 +55,13 @@ function fixture() { const randomId = '00000000-0000-4000-8000-000000000001'; +function assertPrivateFile(filePath) { + assert.equal(fs.statSync(filePath).isFile(), true); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + } +} + test('serves the built frontend and requires a fresh live provider check for readiness', async (t) => { const context = fixture(); t.after(() => context.close()); @@ -77,9 +84,9 @@ test('serves the built frontend and requires a fresh live provider check for rea assert.equal(ready.body.live_check.fresh, true); assert.equal(ready.body.live_check.providers.web_search.ok, true); assert.equal(ready.body.live_check.providers.agent_plan_model.ok, true); - assert.equal(fs.statSync(context.config.providerHealthPath).mode & 0o777, 0o600); + assertPrivateFile(context.config.providerHealthPath); for (const suffix of ['', '-wal', '-shm']) { - assert.equal(fs.statSync(`${context.config.databasePath}${suffix}`).mode & 0o777, 0o600); + assertPrivateFile(`${context.config.databasePath}${suffix}`); } }); diff --git a/demohouse/personal-investment-assistant/app/tests/unit/database.test.js b/demohouse/personal-investment-assistant/app/tests/unit/database.test.js index 47f4d642..0e261630 100644 --- a/demohouse/personal-investment-assistant/app/tests/unit/database.test.js +++ b/demohouse/personal-investment-assistant/app/tests/unit/database.test.js @@ -9,7 +9,14 @@ import { createDatabase } from '../../src/server/db/database.js'; test('migrates legacy monitor tables without losing settings or run history', (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-assistant-db-')); const databasePath = path.join(directory, 'legacy.sqlite'); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + let repository; + t.after(() => { + try { + repository?.close(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); const legacy = new DatabaseSync(databasePath); legacy.exec(` @@ -63,8 +70,7 @@ test('migrates legacy monitor tables without losing settings or run history', (t `).run(); legacy.close(); - const repository = createDatabase(databasePath); - t.after(() => repository.close()); + repository = createDatabase(databasePath); const settings = repository.getMonitorSettings('stock-1'); assert.equal(settings.enabled, true); @@ -108,10 +114,16 @@ test('migrates legacy monitor tables without losing settings or run history', (t test('lists every saved report when no explicit history limit is requested', (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-assistant-history-')); const databasePath = path.join(directory, 'history.sqlite'); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + let repository; + t.after(() => { + try { + repository?.close(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); - const repository = createDatabase(databasePath); - t.after(() => repository.close()); + repository = createDatabase(databasePath); const stock = repository.createStock({ name: '示例科技', code: '000001.SZ', @@ -150,7 +162,14 @@ test('lists every saved report when no explicit history limit is requested', (t) test('never persists raw web-search content in report_json', (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-assistant-storage-')); const databasePath = path.join(directory, 'storage.sqlite'); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + let reopened; + t.after(() => { + try { + reopened?.close(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); const repository = createDatabase(databasePath); const stock = repository.createStock({ @@ -217,8 +236,7 @@ test('never persists raw web-search content in report_json', (t) => { raw.close(); assert.doesNotMatch(storedJson, /RAW_DATABASE_PROVIDER_/); - const reopened = createDatabase(databasePath); - t.after(() => reopened.close()); + reopened = createDatabase(databasePath); const source = reopened.getLatestReport(stock.id, 'brief').report.evidence[0]; assert.equal(source.title, '示例科技发布最新产品'); assert.equal(source.url, 'https://example.com/product'); @@ -228,7 +246,14 @@ test('never persists raw web-search content in report_json', (t) => { test('migrates raw web-search content already stored by an earlier version', (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-assistant-report-migration-')); const databasePath = path.join(directory, 'migration.sqlite'); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + let migrated; + t.after(() => { + try { + migrated?.close(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); const repository = createDatabase(databasePath); const stock = repository.createStock({ @@ -289,8 +314,7 @@ test('migrates raw web-search content already stored by an earlier version', (t) ); legacy.close(); - const migrated = createDatabase(databasePath); - t.after(() => migrated.close()); + migrated = createDatabase(databasePath); const report = migrated.getLatestReport(stock.id, 'brief').report; const serialized = JSON.stringify(report); diff --git a/demohouse/personal-investment-assistant/docs/release-checklist.md b/demohouse/personal-investment-assistant/docs/release-checklist.md new file mode 100644 index 00000000..b46a2344 --- /dev/null +++ b/demohouse/personal-investment-assistant/docs/release-checklist.md @@ -0,0 +1,60 @@ +# 发布检查表 + +## 技术门禁 + +- [x] `npm run verify` 完成应用静态检查、单元与 API 集成测试、生产构建、初始化器测试、Skill 结构校验和凭证扫描。 +- [x] 中文 Skill 只初始化仓库内现成网站,不讨论网站方案,也不创建第二份用户项目。 +- [x] 同一份 Skill 支持安装到 Codex 与 Claude Code 的个人 Skill 目录,双客户端安装、重复安装保护和强制更新均通过隔离测试。 +- [x] Skill 会先收集并确认证券名称、代码、市场、逐股关注偏好和盘后监控安排。 +- [x] 初始化默认使用一枚 Agent Plan Key;DataPro 和豆包搜索继承该 Key,同时保留显式高级覆盖项。 +- [x] 已对照官方接入说明确认:用户在 Agent Plan 中开启豆包搜索 Harness 后,使用同一枚 Agent Plan 专属 API Key 调用模型、DataPro 和豆包搜索,不要求第二枚搜索 Key。 +- [x] 临时 Profile 不包含凭证,文件权限为 `0600`,支持导入后自动删除。 +- [x] 从隔离目录安装正式应用,按锁文件安装 181 个包,`npm audit` 报告 0 个漏洞。 +- [x] 隔离安装完成应用检查、237 项单元与 API 集成测试、1,630 模块生产构建和生产依赖裁剪。 +- [x] 真实 live doctor 中 DataPro、豆包搜索和 Agent Plan 模型均返回 `ok: true`。 +- [x] 真实生成一份个股简评和一份盘后风险摘要,两份报告使用不同 ID、证据快照和历史记录。 +- [x] 个股简评包含当天行情、最新已披露财务和与用户偏好匹配的公开材料。 +- [x] 盘后风险摘要使用独立检查窗口,不复用简评联网来源;没有合格事件来源时只展示可核验的市场事实。 +- [x] 报告正文、摘要、结论和引用来源通过本地证据校验与 Agent Plan 模型独立语义复审。 +- [x] SQLite 只保存报告正文、结构化专业数据和最小化网页来源记录;单元测试覆盖新报告落库与旧数据库迁移,确认豆包搜索原始摘要不进入历史。 +- [x] 审校器返回数字索引、方括号索引或章节标题定位时,都能精确剔除问题段落并重新复审。 +- [x] 单一媒体的经营数字、产品参数、持仓金额或重大风险事实不会被当作权威确定事实展示。 +- [x] 首页、两类报告、联网来源链接、历史记录、监控设置保存、下次执行时间和调度状态已在桌面浏览器实际检查。 +- [x] 桌面页面在 1084 像素宽视口无横向溢出;本轮不对移动端作发布承诺。 +- [x] 隔离环境观察到真实自动调度执行记录;手动生成和自动生成使用同一报告服务并分别进入历史。 +- [x] 更新采用临时目录和原子替换,失败不删除已有 SQLite、报告历史或凭证。 +- [x] `/api/usage-log` 和 `/api/usage-summary` 只保存本地用量元数据,不保存凭证或完整 Provider 正文。 + +## 开源发布状态 + +- [x] 发布范围只包含应用源码、中文 Skill、测试和说明文档;不包含 API Key、本地数据库、日志、真实报告、Provider 原始返回或运行备份。 +- [x] 采用用户自备 Agent Plan Key 的本地运行模式;第三方数据和服务的使用仍由使用者遵守其账号权限、服务条款和许可要求。 +- [x] 项目未附带宣传截图或第三方品牌素材;README 已明确独立项目属性和第三方名称、商标的权利归属。 +- [x] 创建 GitHub 公开仓库 [`3494036618-eng/personal-investment-assistant`](https://github.com/3494036618-eng/personal-investment-assistant),并准备 `v0.1.3` 标签、SHA-256 校验和与 Release Notes。 +- [x] `v0.2.0` 采用根目录 `app/` 与 `skills/investment-assistant/` 分离结构,已创建公开标签、SHA-256 校验和与 GitHub Release。 +- [x] `v0.3.0` 增加 Codex 与 Claude Code 双端 Skill 支持,已创建公开标签、SHA-256 校验和与 GitHub Release。 +- [x] GitHub 仓库已启用私密安全报告渠道;`SECURITY.md` 要求安全问题不得进入公开 Issue。 +- [x] 对最终 Git 候选内容完成独立凭证、数据库、日志、大文件和大段 Provider 返回扫描。 + +GitHub 创建、标签和安全报告渠道属于托管平台发布动作,不影响源码和 Skill 在本地通过技术门禁。 + +## 2026-07-27 验收记录 + +- 工程验证:34 个 JavaScript 文件静态检查,237 项单元与 API 集成测试全部通过,Vite 成功构建 1,630 个模块。 +- 初始化验证:现成应用安装、私密配置、Profile 导入、真实 Provider 探测、启动和首批报告生成全部走通。 +- 真实配置:隔离环境导入贵州茅台 `600519`,关注“盈利能力、渠道库存、品牌优势、行业动态”,启用工作日 `18:00`、`Asia/Shanghai` 盘后监控。 +- 真实报告:个股简评使用 4 条最终证据,盘后风险摘要使用 2 条最终证据;两类报告均保存到独立历史,最终状态为 `completed`,语义审校问题为空。 +- 缺陷回归:修复了审校章节定位格式不兼容、摘要只保留出处引导语、基金持仓文章误归市场异动,以及媒体数字在保守回退路径中重新进入正文的问题。 +- 桌面验收:首页显示证券与四项偏好;个股简评显示四段结构、编号来源和可跳转网页;盘后页显示独立正文、历史、监控设置、下次执行和“调度服务运行中”;设置保存成功。 +- 自动调度:隔离服务日志记录到期任务执行完成,数据库同时保留手动与自动执行记录。 +- 单 Key 路径:官方材料确认 Agent Plan Key 可直接用于豆包搜索;2026-07-27 的独立单 Key探测中模型与 DataPro 正常,豆包搜索曾返回临时错误 `10500`。该错误按上游运行状态处理,不要求用户补充第二枚 Key;初始化仍以 `doctor.mjs --live` 的当次结果作为可用性门槛。 +- 外部依赖:DataPro、豆包搜索和 Agent Plan 模型的可用性、额度与返回质量仍由用户账号和上游服务决定;本地测试不能替代 Provider 可用性承诺。 +- 开源候选复验:从无 `node_modules`、无 `dist` 的临时副本执行 `npm ci`,安装 181 个依赖且 `npm audit` 为 0;随后完成 36 个 JavaScript 文件检查、249 项测试、1,630 模块生产构建、初始化器测试和 Skill 凭证扫描。 +- 标准结构复验:对照 Agent Skills 规范,将应用源码迁移到根目录 `app/`,Skill 固定为 `skills/investment-assistant/`;仓库安装器在安装阶段生成自包含的 `assets/app/`,源码仓库不保留重复应用副本。 + +## 2026-07-28 Claude Code 兼容验收 + +- 客户端支持:同一份中文 Skill 可安装到 Codex 的 `${CODEX_HOME:-~/.codex}/skills/investment-assistant` 或 Claude Code 的 `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/investment-assistant`。 +- 安装验证:两个客户端均通过隔离安装、重复安装阻止、`--force` 原子更新及自包含应用检查,测试未修改真实用户 Skill 目录。 +- 工程验证:36 个 JavaScript 文件静态检查、249 项单元与 API 集成测试、1,630 模块生产构建、Skill 结构校验和凭证扫描全部通过。 +- 交付入口:公开口令固定指向 `v0.3.0` 标签中的 `skills/investment-assistant/SKILL.md`,避免后续 `main` 变化影响已交付版本。 diff --git a/demohouse/personal-investment-assistant/package.json b/demohouse/personal-investment-assistant/package.json index 88486246..66dd50a6 100644 --- a/demohouse/personal-investment-assistant/package.json +++ b/demohouse/personal-investment-assistant/package.json @@ -1,9 +1,13 @@ { "name": "investment-assistant-oss", "private": true, - "version": "0.3.0", + "version": "0.3.1", "description": "A source-backed personal investment research assistant powered by Volcengine Agent Plan, DataPro and Doubao Search.", "scripts": { + "release:validate": "node scripts/validate-public-release.mjs", + "release:validate:checkout": "node scripts/validate-release-checkout.mjs", + "release:test:checkout": "node scripts/test-release-checkout.mjs", + "release:verify:public": "node scripts/verify-public-install.mjs", "app:install": "npm --prefix app install", "app:dev": "npm --prefix app run dev", "app:build": "npm --prefix app run build", @@ -14,8 +18,9 @@ "skill:install:codex": "node scripts/install-codex-skill.mjs", "skill:install:claude": "node scripts/install-claude-code-skill.mjs", "skill:install:all": "node scripts/install-agent-skill.mjs --target all", + "skill:command": "node scripts/print-public-skill-command.mjs", "skill:validate": "node scripts/validate-skill.mjs", - "verify": "npm run app:check && npm run app:test && npm run app:build && npm run skill:test && npm run skill:validate" + "verify": "npm run release:validate && npm run release:test:checkout && npm run app:check && npm run app:test && npm run app:build && npm run skill:test && npm run skill:validate" }, "engines": { "node": ">=22.13" diff --git a/demohouse/personal-investment-assistant/scripts/print-public-skill-command.mjs b/demohouse/personal-investment-assistant/scripts/print-public-skill-command.mjs new file mode 100644 index 00000000..054a55fa --- /dev/null +++ b/demohouse/personal-investment-assistant/scripts/print-public-skill-command.mjs @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const canonicalRepository = 'https://github.com/3494036618-eng/personal-investment-assistant'; +const canonicalSkillPath = 'skills/investment-assistant/SKILL.md'; +const officialRepository = 'https://github.com/volcengine/ai-app-lab'; +const officialSkillPath = 'demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md'; +const officialEntryUrl = `${officialRepository}/blob/main/${officialSkillPath}`; + +function option(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ''; + const value = process.argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +function usage() { + return ` +生成个人投资助手公开初始化口令 + +用法: + node scripts/print-public-skill-command.mjs --official + + node scripts/print-public-skill-command.mjs --official-ref <40位commit SHA> + + node scripts/print-public-skill-command.mjs \\ + --repository https://github.com// \\ + --ref v${packageJson.version} \\ + [--skill-path skills/investment-assistant/SKILL.md] \\ + [--allow-custom-repository] + +说明: + - --official 生成 AI App Lab 的 main 最新入口;重大宣传应在 PR 合并后使用 --official-ref 固定到合并 commit。 + - --official-ref 只接受完整 40 位 commit SHA。 + - --repository 必须是公开 GitHub 仓库根地址。 + - --ref 只接受 vX.Y.Z release tag 或完整 40 位 commit SHA。 + - 默认只允许本项目独立发行地址或 AI App Lab 官方路径;测试 fork 必须显式使用 --allow-custom-repository。 + - 本命令只生成文字,不访问网络、不修改文件。 +`.trimStart(); +} + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(usage()); + return; + } + + if (process.argv.includes('--official')) { + if (process.argv.length !== 3) { + throw new Error('--official 不能与其他参数组合;固定宣传版本请使用 --official-ref。'); + } + process.stdout.write(`帮我初始化个人投资助手:${officialEntryUrl}\n`); + return; + } + + const officialRef = option('--official-ref'); + if (officialRef) { + if (!/^[0-9a-f]{40}$/i.test(officialRef)) { + throw new Error('--official-ref 必须是完整 40 位 commit SHA。'); + } + if (process.argv.length !== 4) { + throw new Error('--official-ref 不能与其他参数组合。'); + } + process.stdout.write(`帮我初始化个人投资助手:${officialRepository}/blob/${officialRef}/${officialSkillPath}\n`); + return; + } + + const repository = option('--repository'); + const ref = option('--ref'); + const skillPath = option('--skill-path') || canonicalSkillPath; + const allowCustomRepository = process.argv.includes('--allow-custom-repository'); + if (!repository || !ref) throw new Error('必须同时提供 --repository 和 --ref。'); + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ref) && !/^[0-9a-f]{40}$/i.test(ref)) { + throw new Error('正式初始化口令必须使用 vX.Y.Z release tag 或完整 40 位 commit SHA。'); + } + + let url; + try { + url = new URL(repository); + } catch { + throw new Error('--repository 不是有效 URL。'); + } + if (url.protocol !== 'https:' || url.hostname !== 'github.com') { + throw new Error('--repository 必须是 https://github.com//。'); + } + + const segments = url.pathname.replace(/\.git$/, '').split('/').filter(Boolean); + if (segments.length !== 2) { + throw new Error('--repository 必须指向 GitHub 仓库根目录,不能包含额外路径。'); + } + + const [owner, repo] = segments; + const normalizedRepository = `https://github.com/${owner}/${repo}`; + const skillPathSegments = skillPath.split('/').filter(Boolean); + if ( + skillPath.startsWith('/') + || skillPathSegments.includes('.') + || skillPathSegments.includes('..') + || skillPathSegments.at(-1) !== 'SKILL.md' + ) { + throw new Error('--skill-path 必须是仓库内以 SKILL.md 结尾的安全相对路径。'); + } + + if (!allowCustomRepository) { + const isCanonicalRelease = normalizedRepository === canonicalRepository + && skillPath === canonicalSkillPath + && ref === `v${packageJson.version}`; + const isOfficialCommit = normalizedRepository === officialRepository + && skillPath === officialSkillPath + && /^[0-9a-f]{40}$/i.test(ref); + if (!isCanonicalRelease && !isOfficialCommit) { + throw new Error('仓库、版本或 Skill 路径不属于当前固定发行链路;测试 fork 必须显式追加 --allow-custom-repository。'); + } + } + + const encodedSkillPath = skillPathSegments.map((segment) => encodeURIComponent(segment)).join('/'); + const entryUrl = `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(ref)}/${encodedSkillPath}`; + process.stdout.write(`帮我初始化个人投资助手:${entryUrl}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/demohouse/personal-investment-assistant/scripts/test-initializer.mjs b/demohouse/personal-investment-assistant/scripts/test-initializer.mjs index ea1ec738..a138df7a 100644 --- a/demohouse/personal-investment-assistant/scripts/test-initializer.mjs +++ b/demohouse/personal-investment-assistant/scripts/test-initializer.mjs @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { assertPreferenceCoverage, assertReportSourcePolicy, @@ -17,6 +17,24 @@ const configDir = path.join(sandbox, 'config'); const installRoot = path.join(sandbox, 'runtime'); const codexHome = path.join(sandbox, 'codex'); const claudeConfigDir = path.join(sandbox, 'claude'); + +function createRecognizedLegacyApp(appRoot) { + fs.writeFileSync(path.join(path.dirname(appRoot), 'package.json'), JSON.stringify({ + name: 'investment-assistant-oss', + private: true, + version: '0.3.0', + })); + fs.mkdirSync(path.join(appRoot, 'src', 'server'), { recursive: true }); + fs.mkdirSync(path.join(appRoot, 'src', 'web'), { recursive: true }); + fs.mkdirSync(path.join(appRoot, 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(appRoot, 'package.json'), JSON.stringify({ + name: '@investment-assistant/app', + version: '0.3.0', + })); + fs.writeFileSync(path.join(appRoot, 'package-lock.json'), '{}\n'); + fs.writeFileSync(path.join(appRoot, 'src', 'server', 'index.js'), '// stale adjacent app\n'); + fs.writeFileSync(path.join(appRoot, 'scripts', 'check.mjs'), '// stale adjacent app\n'); +} fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'credentials.env'), [ 'ARK_API_KEY="single-agent-plan-key"', @@ -85,6 +103,24 @@ try { assert.ok(fs.existsSync(path.join(client.installedSkill, 'SKILL.md'))); assertApplicationSource(path.join(client.installedSkill, 'assets', 'app')); + const adjacentApp = path.resolve(client.installedSkill, '..', '..', 'app'); + createRecognizedLegacyApp(adjacentApp); + const installedLib = pathToFileURL(path.join(client.installedSkill, 'scripts', 'lib.mjs')).href; + const sourceSelection = spawnSync(process.execPath, [ + '--input-type=module', + '--eval', + `const { paths } = await import(${JSON.stringify(installedLib)}); process.stdout.write(paths.sourceApp);`, + ], { + env: { ...process.env, ...client.environment }, + encoding: 'utf8', + }); + assert.equal(sourceSelection.status, 0, sourceSelection.stderr || sourceSelection.stdout); + assert.equal( + fs.realpathSync(sourceSelection.stdout), + fs.realpathSync(path.join(client.installedSkill, 'assets', 'app')), + `${client.label} 安装后必须优先使用 Skill 内嵌应用,不能误用客户端目录旁的旧 app。`, + ); + const duplicateResult = spawnSync(process.execPath, [ path.join(root, 'scripts', client.script), ], { @@ -105,6 +141,71 @@ try { assertApplicationSource(path.join(client.installedSkill, 'assets', 'app')); } + const allSandbox = path.join(sandbox, 'all-clients'); + const allInstall = spawnSync(process.execPath, [ + path.join(root, 'scripts', 'install-agent-skill.mjs'), + '--target', + 'all', + ], { + env: { + ...process.env, + CODEX_HOME: path.join(allSandbox, 'codex'), + CLAUDE_CONFIG_DIR: path.join(allSandbox, 'claude'), + }, + encoding: 'utf8', + }); + assert.equal(allInstall.status, 0, allInstall.stderr || allInstall.stdout); + assert.ok(fs.existsSync(path.join( + allSandbox, + 'codex', + 'skills', + 'investment-assistant', + 'SKILL.md', + ))); + assert.ok(fs.existsSync(path.join( + allSandbox, + 'claude', + 'skills', + 'investment-assistant', + 'SKILL.md', + ))); + + const commandPrinter = path.join(root, 'scripts', 'print-public-skill-command.mjs'); + const commandCases = [ + { + args: ['--official'], + expected: '帮我初始化个人投资助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md', + }, + { + args: ['--repository', 'https://github.com/3494036618-eng/personal-investment-assistant', '--ref', 'v0.3.1'], + expected: '帮我初始化个人投资助手:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.1/skills/investment-assistant/SKILL.md', + }, + { + args: ['--official-ref', '0123456789abcdef0123456789abcdef01234567'], + expected: '帮我初始化个人投资助手:https://github.com/volcengine/ai-app-lab/blob/0123456789abcdef0123456789abcdef01234567/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md', + }, + ]; + for (const testCase of commandCases) { + const result = spawnSync(process.execPath, [commandPrinter, ...testCase.args], { + env: process.env, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), testCase.expected); + } + for (const args of [ + ['--repository', 'https://github.com/3494036618-eng/personal-investment-assistant', '--ref', 'main'], + ['--repository', 'https://github.com/wrong-owner/personal-investment-assistant', '--ref', 'v0.3.1'], + ['--official-ref', '0123456789abcdef'], + ]) { + const rejected = spawnSync(process.execPath, [commandPrinter, ...args], { + env: process.env, + encoding: 'utf8', + }); + assert.equal(rejected.status, 1); + assert.doesNotMatch(rejected.stderr, /\n\s+at |file:\/\/|\/Users\//); + } + const onboardText = fs.readFileSync(path.join(skillRoot, 'scripts', 'onboard.mjs'), 'utf8'); assert.doesNotMatch(onboardText, /project\.mjs|--target/u); assert.match(onboardText, /--profile/u); diff --git a/demohouse/personal-investment-assistant/scripts/test-release-checkout.mjs b/demohouse/personal-investment-assistant/scripts/test-release-checkout.mjs new file mode 100644 index 00000000..1677fa5a --- /dev/null +++ b/demohouse/personal-investment-assistant/scripts/test-release-checkout.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CANONICAL_REPOSITORY, + validateImmutableRelease, + validateReleaseCheckout, +} from './validate-release-checkout.mjs'; + +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-release-checkout-')); + +function git(args) { + const result = spawnSync('git', ['-C', temporaryRoot, ...args], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +} + +function rejects(message, callback) { + assert.throws(callback, undefined, message); +} + +try { + git(['init', '-b', 'main']); + git(['config', 'user.name', 'Release Test']); + git(['config', 'user.email', 'release-test@example.invalid']); + fs.writeFileSync(path.join(temporaryRoot, 'fixture.txt'), 'clean\n'); + git(['add', 'fixture.txt']); + git(['commit', '-m', 'test fixture']); + git(['remote', 'add', 'origin', `${CANONICAL_REPOSITORY}.git`]); + git(['tag', 'v0.3.1']); + const head = git(['rev-parse', 'HEAD']); + + const report = validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v0.3.1', + expectedCommit: head, + verifyRemote: false, + }); + assert.equal(report.repository, CANONICAL_REPOSITORY); + assert.equal(report.tag, 'v0.3.1'); + assert.equal(report.commit, head); + + fs.writeFileSync(path.join(temporaryRoot, 'fixture.txt'), 'dirty\n'); + rejects('dirty checkout 必须失败', () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v0.3.1', + verifyRemote: false, + })); + fs.writeFileSync(path.join(temporaryRoot, 'fixture.txt'), 'clean\n'); + + git(['remote', 'set-url', 'origin', 'https://github.com/wrong-owner/personal-investment-assistant.git']); + rejects('错误 origin 必须失败', () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v0.3.1', + verifyRemote: false, + })); + git(['remote', 'set-url', 'origin', `${CANONICAL_REPOSITORY}.git`]); + + rejects('错误 tag 必须失败', () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v9.9.9', + verifyRemote: false, + })); + rejects('错误 commit 必须失败', () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v0.3.1', + expectedCommit: '0'.repeat(40), + verifyRemote: false, + })); + + fs.writeFileSync(path.join(temporaryRoot, '.gitignore'), 'ignored-artifact.txt\n'); + git(['add', '.gitignore']); + git(['commit', '-m', 'ignore fixture artifact']); + git(['tag', '-f', 'v0.3.1']); + fs.writeFileSync(path.join(temporaryRoot, 'ignored-artifact.txt'), 'must still fail\n'); + rejects('被忽略的额外文件必须失败', () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: 'v0.3.1', + verifyRemote: false, + })); + + const immutableRelease = { + tag_name: 'v0.3.1', + target_commitish: head, + immutable: true, + draft: false, + prerelease: false, + }; + const response = (body) => async () => ({ status: 200, json: async () => body }); + await validateImmutableRelease({ tag: 'v0.3.1', commit: head }, response(immutableRelease)); + await validateImmutableRelease( + { tag: 'v0.3.1', commit: head }, + response({ ...immutableRelease, target_commitish: 'main' }), + ); + await assert.rejects( + validateImmutableRelease( + { tag: 'v0.3.1', commit: head }, + response({ ...immutableRelease, immutable: false }), + ), + /immutable/, + ); + await assert.rejects( + validateImmutableRelease( + { tag: 'v0.3.1', commit: head }, + response({ ...immutableRelease, target_commitish: '0'.repeat(40) }), + ), + /commit/, + ); + + process.stdout.write('发行 checkout 校验器测试通过:正确来源与 immutable Release 放行,错误 origin、tag、commit、可变 Release、脏工作区和 ignored 残留全部拒绝。\n'); +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/demohouse/personal-investment-assistant/scripts/validate-public-release.mjs b/demohouse/personal-investment-assistant/scripts/validate-public-release.mjs new file mode 100644 index 00000000..6493f678 --- /dev/null +++ b/demohouse/personal-investment-assistant/scripts/validate-public-release.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ignoredDirectories = new Set(['.git', 'coverage', 'dist', 'node_modules']); +const requiredPaths = [ + '.github/workflows/ci.yml', + 'CONTRIBUTING.md', + 'LICENSE', + 'PRIVACY.md', + 'README.md', + 'SECURITY.md', + 'SUPPORT.md', + 'app/.env.example', + 'app/package-lock.json', + 'app/package.json', + 'docs/release-checklist.md', + 'package.json', + 'scripts/print-public-skill-command.mjs', + 'scripts/test-release-checkout.mjs', + 'scripts/validate-public-release.mjs', + 'scripts/validate-release-checkout.mjs', + 'scripts/verify-public-install.mjs', + 'skills/investment-assistant/SKILL.md', +]; +const forbiddenFilePatterns = [ + /^\.DS_Store$/i, + /^\.env$/i, + /^\.env\.(?!example$|sample$)/i, + /\.(?:db|log|mov|mp4|mkv|p12|pem|pfx|pid|sqlite|sqlite3)$/i, +]; +const publicContentRules = [ + { id: 'macos_user_path', pattern: /\/Users\/[^/\s"'`]+/ }, + { id: 'macos_temporary_path', pattern: /\/var\/folders\// }, + { id: 'clipboard_artifact', pattern: /codex-clipboard/i }, + { + id: 'unexpected_investment_repository', + pattern: /github\.com\/(?!3494036618-eng\/personal-investment-assistant(?:\.git)?(?:[\/\s`'"),;]|$)|volcengine\/ai-app-lab(?:[\/\s`'"),;]|$))[^/\s]+\/personal-investment-assistant/i, + }, + { id: 'possible_agent_plan_key', pattern: /ark-[A-Za-z0-9]{8,}(?:-[A-Za-z0-9]{4,}){2,}/ }, +]; +const contentScanExclusions = new Set([ + 'scripts/test-release-checkout.mjs', + 'scripts/test-initializer.mjs', + 'scripts/validate-public-release.mjs', + 'scripts/validate-skill.mjs', +]); + +function normalize(relativePath) { + return relativePath.split(path.sep).join('/'); +} + +function walkFiles(current, output = []) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + output.push(path.join(current, entry.name)); + continue; + } + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) walkFiles(path.join(current, entry.name), output); + continue; + } + if (entry.isFile()) output.push(path.join(current, entry.name)); + } + return output; +} + +function releaseFiles() { + const tracked = spawnSync( + 'git', + ['-C', root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + if (tracked.status === 0) { + return tracked.stdout + .split('\0') + .filter(Boolean) + .map((relativePath) => path.join(root, relativePath)) + .filter((filePath) => fs.existsSync(filePath)); + } + return walkFiles(root); +} + +function isText(bytes) { + return !bytes.subarray(0, 4096).includes(0); +} + +function markdownLinkIssues(relativePath, text) { + const issues = []; + const linkPattern = /!?\[[^\]]*]\(([^)]+)\)/g; + for (const match of text.matchAll(linkPattern)) { + const rawTarget = match[1].trim().replace(/^<|>$/g, '').split(/\s+["']/)[0]; + if (!rawTarget || /^(?:#|https?:\/\/|mailto:)/i.test(rawTarget)) continue; + const targetWithoutAnchor = rawTarget.split('#')[0]; + if (!targetWithoutAnchor) continue; + let decodedTarget = targetWithoutAnchor; + try { + decodedTarget = decodeURIComponent(targetWithoutAnchor); + } catch { + issues.push(`${relativePath}: invalid URL encoding in Markdown link ${rawTarget}`); + continue; + } + const resolved = path.resolve(root, path.dirname(relativePath), decodedTarget); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + issues.push(`${relativePath}: Markdown link escapes repository ${rawTarget}`); + } else if (!fs.existsSync(resolved)) { + issues.push(`${relativePath}: broken Markdown link ${rawTarget}`); + } + } + return issues; +} + +const issues = []; +const files = releaseFiles(); +const relativeFiles = new Set(files.map((filePath) => normalize(path.relative(root, filePath)))); + +for (const requiredPath of requiredPaths) { + if (!relativeFiles.has(requiredPath)) issues.push(`missing required release file: ${requiredPath}`); +} + +for (const filePath of files) { + const relativePath = normalize(path.relative(root, filePath)); + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + issues.push(`symbolic link is not allowed in the release tree: ${relativePath}`); + continue; + } + if (forbiddenFilePatterns.some((pattern) => pattern.test(path.basename(filePath)))) { + issues.push(`private or generated file is tracked: ${relativePath}`); + continue; + } + if (stat.size > 5 * 1024 * 1024) { + issues.push(`unexpected file larger than 5 MiB: ${relativePath}`); + continue; + } + const bytes = fs.readFileSync(filePath); + if (!isText(bytes)) continue; + const text = bytes.toString('utf8'); + if (!contentScanExclusions.has(relativePath)) { + for (const rule of publicContentRules) { + if (rule.pattern.test(text)) issues.push(`${relativePath}: ${rule.id}`); + } + } + if (relativePath.endsWith('.md')) issues.push(...markdownLinkIssues(relativePath, text)); +} + +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const appPackage = JSON.parse(fs.readFileSync(path.join(root, 'app', 'package.json'), 'utf8')); +const appLock = JSON.parse(fs.readFileSync(path.join(root, 'app', 'package-lock.json'), 'utf8')); +const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); +const skill = fs.readFileSync(path.join(root, 'skills', 'investment-assistant', 'SKILL.md'), 'utf8'); +const ci = fs.readFileSync(path.join(root, '.github', 'workflows', 'ci.yml'), 'utf8'); +const canonicalRepository = 'https://github.com/3494036618-eng/personal-investment-assistant'; +const canonicalReleaseRef = `v${packageJson.version}`; +const canonicalReleaseSkillUrl = `${canonicalRepository}/blob/${canonicalReleaseRef}/skills/investment-assistant/SKILL.md`; +const officialSkillUrl = 'https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md'; + +for (const [label, text] of [['README', readme], ['Skill', skill]]) { + if (!text.includes(officialSkillUrl)) issues.push(`${label} is missing the official AI App Lab Skill URL`); + if (!text.includes(canonicalReleaseSkillUrl)) issues.push(`${label} is missing the fixed release Skill URL`); +} +if (appPackage.version !== packageJson.version) issues.push('app/package.json version is inconsistent'); +if (appLock.version !== packageJson.version || appLock.packages?.['']?.version !== packageJson.version) { + issues.push('app/package-lock.json version is inconsistent'); +} +if (packageJson.private !== true) issues.push('package.json must remain private'); +if (packageJson.engines?.node !== '>=22.13') issues.push('package.json must require Node.js >=22.13'); +if (!/os:\s*\[ubuntu-latest, macos-latest, windows-latest]/.test(ci)) { + issues.push('GitHub Actions must test Ubuntu, macOS and Windows'); +} +if (!/node-version:\s*['"]22\.13['"]/.test(ci)) issues.push('GitHub Actions must test Node.js 22.13'); +if (!/npm run verify/.test(ci)) issues.push('GitHub Actions must run npm run verify'); +if (!skill.includes(`固定发行版本:${canonicalReleaseRef}`)) issues.push('Skill is missing the fixed release version'); +if (!skill.includes(`固定发行仓库:${canonicalRepository}`)) issues.push('Skill is missing the fixed release repository'); + +assert.deepEqual(issues, [], `公开发布检查失败:\n- ${issues.join('\n- ')}`); +process.stdout.write(`公开发布检查通过:${files.length} 个文件,未发现私密文件、环境特定路径、异常仓库地址或失效的相对文档链接。\n`); diff --git a/demohouse/personal-investment-assistant/scripts/validate-release-checkout.mjs b/demohouse/personal-investment-assistant/scripts/validate-release-checkout.mjs new file mode 100644 index 00000000..26e69d3a --- /dev/null +++ b/demohouse/personal-investment-assistant/scripts/validate-release-checkout.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const CANONICAL_REPOSITORY = 'https://github.com/3494036618-eng/personal-investment-assistant'; +const RELEASE_API = 'https://api.github.com/repos/3494036618-eng/personal-investment-assistant/releases/tags'; + +function runGit(root, args) { + const result = spawnSync('git', ['-C', root, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + throw new Error(`Git checkout 校验失败:git ${args.join(' ')}。`); + } + return result.stdout.trim(); +} + +function normalizeRepository(value) { + let url; + try { + url = new URL(String(value || '').trim()); + } catch { + throw new Error('origin 不是有效的公开 HTTPS GitHub 仓库地址。'); + } + if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.username || url.password) { + throw new Error('origin 必须是无凭证的公开 HTTPS GitHub 仓库地址。'); + } + const segments = url.pathname.replace(/\/+$/, '').replace(/\.git$/, '').split('/').filter(Boolean); + if (segments.length !== 2) throw new Error('origin 必须指向 GitHub 仓库根目录。'); + return `https://github.com/${segments[0]}/${segments[1]}`; +} + +function resolveRemoteTagCommit(root, repository, tag) { + const output = runGit(root, [ + 'ls-remote', + '--exit-code', + `${normalizeRepository(repository)}.git`, + `refs/tags/${tag}`, + `refs/tags/${tag}^{}`, + ]); + const rows = output.split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + const commit = (peeled || direct)?.[0] || ''; + assert.match(commit, /^[0-9a-f]{40}$/i, `远程 ${tag} 没有解析到完整 commit SHA。`); + return commit.toLowerCase(); +} + +function packageVersion(root) { + const packagePath = path.join(root, 'package.json'); + assert.ok(fs.existsSync(packagePath), 'checkout 根目录缺少 package.json。'); + const parsed = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + assert.match(String(parsed.version || ''), /^\d+\.\d+\.\d+$/, 'package.json version 无效。'); + return parsed.version; +} + +export function validateReleaseCheckout({ + root = scriptRoot, + expectedRepository = CANONICAL_REPOSITORY, + expectedTag = `v${packageVersion(root)}`, + expectedCommit = '', + verifyRemote = true, +} = {}) { + const resolvedRoot = path.resolve(root); + if (expectedCommit && !/^[0-9a-f]{40}$/i.test(expectedCommit)) { + throw new Error('expected commit 必须是完整 40 位 SHA。'); + } + + const actualRepository = normalizeRepository(runGit(resolvedRoot, ['remote', 'get-url', 'origin'])); + assert.equal(actualRepository, normalizeRepository(expectedRepository), 'origin 不是固定发行仓库。'); + + const exactTag = runGit(resolvedRoot, ['describe', '--tags', '--exact-match', 'HEAD']); + assert.equal(exactTag, expectedTag, `checkout 必须精确位于 ${expectedTag}。`); + + const head = runGit(resolvedRoot, ['rev-parse', 'HEAD']).toLowerCase(); + const tagCommit = runGit(resolvedRoot, ['rev-parse', `${expectedTag}^{commit}`]).toLowerCase(); + assert.equal(head, tagCommit, 'tag 没有指向当前 checkout commit。'); + if (expectedCommit) assert.equal(head, expectedCommit.toLowerCase(), 'checkout commit 与预期发行 commit 不一致。'); + if (verifyRemote) { + const remoteCommit = resolveRemoteTagCommit(resolvedRoot, actualRepository, expectedTag); + assert.equal(head, remoteCommit, 'checkout commit 与远程发行 tag 不一致。'); + } + + const status = runGit(resolvedRoot, ['status', '--porcelain', '--untracked-files=all', '--ignored=matching']); + assert.equal(status, '', '发行 checkout 存在未提交、未跟踪或被忽略的额外文件。'); + + return { repository: actualRepository, tag: exactTag, commit: head }; +} + +export async function validateImmutableRelease(report, fetchImpl = fetch) { + const response = await fetchImpl(`${RELEASE_API}/${encodeURIComponent(report.tag)}`, { + headers: { 'user-agent': 'personal-investment-assistant-checkout-validator' }, + signal: AbortSignal.timeout(20_000), + }); + assert.equal(response.status, 200, `GitHub Release 不可用:HTTP ${response.status}。`); + const release = await response.json(); + assert.equal(release.tag_name, report.tag, 'GitHub Release tag 与 checkout 不一致。'); + assert.equal(release.draft, false, 'GitHub Release 仍是草稿。'); + assert.equal(release.prerelease, false, 'GitHub Release 不能是预发布版本。'); + assert.equal(release.immutable, true, 'GitHub Release 必须启用 immutable,防止 tag 或资产被事后替换。'); + const targetCommitish = String(release.target_commitish || '').toLowerCase(); + if (/^[0-9a-f]{40}$/i.test(targetCommitish)) { + assert.equal(targetCommitish, report.commit, 'GitHub Release commit 与 checkout 不一致。'); + } +} + +function readOption(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ''; + const value = process.argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const args = process.argv.slice(2); + if (args.length !== 0 && !(args.length === 2 && args[0] === '--expected-commit')) { + throw new Error('只允许无参数运行,或提供 --expected-commit <完整40位SHA>。'); + } + const report = validateReleaseCheckout({ expectedCommit: readOption('--expected-commit') }); + process.stdout.write(`发行 checkout 校验通过:${report.tag} ${report.commit}\n`); + } catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/demohouse/personal-investment-assistant/scripts/validate-skill.mjs b/demohouse/personal-investment-assistant/scripts/validate-skill.mjs index 288c51af..3f1128aa 100644 --- a/demohouse/personal-investment-assistant/scripts/validate-skill.mjs +++ b/demohouse/personal-investment-assistant/scripts/validate-skill.mjs @@ -11,7 +11,12 @@ const rootScripts = [ 'scripts/install-agent-skill.mjs', 'scripts/install-codex-skill.mjs', 'scripts/install-claude-code-skill.mjs', + 'scripts/print-public-skill-command.mjs', 'scripts/test-initializer.mjs', + 'scripts/test-release-checkout.mjs', + 'scripts/validate-public-release.mjs', + 'scripts/validate-release-checkout.mjs', + 'scripts/verify-public-install.mjs', ]; const requiredFiles = [ 'SKILL.md', @@ -110,6 +115,13 @@ for (const requiredText of [ 'npm run skill:install:codex', 'npm run skill:install:claude', '/investment-assistant', + '固定发行仓库:https://github.com/3494036618-eng/personal-investment-assistant', + '固定发行版本:v0.3.1', + '固定发行 Skill:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.1/skills/investment-assistant/SKILL.md', + 'https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md', + '不能下载 AI App Lab 的整个 monorepo', + 'validate-release-checkout.mjs', + 'npm --prefix "$release_root/app" ci', ]) { if (!skillText.includes(requiredText)) throw new Error(`SKILL.md 缺少初始化关键规则:${requiredText}`); } @@ -156,6 +168,11 @@ for (const marker of [ 'npm run skill:install:codex', 'npm run skill:install:claude', '/investment-assistant', + 'npm run skill:command -- --official', + 'npm run skill:command -- --official-ref', + 'npm run release:verify:public', + 'https://github.com/3494036618-eng/personal-investment-assistant/tree/v0.3.1', + 'https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md', ]) { if (!readmeText.includes(marker)) throw new Error(`README 缺少主入口说明:${marker}`); } @@ -175,13 +192,27 @@ for (const marker of [ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); for (const scriptName of [ + 'release:validate', + 'release:validate:checkout', + 'release:test:checkout', + 'release:verify:public', 'skill:install:codex', 'skill:install:claude', 'skill:install:all', + 'skill:command', ]) { if (!packageJson.scripts?.[scriptName]) throw new Error(`package.json 缺少命令:${scriptName}`); } +const appPackageJson = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8')); +const appPackageLock = JSON.parse(fs.readFileSync(path.join(appRoot, 'package-lock.json'), 'utf8')); +if (packageJson.version !== '0.3.1') throw new Error('根 package.json 必须是 v0.3.1。'); +if (appPackageJson.version !== packageJson.version) throw new Error('app/package.json 版本不一致。'); +if (appPackageLock.version !== packageJson.version + || appPackageLock.packages?.['']?.version !== packageJson.version) { + throw new Error('app/package-lock.json 版本不一致。'); +} + const secretPattern = /ark-[A-Za-z0-9]{8,}(?:-[A-Za-z0-9]{4,}){2,}/g; const ignored = new Set(['node_modules', '.git', 'dist']); function walk(directory) { diff --git a/demohouse/personal-investment-assistant/scripts/verify-public-install.mjs b/demohouse/personal-investment-assistant/scripts/verify-public-install.mjs new file mode 100644 index 00000000..1a111e4a --- /dev/null +++ b/demohouse/personal-investment-assistant/scripts/verify-public-install.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateImmutableRelease } from './validate-release-checkout.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const repository = 'https://github.com/3494036618-eng/personal-investment-assistant'; +const tag = `v${packageJson.version}`; +const officialRepository = 'volcengine/ai-app-lab'; +const officialProjectPath = 'demohouse/personal-investment-assistant'; +const officialSkillPath = `${officialProjectPath}/skills/investment-assistant/SKILL.md`; +const officialUpstreamPath = `${officialProjectPath}/UPSTREAM.json`; +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'investment-public-install-')); +const checkout = path.join(temporaryRoot, 'checkout'); +const officialCheckout = path.join(temporaryRoot, 'ai-app-lab'); + +function option(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ''; + const value = process.argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || root, + env: options.env || process.env, + encoding: 'utf8', + stdio: options.capture === false ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + const detail = options.capture === false ? '' : `\n${result.stderr || result.stdout}`; + throw new Error(`${command} ${args.join(' ')} 失败,退出码 ${result.status}。${detail}`); + } + return options.capture === false ? '' : result.stdout.trim(); +} + +function npmInvocation(args) { + const cli = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate)); + if (cli) return { command: process.execPath, args: [cli, ...args] }; + if (process.platform === 'win32') { + return { + command: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', ['npm', ...args].join(' ')], + }; + } + return { command: 'npm', args }; +} + +function remoteTagCommit() { + const output = run('git', [ + 'ls-remote', + '--exit-code', + `${repository}.git`, + `refs/tags/${tag}`, + `refs/tags/${tag}^{}`, + ]); + const rows = output.split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + const commit = (peeled || direct)?.[0] || ''; + assert.match(commit, /^[0-9a-f]{40}$/i, `远程 ${tag} 没有解析到完整 commit SHA。`); + return commit.toLowerCase(); +} + +async function fetchText(url) { + const response = await fetch(url, { + headers: { 'user-agent': 'personal-investment-assistant-release-verifier' }, + signal: AbortSignal.timeout(20_000), + }); + assert.equal(response.status, 200, `远程文件不可用:${url},HTTP ${response.status}。`); + return response.text(); +} + +function trackedManifest(repositoryRoot, prefix = '') { + const args = ['-C', repositoryRoot, 'ls-files', '--stage', '-z']; + if (prefix) args.push('--', prefix); + const tracked = run('git', args).split('\0').filter(Boolean).map((record) => { + const separator = record.indexOf('\t'); + assert.notEqual(separator, -1, `无法解析 Git index 记录:${record}`); + const [mode] = record.slice(0, separator).split(/\s+/); + assert.match(mode, /^100(?:644|755)$/, `发行镜像包含不支持的 Git mode:${mode}`); + return { mode, trackedPath: record.slice(separator + 1) }; + }); + return tracked + .map(({ mode, trackedPath }) => { + const relativePath = prefix + ? path.relative(prefix, trackedPath).split(path.sep).join('/') + : trackedPath; + return { mode, trackedPath, relativePath }; + }) + .filter(({ relativePath }) => relativePath !== 'UPSTREAM.json') + .map(({ mode, trackedPath, relativePath }) => ({ + path: relativePath, + mode, + sha256: createHash('sha256') + .update(fs.readFileSync(path.join(repositoryRoot, trackedPath))) + .digest('hex'), + })) + .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); +} + +async function verifyOfficialMirror(officialRef, releaseCommit) { + if (!officialRef) { + process.stdout.write('未提供 --official-ref:本轮只验证独立仓库发行包,不放行官方宣传。\n'); + return; + } + assert.match(officialRef, /^[0-9a-f]{40}$/i, '--official-ref 必须是完整 40 位 AI App Lab commit SHA。'); + const rawBase = `https://raw.githubusercontent.com/${officialRepository}/${officialRef}`; + const [officialSkill, upstreamText] = await Promise.all([ + fetchText(`${rawBase}/${officialSkillPath}`), + fetchText(`${rawBase}/${officialUpstreamPath}`), + ]); + const releaseSkill = fs.readFileSync( + path.join(checkout, 'skills', 'investment-assistant', 'SKILL.md'), + 'utf8', + ); + assert.equal(officialSkill, releaseSkill, 'AI App Lab Skill 与独立发行包不一致。'); + + const upstream = JSON.parse(upstreamText); + assert.equal(String(upstream.repository || '').replace(/\/+$/, ''), repository); + assert.equal(upstream.version, packageJson.version, 'UPSTREAM.json 版本与发行包不一致。'); + assert.equal(String(upstream.commit || '').toLowerCase(), releaseCommit, 'UPSTREAM.json commit 与发行 tag 不一致。'); + + run('git', [ + 'clone', + '--filter=blob:none', + '--sparse', + '--no-checkout', + `https://github.com/${officialRepository}.git`, + officialCheckout, + ]); + run('git', ['-C', officialCheckout, 'sparse-checkout', 'set', officialProjectPath]); + run('git', ['-C', officialCheckout, 'rev-parse', '--verify', `${officialRef}^{commit}`]); + run('git', ['-C', officialCheckout, 'merge-base', '--is-ancestor', officialRef, 'refs/remotes/origin/main']); + run('git', ['-C', officialCheckout, 'checkout', '--detach', officialRef]); + assert.deepEqual( + trackedManifest(officialCheckout, officialProjectPath), + trackedManifest(checkout), + 'AI App Lab 中的完整个人投资助手镜像与独立发行包不一致。', + ); + + const command = run(process.execPath, [ + path.join(checkout, 'scripts', 'print-public-skill-command.mjs'), + '--official-ref', + officialRef, + ]); + assert.equal( + command, + `帮我初始化个人投资助手:https://github.com/${officialRepository}/blob/${officialRef}/${officialSkillPath}`, + ); + process.stdout.write(`AI App Lab 固定 commit 完整镜像验证通过:${officialRef}\n`); +} + +try { + const args = process.argv.slice(2); + if (args.length !== 0 && !(args.length === 2 && args[0] === '--official-ref')) { + throw new Error('只允许无参数运行,或提供 --official-ref 。'); + } + const officialRef = option('--official-ref'); + if (officialRef && !/^[0-9a-f]{40}$/i.test(officialRef)) { + throw new Error('--official-ref 必须是完整 40 位 AI App Lab commit SHA。'); + } + + const releaseCommit = remoteTagCommit(); + process.stdout.write(`远程 tag 已解析:${tag} ${releaseCommit}\n`); + await validateImmutableRelease({ tag, commit: releaseCommit }); + process.stdout.write(`GitHub immutable Release 验证通过:${tag} ${releaseCommit}\n`); + + run('git', [ + 'clone', + '--depth', + '1', + '--branch', + tag, + '--single-branch', + `${repository}.git`, + checkout, + ]); + run(process.execPath, [ + path.join(checkout, 'scripts', 'validate-release-checkout.mjs'), + '--expected-commit', + releaseCommit, + ], { cwd: checkout, capture: false }); + + const npmInstall = npmInvocation(['--prefix', 'app', 'ci']); + run(npmInstall.command, npmInstall.args, { cwd: checkout, capture: false }); + const npmVerify = npmInvocation(['run', 'verify']); + run(npmVerify.command, npmVerify.args, { cwd: checkout, capture: false }); + await verifyOfficialMirror(officialRef, releaseCommit); + process.stdout.write(`公开安装验收通过:${repository}/tree/${tag}\n`); +} catch (error) { + process.stderr.write(`公开安装验收失败:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md b/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md index 2dcd3e72..74748c29 100644 --- a/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md +++ b/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md @@ -9,25 +9,93 @@ description: 中文个人投资助手初始化 Skill。引导用户确认真实 ## 远程 Skill 入口 -用户可能直接通过下面的公开 Skill URL 触发本流程,而不是预先克隆仓库或安装 Skill: +用户可能直接通过公开 Skill URL 触发本流程,而不是预先克隆仓库或安装 Skill。火山方舟 +AI App Lab 中的官方入口为: ```text -https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md +帮我初始化个人投资助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md ``` -如果当前环境中不存在 `{baseDir}/scripts/status.mjs`,说明本 Skill 是从远程 URL 打开的。此时 Codex 或 Claude Code 必须: +该 URL 只负责让 Agent 找到本 Skill,不是应用包下载源。应用包必须固定从以下发行版本 +获取,不得根据入口 URL 改用其他仓库、分支或同名项目: -1. 获取完整仓库 `https://github.com/volcengine/ai-app-lab`,不能只下载 `SKILL.md`。 -2. 将项目根目录设为仓库中的 `demohouse/personal-investment-assistant`,将 `{baseDir}` 设为该目录下的 `skills/investment-assistant`。 -3. 确认 `{baseDir}/scripts/`、`{baseDir}/references/` 和项目根目录 `app/` 均存在后,再进入阶段 0。 -4. 已有同名目录时先核对 Git remote 和工作区状态,不覆盖用户改动,不创建第二套应用。 -5. 当前客户端是 Codex 时,在项目根目录执行 `npm run skill:install:codex`;当前客户端是 Claude Code 时执行 `npm run skill:install:claude`。安装只写入对应客户端的个人 Skill 目录。 -6. 安装完成后直接使用当前仓库中的 Skill 继续初始化,不把“Skill 已复制”说成“网站已经可用”。 +```text +固定发行仓库:https://github.com/3494036618-eng/personal-investment-assistant +固定发行版本:v0.3.1 +固定发行 Skill:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.1/skills/investment-assistant/SKILL.md +``` -用户可直接把这句话交给支持联网和终端操作的 Codex 或 Claude Code: +如果当前环境中不存在 `{baseDir}/scripts/status.mjs`,说明本 Skill 是从远程 URL 打开的。 +此时 Codex 或 Claude Code 必须: + +1. 无论用户从官方入口还是发行 Skill 打开本文件,都只取得上面指定的独立发行仓库 + `v0.3.1` 完整版本。先说明会把公开源码写入本机,然后在 macOS/Linux 的 POSIX shell + 中使用系统临时目录执行: + + ```bash + release_root="$(mktemp -d "${TMPDIR:-/tmp}/personal-investment-assistant-v0.3.1-XXXXXX")" + git clone --depth 1 --branch v0.3.1 --single-branch \ + https://github.com/3494036618-eng/personal-investment-assistant.git "$release_root" + ``` + + Windows PowerShell 使用以下等价命令: + + ```powershell + $releaseRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("personal-investment-assistant-v0.3.1-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $releaseRoot -ErrorAction Stop | Out-Null + git clone --depth 1 --branch v0.3.1 --single-branch ` + https://github.com/3494036618-eng/personal-investment-assistant.git $releaseRoot + if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $releaseRoot -Recurse -Force + throw "v0.3.1 clone 失败,已停止且不会换源或降级。" + } + ``` + + 不能下载 AI App Lab 的整个 monorepo,不能使用 `main`,不能只下载 `SKILL.md`,也不能 + 通过搜索结果猜测同名仓库。clone 失败时只删除本次新建的临时目录并停止,不能换源或降级。 +2. 在下载仓库中执行来源校验;脚本会核对 origin、精确 tag、本地 commit、远程 tag 当前 + 指向以及干净工作区,任一不符都会失败: + + ```bash + node "$release_root/scripts/validate-release-checkout.mjs" + ``` + + Windows PowerShell 对应执行: + + ```powershell + node (Join-Path $releaseRoot "scripts/validate-release-checkout.mjs") + if ($LASTEXITCODE -ne 0) { throw "发行来源校验失败,停止安装。" } + ``` + + 校验失败时停止,不继续安装;不得复用、修改、清理或覆盖任何已有源码目录。 +3. 将项目根目录设为 `release_root`,将 `{baseDir}` 设为其中的 + `skills/investment-assistant`,确认 `{baseDir}/scripts/`、`{baseDir}/references/`、 + 仓库根目录 `app/` 和 `package.json` 均存在。 +4. 在项目根目录先按锁文件安装测试依赖,再运行完整验证: + + ```bash + npm --prefix "$release_root/app" ci + (cd "$release_root" && npm run verify) + ``` + + Windows PowerShell 在 `$releaseRoot` 中依次执行 `npm --prefix app ci` 和 + `npm run verify`。公开包扫描、来源校验器测试、249 项应用测试、生产构建、Skill + 隔离安装和凭证扫描全部通过后,才按当前客户端安装: + - Codex:`npm run skill:install:codex` + - Claude Code:`npm run skill:install:claude` + - 用户明确要求两端都安装:`npm run skill:install:all` + 已安装旧版时先说明影响,再为对应命令追加 `-- --force`。 +5. 立即使用刚安装的 Skill 继续阶段 0,不要求用户重复提供源码目录。当前流程切换到安装后的 + Skill,或流程结束、取消、失败且不再需要源码 checkout 时,才删除本次创建的临时目录; + 删除前必须再次确认目录名称以 `personal-investment-assistant-v0.3.1-` 开头,不能删除用户 + 已有目录。 +6. 下载、校验和安装阶段不创建云资源、不调用 Agent Plan、DataPro 或豆包搜索,也不产生 + AFP。后续真实探测和报告生成仍须按阶段 2 的用户确认执行。 + +独立发行入口也可以直接触发同一流程: ```text -帮我初始化个人投资助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/personal-investment-assistant/skills/investment-assistant/SKILL.md +帮我初始化个人投资助手:https://github.com/3494036618-eng/personal-investment-assistant/blob/v0.3.1/skills/investment-assistant/SKILL.md ``` 已经安装后,Codex 可通过 `$investment-assistant` 触发,Claude Code 可通过 diff --git a/demohouse/personal-investment-assistant/skills/investment-assistant/scripts/lib.mjs b/demohouse/personal-investment-assistant/skills/investment-assistant/scripts/lib.mjs index 6a4cbb83..b02ee7db 100644 --- a/demohouse/personal-investment-assistant/skills/investment-assistant/scripts/lib.mjs +++ b/demohouse/personal-investment-assistant/skills/investment-assistant/scripts/lib.mjs @@ -6,10 +6,20 @@ import { fileURLToPath } from 'node:url'; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const skillRoot = path.resolve(scriptsDir, '..'); -const repositoryApp = path.resolve(skillRoot, '..', '..', 'app'); +const repositoryRoot = path.resolve(skillRoot, '..', '..'); +const repositoryApp = path.join(repositoryRoot, 'app'); const bundledApp = path.join(skillRoot, 'assets', 'app'); const cliErrorHandler = Symbol.for('investment-assistant.cli-error-handler'); +function isSourceRepository(root) { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + return manifest.name === 'investment-assistant-oss' && manifest.private === true; + } catch { + return false; + } +} + if (!globalThis[cliErrorHandler]) { globalThis[cliErrorHandler] = true; const reportFatal = (error) => { @@ -24,9 +34,9 @@ if (!globalThis[cliErrorHandler]) { export const paths = { skillRoot, - sourceApp: fs.existsSync(path.join(repositoryApp, 'package.json')) - ? repositoryApp - : bundledApp, + sourceApp: fs.existsSync(path.join(bundledApp, 'package.json')) + ? bundledApp + : (isSourceRepository(repositoryRoot) ? repositoryApp : bundledApp), installRoot: path.resolve(process.env.INVESTMENT_ASSISTANT_HOME || path.join(os.homedir(), '.local', 'share', 'investment-assistant')), configDir: path.resolve(process.env.INVESTMENT_ASSISTANT_CONFIG_HOME