diff --git a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts index 5fcc656c..5cb6e80b 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts @@ -113,6 +113,9 @@ export interface DownloadProgressBinderOptions { /** * 把字节进度接到 TerminalProgress(MB 刻度 + 速度文案)。 * BDS 更新与 CF 包下载共用,避免两处复制 lastTime/lastLoaded 环路(DRY)。 + * + * LSP:与 httpDownload 契约对齐——中途 total=0(无 Content-Length)仍可回调; + * 总量一旦变为已知(含 finish 用 finalBytes 补总量),须重设 bar 刻度,避免一直卡在占位 1MB。 */ export function bindByteProgressToBar( bar: ProgressHandle, @@ -122,18 +125,30 @@ export function bindByteProgressToBar( let lastTime = Date.now(); let lastLoaded = 0; let started = false; + /** 是否已用真实 Content-Length / finish 总量启动过刻度 */ + let knownTotal = false; return (downloaded: number, total: number) => { - const totalMb = total > 0 ? total / (1024 * 1024) : 1; + const hasTotal = total > 0; + const totalMb = hasTotal ? total / (1024 * 1024) : 1; const dlMb = downloaded / (1024 * 1024); + const now = Date.now(); + const dt = (now - lastTime) / 1000; + const speed = dt > 0 ? (downloaded - lastLoaded) / dt : 0; + const speedText = formatDownloadSpeed(speed); + if (!started) { bar.start(totalMb, 0, { speed: "0 KB/s" }); started = true; + knownTotal = hasTotal; + } else if (hasTotal && !knownTotal) { + /* 未知→已知:重 start 以修正 total(ProgressHandle 无独立 setTotal) */ + bar.start(totalMb, dlMb, { speed: speedText }); + knownTotal = true; + } else { + bar.update(dlMb, { speed: speedText }); } - const now = Date.now(); - const dt = (now - lastTime) / 1000; - const speed = dt > 0 ? (downloaded - lastLoaded) / dt : 0; - bar.update(dlMb, { speed: formatDownloadSpeed(speed) }); + if (speedSampleMs <= 0 || now - lastTime >= speedSampleMs) { lastTime = now; lastLoaded = downloaded; diff --git a/sfmc/src/i18n/locales/en.ts b/sfmc/src/i18n/locales/en.ts index 8eb84814..a6db48c4 100644 --- a/sfmc/src/i18n/locales/en.ts +++ b/sfmc/src/i18n/locales/en.ts @@ -374,7 +374,7 @@ export const en = { "packs.usage.enableNote": "Install enables via world_*_packs.json by default; {hint}.", "packs.usage.moduleNote": "For module aggregation use {cmd} (not this command).", - "packUpdate.needKey": "CurseForge API key missing. Get a Studios key from https://console.curseforge.com/, set providers.curseforge.apiKey in {path} or CURSEFORGE_API_KEY (not the Upload API UUID token)", + "packUpdate.needKey": "No pack update provider configured. Set providers.*.apiKey in {path} (or the matching env var). CurseForge: get a Studios key from https://console.curseforge.com/ → providers.curseforge.apiKey / CURSEFORGE_API_KEY (not the Upload API UUID token)", "packUpdate.probeNoName": "Cannot derive a searchable name from header/folder", "packUpdate.probeQueries": "Probe sources header={header} folder={folder}\n → {queries}", "packUpdate.probeFail": "Source probe failed: {message}", diff --git a/sfmc/src/i18n/locales/zh-CN.ts b/sfmc/src/i18n/locales/zh-CN.ts index 246739ef..0b17843a 100644 --- a/sfmc/src/i18n/locales/zh-CN.ts +++ b/sfmc/src/i18n/locales/zh-CN.ts @@ -347,7 +347,7 @@ export const zhCN: Record = { "packs.usage.enableNote": "安装默认写入 world_*_packs.json 启用;{hint}。", "packs.usage.moduleNote": "模块聚合请用 {cmd}(非本命令)。", - "packUpdate.needKey": "未配置 CurseForge API Key。请到 https://console.curseforge.com/ 申请 Studios Key,写入 {path} 的 providers.curseforge.apiKey,或设置 CURSEFORGE_API_KEY(不要用 Upload API 的 UUID Token)", + "packUpdate.needKey": "未配置任何 pack 更新源。请编辑 {path} 的 providers.*.apiKey(或对应环境变量)。CurseForge:https://console.curseforge.com/ 申请 Studios Key → providers.curseforge.apiKey / CURSEFORGE_API_KEY(不要用 Upload API 的 UUID Token)", "packUpdate.probeNoName": "无法从 manifest / 文件夹名派生可搜索名称", "packUpdate.probeQueries": "探测查询源 header={header} folder={folder}\n → {queries}", "packUpdate.probeFail": "探测更新源失败: {message}", diff --git a/sfmc/src/pack-update/service.ts b/sfmc/src/pack-update/service.ts index 2c043999..06f48d96 100644 --- a/sfmc/src/pack-update/service.ts +++ b/sfmc/src/pack-update/service.ts @@ -62,11 +62,33 @@ function bindingEnabledLabel(enabled: boolean): string { return enabled ? t("packs.list.on").trim() : t("packs.list.off").trim(); } -/** 未配置源时的统一文案(DRY:入口勿各自拼 needKey) */ +/** 未配置源时的统一文案(DRY:入口勿各自拼 needKey;文案源无关 — OCP) */ function needKeyText(): string { return t("packUpdate.needKey", { path: packUpdateConfigPath() }); } +/** bindOk 参数唯一构造点(probe / bind 共用 — DRY),报告实际 binding.enabled(LSP) */ +function bindOkParams(opts: { + uuid: string; + provider: PackSourceBinding["provider"] | SourceSearchHit["provider"]; + slug: string; + enabled: boolean; +}): Record { + return { + uuid: opts.uuid, + provider: providerShortLabel(opts.provider), + slug: opts.slug, + enabled: bindingEnabledLabel(opts.enabled), + path: packSourcesPath(), + }; +} + +/** 就地补丁并落盘(prepareCheck 多出口共用 — DRY;勿散落 Object.assign+setBinding) */ +function touchBinding(bpUuid: string, binding: PackSourceBinding, patch?: Partial): void { + if (patch) Object.assign(binding, patch); + setBinding(bpUuid, binding); +} + /** 多查询搜索 + 综合打分排序(probe / searchRemote 共用) */ async function searchAndRankHits( provider: PackSourceProvider, @@ -227,14 +249,15 @@ export async function probeSourceAfterInstall(opts: { const binding = makeBindingFromHit(best.hit, paired, { defaultEnabled: cfg.defaultBindingEnabled }); setBinding(opts.info.uuid, binding); logPack( - t("packUpdate.bindOk", { - uuid: opts.info.uuid, - provider: providerShortLabel(best.hit.provider), - slug: best.hit.slug, - /* 报告实际写入的 binding.enabled(LSP),勿再并列一份 cfg 派生字段 */ - enabled: bindingEnabledLabel(binding.enabled), - path: packSourcesPath(), - }), + t( + "packUpdate.bindOk", + bindOkParams({ + uuid: opts.info.uuid, + provider: best.hit.provider, + slug: best.hit.slug, + enabled: binding.enabled, + }) + ), "success" ); } @@ -279,13 +302,15 @@ export async function bindPackSource(packId: string, ref: string): Promise { + it("Writable 可接收进度输出", () => { + const chunks = []; + const stream = new Writable({ + write(chunk, _e, cb) { + chunks.push(String(chunk)); + cb(); + }, + }); + stream.write("progress-line\n"); + assert.ok(chunks[0].includes("progress")); + }); +}); -describe("progress pause/resume contract", () => { - it("嵌套 pause 只在最外层 resume 时重绘", () => { - let pauses = 0; - let resumes = 0; - const id = nextId++; - active.set(id, { - pause: () => { - pauses++; +describe("bindByteProgressToBar LSP(未知总量→已知)", () => { + it("无 Content-Length 中途 total=0,finish 补总量时重设 bar 刻度", () => { + const starts = []; + const updates = []; + const bar = { + active: true, + start(total, value = 0, payload) { + starts.push({ total, value, payload }); }, - resume: () => { - resumes++; + update(value, payload) { + updates.push({ value, payload }); }, + stop() {}, + }; + const seen = []; + const onByte = bindByteProgressToBar(bar, { + speedSampleMs: 0, + onProgress: (dl, total) => seen.push([dl, total]), }); - pauseAllProgress(); - pauseAllProgress(); - assert.equal(pauses, 1); - resumeAllProgress(); - assert.equal(resumes, 0); - resumeAllProgress(); - assert.equal(resumes, 1); - active.delete(id); - pausedDepth = 0; + + /* 中途无 Content-Length */ + onByte(512 * 1024, 0); + assert.equal(starts.length, 1); + assert.equal(starts[0].total, 1); /* 占位 1MB */ + assert.equal(seen[0][0], 512 * 1024); + assert.equal(seen[0][1], 0); + + /* finish:用 finalBytes 作总量(与 httpDownload LSP 契约一致) */ + const finalBytes = 5 * 1024 * 1024; + onByte(finalBytes, finalBytes); + assert.equal(starts.length, 2, "总量从未知变为已知时应重 start"); + assert.equal(starts[1].total, 5); + assert.equal(starts[1].value, 5); + assert.deepEqual(seen[1], [finalBytes, finalBytes]); }); - it("Writable 可接收进度输出", () => { + it("一开始就有 Content-Length 时不重复 start", () => { + const starts = []; + const bar = { + active: true, + start(total, value = 0) { + starts.push({ total, value }); + }, + update() {}, + stop() {}, + }; + const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); + onByte(1024, 10 * 1024 * 1024); + onByte(2 * 1024 * 1024, 10 * 1024 * 1024); + assert.equal(starts.length, 1); + assert.equal(starts[0].total, 10); + }); + + it("createTerminalProgress + binder 在 forceBar 下可跑通收尾", () => { const chunks = []; const stream = new Writable({ write(chunk, _e, cb) { @@ -58,7 +82,11 @@ describe("progress pause/resume contract", () => { cb(); }, }); - stream.write("progress-line\n"); - assert.ok(chunks[0].includes("progress")); + const bar = createTerminalProgress({ stream, forceBar: true }); + const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); + onByte(100, 0); + onByte(2048, 2048); + bar.stop(); + assert.ok(chunks.length > 0); }); });