Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion sfmc/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 1 addition & 1 deletion sfmc/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export const zhCN: Record<MessageKey, string> = {
"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 Keyproviders.curseforge.apiKey / CURSEFORGE_API_KEY(不要用 Upload API 的 UUID Token)",
"packUpdate.probeNoName": "无法从 manifest / 文件夹名派生可搜索名称",
"packUpdate.probeQueries": "探测查询源 header={header} folder={folder}\n → {queries}",
"packUpdate.probeFail": "探测更新源失败: {message}",
Expand Down
81 changes: 52 additions & 29 deletions sfmc/src/pack-update/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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<PackSourceBinding>): void {
if (patch) Object.assign(binding, patch);
setBinding(bpUuid, binding);
}

/** 多查询搜索 + 综合打分排序(probe / searchRemote 共用) */
async function searchAndRankHits(
provider: PackSourceProvider,
Expand Down Expand Up @@ -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"
);
}
Expand Down Expand Up @@ -279,13 +302,15 @@ export async function bindPackSource(packId: string, ref: string): Promise<strin
});
setBinding(pack.uuid, binding);
return c.green(
t("packUpdate.bindOk", {
uuid: pack.uuid,
provider: providerShortLabel(hit.provider),
slug: hit.slug,
enabled: bindingEnabledLabel(binding.enabled),
path: packSourcesPath(),
})
t(
"packUpdate.bindOk",
bindOkParams({
uuid: pack.uuid,
provider: hit.provider,
slug: hit.slug,
enabled: binding.enabled,
})
)
);
}

Expand Down Expand Up @@ -369,8 +394,7 @@ async function prepareCheck(
const localVer: SemVer3 = localBp?.version ?? [0, 0, 0];
const provider = createPackSourceProvider(cfg, binding.provider);

binding.lastCheckedAt = new Date().toISOString();
setBinding(bpUuid, binding);
touchBinding(bpUuid, binding, { lastCheckedAt: new Date().toISOString() });

if (!binding.enabled) {
return withLocalBp(
Expand All @@ -389,8 +413,7 @@ async function prepareCheck(

/* 已成功应用(或确认无需覆盖)同一 fileId:不再下载 */
if (binding.lastAppliedFileId != null && binding.lastAppliedFileId === file.fileId) {
binding.lastFileId = file.fileId;
setBinding(bpUuid, binding);
touchBinding(bpUuid, binding, { lastFileId: file.fileId });
return withLocalBp(
baseCheckFields(bpUuid, name, localVer, binding, {
fileId: file.fileId,
Expand Down Expand Up @@ -444,11 +467,10 @@ async function prepareCheck(
* 远程 BP 版本未更高:无需覆盖安装,但仍记下 fileId,
* 否则每次启动都会重复下载同一归档(lastApplied 一直为空)。
*/
binding.lastFileId = file.fileId;
if (!decision.remoteNewer) {
binding.lastAppliedFileId = file.fileId;
}
setBinding(bpUuid, binding);
touchBinding(bpUuid, binding, {
lastFileId: file.fileId,
...(decision.remoteNewer ? {} : { lastAppliedFileId: file.fileId }),
});

return withLocalBp(
{
Expand Down Expand Up @@ -584,10 +606,11 @@ async function applyUpdate(r: CheckResult, cfg: PackUpdateConfig): Promise<{ ok:
await enableInstalledPack({ bdsRoot, levelName, info: rpInfo });
}

r.binding.lastAppliedFileId = r.fileId;
r.binding.lastFileId = r.fileId;
r.binding.lastCheckedAt = new Date().toISOString();
setBinding(r.bpUuid, r.binding);
touchBinding(r.bpUuid, r.binding, {
lastAppliedFileId: r.fileId,
lastFileId: r.fileId,
lastCheckedAt: new Date().toISOString(),
});

return {
ok: true,
Expand Down
114 changes: 71 additions & 43 deletions sfmc/terminal-progress.test.mjs
Original file line number Diff line number Diff line change
@@ -1,64 +1,92 @@
/**
* terminal-progress 注册表测试(构建后从 dist 导入;此处内联最小实现验证契约)
* terminal-progress / bindByteProgressToBar 契约测试
* 从 @sfmc-bds/sdk/logs 导入权威实现(需 workspace build)。
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Writable } from "node:stream";
import { bindByteProgressToBar, createTerminalProgress } from "@sfmc-bds/sdk/logs";

const active = new Map();
let nextId = 1;
let pausedDepth = 0;

function pauseAllProgress() {
if (active.size === 0) return;
pausedDepth++;
if (pausedDepth !== 1) return;
for (const e of active.values()) e.pause();
}
function resumeAllProgress() {
if (active.size === 0) {
pausedDepth = 0;
return;
}
if (pausedDepth <= 0) return;
pausedDepth--;
if (pausedDepth !== 0) return;
for (const e of active.values()) e.resume();
}
describe("progress pause/resume contract (registry)", () => {
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) {
chunks.push(String(chunk));
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);
});
});