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
21 changes: 11 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

ScriptsForMinecraftServer is a Minecraft Bedrock Script API (SAPI) plugin with five runtime components sharing one git repo:
ScriptsForMinecraftServer is a Minecraft Bedrock Script API (SAPI) plugin with runtime components sharing one git repo:

| Path | Role | Runtime |
|------|------|---------|
| `db-server/` | SQLite HTTP REST API (port 3001) | Node.js 22.13+ |
| `qq-bridge/` | QQ bridge via LLBot OneBot 11 (WS 3002 only) | Node.js |
| `panel/` | TUI management dashboard | Node.js (Ink) |
| `BDSTools/` | BDS auto-updater + behavior-pack assembler | Node.js |
| `bds-tools/` | BDS auto-updater + behavior-pack assembler | Node.js |
| `sfmc/` | REPL / CLI supervisor / BP build pipeline | Node.js |
| `modules/packages/<id>/` | Per-module packages; each one a first-class citizen | Node.js + SAPI |
| `modules/sdk/@sfmc-sdk/` | Shared SDK (SAPI/Node umbrella) | consumed by modules |
Expand All @@ -38,16 +37,18 @@ and bundles them in one go — there is no manual entry.ts to edit.

```bash
cd db-server
node inedx.js # starts on 127.0.0.1:3001
DB_PORT=4000 node inedx.js # override port
npm run start # node dist/index.js → 127.0.0.1:3001
DB_PORT=4000 npm run start # override port
```

### Management panel
### sfmc CLI(原 panel/ TUI 已移除)

```bash
node panel/index.js # TUI mode (requires interactive terminal)
node panel/index.js --cli # print status and exit (pipe-friendly)
node panel/index.js --no-tui # keep services running, no TUI
node index.js # REPL(交互)
node index.js status # 打印状态后退出
node index.js start <svc> # 启动服务(db / qq / update / manager)
node index.js stop <svc>
node index.js init # 安装向导
```

### Dev tools (run from repo root)
Expand Down Expand Up @@ -89,7 +90,7 @@ Truth source: `modules/catalog.json`(本地 mirror,由已装包投影) + `module
2. `catalog-sync` / install 会把 manifest 投影进 `modules/catalog.json`
3. Run `sfmc behavior-pack build && sfmc behavior-pack deploy`, restart BDS

Module enable/disable at runtime goes through Panel → `POST /api/sfmc/modules/:id/{enable|disable}` → db-server writes `module-lock.json` → SAPI calls `ConfigManager.refreshModules()`. **No hot-reload: restart BDS for changes to take effect.**
Module enable/disable at runtime goes through CLI → `POST /api/sfmc/modules/:id/{enable|disable}` → db-server writes `module-lock.json` → SAPI calls `ConfigManager.refreshModules()`. **No hot-reload: restart BDS for changes to take effect.**

### Configuration Model

Expand Down
1 change: 1 addition & 0 deletions bds-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"./pack-manager": "./dist/cli-pack-manager.js",
"./pack-manager-lib": "./dist/pack-manager.js",
"./world-packs": "./dist/world-packs.js",
"./http": "./dist/http.js",
"./taskbar": "./dist/taskbar.js",
"./update-result": "./dist/update-result.js",
"./zipx": "./dist/zipx.js",
Expand Down
51 changes: 18 additions & 33 deletions bds-tools/src/check-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
*/

import {
bindByteProgressToBar,
createFileSink,
createLogger,
createStdoutSink,
createTerminalProgress,
formatDownloadSpeed,
} from "@sfmc-bds/sdk/logs";
import fs from "node:fs";
import os from "node:os";
Expand Down Expand Up @@ -297,53 +297,38 @@ export async function runUpdate(): Promise<number> {
let lastErr: Error | null = null;
const downloadTimeoutMs = (cfg.download_timeout ?? 120) * 1000;
/* stderr 被 pipe 时不画 bar;用文本进度给 sfmc REPL 等父进程 */
const progressBar = createTerminalProgress({
stream: process.stderr,
logger: (msg) => log.info(msg.startsWith("进度") ? `下载${msg.slice(2)}` : msg),
format: "下载进度 | {bar} | {percentage}% | {value}/{total} MB | 速度: {speed}",
});

let lastTime = Date.now();
let lastLoaded = 0;
let barStarted = false;
if (isTaskbarSupported() && process.stdout.isTTY) {
log.info("检测到 Windows Terminal,任务栏进度已启用 (OSC 9;4)");
}
for (const url of downloadUrls) {
/* 每次尝试新建 bar + binder,避免失败重试时 started 状态残留(LSP) */
const progressBar = createTerminalProgress({
stream: process.stderr,
logger: (msg) => log.info(msg.startsWith("进度") ? `下载${msg.slice(2)}` : msg),
format: "下载进度 | {bar} | {percentage}% | {value}/{total} MB | 速度: {speed}",
});
const onByteProgress = bindByteProgressToBar(progressBar, {
speedSampleMs: 0,
onProgress: (dl, total) => {
const pct = total > 0 ? (dl / total) * 100 : 0;
setTaskbarProgress(pct);
},
});
try {
setTaskbarProgress(0);
await httpDownload(url, zipPath, {
totalTimeoutMs: Math.max(downloadTimeoutMs, 600_000), // 不少于 10 分钟
onProgress: (dl, total) => {
const now = Date.now();
const timeDelta = (now - lastTime) / 1000; // 秒
const bytesDelta = dl - lastLoaded;
const speed = timeDelta > 0 ? bytesDelta / timeDelta : 0;
const speedStr = formatDownloadSpeed(speed);
const totalMb = total > 0 ? total / (1024 * 1024) : 0;
const dlMb = dl / (1024 * 1024);
const pct = total > 0 ? (dl / total) * 100 : 0;

if (!barStarted) {
progressBar.start(totalMb || 1, 0, { speed: "0 KB/s" });
barStarted = true;
setTaskbarProgress(0);
}
progressBar.update(dlMb, { speed: speedStr });
setTaskbarProgress(pct);
lastLoaded = dl;
lastTime = now;
},
onProgress: onByteProgress,
});
if (barStarted) progressBar.stop();
if (progressBar.active) progressBar.stop();
// 下载完成,任务栏收到 100% 绿条后清掉
setTaskbarProgress(100);
clearTaskbarProgress();
downloaded = true;
lastErr = null;
break;
} catch (e) {
if (barStarted) progressBar.stop();
barStarted = false;
if (progressBar.active) progressBar.stop();
// 下载失败,任务栏亮红
setTaskbarProgress(100, "error");
clearTaskbarProgress();
Expand Down
3 changes: 2 additions & 1 deletion bds-tools/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,11 @@ export async function httpDownload(
const stream: Readable = res;
stream.on("data", (chunk: Buffer) => {
downloaded += chunk.length;
if (opts.onProgress && total) {
if (opts.onProgress) {
const now = Date.now();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
lastProgressAt = now;
/* total=0(无 Content-Length)时仍回调,调用方可用已下载字节更新 UI */
opts.onProgress(downloaded, total);
}
}
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk/@sfmc-sdk/src/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ export {
withProgressPaused,
hasActiveProgress,
formatDownloadSpeed,
bindByteProgressToBar,
} from "./terminal-progress.js";
export type {
TerminalProgressOptions,
ProgressHandle,
ProgressLogFn,
DownloadProgressBinderOptions,
} from "./terminal-progress.js";
42 changes: 42 additions & 0 deletions modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,48 @@ export function formatDownloadSpeed(bytesPerSec: number): string {
return `${speed.toFixed(0)} B/s`;
}

export interface DownloadProgressBinderOptions {
/**
* 速度采样间隔(毫秒)。
* >0 时按间隔刷新 lastLoaded;0 表示每次回调都重采样(与旧 BDS 更新行为一致)。
*/
speedSampleMs?: number;
/** 额外进度钩子(如任务栏 OSC),不替代 bar 更新 */
onProgress?: ((downloaded: number, total: number) => void) | undefined;
}

/**
* 把字节进度接到 TerminalProgress(MB 刻度 + 速度文案)。
* BDS 更新与 CF 包下载共用,避免两处复制 lastTime/lastLoaded 环路(DRY)。
*/
export function bindByteProgressToBar(
bar: ProgressHandle,
opts: DownloadProgressBinderOptions = {}
): (downloaded: number, total: number) => void {
const speedSampleMs = opts.speedSampleMs ?? 250;
let lastTime = Date.now();
let lastLoaded = 0;
let started = false;

return (downloaded: number, total: number) => {
const totalMb = total > 0 ? total / (1024 * 1024) : 1;
const dlMb = downloaded / (1024 * 1024);
if (!started) {
bar.start(totalMb, 0, { speed: "0 KB/s" });
started = true;
}
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;
}
opts.onProgress?.(downloaded, total);
};
}

function renderBarLine(
value: number,
total: number,
Expand Down
77 changes: 77 additions & 0 deletions sfmc/pack-update-config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* pack-update 配置 / provider 解析契约测试
* 从 dist 导入权威实现(DRY / LSP)。
* 需先 `npm run build -w @sfmc-bds/cli`。
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { pathToFileURL } from "node:url";

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pack-upd-cfg-"));
process.env.SFMC_ROOT = tmpRoot;
fs.mkdirSync(path.join(tmpRoot, "configs"), { recursive: true });

const { loadPackUpdateConfig, getPackMatchConfig } = await import(
pathToFileURL(path.resolve("dist/pack-update/config.js")).href
);
const { providerShortLabel, resolveConfiguredPackProvider, createPackSourceProvider } = await import(
pathToFileURL(path.resolve("dist/pack-update/providers/index.js")).href
);

describe("pack-update config + provider resolve", () => {
before(() => {
fs.mkdirSync(path.join(tmpRoot, "configs"), { recursive: true });
});

after(() => {
try {
fs.rmSync(tmpRoot, { recursive: true, force: true });
} catch {
/* ignore */
}
});

it("defaultBindingEnabled 默认 false,且出现在类型化配置上", () => {
const cfgPath = path.join(tmpRoot, "configs", "pack-update.json");
if (fs.existsSync(cfgPath)) fs.unlinkSync(cfgPath);
const cfg = loadPackUpdateConfig();
assert.equal(cfg.defaultBindingEnabled, false);
assert.equal(typeof cfg.match.nameMinScore, "number");
});

it("旧版 providers.curseforge.match 提升到顶层 match", () => {
const cfgPath = path.join(tmpRoot, "configs", "pack-update.json");
fs.writeFileSync(
cfgPath,
JSON.stringify({
providers: {
curseforge: {
enabled: true,
apiKey: "",
match: { nameMinScore: 0.91, stripFolderTags: false },
},
},
}),
"utf8"
);
const cfg = loadPackUpdateConfig();
assert.equal(cfg.match.nameMinScore, 0.91);
assert.equal(cfg.match.stripFolderTags, false);
assert.equal(getPackMatchConfig(cfg).nameMinScore, 0.91);
assert.equal("match" in cfg.providers.curseforge, false);
});

it("providerShortLabel / resolveConfiguredPackProvider 契约", () => {
assert.equal(providerShortLabel("curseforge"), "cf");
const cfg = loadPackUpdateConfig();
assert.equal(resolveConfiguredPackProvider(cfg), null);
cfg.providers.curseforge.apiKey = "test-key-not-real";
const p = resolveConfiguredPackProvider(cfg);
assert.ok(p);
assert.equal(p.id, "curseforge");
assert.equal(createPackSourceProvider(cfg, "curseforge").id, "curseforge");
});
});
2 changes: 1 addition & 1 deletion sfmc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"start": "node ./dist/main.js",
"build": "node ../scripts/esbuild-transpile.mjs --dts",
"typecheck": "tsc7 --noEmit -p tsconfig.json",
"test": "npm run build && node --test pack-update-policy.test.mjs terminal-progress.test.mjs",
"test": "npm run build && node --test pack-update-policy.test.mjs pack-update-config.test.mjs terminal-progress.test.mjs",
"prepublishOnly": "npm run build"
},
"dependencies": {
Expand Down
4 changes: 2 additions & 2 deletions sfmc/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,10 @@ export const en = {
"packUpdate.probeHit": "Possible update source: {name} → {hit} ({score})\n {url}",
"packUpdate.editHint": "You can later edit {path} to change/disable sources (enabled: false)",
"packUpdate.confirmBind": "Bind this CurseForge project as update source? {hit}",
"packUpdate.probeAutoBind": "Non-interactive: auto-binding → cf:{slug}\n {path}",
"packUpdate.probeAutoBind": "Non-interactive: auto-binding → {provider}:{slug}\n {path}",
"packUpdate.probeNoAutoBind": "Non-interactive: not auto-binding; use packs bind",
"packUpdate.bindSkipped": "Bind skipped",
"packUpdate.bindOk": "Bound {uuid} → cf:{slug} [{enabled}]\n file: {path}",
"packUpdate.bindOk": "Bound {uuid} → {provider}:{slug} [{enabled}]\n file: {path}",
"packUpdate.bindBpOnly": "Only behavior packs (BP) can be bound",
"packUpdate.resolveFail": "Cannot resolve CurseForge project: {ref}",
"packUpdate.searchEmpty": "No CurseForge results: {query}",
Expand Down
4 changes: 2 additions & 2 deletions sfmc/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,10 @@ export const zhCN: Record<MessageKey, string> = {
"packUpdate.probeHit": "找到可能的更新源: {name} → {hit} ({score})\n {url}",
"packUpdate.editHint": "稍后可编辑 {path} 修改/关闭对应源(enabled: false)",
"packUpdate.confirmBind": "将此 CurseForge 项目绑定为更新源? {hit}",
"packUpdate.probeAutoBind": "非交互环境:自动绑定 → cf:{slug}\n {path}",
"packUpdate.probeAutoBind": "非交互环境:自动绑定 → {provider}:{slug}\n {path}",
"packUpdate.probeNoAutoBind": "非交互环境不自动绑定;可用 packs bind 手动绑定",
"packUpdate.bindSkipped": "已跳过绑定",
"packUpdate.bindOk": "已绑定 {uuid} → cf:{slug} [{enabled}]\n 文件: {path}",
"packUpdate.bindOk": "已绑定 {uuid} → {provider}:{slug} [{enabled}]\n 文件: {path}",
"packUpdate.bindBpOnly": "只能为行为包(BP)绑定更新源",
"packUpdate.resolveFail": "无法解析 CurseForge 项目: {ref}",
"packUpdate.searchEmpty": "CurseForge 无结果: {query}",
Expand Down
48 changes: 11 additions & 37 deletions sfmc/src/pack-update/providers/curseforge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
* - 官方 api.curseforge.com:getMod / files / download(需 Studios x-api-key)
* - 搜索:优先官方;若 403 则回退 api.curse.tools(部分 key 对 /v1/mods/search 被拒)
* - Bedrock gameId = 78022,Addons classId = 4984
* - 下载走 bds-tools httpDownload(流式落盘,与 BDS 更新同一权威 — DRY/DIP)
*/
import { createTerminalProgress, formatDownloadSpeed } from "@sfmc-bds/sdk/logs";
import fs from "node:fs";
import path from "node:path";
import { httpDownload } from "@sfmc-bds/bds-tools/http";
import { bindByteProgressToBar, createTerminalProgress } from "@sfmc-bds/sdk/logs";
import type {
CurseForgeProviderConfig,
PackReleaseType,
Expand Down Expand Up @@ -266,43 +266,17 @@ export class CurseForgeBedrockProvider implements PackSourceProvider {
stream: process.stderr,
format: `下载 ${file.fileName} | {bar} | {percentage}% | {value}/{total} MB | {speed}`,
});
const res = await fetch(file.downloadUrl, {
headers: this.headers(true),
redirect: "follow",
const onByteProgress = bindByteProgressToBar(bar, {
speedSampleMs: 250,
...(onProgress ? { onProgress } : {}),
});
if (!res.ok || !res.body) {
throw new Error(`下载失败 HTTP ${res.status}`);
}
const total = Number(res.headers.get("content-length") ?? 0);
const totalMb = total > 0 ? total / (1024 * 1024) : 1;
bar.start(totalMb, 0, { speed: "0 KB/s" });
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
let lastTime = Date.now();
let lastLoaded = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
const now = Date.now();
const dt = (now - lastTime) / 1000;
const speed = dt > 0 ? (loaded - lastLoaded) / dt : 0;
const speedStr = formatDownloadSpeed(speed);
bar.update(loaded / (1024 * 1024), { speed: speedStr });
onProgress?.(loaded, total);
if (dt >= 0.25) {
lastTime = now;
lastLoaded = loaded;
}
}
await httpDownload(file.downloadUrl, destPath, {
headers: this.headers(true),
onProgress: onByteProgress,
});
} finally {
bar.stop();
if (bar.active) bar.stop();
}
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, buf);
}
}
Loading