diff --git a/.env-example b/.env-example index 94949865..8e133fb6 100644 --- a/.env-example +++ b/.env-example @@ -2,7 +2,7 @@ #! ice # AI_LIVE_BASE_URL=https://ice.v.ua -# AI_LIVE_API_KEY=REMOVED_HISTORICAL_KEY +# AI_LIVE_API_KEY= AI_LIVE_PRIMARY_MODEL_NAME=deepseek-v4-flash-vision-exp @@ -12,10 +12,20 @@ AI_LIVE_PRIMARY_MODEL_NAME=deepseek-v4-flash-vision-exp LANGSMITH_TRACING=false LANGSMITH_ENDPOINT=https://api.smith.langchain.com LANGSMITH_PROJECT=ncurator -LANGSMITH_API_KEY=REMOVED_HISTORICAL_KEY +LANGSMITH_API_KEY= LANGCHAIN_CALLBACKS_BACKGROUND=true -# Release(本地 Windows/macOS 发布,不要提交真实密钥) +# Release(国内 COS 为默认发布目标;不要提交真实密钥) +RELEASE_STORAGE_TARGET=cos +TENCENT_SECRET_ID= +TENCENT_SECRET_KEY= +COS_BUCKET=ncurator-1324873382 +COS_REGION=ap-guangzhou +COS_PUBLIC_BASE_URL=https://download.guanzhangai.cn +# 仅在明确执行真实发布时临时设置,普通打包和 dry-run 必须留空。 +NCURATOR_ALLOW_LIVE_PUBLISH= + +# 海外 R2 备用发布目标 R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= R2_ACCOUNT_ID= diff --git a/build/app-update.yml b/build/app-update.yml index cc9cb6f0..38e43f33 100644 --- a/build/app-update.yml +++ b/build/app-update.yml @@ -1,3 +1,3 @@ provider: generic -url: https://download.ncurator.com/updates/latest +url: https://download.guanzhangai.cn/updates/latest updaterCacheDirName: ncurator-updater diff --git a/doc/prd/settings.md b/doc/prd/settings.md index 01ee4fe2..f348800c 100644 --- a/doc/prd/settings.md +++ b/doc/prd/settings.md @@ -102,7 +102,7 @@ #### SET-MODEL-004 免费 AI Key 入口 -- 系统行为:大模型配置页在配置数量与“新增配置”之间展示“免费获取 AI Key”入口,点击后通过系统默认浏览器打开官网免费 Key 页面 `https://www.ncurator.com/zh/free-keys`,当前设置页保持不变。 +- 系统行为:大模型配置页在配置数量与“新增配置”之间展示“免费获取 AI Key”入口,点击后通过系统默认浏览器打开国内官网免费 Key 页面 `https://www.guanzhangai.cn/zh/free-keys`,当前设置页保持不变。 - 验收: - `SET-MODEL-004-AC1` 入口位于配置数量与“新增配置”之间,并与操作区保持对齐。 - `SET-MODEL-004-AC2` 点击入口后调用系统外链能力打开官网免费 Key 页面,不改变当前设置页路由。 @@ -286,7 +286,7 @@ #### SET-HELP-001 设置页使用帮助入口 -- 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`,当前设置页保持不变。 +- 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`,当前设置页保持不变。 - 验收: - `SET-HELP-001-AC1` “使用帮助”入口紧接在“向量模型”下方,使用与其他设置菜单项一致的样式,并具有“查看使用帮助”的可访问名称。 - `SET-HELP-001-AC2` 点击“使用帮助”后调用系统外链能力打开官方中文使用手册,不改变当前设置页路由。 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 6718a0b5..6339285d 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -295,7 +295,7 @@ #### WB-COMPOSER-011 使用帮助入口 -- 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`。 +- 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`。 - 验收: - `WB-COMPOSER-011-AC1` 使用帮助按钮可见,具有“查看使用帮助”的可访问名称和说明。 - `WB-COMPOSER-011-AC2` 点击使用帮助按钮后,系统打开官方中文使用手册,不改变当前会话和输入草稿。 diff --git a/doc/release.md b/doc/release.md index 37d076e9..d1345897 100644 --- a/doc/release.md +++ b/doc/release.md @@ -1,6 +1,6 @@ # 桌面端构建与发布 -本文是 Windows、macOS 构建、正式包回归和 R2 发布的统一说明。当前发布 Windows x64、macOS arm64 和 macOS x64,不发布 Linux。Windows 使用 `windows-vX.Y.Z`,macOS 使用 `mac-vX.Y.Z`,两个平台独立递增和发布。 +本文是 Windows、macOS 构建、正式包回归和对象存储发布的统一说明。国内默认发布到腾讯云 COS,海外可显式切换到 Cloudflare R2。当前发布 Windows x64、macOS arm64 和 macOS x64,不发布 Linux。Windows 使用 `windows-vX.Y.Z`,macOS 使用 `mac-vX.Y.Z`,两个平台独立递增和发布。 注意评估改动影响,只回归需要回归的测试用例,避免动不动就全部回归,尤其是在回归一次后,修复的代码并不影响已通过的测试用例时。 @@ -52,9 +52,9 @@ pnpm release:start -- --platform mac --version X.Y.Z ### 干净构建与候选包 -prepare 阶段从 Tag 创建独立工作区,执行 `pnpm install --frozen-lockfile`、正式构建、安装包审计和 bundle 固化。它不复用当前源码、旧 `node_modules` 或旧 `dist`,也不上传 R2。 +prepare 阶段从 Tag 创建独立工作区,执行 `pnpm install --frozen-lockfile`、正式构建、安装包审计和 bundle 固化。它不复用当前源码、旧 `node_modules` 或旧 `dist`,也不上传任何对象存储。 -Windows 的用户可见名称统一为“馆长”:安装包为 `馆长-X.Y.Z-Windows-x64.exe`,安装后的主程序为 `馆长.exe`,快捷方式与卸载项也使用“馆长”。npm 包名、`appId`、`.ncurator` 目录和 R2 对象键等内部技术标识保持不变。 +Windows 的用户可见名称统一为“馆长”:安装包为 `馆长-X.Y.Z-Windows-x64.exe`,安装后的主程序为 `馆长.exe`,快捷方式与卸载项也使用“馆长”。npm 包名、`appId`、`.ncurator` 目录和对象键等内部技术标识保持不变。 候选包保存在: @@ -116,6 +116,14 @@ pnpm release:mac -- --tag mac-vX.Y.Z --publish-only pnpm release:status -- --tag ``` +真实发布默认锁定。只有用户明确批准上线后,才可在单次发布终端中设置: + +```bash +export NCURATOR_ALLOW_LIVE_PUBLISH=publish-to-production +``` + +普通构建、候选包测试和 `pnpm release:storage:plan` 都不需要这个变量。未设置时,`--publish-only`、上传脚本和 finalize 都会在发起网络写入前失败。 + ## 2. 构建与平台 ### Windows @@ -132,9 +140,9 @@ Windows 本地发布环境: - Windows x64、Node.js 22 x64、项目指定版本的 pnpm; - Python 3.12 x64、Git、tar; -- 发布到 R2 时还需要 AWS CLI 和 R2 配置。 +- 发布到 COS 或 R2 时还需要 AWS CLI 和对应存储配置。 -`pnpm release:doctor` 当前检查 Windows 本地发布与 R2 访问条件。 +`pnpm release:doctor` 检查 Windows 本地发布与当前存储目标的访问条件。 ### macOS @@ -184,7 +192,7 @@ release-stage/ ## 3. 发布与维护 -### R2 与公开通道 +### COS、R2 与公开通道 Windows 和 macOS 使用独立目录、latest metadata 和固定下载地址: @@ -203,9 +211,20 @@ downloads/latest/macos-arm64.dmg downloads/latest/macos-x64.dmg ``` -版本化产物使用不可变缓存;latest、manifest 和固定下载使用 `no-store`。fragment 中的 `name` 记录用户可见文件名,`storageName` 记录 R2 ASCII 对象名;二者共同进入候选 bundle 校验。Windows 固定下载与 manifest 返回 `馆长-X.Y.Z-Windows-x64.exe`,自动更新 metadata 则指向对应的 ASCII 对象键。 +版本化产物使用不可变缓存;latest、manifest 和固定下载使用 `no-store`。fragment 中的 `name` 记录用户可见文件名,`storageName` 记录对象存储的 ASCII 对象名;二者共同进入候选 bundle 校验。Windows 固定下载与 manifest 返回 `馆长-X.Y.Z-Windows-x64.exe`,自动更新 metadata 则指向对应的 ASCII 对象键。 -本地 `.env` 使用: +国内默认目标使用: + +```text +RELEASE_STORAGE_TARGET=cos +TENCENT_SECRET_ID +TENCENT_SECRET_KEY +COS_BUCKET=ncurator-1324873382 +COS_REGION=ap-guangzhou +COS_PUBLIC_BASE_URL=https://download.guanzhangai.cn +``` + +海外发布时显式设置 `RELEASE_STORAGE_TARGET=r2`,并使用: ```text R2_ACCESS_KEY_ID @@ -215,9 +234,20 @@ R2_BUCKET_NAME R2_PUBLIC_BASE_URL=https://download.ncurator.com ``` +腾讯云侧固定配置:COS 桶为 `ncurator-1324873382`、地域为广州;`download.guanzhangai.cn` 在备案通过后接入腾讯云 CDN,CDN 回源该私有桶。发布子用户使用 [最小 COS 权限模板](../scripts/release/cos/cam-policy.json),不授予账号管理、域名、DNS 或其他桶权限。CDN 刷新权限后续单独配置,不混入上传密钥。 + +官网与桌面端的国内地址固定为: + +```text +官网:https://www.guanzhangai.cn +下载:https://download.guanzhangai.cn +``` + +海外官网 `www.ncurator.com` 仍通过 Cloudflare 访问,官网下载 API 会按请求域名选择国内 COS 或海外 R2。桌面端发行包统一使用国内官网与国内更新通道。 + publish 会上传候选产物和 fragment,更新对应平台的自动更新 metadata、官网 manifest 与固定下载地址,然后验证公开文件大小、sha512、文件名和 manifest。公开验证成功后才把候选状态标记为 `published`;随后清理本地 `tests/logs/**`,不在失败、prepare-only 或 test-only 阶段清理。 -公开验证成功后会清理该平台更早版本的 artifacts 与 release-status,R2 只保留当前最新版本。 +公开验证成功后会清理该平台更早版本的 artifacts 与 release-status,当前发布目标只保留最新版本。 ### 本地保留与清理 @@ -228,13 +258,13 @@ prepare 和 test 的临时代码、依赖与构建目录位于 `.ncurator/releas ### 自动更新与下载 ```text -Windows 清单:https://download.ncurator.com/releases/latest/windows.json -macOS 清单:https://download.ncurator.com/releases/latest/macos.json -Windows 下载:https://download.ncurator.com/downloads/latest/windows-x64.exe -macOS arm64:https://download.ncurator.com/downloads/latest/macos-arm64.dmg -macOS x64:https://download.ncurator.com/downloads/latest/macos-x64.dmg -Windows 更新:https://download.ncurator.com/updates/latest/latest.yml -macOS 更新:https://download.ncurator.com/updates/latest/latest-mac.yml +Windows 清单:https://download.guanzhangai.cn/releases/latest/windows.json +macOS 清单:https://download.guanzhangai.cn/releases/latest/macos.json +Windows 下载:https://download.guanzhangai.cn/downloads/latest/windows-x64.exe +macOS arm64:https://download.guanzhangai.cn/downloads/latest/macos-arm64.dmg +macOS x64:https://download.guanzhangai.cn/downloads/latest/macos-x64.dmg +Windows 更新:https://download.guanzhangai.cn/updates/latest/latest.yml +macOS 更新:https://download.guanzhangai.cn/updates/latest/latest-mac.yml ``` 应用启动 5 秒后静默检查更新,持续运行时每 4 小时检查一次。发现新版本后由用户主动下载;下载完成后可以立即重启或稍后安装。 @@ -253,10 +283,10 @@ Windows 更新采用“先完成应用关闭、再启动安装器”的时序: - Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`。 - 候选包已生成但测试中断:基于原候选包重新执行 `--test-only`。 -- 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:r2:finalize`。 +- 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:storage:finalize`。 - 已发布版本存在问题:通常发布更高版本修复包。回退下载通道不会让已安装客户端自动降级。 -同一个 Tag 的 R2 对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。 +同一个 Tag 的对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。 ## 4. 命令参考 @@ -297,7 +327,8 @@ pnpm test:e2e:package -- --bundle --case --case ```bash pnpm release:status -- --tag -pnpm release:r2:finalize -- --tag +pnpm release:storage:plan +pnpm release:storage:finalize -- --tag ``` 常见失败处理:构建或审计失败时先确认真实运行依赖,不直接放宽包规则;macOS 签名或启动失败时停止该架构发布;正式包 E2E 失败时读取 package summary 和对应 case raw logs;公开 metadata、下载或哈希验证失败时停止更新 latest 通道。 diff --git a/doc/test-plan/settings-test.md b/doc/test-plan/settings-test.md index 2cfdb34b..62a9c049 100644 --- a/doc/test-plan/settings-test.md +++ b/doc/test-plan/settings-test.md @@ -53,7 +53,7 @@ Settings 顶层 suite 对齐 PRD 模块: - What:大模型配置页展示免费 AI Key 官网入口,并保持与配置操作区的正确位置关系。 - Why:用户需要在新增配置前快速获得可体验的 Key,同时外链操作不能破坏当前设置页状态。 - How:进入大模型配置页,检查入口位于配置数量与“新增配置”之间,点击后收集真实 Renderer → Preload 外链调用信号。 -- Oracle:入口位置和可访问名称符合 `SET-MODEL-004-AC1`;外链目标为 `https://www.ncurator.com/zh/free-keys` 且当前路由不变,对齐 `SET-MODEL-004-AC2`。 +- Oracle:入口位置和可访问名称符合 `SET-MODEL-004-AC1`;外链目标为 `https://www.guanzhangai.cn/zh/free-keys` 且当前路由不变,对齐 `SET-MODEL-004-AC2`。 - Not:不访问外部网页内容,也不验证免费 Key 的实时可用性。 #### `model-delete-config-feedback` diff --git a/electron-builder.yml b/electron-builder.yml index 7d1a945e..2b2b18b1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -74,4 +74,4 @@ dmg: npmRebuild: false publish: provider: generic - url: https://download.ncurator.com/updates/latest + url: https://download.guanzhangai.cn/updates/latest diff --git a/package.json b/package.json index 0b3871ae..70984d21 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Local-first AI workspace for documents, research, and task execution", "main": "./out/main/index.js", "author": "NCurator", - "homepage": "https://www.ncurator.com", + "homepage": "https://www.guanzhangai.cn", "scripts": { "format": "prettier --write .", "lint": "eslint --cache .", @@ -34,8 +34,10 @@ "release:win": "node scripts/release/windows.mjs", "release:doctor": "node scripts/release/doctor.mjs", "release:status": "node scripts/release/status.mjs", + "release:storage:plan": "node scripts/release/storage/plan.mjs", "release:signing:setup": "node scripts/release/setup-macos-signing.mjs", "release:mac": "node scripts/release/macos.mjs", + "release:storage:finalize": "node scripts/release/r2/finalize.mjs", "release:r2:finalize": "node scripts/release/r2/finalize.mjs", "test:workbench": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source", "test:workbench:e2e": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source", diff --git a/scripts/release/cos/cam-policy.json b/scripts/release/cos/cam-policy.json new file mode 100644 index 00000000..674f6933 --- /dev/null +++ b/scripts/release/cos/cam-policy.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "statement": [ + { + "effect": "allow", + "action": [ + "name/cos:GetBucket", + "name/cos:HeadObject", + "name/cos:GetObject", + "name/cos:PutObject", + "name/cos:DeleteObject", + "name/cos:InitiateMultipartUpload", + "name/cos:ListMultipartUploads", + "name/cos:ListParts", + "name/cos:UploadPart", + "name/cos:CompleteMultipartUpload", + "name/cos:AbortMultipartUpload" + ], + "resource": ["qcs::cos:ap-guangzhou:uid/1324873382:ncurator-1324873382/*"] + } + ] +} diff --git a/scripts/release/doctor.mjs b/scripts/release/doctor.mjs index d833738f..77bddf63 100644 --- a/scripts/release/doctor.mjs +++ b/scripts/release/doctor.mjs @@ -3,6 +3,7 @@ import process from 'node:process' import { promisify } from 'node:util' import { loadReleaseEnvironment } from './shared/environment.mjs' import { inspectLocalWindowsRelease } from './shared/windows.mjs' +import { resolveReleaseStorageConfig } from './storage/config.mjs' const execFileAsync = promisify(execFile) const ROOT = process.cwd() @@ -22,14 +23,24 @@ if (!inspection.ready) { process.stdout.write('- pnpm configured version: ready\n') process.stdout.write('- AWS CLI: ready\n') - const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` - const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '') + const storage = resolveReleaseStorageConfig() await execFileAsync( inspection.awsExecutable, - ['s3api', 'head-bucket', '--bucket', bucket, '--endpoint-url', endpoint, '--region', 'auto'], - { maxBuffer: 10 * 1024 * 1024 } + [ + 's3api', + 'head-bucket', + '--bucket', + storage.bucket, + '--endpoint-url', + storage.endpoint, + '--region', + storage.region + ], + { env: { ...process.env, ...storage.credentials }, maxBuffer: 10 * 1024 * 1024 } ).catch((error) => { - throw new Error(`R2 access check failed: ${String(error?.stderr || error?.message || error)}`) + throw new Error( + `${storage.label} access check failed: ${String(error?.stderr || error?.message || error)}` + ) }) - process.stdout.write('- Cloudflare R2 access: ready\n') + process.stdout.write(`- ${storage.label} access: ready\n`) } diff --git a/scripts/release/macos.mjs b/scripts/release/macos.mjs index 26dfb199..fccd3e7d 100644 --- a/scripts/release/macos.mjs +++ b/scripts/release/macos.mjs @@ -18,6 +18,7 @@ import { createReleaseStageDeadline } from './shared/stage-deadline.mjs' import { prepareReleaseWorkspace } from './workspace.mjs' import { parseReleaseTag } from './tag.mjs' import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs' +import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs' const execFileAsync = promisify(execFile) const ROOT = process.cwd() @@ -56,6 +57,7 @@ if (!releaseVersion || modes.length !== 1) { ) } const mode = modes[0][0] +if (mode === 'publish') assertLivePublishAllowed() if (includeX64E2E && mode !== 'test') { throw new Error('--include-x64-e2e is only valid with --test-only') } @@ -113,22 +115,7 @@ const validateEnvironment = async () => { } if (mode === 'publish') { - for (const name of [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'R2_ACCOUNT_ID', - 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' - ]) { - if (!String(process.env[name] || '').trim()) { - throw new Error(`missing environment variable: ${name}`) - } - } - if ( - String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') !== 'https://download.ncurator.com' - ) { - throw new Error('R2_PUBLIC_BASE_URL must be https://download.ncurator.com') - } + resolveReleaseStorageConfig() const awsExecutable = String(process.env.NCURATOR_AWS_CLI || '').trim() || path.join(defaultToolRoot, 'awscli', 'bin', 'aws') @@ -227,7 +214,13 @@ const sanitizedTestEnvironment = () => { 'AWS_SECRET_ACCESS_KEY', 'R2_ACCOUNT_ID', 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' + 'R2_PUBLIC_BASE_URL', + 'TENCENT_SECRET_ID', + 'TENCENT_SECRET_KEY', + 'COS_BUCKET', + 'COS_REGION', + 'COS_PUBLIC_BASE_URL', + 'NCURATOR_ALLOW_LIVE_PUBLISH' ]) { delete environment[name] } diff --git a/scripts/release/r2/client.mjs b/scripts/release/r2/client.mjs index bc4a0e2a..5df39e5b 100644 --- a/scripts/release/r2/client.mjs +++ b/scripts/release/r2/client.mjs @@ -1,71 +1,3 @@ -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' -import { promisify } from 'node:util' -import { loadReleaseEnvironment } from '../shared/environment.mjs' - -const execFileAsync = promisify(execFile) - -export const createR2Client = async (root = process.cwd()) => { - await loadReleaseEnvironment(root) - for (const name of [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'R2_ACCOUNT_ID', - 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' - ]) { - if (!String(process.env[name] || '').trim()) - throw new Error(`missing environment variable: ${name}`) - } - const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') - if (publicBaseUrl !== 'https://download.ncurator.com') { - throw new Error(`unexpected R2 public URL: ${publicBaseUrl}`) - } - const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` - const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '') - const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws') - const windowsAws = process.env.ProgramFiles - ? path.join(process.env.ProgramFiles, 'Amazon', 'AWSCLIV2', 'aws.exe') - : '' - const awsExecutable = - String(process.env.NCURATOR_AWS_CLI || '').trim() || - (existsSync(localAws) ? localAws : existsSync(windowsAws) ? windowsAws : 'aws') - const aws = (args) => - execFileAsync(awsExecutable, [...args, '--endpoint-url', endpoint, '--region', 'auto'], { - maxBuffer: 20 * 1024 * 1024 - }) - - const listKeys = async (prefix) => { - const keys = [] - let continuationToken - do { - const args = [ - 's3api', - 'list-objects-v2', - '--bucket', - bucket, - '--prefix', - prefix, - '--output', - 'json' - ] - if (continuationToken) args.push('--continuation-token', continuationToken) - const page = JSON.parse((await aws(args)).stdout) - keys.push(...(page.Contents || []).map((entry) => entry.Key)) - continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined - } while (continuationToken) - return keys - } - - const deleteKeys = async (keys) => { - for (const key of [...new Set(keys)]) { - await aws(['s3api', 'delete-object', '--bucket', bucket, '--key', key]) - process.stdout.write(`[release] removed old R2 object ${key}\n`) - } - } - - return { aws, bucket, publicBaseUrl, listKeys, deleteKeys } -} +// Compatibility entry point. Shared release metadata remains under r2/, while the +// storage client now supports Tencent COS (default) and Cloudflare R2. +export { createReleaseStorageClient as createR2Client } from '../storage/client.mjs' diff --git a/scripts/release/r2/finalize-macos.mjs b/scripts/release/r2/finalize-macos.mjs index 6f9c6f2f..bbcc132d 100644 --- a/scripts/release/r2/finalize-macos.mjs +++ b/scripts/release/r2/finalize-macos.mjs @@ -6,7 +6,7 @@ import process from 'node:process' import { promisify } from 'node:util' import { assertReleaseFragment } from './fragment-contract.mjs' import { fetchWithRetry } from '../shared/http.mjs' -import { createR2Client } from './client.mjs' +import { createReleaseStorageClient } from '../storage/client.mjs' import { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.mjs' import { parseReleaseTag } from '../tag.mjs' @@ -22,7 +22,8 @@ try { throw new Error('usage: node scripts/release/r2/finalize-macos.mjs --tag ') } -const { aws, bucket, publicBaseUrl, listKeys, deleteKeys } = await createR2Client(ROOT) +const { aws, bucket, publicBaseUrl, listKeys, deleteKeys, label } = + await createReleaseStorageClient(ROOT) const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-macos-${releaseTag}-`)) const downloadAndVerifyFragment = async (platform) => { @@ -43,7 +44,7 @@ const downloadAndVerifyFragment = async (platform) => { .stdout ) if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { - throw new Error(`R2 object does not match release fragment: ${key}`) + throw new Error(`${label} object does not match release fragment: ${key}`) } } return fragment diff --git a/scripts/release/r2/finalize-windows.mjs b/scripts/release/r2/finalize-windows.mjs index 6203d8e2..9c56665a 100644 --- a/scripts/release/r2/finalize-windows.mjs +++ b/scripts/release/r2/finalize-windows.mjs @@ -4,7 +4,7 @@ import path from 'node:path' import process from 'node:process' import { assertReleaseFragment } from './fragment-contract.mjs' import { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.mjs' -import { createR2Client } from './client.mjs' +import { createReleaseStorageClient } from '../storage/client.mjs' import { fetchWithRetry } from '../shared/http.mjs' import { parseReleaseTag } from '../tag.mjs' @@ -18,7 +18,8 @@ try { throw new Error('usage: node scripts/release/r2/finalize-windows.mjs --tag ') } -const { aws, bucket, publicBaseUrl, listKeys, deleteKeys } = await createR2Client(ROOT) +const { aws, bucket, publicBaseUrl, listKeys, deleteKeys, label } = + await createReleaseStorageClient(ROOT) const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-windows-${releaseTag}-`)) try { @@ -39,7 +40,7 @@ try { .stdout ) if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { - throw new Error(`R2 object does not match release fragment: ${key}`) + throw new Error(`${label} object does not match release fragment: ${key}`) } } diff --git a/scripts/release/r2/upload-fragment.mjs b/scripts/release/r2/upload-fragment.mjs index 88d0a6b2..a4e1e377 100644 --- a/scripts/release/r2/upload-fragment.mjs +++ b/scripts/release/r2/upload-fragment.mjs @@ -1,31 +1,13 @@ -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { promisify } from 'node:util' -import { loadReleaseEnvironment } from '../shared/environment.mjs' import { assertReleaseFragment } from './fragment-contract.mjs' import { fetchWithRetry } from '../shared/http.mjs' import { artifactKey, statusKey } from './storage-layout.mjs' +import { createReleaseStorageClient } from '../storage/client.mjs' -const execFileAsync = promisify(execFile) -await loadReleaseEnvironment(process.cwd()) const [inputDirectory = '', fragmentPath = ''] = process.argv.slice(2) -const requiredEnvironment = [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'R2_ACCOUNT_ID', - 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' -] -for (const name of requiredEnvironment) { - if (!String(process.env[name] || '').trim()) - throw new Error(`missing environment variable: ${name}`) -} - if (!inputDirectory || !fragmentPath) { throw new Error('usage: node scripts/release/r2/upload-fragment.mjs ') } @@ -33,17 +15,7 @@ if (!inputDirectory || !fragmentPath) { const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')) assertReleaseFragment(fragment) -const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` -const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '') -const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') -const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws') -const awsExecutable = - String(process.env.NCURATOR_AWS_CLI || '').trim() || (existsSync(localAws) ? localAws : 'aws') - -const aws = async (args) => - execFileAsync(awsExecutable, [...args, '--endpoint-url', endpoint, '--region', 'auto'], { - maxBuffer: 10 * 1024 * 1024 - }) +const { aws, bucket, publicBaseUrl, label } = await createReleaseStorageClient() const contentType = (name) => { if (name.endsWith('.json')) return 'application/json' @@ -99,9 +71,9 @@ for (const file of fragment.files) { } const remote = await readRemoteObject(key) - if (!remote) throw new Error(`R2 object disappeared after upload: ${key}`) + if (!remote) throw new Error(`${label} object disappeared after upload: ${key}`) if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { - throw new Error(`R2 verification failed: ${key}`) + throw new Error(`${label} verification failed: ${key}`) } const publicResponse = await fetchWithRetry(`${publicBaseUrl}/${key}`, { @@ -109,7 +81,7 @@ for (const file of fragment.files) { cache: 'no-store' }) if (!publicResponse.ok || Number(publicResponse.headers.get('content-length')) !== file.size) { - throw new Error(`public R2 verification failed: ${key}`) + throw new Error(`public ${label} verification failed: ${key}`) } } diff --git a/scripts/release/shared/environment.mjs b/scripts/release/shared/environment.mjs index e9939570..5a57d578 100644 --- a/scripts/release/shared/environment.mjs +++ b/scripts/release/shared/environment.mjs @@ -25,13 +25,6 @@ export const loadReleaseEnvironment = async (root) => { if (!process.env[name]) process.env[name] = value } - if (!process.env.AWS_ACCESS_KEY_ID && process.env.R2_ACCESS_KEY_ID) { - process.env.AWS_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID - } - if (!process.env.AWS_SECRET_ACCESS_KEY && process.env.R2_SECRET_ACCESS_KEY) { - process.env.AWS_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY - } - if (process.platform === 'win32' && !process.env.NCURATOR_AWS_CLI) { const candidates = [ process.env.ProgramFiles diff --git a/scripts/release/shared/windows.mjs b/scripts/release/shared/windows.mjs index 31d030cc..5d30fdf4 100644 --- a/scripts/release/shared/windows.mjs +++ b/scripts/release/shared/windows.mjs @@ -8,10 +8,11 @@ import { loadReleaseEnvironment } from './environment.mjs' import { assertReleaseFragment } from '../r2/fragment-contract.mjs' import { fetchWithRetry } from './http.mjs' import { artifactKey, statusKey } from '../r2/storage-layout.mjs' +import { DOMESTIC_DOWNLOAD_BASE_URL, resolveReleaseStorageConfig } from '../storage/config.mjs' const execFileAsync = promisify(execFile) const WINDOWS_PLATFORM = 'windows-x64' -const PUBLIC_RELEASE_BASE_URL = 'https://download.ncurator.com' +const PUBLIC_RELEASE_BASE_URL = DOMESTIC_DOWNLOAD_BASE_URL const commandResult = async (command, args, options = {}) => { try { @@ -139,20 +140,10 @@ export const inspectLocalWindowsRelease = async (root, { requirePublish = true } } if (requirePublish) { - for (const name of [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'R2_ACCOUNT_ID', - 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' - ]) { - if (!String(process.env[name] || '').trim()) { - issues.push(`missing environment variable: ${name}`) - } - } - const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL || '').replace(/\/$/u, '') - if (publicBaseUrl && publicBaseUrl !== PUBLIC_RELEASE_BASE_URL) { - issues.push(`R2_PUBLIC_BASE_URL must be ${PUBLIC_RELEASE_BASE_URL}`) + try { + resolveReleaseStorageConfig() + } catch (error) { + issues.push(error instanceof Error ? error.message : String(error)) } } @@ -192,42 +183,46 @@ export const verifyPublicReleaseFragment = async ( publicBaseUrl = PUBLIC_RELEASE_BASE_URL ) => { for (const file of fragment.files) { - const response = await fetchWithRetry(`${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`, { - method: 'HEAD', - cache: 'no-store' - }) + const response = await fetchWithRetry( + `${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`, + { + method: 'HEAD', + cache: 'no-store' + } + ) if (!response.ok || Number(response.headers.get('content-length')) !== file.size) { throw new Error(`remote release asset verification failed: ${file.name}`) } } } -export const verifyR2ReleaseFragment = async (fragment, awsExecutable) => { - const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` - const bucket = String(process.env.R2_BUCKET_NAME || '').replace(/^\/+|\/+$/gu, '') - if (!awsExecutable || !bucket || !process.env.R2_ACCOUNT_ID) { - throw new Error('R2 verification configuration is incomplete') - } +export const verifyReleaseStorageFragment = async (fragment, awsExecutable) => { + const config = resolveReleaseStorageConfig() + if (!awsExecutable) throw new Error(`${config.label} verification configuration is incomplete`) for (const file of fragment.files) { const key = artifactKey(fragment, file.storageName) - const result = await commandResult(awsExecutable, [ - 's3api', - 'head-object', - '--bucket', - bucket, - '--key', - key, - '--output', - 'json', - '--endpoint-url', - endpoint, - '--region', - 'auto' - ]) - if (!result.ok) throw new Error(`R2 release asset lookup failed: ${key}`) + const result = await commandResult( + awsExecutable, + [ + 's3api', + 'head-object', + '--bucket', + config.bucket, + '--key', + key, + '--output', + 'json', + '--endpoint-url', + config.endpoint, + '--region', + config.region + ], + { env: { ...process.env, ...config.credentials } } + ) + if (!result.ok) throw new Error(`${config.label} release asset lookup failed: ${key}`) const remote = JSON.parse(result.stdout) if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { - throw new Error(`R2 release asset does not match fragment: ${key}`) + throw new Error(`${config.label} release asset does not match fragment: ${key}`) } } } diff --git a/scripts/release/status.mjs b/scripts/release/status.mjs index 73dd7c69..5b7b797c 100644 --- a/scripts/release/status.mjs +++ b/scripts/release/status.mjs @@ -6,9 +6,10 @@ import { readPublicReleaseFragment, resolveAwsExecutable, verifyPublicReleaseFragment, - verifyR2ReleaseFragment + verifyReleaseStorageFragment } from './shared/windows.mjs' import { parseReleaseTag } from './tag.mjs' +import { resolveReleaseStorageConfig } from './storage/config.mjs' const ROOT = process.cwd() await loadReleaseEnvironment(ROOT) @@ -22,10 +23,12 @@ try { throw new Error('usage: node scripts/release/status.mjs --tag ') } -const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL || PUBLIC_RELEASE_BASE_URL).replace( - /\/$/u, - '' -) +let publicBaseUrl = PUBLIC_RELEASE_BASE_URL +try { + publicBaseUrl = resolveReleaseStorageConfig().publicBaseUrl +} catch { + // Public status checks can still run against the domestic channel without local credentials. +} const platforms = releaseFamily === 'mac' ? ['macos-arm64', 'macos-x64'] : ['windows-x64'] const awsExecutable = await resolveAwsExecutable() if (!awsExecutable) throw new Error('AWS CLI is required to verify release sha512 metadata') @@ -36,7 +39,7 @@ for (const platform of platforms) { continue } await verifyPublicReleaseFragment(fragment, publicBaseUrl) - await verifyR2ReleaseFragment(fragment, awsExecutable) + await verifyReleaseStorageFragment(fragment, awsExecutable) const application = fragment.audit.applications[0] const installedMiB = (Number(application.totalBytes || 0) / 1024 / 1024).toFixed(1) process.stdout.write(`${platform.padEnd(14)} ready, audited, installed ${installedMiB} MiB\n`) diff --git a/scripts/release/storage/client.mjs b/scripts/release/storage/client.mjs new file mode 100644 index 00000000..02406c4a --- /dev/null +++ b/scripts/release/storage/client.mjs @@ -0,0 +1,69 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { loadReleaseEnvironment } from '../shared/environment.mjs' +import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './config.mjs' + +const execFileAsync = promisify(execFile) + +export const createReleaseStorageClient = async (root = process.cwd()) => { + await loadReleaseEnvironment(root) + assertLivePublishAllowed() + const config = resolveReleaseStorageConfig() + const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws') + const windowsAws = process.env.ProgramFiles + ? path.join(process.env.ProgramFiles, 'Amazon', 'AWSCLIV2', 'aws.exe') + : '' + const awsExecutable = + String(process.env.NCURATOR_AWS_CLI || '').trim() || + (existsSync(localAws) ? localAws : existsSync(windowsAws) ? windowsAws : 'aws') + const aws = (args) => + execFileAsync( + awsExecutable, + [...args, '--endpoint-url', config.endpoint, '--region', config.region], + { + env: { ...process.env, ...config.credentials }, + maxBuffer: 20 * 1024 * 1024 + } + ) + + const listKeys = async (prefix) => { + const keys = [] + let continuationToken + do { + const args = [ + 's3api', + 'list-objects-v2', + '--bucket', + config.bucket, + '--prefix', + prefix, + '--output', + 'json' + ] + if (continuationToken) args.push('--continuation-token', continuationToken) + const page = JSON.parse((await aws(args)).stdout) + keys.push(...(page.Contents || []).map((entry) => entry.Key)) + continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined + } while (continuationToken) + return keys + } + + const deleteKeys = async (keys) => { + for (const key of [...new Set(keys)]) { + await aws(['s3api', 'delete-object', '--bucket', config.bucket, '--key', key]) + process.stdout.write(`[release] removed old ${config.label} object ${key}\n`) + } + } + + return { + ...config, + aws, + awsExecutable, + listKeys, + deleteKeys + } +} diff --git a/scripts/release/storage/config.mjs b/scripts/release/storage/config.mjs new file mode 100644 index 00000000..256f91e2 --- /dev/null +++ b/scripts/release/storage/config.mjs @@ -0,0 +1,68 @@ +import process from 'node:process' + +export const DOMESTIC_DOWNLOAD_BASE_URL = 'https://download.guanzhangai.cn' +export const GLOBAL_DOWNLOAD_BASE_URL = 'https://download.ncurator.com' +export const LIVE_PUBLISH_CONFIRMATION = 'publish-to-production' + +const required = (environment, name) => { + const value = String(environment[name] || '').trim() + if (!value) throw new Error(`missing environment variable: ${name}`) + return value +} + +const normalizedBucket = (value) => value.replace(/^\/+|\/+$/gu, '') +const normalizedBaseUrl = (value) => value.replace(/\/$/u, '') + +export const resolveReleaseStorageConfig = (environment = process.env) => { + const target = String(environment.RELEASE_STORAGE_TARGET || 'cos') + .trim() + .toLowerCase() + if (target === 'cos') { + const region = required(environment, 'COS_REGION') + const publicBaseUrl = normalizedBaseUrl(required(environment, 'COS_PUBLIC_BASE_URL')) + if (publicBaseUrl !== DOMESTIC_DOWNLOAD_BASE_URL) { + throw new Error(`COS_PUBLIC_BASE_URL must be ${DOMESTIC_DOWNLOAD_BASE_URL}`) + } + return { + target, + label: 'Tencent COS', + bucket: normalizedBucket(required(environment, 'COS_BUCKET')), + region, + endpoint: `https://cos.${region}.myqcloud.com`, + publicBaseUrl, + credentials: { + AWS_ACCESS_KEY_ID: required(environment, 'TENCENT_SECRET_ID'), + AWS_SECRET_ACCESS_KEY: required(environment, 'TENCENT_SECRET_KEY'), + AWS_REQUEST_CHECKSUM_CALCULATION: 'WHEN_REQUIRED', + AWS_RESPONSE_CHECKSUM_VALIDATION: 'WHEN_REQUIRED' + } + } + } + if (target === 'r2') { + const publicBaseUrl = normalizedBaseUrl(required(environment, 'R2_PUBLIC_BASE_URL')) + if (publicBaseUrl !== GLOBAL_DOWNLOAD_BASE_URL) { + throw new Error(`R2_PUBLIC_BASE_URL must be ${GLOBAL_DOWNLOAD_BASE_URL}`) + } + return { + target, + label: 'Cloudflare R2', + bucket: normalizedBucket(required(environment, 'R2_BUCKET_NAME')), + region: 'auto', + endpoint: `https://${required(environment, 'R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`, + publicBaseUrl, + credentials: { + AWS_ACCESS_KEY_ID: required(environment, 'R2_ACCESS_KEY_ID'), + AWS_SECRET_ACCESS_KEY: required(environment, 'R2_SECRET_ACCESS_KEY') + } + } + } + throw new Error('RELEASE_STORAGE_TARGET must be cos or r2') +} + +export const assertLivePublishAllowed = (environment = process.env) => { + if (String(environment.NCURATOR_ALLOW_LIVE_PUBLISH || '').trim() !== LIVE_PUBLISH_CONFIRMATION) { + throw new Error( + `live publish is locked; set NCURATOR_ALLOW_LIVE_PUBLISH=${LIVE_PUBLISH_CONFIRMATION} only for an approved production release` + ) + } +} diff --git a/scripts/release/storage/plan.mjs b/scripts/release/storage/plan.mjs new file mode 100644 index 00000000..ff5db582 --- /dev/null +++ b/scripts/release/storage/plan.mjs @@ -0,0 +1,21 @@ +import process from 'node:process' +import { DOMESTIC_DOWNLOAD_BASE_URL } from './config.mjs' + +const plan = { + mode: 'dry-run', + storage: 'Tencent COS', + bucket: String(process.env.COS_BUCKET || 'ncurator-1324873382'), + region: String(process.env.COS_REGION || 'ap-guangzhou'), + publicBaseUrl: String(process.env.COS_PUBLIC_BASE_URL || DOMESTIC_DOWNLOAD_BASE_URL), + writes: [ + 'artifacts///*', + 'release-status//.json', + 'updates/latest/latest*.yml', + 'releases/latest/.json', + 'downloads/latest/*' + ], + networkRequests: 0, + note: 'This command never uploads, deletes, tags, commits, or pushes.' +} + +process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`) diff --git a/scripts/release/windows.mjs b/scripts/release/windows.mjs index 4008b7d2..96cff4a9 100644 --- a/scripts/release/windows.mjs +++ b/scripts/release/windows.mjs @@ -16,8 +16,9 @@ import { inspectLocalWindowsRelease, readPublicReleaseFragment, verifyPublicReleaseFragment, - verifyR2ReleaseFragment + verifyReleaseStorageFragment } from './shared/windows.mjs' +import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs' import { prepareReleaseWorkspace } from './workspace.mjs' import { parseReleaseTag } from './tag.mjs' import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs' @@ -75,6 +76,7 @@ const run = (command, commandArgs, options = {}) => }) const requirePublish = mode === 'publish' +if (requirePublish) assertLivePublishAllowed() const inspection = await inspectLocalWindowsRelease(ROOT, { requirePublish }) if (!inspection.ready) { throw new Error(`local Windows release is unavailable:\n- ${inspection.issues.join('\n- ')}`) @@ -151,7 +153,13 @@ const testEnvironment = (environment) => { 'AWS_SECRET_ACCESS_KEY', 'R2_ACCOUNT_ID', 'R2_BUCKET_NAME', - 'R2_PUBLIC_BASE_URL' + 'R2_PUBLIC_BASE_URL', + 'TENCENT_SECRET_ID', + 'TENCENT_SECRET_KEY', + 'COS_BUCKET', + 'COS_REGION', + 'COS_PUBLIC_BASE_URL', + 'NCURATOR_ALLOW_LIVE_PUBLISH' ]) { delete sanitized[name] } @@ -278,14 +286,15 @@ if (mode === 'test') { process.exit(0) } -const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') +const storage = resolveReleaseStorageConfig() +const publicBaseUrl = storage.publicBaseUrl const existing = await readPublicReleaseFragment(releaseTag, 'windows-x64', publicBaseUrl) if (existing) { await verifyPublicReleaseFragment(existing, publicBaseUrl) - await verifyR2ReleaseFragment(existing, inspection.awsExecutable) + await verifyReleaseStorageFragment(existing, inspection.awsExecutable) await cleanupLocalTestLogsSafely() await cleanupLocalCandidates() - process.stdout.write(`[release] Windows ${releaseTag} is already complete on R2\n`) + process.stdout.write(`[release] Windows ${releaseTag} is already complete on ${storage.label}\n`) process.exit(0) } const candidate = await assertCandidatePublishable({ @@ -308,7 +317,7 @@ await run( { cwd: ROOT, env: process.env } ) await verifyPublicReleaseFragment(candidate.verified.fragment, publicBaseUrl) -await verifyR2ReleaseFragment(candidate.verified.fragment, inspection.awsExecutable) +await verifyReleaseStorageFragment(candidate.verified.fragment, inspection.awsExecutable) await markCandidatePublished({ tag: releaseTag, platform: 'windows-x64' }) await cleanupLocalTestLogsSafely() await cleanupLocalCandidates() diff --git a/src/main/services/telemetry/TelemetryService.ts b/src/main/services/telemetry/TelemetryService.ts index 1f0e249f..b08edd18 100644 --- a/src/main/services/telemetry/TelemetryService.ts +++ b/src/main/services/telemetry/TelemetryService.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' +import { OFFICIAL_WEBSITE_URL } from '../../../shared/constants' export type TelemetryEventName = 'app_started' | 'message_submit_completed' @@ -49,7 +50,7 @@ type PersistedTelemetryState = { type DeliveryResult = 'accepted' | 'discard' | 'retry' -const DEFAULT_ENDPOINT = 'https://www.ncurator.com/api/telemetry/events' +const DEFAULT_ENDPOINT = `${OFFICIAL_WEBSITE_URL}/api/telemetry/events` const MAX_PENDING_EVENTS = 500 const RETRY_BACKOFF_MS = 30_000 const DELIVERY_TIMEOUT_MS = 20_000 diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 482ea185..f7c8a2fb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,7 +1,8 @@ export const APP_ID = 'com.yoan.ncurator' -export const USER_MANUAL_URL = 'https://www.ncurator.com/zh/manual' -export const FREE_AI_KEYS_URL = 'https://www.ncurator.com/zh/free-keys' -export const OFFICIAL_DOWNLOAD_PAGE_URL = 'https://www.ncurator.com/zh' +export const OFFICIAL_WEBSITE_URL = 'https://www.guanzhangai.cn' +export const USER_MANUAL_URL = `${OFFICIAL_WEBSITE_URL}/zh/manual` +export const FREE_AI_KEYS_URL = `${OFFICIAL_WEBSITE_URL}/zh/free-keys` +export const OFFICIAL_DOWNLOAD_PAGE_URL = `${OFFICIAL_WEBSITE_URL}/zh` export const NO_EFFECTIVE_FILE_CHANGE_MESSAGE = '本次未检测到文件内容变化' export const APP_UPDATE_GET_SNAPSHOT_CHANNEL = 'app-update-get-snapshot' diff --git a/tests/case/unit/release/app-update-publish-config.test.ts b/tests/case/unit/release/app-update-publish-config.test.ts index dd8f3446..036505f2 100644 --- a/tests/case/unit/release/app-update-publish-config.test.ts +++ b/tests/case/unit/release/app-update-publish-config.test.ts @@ -2,6 +2,12 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { + FREE_AI_KEYS_URL, + OFFICIAL_DOWNLOAD_PAGE_URL, + OFFICIAL_WEBSITE_URL, + USER_MANUAL_URL +} from '../../../../src/shared/constants' type BuilderConfig = { productName?: string @@ -16,6 +22,21 @@ const readBuilderConfig = async (): Promise => parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as BuilderConfig describe('app update publish config', () => { + it('uses one domestic website base for every desktop website entry', async () => { + const [packageJsonText, telemetryService] = await Promise.all([ + readFile(path.resolve('package.json'), 'utf8'), + readFile(path.resolve('src/main/services/telemetry/TelemetryService.ts'), 'utf8') + ]) + const packageJson = JSON.parse(packageJsonText) as { homepage?: string } + + expect(OFFICIAL_WEBSITE_URL).toBe('https://www.guanzhangai.cn') + expect(USER_MANUAL_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh/manual`) + expect(FREE_AI_KEYS_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh/free-keys`) + expect(OFFICIAL_DOWNLOAD_PAGE_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh`) + expect(packageJson.homepage).toBe(OFFICIAL_WEBSITE_URL) + expect(telemetryService).toContain('`${OFFICIAL_WEBSITE_URL}/api/telemetry/events`') + }) + it('uses the direct update feed and updater-aware NSIS behavior', async () => { const [config, include] = await Promise.all([ readBuilderConfig(), @@ -24,7 +45,7 @@ describe('app update publish config', () => { expect(config.publish).toEqual({ provider: 'generic', - url: 'https://download.ncurator.com/updates/latest' + url: 'https://download.guanzhangai.cn/updates/latest' }) expect(config.nsis?.include).toBe('build/nsis-updated-silent.nsh') expect(config.nsis?.oneClick).toBe(false) @@ -87,14 +108,15 @@ describe('app update publish config', () => { expect(prepareRelease).not.toContain('--actions') }) - it('finalizes Windows and macOS release channels independently', async () => { - const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract] = + it('finalizes Windows and macOS release channels independently with a live-publish lock', async () => { + const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract, storageConfig] = await Promise.all([ readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'), readFile(path.resolve('scripts/release/macos.mjs'), 'utf8'), readFile(path.resolve('scripts/release/r2/finalize-windows.mjs'), 'utf8'), readFile(path.resolve('scripts/release/r2/finalize-macos.mjs'), 'utf8'), - readFile(path.resolve('scripts/release/tag.mjs'), 'utf8') + readFile(path.resolve('scripts/release/tag.mjs'), 'utf8'), + readFile(path.resolve('scripts/release/storage/config.mjs'), 'utf8') ]) expect(windowsRelease).toContain("'r2', 'finalize-windows.mjs'") @@ -106,6 +128,10 @@ describe('app update publish config', () => { expect(windowsFinalize).not.toContain('--keep-old') expect(macFinalize).not.toContain('--keep-old') expect(tagContract).toContain('/^(mac|windows)-v') + expect(windowsRelease).toContain('assertLivePublishAllowed') + expect(macRelease).toContain('assertLivePublishAllowed') + expect(storageConfig).toContain("RELEASE_STORAGE_TARGET || 'cos'") + expect(storageConfig).toContain('download.guanzhangai.cn') }) it('shares source caches directly and warms packaged E2E before parallel workers', async () => { diff --git a/tests/case/unit/release/release-storage-config.test.ts b/tests/case/unit/release/release-storage-config.test.ts new file mode 100644 index 00000000..07da81a2 --- /dev/null +++ b/tests/case/unit/release/release-storage-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + assertLivePublishAllowed, + resolveReleaseStorageConfig +} from '../../../../scripts/release/storage/config.mjs' + +describe('release storage config', () => { + it('uses the domestic COS channel by default', () => { + const config = resolveReleaseStorageConfig({ + COS_BUCKET: 'ncurator-1324873382', + COS_REGION: 'ap-guangzhou', + COS_PUBLIC_BASE_URL: 'https://download.guanzhangai.cn', + TENCENT_SECRET_ID: 'test-id', + TENCENT_SECRET_KEY: 'test-key' + }) + + expect(config.target).toBe('cos') + expect(config.bucket).toBe('ncurator-1324873382') + expect(config.endpoint).toBe('https://cos.ap-guangzhou.myqcloud.com') + expect(config.publicBaseUrl).toBe('https://download.guanzhangai.cn') + }) + + it('retains R2 as an explicit overseas target', () => { + const config = resolveReleaseStorageConfig({ + RELEASE_STORAGE_TARGET: 'r2', + R2_ACCESS_KEY_ID: 'test-id', + R2_SECRET_ACCESS_KEY: 'test-key', + R2_ACCOUNT_ID: 'account', + R2_BUCKET_NAME: 'ncurator', + R2_PUBLIC_BASE_URL: 'https://download.ncurator.com' + }) + + expect(config.target).toBe('r2') + expect(config.endpoint).toBe('https://account.r2.cloudflarestorage.com') + }) + + it('blocks every real publish unless the explicit confirmation is present', () => { + expect(() => assertLivePublishAllowed({})).toThrow('live publish is locked') + expect(() => + assertLivePublishAllowed({ NCURATOR_ALLOW_LIVE_PUBLISH: 'publish-to-production' }) + ).not.toThrow() + }) +})