diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1295efff31..11d99ff8aa 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -267,6 +267,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT | | [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | | [`vite`](https://github.com/vitejs/vite) | MIT | diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index cee11ae495..4cbef48dc5 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -18,14 +18,14 @@ en: b407de63118c9bf9 zh: de19f1ba9feacbbf /desktop-publication/deployment-and-test-release-identity: - en: 405588e021bcfb60 - zh: d9f9700b0b6db6f8 + en: eed7c4ed0d4e2758 + zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: 2a4c72e884502909 - zh: 29b112db69971cc7 + en: fc2d126835d00f12 + zh: fceda915f9ce5558 /desktop-publication/upload-credentials: en: 3a14a53c7eca80f7 zh: bb122276f6648f74 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 0b5f40901f..5559cd0177 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. -The update destination and upload credentials follow the selected deployment: +The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads): | Environment | Public origin | COS bucket | COS credentials | |---|---|---|---| @@ -71,9 +71,9 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. -Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. -An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including URL, task ID, status events and final result. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; EOF, timeout or access denial does not imply EO canceled the task. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. +An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including versioned plans/results, script fingerprints, URL, task ID and status events. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index a610c72f02..6dd23e1cda 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 -更新目标与上传凭据都与所选环境对应: +更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads): | 环境 | 公开 origin | COS bucket | COS 凭据 | |---|---|---|---| @@ -71,9 +71,9 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo 固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 -生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限,无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 -缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含 URL、任务 ID、状态事件和最终结果。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接结束、超时或权限拒绝不代表 EO 已取消任务。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 +缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index a331c88388..4d42b3dd1e 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -105,3 +105,11 @@ export function resolveDesktopUploadConfig( * @returns Validated channel. */ export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' + +/** + * Read a required nonempty deployment setting. + * @param env Packaging or upload settings. + * @param name Setting name included in missing-value diagnostics. + * @returns Trimmed setting value. + */ +export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index d8d2dac198..835beadd6d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -110,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh * @param {string} name - Environment variable to read. * @returns {string} Trimmed setting. */ -function requiredEnvironmentValue(env, name) { +export function requiredEnvironmentValue(env, name) { const value = env[name]?.trim() if (value === undefined || value === '') { throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`) diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index bf2361a695..edfd9cdf78 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) { */ export function desktopTargetPlatform(target) { assertSupportedTarget(target) - return { - platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'), - arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'), - } + const targets = /** @type {const} */ ({ + 'mac-arm64': { platform: 'darwin', arch: 'arm64' }, + 'mac-x64': { platform: 'darwin', arch: 'x64' }, + 'win-x64': { platform: 'win32', arch: 'x64' }, + }) + return targets[target] } /** diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts index ce7ddf6940..30b530d854 100644 --- a/apps/desktop/scripts/desktop-edgeone.ts +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -1,10 +1,12 @@ /** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' -import { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { teo } from 'tencentcloud-sdk-nodejs-teo' import type { DesktopPackageTargetName } from './package-target.ts' -import { desktopLatestInstallerFilename, resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' import { recordPackagingEvent } from './packaging-run.mjs' /** Validated production destination and credentials; never serialize this value. */ @@ -18,10 +20,14 @@ export interface DesktopPurgeConfig { readonly pollIntervalMs: number } -function required(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]?.trim() - if (!value) throw new DesktopPurgeError(`desktop purge: ${name} must be set in the target dotenv`) - return value +/** SDK operations used by one purge; callers may supply an isolated client. */ +export type DesktopPurgeClient = Pick, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'> + +/** Optional existing task, credential-launcher context, and caller-owned SDK client. */ +export interface DesktopPurgeOptions { + readonly jobId?: string + readonly credentialLauncher?: boolean + readonly client?: DesktopPurgeClient } function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number { @@ -37,19 +43,23 @@ function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): n * Resolve the production URL, EO zone and existing COS credentials before any upload. * @param env Target dotenv settings, with any credential-launcher replacement already applied. * @param target Fixed installer selected by the operator. + * @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery. * @returns Required EO settings; test deployments and invalid settings throw before network writes. */ -export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName): DesktopPurgeConfig { - if (env.DSH_DESKTOP_AUTO_UPDATE_ENV?.trim() !== 'production') throw new DesktopPurgeError('desktop purge: production is required') - const update = resolveDesktopAutoUpdateConfig(env, target === 'win-x64' ? 'win32' : 'darwin', target === 'mac-arm64' ? 'arm64' : 'x64') - const zoneId = required(env, 'DOWNLOAD_PROD_EO_ZONE_ID') +export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, + publicUrl?: string, +): DesktopPurgeConfig { + if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required') + const { platform, arch } = desktopTargetPlatform(target) + const update = resolveDesktopUploadConfig(env, platform, arch) + const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID') if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000) const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000) if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout') return { - target, zoneId, publicUrl: `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, - secretId: required(env, 'DOWNLOAD_PROD_COS_SECRET_ID'), secretKey: required(env, 'DOWNLOAD_PROD_COS_SECRET_KEY'), + target, zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName), timeoutMs, pollIntervalMs, } } @@ -68,15 +78,33 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin return value } +function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined { + return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) +} + +function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean { + const code = errorCode(error, config) + // The pinned SDK wraps transport failures without preserving their original code. + if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) { + return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500)) + } + return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!) + || ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError', + 'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code)) +} + /** * Submit one URL purge, or wait for an existing task without submitting again. * @param config Validated production destination and credentials. * @param recordsRoot Parent directory for separate purge records; upload records remain intact. - * @param existingJobId EO task to resume; omitted submits once, without automatic write retries. + * @param options Existing task, launcher context, and optional SDK client; submission is never retried. * @returns Resolves only after EO reports success for the exact target URL and task ID. * @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged. */ -export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, existingJobId?: string): Promise { +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, + options: DesktopPurgeOptions = {}, +): Promise { + const existingJobId = options.jobId if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) { throw new DesktopPurgeError('desktop purge: invalid --job-id') } @@ -84,6 +112,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR let jobId = existingJobId let stage = 'prepare' let success = false + let observedTask = false + let lastQueryFailed = false let failure: string | undefined let rejected = false let report: DesktopPurgeError | undefined @@ -92,11 +122,12 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`)) process.stdout.write(`desktop purge: record ${directory}\n`) await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) - await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ zoneId: config.zoneId, publicUrl: config.publicUrl, + const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') } + await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, zoneId: config.zoneId, publicUrl: config.publicUrl, target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`, { flag: 'wx', mode: 0o600, flush: true }) const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) } - const client = new teo.v20220901.Client({ + const client = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', profile: { httpProfile }, }) @@ -116,12 +147,28 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR } stage = 'DescribePurgeTasks' while (performance.now() < deadline) { - client.profile.httpProfile = { ...httpProfile, reqTimeout: Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) } - const response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, - Filters: [{ Name: 'job-id', Values: [jobId] }, { Name: 'target', Values: [config.publicUrl] }] }) + client.profile.httpProfile!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) + let response: Awaited> + try { + response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, + Filters: [{ Name: 'job-id', Values: [jobId] }] }) + } catch (error) { + const code = errorCode(error, config) + if (!transientQueryError(error, config)) throw error + lastQueryFailed = true + recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' }) + const remaining = deadline - performance.now() + if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) + continue + } + lastQueryFailed = false if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit') const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? [] + if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) { + throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target') + } const task = tasks[0] + observedTask ||= task !== undefined if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID') const status = task?.Status recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) }) @@ -135,13 +182,18 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR const remaining = deadline - performance.now() if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) } - if (!success) throw new DesktopPurgeError('timed out waiting for EO; task completion is unknown') + if (!success) throw new DesktopPurgeError(observedTask || lastQueryFailed + ? 'timed out waiting for EO; task completion is unknown' + : 'EO task was not found before the deadline; check the zone, job ID and EO task history') } catch (error) { - const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + const code = errorCode(error, config) failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})` + const command = options.credentialLauncher + ? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}` + : `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}` const recovery = rejected - ? `Resolve the EO failure, then retry with pnpm run purge:latest ${config.target}.` - : jobId ? `Resume with pnpm run purge:latest ${config.target} --job-id ${jobId}.` + ? `Resolve the EO failure, then ${command}.` + : jobId ? `Resume with ${command}.` : 'Inspect EO task history before submitting another purge.' const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation') ? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : '' @@ -149,8 +201,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR } finally { if (directory !== undefined) { try { - await writeFile(join(directory, 'result.json'), `${JSON.stringify({ success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) } catch (error) { // An unavailable journal cannot establish completion for a subsequent process. report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`) diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index aa3cd49aa5..6f40c974c7 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) /** Object keys fingerprinted into every upload record to bind evidence to this uploader. */ -const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts'] +const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts'] async function fingerprint(artifact: DesktopUploadArtifact) { const sha512 = createHash('sha512') diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index f94e0fdb72..f95f3cd6e5 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -4,7 +4,8 @@ import { resolve } from 'node:path' import { parseArgs } from 'node:util' import type { DesktopPackageTargetName } from './package-target.ts' import { createDesktopCos } from './desktop-cos.ts' -import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts' import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts' @@ -15,18 +16,13 @@ const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x function targetName(value: string): DesktopPackageTargetName { if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) { - throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) + throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) } return value as DesktopPackageTargetName } -function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string { - const value = environment[name]?.trim() - if (value === undefined || value === '') { - throw new Error(`desktop upload: ${name} must be set to a non-empty value`) - } - return value -} +/** Invalid command arguments or release configuration, reported before network writes. */ +class DesktopUploadUsageError extends Error {} /** * Use the credential launcher's selected deployment only when it matches the packaged release destination. @@ -42,15 +38,14 @@ export function resolveCredentialUploadEnvironment( selected: 'test' | 'production', bucket: string, target: DesktopPackageTargetName, ): NodeJS.ProcessEnv { - const platform = target === 'win-x64' ? 'win32' : 'darwin' - const arch = target === 'mac-arm64' ? 'arm64' : 'x64' + const { platform, arch } = desktopTargetPlatform(target) const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch) if (destination.environment !== selected || destination.bucket !== bucket) { - throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') + throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') } if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected || injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) { - throw new Error('desktop upload: credential launcher environment differs from its explicit arguments') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments') } return { ...fileEnvironment, @@ -59,6 +54,15 @@ export function resolveCredentialUploadEnvironment( } } +function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) { + try { + return resolveDesktopPurgeConfig(environment, target, publicUrl) + } catch (error) { + if (error instanceof DesktopPurgeError) throw error + throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.') + } +} + /** * Upload the selected target, refresh production fixed downloads, or resume only their cache refresh. * @param args Target and upload options from the command line. @@ -66,38 +70,48 @@ export function resolveCredentialUploadEnvironment( * @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { - const { positionals, values } = parseArgs({ args: args.filter(arg => arg !== '--'), allowPositionals: true, options: { + const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { latest: { type: 'boolean' }, 'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] if (target === undefined || positionals.length !== 1) { - throw new Error('desktop upload: expected exactly one target') + throw new DesktopUploadUsageError('desktop upload: expected exactly one target') } const name = targetName(target) if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) { - throw new Error('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + } + let fileEnvironment: NodeJS.ProcessEnv + try { + fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform) + } catch (error) { + throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.') } - const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') const launcher = values['credential-launcher'] === true if (launcher ? values.environment === undefined || values.bucket === undefined : values.environment !== undefined || values.bucket !== undefined) { - throw new Error('desktop upload: credential launcher requires an explicit environment and bucket') + throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket') } if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') { - throw new Error('desktop upload: credential launcher environment must be test or production') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production') } const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment + try { + resolveDesktopAutoUpdateEnvironment(environment) + } catch (error) { + throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".') + } const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') if (values['purge-only']) { - await purgeDesktopInstaller(resolveDesktopPurgeConfig(environment, name), recordsRoot, values['job-id']) + await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher }) return } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) - const purge = plan.environment === 'production' && values.latest ? resolveDesktopPurgeConfig(environment, name) : undefined + const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), @@ -106,7 +120,7 @@ export async function uploadDesktopTarget(args: string[]): Promise { await uploadDesktopRelease(plan, cos, recordsRoot) if (purge !== undefined) { try { - await purgeDesktopInstaller(purge, recordsRoot) + await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher }) } catch (error) { throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`) } @@ -140,13 +154,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void { process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`) } -if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - uploadDesktopTarget(process.argv.slice(2)).catch((error) => { +/** + * Report command failures without exposing raw storage or SDK exceptions. + * @param args Target and options forwarded by the package command or credential launcher. + * @returns Resolves after reporting any failure and setting the process exit status. + */ +export async function runDesktopUploadCommand(args: string[]): Promise { + try { + await uploadDesktopTarget(args) + } catch (error) { + const usage = error instanceof DesktopUploadUsageError + || (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_')) const message = error instanceof DesktopPurgeError ? error.message - : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' - // The DPAPI launcher suppresses raw stderr; this error contains only public purge diagnostics. - const output = error instanceof DesktopPurgeError && process.argv.includes('--credential-launcher') ? process.stdout : process.stderr + : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only [--job-id ]]]; targets: mac-arm64, mac-x64, win-x64.` + : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' + // The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details. + const output = args.includes('--credential-launcher') ? process.stdout : process.stderr output.write(`${message}\n`) process.exitCode = 1 - }) + } +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + void runDesktopUploadCommand(process.argv.slice(2)) } diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index 410ff31384..eb94f61bde 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -58,10 +58,12 @@ $credentials = $null $startInfo = $null $stage = 'decrypt-file' +if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { + Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.' + exit 1 +} + try { - if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { - throw 'PurgeOnly requires Latest and production; JobId requires PurgeOnly.' - } if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows DPAPI is required.' } diff --git a/apps/desktop/tests/desktop-edgeone.spec.ts b/apps/desktop/tests/desktop-edgeone.spec.ts new file mode 100644 index 0000000000..2578b2327b --- /dev/null +++ b/apps/desktop/tests/desktop-edgeone.spec.ts @@ -0,0 +1,220 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts' + +const environment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10', +} +const config = resolveDesktopPurgeConfig(environment, 'mac-arm64') +const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' } +const roots: string[] = [] +let time = 0 + +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) })) + +beforeEach(() => { + time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => time) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) +}) +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-purge-')) + roots.push(root) + // Real SDK initialization, with both network operations replaced before use. + const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } }) + const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' }) + const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] }) + const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) => + purgeDesktopInstaller(config, root, { ...options, client }) + const directory = async () => join(root, (await readdir(root))[0]!) + const result = async (): Promise => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8')) + return { root, client, create, describe, run, directory, result } +} + +function sdkError(code: string) { + return Object.assign(new Error('private service details fixture-secret-key'), { code }) +} + +describe('production EdgeOne configuration', () => { + it.each([ + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u], + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u], + [{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u], + [{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u], + [{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u], + ] as const)('rejects invalid settings %j', (patch, message) => { + expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message) + }) + + it('uses the upload plan URL verbatim and derives only cache-only destinations', () => { + const url = 'https://download.deepseek.com/changed/installer.dmg' + expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url) + expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe') + expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg') + }) +}) + +describe('EdgeOne task tracking', () => { + it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => { + const f = await fixture() + const profile = f.client.profile.httpProfile + f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] }) + await f.run() + expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + expect(f.describe).toHaveBeenCalledTimes(3) + expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] }) + expect(f.client.profile.httpProfile).toBe(profile) + expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 }) + expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' }) + const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8') + const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex') + expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } }) + expect(plan).not.toContain(config.secretId) + expect(plan).not.toContain(config.secretKey) + }) + + it('resumes an existing multi-URL job without resubmission', async () => { + const f = await fixture() + f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] }) + await f.run({ jobId: 'job-1' }) + expect(f.create).not.toHaveBeenCalled() + expect(await f.result()).toMatchObject({ success: true }) + }) + + it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => { + const f = await fixture() + f.describe.mockRejectedValueOnce(sdkError(code)) + await f.run() + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).toHaveBeenCalledTimes(2) + }) + + it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => { + const f = await fixture() + const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '', + ...httpCode === undefined ? {} : { httpCode } }) + f.describe.mockRejectedValue(error) + await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u) + expect(f.describe).toHaveBeenCalledTimes(3) + expect(await f.result()).toMatchObject({ success: false }) + }) + + it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => { + const f = await fixture() + f.describe.mockRejectedValue(sdkError(code)) + await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u')) + expect(f.describe).toHaveBeenCalledOnce() + expect(await f.result()).toMatchObject({ success: false }) + const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8') + expect(evidence).not.toContain('private service details') + expect(evidence).not.toContain(config.secretKey) + }) + + it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => { + const f = await fixture() + f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] }) + await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.') + expect(f.describe).toHaveBeenCalledOnce() + }) + + it('does not poll a rejected submission', async () => { + const f = await fixture() + f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] }) + await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u) + expect(f.describe).not.toHaveBeenCalled() + }) + + it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => { + const f = await fixture() + f.create.mockResolvedValue(response) + await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u) + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).not.toHaveBeenCalled() + }) + + it('never retries a submission with an unknown outcome', async () => { + const f = await fixture() + f.create.mockRejectedValue(sdkError('ECONNRESET')) + await expect(f.run()).rejects.toThrow(/Inspect EO task history/u) + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).not.toHaveBeenCalled() + }) + + it.each([ + [{ Tasks: [] }, /task was not found/u], + [{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u], + ])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => { + const f = await fixture() + f.describe.mockResolvedValue(response) + await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message) + expect(f.describe).toHaveBeenCalledTimes(3) + }) + + it.each([ + [{ TotalCount: 1001 }, /page limit/u], + [{ Tasks: [task, task] }, /multiple entries/u], + [{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u], + [{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u], + ])('refuses ambiguous or mismatched task responses: %j', async (response, message) => { + const f = await fixture() + f.describe.mockResolvedValue(response) + await expect(f.run()).rejects.toThrow(message) + }) + + it('retains the DPAPI launcher and original identity in recovery instructions', async () => { + const f = await fixture() + f.describe.mockRejectedValue(sdkError('UnauthorizedOperation')) + await expect(f.run({ credentialLauncher: true })).rejects + .toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u) + }) + + it('does not report completion when the final record cannot be saved', async () => { + const f = await fixture() + f.describe.mockImplementation(async () => { + await writeFile(join(await f.directory(), 'result.json'), 'occupied') + return { Tasks: [task] } + }) + await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u) + const output = vi.spyOn(process.stdout, 'write') + expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed')) + }) + + it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => { + const f = await fixture() + await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u) + expect(await readdir(f.root)).toEqual([]) + expect(f.create).not.toHaveBeenCalled() + expect(f.describe).not.toHaveBeenCalled() + }) + + it('lazily initializes the pinned SDK when no client is supplied', async () => { + const f = await fixture() + vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) + vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType) { + expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' }) + return { Tasks: [task] } + }) + await purgeDesktopInstaller(config, f.root) + expect(await f.result()).toMatchObject({ success: true }) + }) + + it('accepts the minimal caller-owned client', async () => { + const f = await fixture() + const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe } + await purgeDesktopInstaller(config, f.root, { client }) + expect(await f.result()).toMatchObject({ success: true }) + }) +}) diff --git a/apps/desktop/tests/purge-command.spec.ts b/apps/desktop/tests/purge-command.spec.ts new file mode 100644 index 0000000000..30a7bbe213 --- /dev/null +++ b/apps/desktop/tests/purge-command.spec.ts @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { z } from 'zod' +import { afterEach, expect, it } from 'vitest' + +const execute = promisify(execFile) +const root = fileURLToPath(new URL('../../..', import.meta.url)) +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +it('forwards the documented pnpm purge command through both package scripts to the real argument parser', async () => { + const directory = await mkdtemp(join(tmpdir(), 'desktop-purge-command-')) + roots.push(directory) + const scripts = join(directory, 'apps/desktop/scripts') + await mkdir(scripts, { recursive: true }) + const manifest = z.object({ name: z.string(), scripts: z.object({ 'purge:latest': z.string() }) }) + const repository = manifest.parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))) + const desktop = manifest.parse(JSON.parse(await readFile(join(root, 'apps/desktop/package.json'), 'utf8'))) + await writeFile(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { 'purge:latest': repository.scripts['purge:latest'] } })) + await writeFile(join(directory, 'pnpm-workspace.yaml'), 'packages:\n - apps/desktop\n') + await writeFile(join(directory, 'apps/desktop/package.json'), JSON.stringify({ name: desktop.name, type: 'module', scripts: { 'purge:latest': desktop.scripts['purge:latest'] } })) + await mkdir(join(directory, 'node_modules')) + for (const dependency of ['.bin', 'pnpm', 'tsx', 'semver']) { + await symlink(join(root, 'node_modules', dependency), join(directory, 'node_modules', dependency), 'junction') + } + await copyFile(join(root, 'apps/desktop/scripts/upload-target.ts'), join(scripts, 'upload-target.ts')) + for (const filename of ['desktop-auto-update-environment.mjs', 'desktop-build-paths.mjs']) { + await copyFile(join(root, 'apps/desktop/scripts', filename), join(scripts, filename)) + } + // Only command forwarding and parsing are under test; all external work fails closed. + const stubs = { + 'desktop-package-environment.mjs': "export const loadDesktopPackageEnvironment = () => ({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })", + 'desktop-cos.ts': "export const createDesktopCos = () => { throw new Error('unexpected COS') }", + 'desktop-upload-plan.ts': "export const createDesktopUploadPlan = () => { throw new Error('unexpected installer read') }", + 'desktop-upload-run.ts': "export const uploadDesktopRelease = () => { throw new Error('unexpected upload') }", + 'desktop-release-tag.ts': "export const desktopReleaseTag = () => { throw new Error('unexpected tag') }; export const tagDesktopRelease = desktopReleaseTag", + 'desktop-edgeone.ts': ` + import assert from 'node:assert/strict' + export class DesktopPurgeError extends Error {} + export const resolveDesktopPurgeConfig = (_env, target) => ({ target }) + export const purgeDesktopInstaller = async (config, _root, options) => { + assert.equal(config.target, 'mac-arm64') + assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false }) + console.log('purge-resume: mac-arm64 job-1') + } + `, + } + await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents))) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name))) + const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1'] + const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => { + expect(error).not.toMatchObject({ killed: true }) + throw error + }) + expect(result.stdout).toContain('purge-resume: mac-arm64 job-1') +}) diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts index 860d7867c2..ce15d7d54a 100644 --- a/apps/desktop/tests/upload-target.spec.ts +++ b/apps/desktop/tests/upload-target.spec.ts @@ -1,9 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopPurgeError, purgeDesktopInstaller } from '../scripts/desktop-edgeone.ts' import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts' import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' -import { uploadDesktopTarget } from '../scripts/upload-target.ts' +import { runDesktopUploadCommand, uploadDesktopTarget } from '../scripts/upload-target.ts' + +vi.mock('../scripts/desktop-edgeone.ts', async importOriginal => ({ + ...await importOriginal(), + purgeDesktopInstaller: vi.fn(), +})) vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() })) vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() })) @@ -27,7 +33,7 @@ describe('desktop upload command', () => { { environment: 'test', latest: false, tagged: false }, ] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => { vi.spyOn(process.stdout, 'write').mockReturnValue(true) - const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } + const credentials = { ...productionEnvironment, DSH_DESKTOP_AUTO_UPDATE_ENV: environment, DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } const plan: DesktopUploadPlan = { environment, target: 'win-x64', version: '1.2.3-alpha.4', publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe', @@ -44,6 +50,15 @@ describe('desktop upload command', () => { expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest }) expect(uploadDesktopRelease).toHaveBeenCalledOnce() expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) + if (environment === 'production' && latest) { + expect(purgeDesktopInstaller).toHaveBeenCalledWith( + expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false }, + ) + expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0]) + .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) + } else { + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + } if (tagged) { expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit })) expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0]) @@ -54,3 +69,74 @@ describe('desktop upload command', () => { } }) }) + +const productionEnvironment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', +} + +describe('cache-only recovery command', () => { + beforeEach(() => { + vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment) + }) + + it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => { + await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []]) + expect(createDesktopUploadPlan).not.toHaveBeenCalled() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(tagDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false }) + }) + + it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { + await expect(uploadDesktopTarget(args)).rejects.toThrow() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + }) + + it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.mocked(createDesktopUploadPlan).mockResolvedValue({ + environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg', + bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [], + }) + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1')) + await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u) + expect(uploadDesktopRelease).toHaveBeenCalledOnce() + expect(purgeDesktopInstaller).toHaveBeenCalledOnce() + expect(tagDesktopRelease).not.toHaveBeenCalled() + }) + + it.each([false, true])('reports safe purge diagnostics to the launcher-visible stream: launcher=%s', async (launcher) => { + const previousExitCode = process.exitCode + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + vi.stubEnv('DSH_DESKTOP_AUTO_UPDATE_ENV', 'production') + vi.stubEnv('DOWNLOAD_PROD_COS_BUCKET', 'fixture-bucket') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_ID', 'injected-id') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_KEY', 'injected-key') + try { + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: safe recovery message')) + await runDesktopUploadCommand(['mac-arm64', '--latest', '--purge-only', ...launcher ? ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'] : []]) + expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n') + expect(launcher ? stderr : stdout).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher })) + } finally { + process.exitCode = previousExitCode + vi.unstubAllEnvs() + } + }) + + it('shows usage without referring to a nonexistent record for invalid arguments', async () => { + const previousExitCode = process.exitCode + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + await runDesktopUploadCommand([]) + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Usage:')) + expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('record directory')) + } finally { + process.exitCode = previousExitCode + } + }) +}) diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 8951f4a01b..4346689ff8 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}]) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}` + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -134,7 +134,14 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('child environment verified') }) - it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it('reports invalid purge arguments before decrypting credentials', async () => { + const result = await check('invalid-purge') + expect(result.code).toBe(1) + expect(result.output).toContain('invalid arguments; PurgeOnly requires Latest and production') + expect(result.output).not.toContain('stage=decrypt-file') + }) + + it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4743039c1..dd99015765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,12 +747,12 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../packages/util/home-paths - '@deepseek-ai/dsh-plugin-manager': - specifier: workspace:* - version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-official-bundles': specifier: workspace:* version: link:../../packages/util/official-bundles + '@deepseek-ai/dsh-plugin-manager': + specifier: workspace:* + version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-util-file-drag': specifier: workspace:* version: link:../../packages/util/file-drag @@ -22404,13 +22404,6 @@ packages: engines: {node: '>= 0.12'} form-data@3.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==} engines: {node: '>= 6'}