按 U-Claw-海外化改造方案.md 与 范围决策记录.md 实施。这是 fork,不回上游: 海外版删掉的正是上游的中国市场默认值。 阶段 0 地基 - 下载源全部改国际:脚本/CI 61 处 + lockfile 880 条 npmmirror URL 归零 (lockfile 那 880 条是 npm 的 resolved 字段,脚本层参数化根本绕不过它) - 移除 install.ps1 里三个第三方 GitHub 加速代理,bundle 改直连 + SHA256 校验 (原来只检查"文件大于 1MB"就解压运行) - 技能内容与分发分离:skills/manifest.json 单一来源,install.sh 1170→658 行、 install.ps1 721→546 行,两者技能内容归零 实测原来是三份不一致:skills-cn 完整、install.sh 约 40%、install.ps1 约 17%, 且 7 个通用技能只有 U 盘版有 —— 一键安装的用户一个能用的技能都没有 - Node 版本三种(v22.14/16/22.1)统一,新建 NODE_VERSION 单一来源 - Config 页三份合一。portable/Config.html 用根相对路径调 API 却只从 file:// 打开, 保存功能已静默失效两个月;现缩为 120 行重定向壳 - 测试接入 CI(此前 node --test 无人运行,所有断言形同虚设) 阶段 1 双语可用 - 浏览器侧 i18n:JSON 为源、生成经典 script(file:// 下 fetch 本地 JSON 被拦) 语言跟盘走不跟机器走:启动器写 data/.openclaw/locale.js - 8 处硬编码 lang="zh-CN" 归零,data-i18n 覆盖 213 处,词条 en/zh 各 279 条 - B3 单框 Key:12 张模型卡 → 一个输入框,前缀识别 provider, 服务端 /api/test-key 发 1-token 请求实测,错误映射成人话 Key 填错到得知:从"直到对话失败"降到 ≤1 秒 - 区域格式 SG:DD/MM/YYYY、12 小时、S$、Asia/Singapore (ICU 在 en-SG 下把 SGD 渲染成裸 $,与美元无法区分,故自行拼 S$) - README 内容分叉而非翻译,§1.3 证据清单逐条清零 阶段 2 降门槛 - 启动逻辑上移 lib/start.mjs:Windows-Start.bat 220→28 行、 Mac-Start.command 235→33 行 修掉 Mac 侧两个 bug:控制台端口硬编码 18788(回落时打开死页)、 微信插件从未在 Mac 上安装 - U 盘根目录 23 → 3 个可点文件,其余进 advanced/ - 首启向导:语言 → 用途(7 角色,manifest 驱动)→ 密钥,答过不再问 - 三档界面,Simple 档隐藏一切技术名词 - 自动自愈:启动失败先自查自修,修不好导出脱敏诊断包 (Doctor 从"用户要知道去点的工具"变成后台机制) 阶段 3 技能库 - 19 个英文技能,planned 归零。sg-weather / sg-transport 的端点均实测过 - SkillHub 从 56 张手写第三方卡片改为 manifest 生成:703→125 行,中文归零 其他 - origin.json 收拢所有运行时地址,tests/origin.test.mjs 保证迁移不会漏 - portable/ 下用户可见中文归零(由断言保证) - 82 项测试 未验证(本机无 Windows / 无 pwsh): - install.ps1、setup.ps1 约 210 行改动从未经 PowerShell 解析器 - 完整启动路径仅在假 node + 假 openclaw 上冒烟 - 8 个 .bat 的盘根推导仅静态断言 详见 U盘实测清单.md 受阻: - 隐藏黑窗口 —— 需代码签名证书(.vbs 已被 Windows 弃用,替代方案都要签名) - 场景卡 —— OpenClaw 上游 Dashboard 无预填 prompt 接口 - 官网 36 条 —— 上游 2026-04-14 拆到私有仓库,无权限
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -17,94 +17,154 @@ function lineOf(content, needle) {
|
||||
return lines[index];
|
||||
}
|
||||
|
||||
test('Windows-Start dependency fallback text escapes parentheses inside IF block', () => {
|
||||
const script = readRepoFile('portable', 'Windows-Start.bat');
|
||||
|
||||
assert.match(
|
||||
lineOf(script, 'Falling back to npm install'),
|
||||
/\^\(USB drives may take 20\+ minutes\^\)\./,
|
||||
);
|
||||
assert.match(
|
||||
lineOf(script, 'pre-installed deps'),
|
||||
/\^\(~200 MB\^\)\./,
|
||||
);
|
||||
// The start logic moved out of the .bat/.command into lib/start.mjs so the two
|
||||
// platforms cannot drift and so user-facing text can be translated. These
|
||||
// assertions moved with it — the launchers are now only expected to find node
|
||||
// and hand off.
|
||||
test('Start launchers are thin shells that delegate to lib/start.mjs', () => {
|
||||
for (const scriptName of ['START HERE - Windows.bat', 'START HERE - Mac.command']) {
|
||||
const script = readRepoFile('portable', scriptName);
|
||||
assert.match(script, /start\.mjs/, `${scriptName} should hand off to lib/start.mjs`);
|
||||
const lines = script.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('REM') && !line.trim().startsWith('#'));
|
||||
assert.ok(
|
||||
lines.length <= 30,
|
||||
`${scriptName} should stay a thin shell, got ${lines.length} code lines`,
|
||||
);
|
||||
// Startup responsibilities that must NOT come back into a launcher script.
|
||||
for (const leaked of [/netstat/, /lsof/, /npm.{0,20}install/, /node_modules/]) {
|
||||
assert.doesNotMatch(script, leaked, `${scriptName} should not re-implement ${leaked}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('portable Windows launchers disable OpenClaw bonjour discovery', () => {
|
||||
for (const scriptName of [
|
||||
'Windows-Start.bat',
|
||||
'Windows-Menu.bat',
|
||||
'Windows-Install.bat',
|
||||
]) {
|
||||
const script = readRepoFile('portable', scriptName);
|
||||
test('portable launchers disable OpenClaw bonjour discovery', () => {
|
||||
// Windows-Menu/Install still launch OpenClaw directly, so they keep the env var.
|
||||
for (const scriptName of ['Windows-Menu.bat', 'Windows-Install.bat']) {
|
||||
const script = readRepoFile('portable', 'advanced', scriptName);
|
||||
assert.match(
|
||||
script,
|
||||
/OPENCLAW_DISABLE_BONJOUR=1/,
|
||||
`${scriptName} should disable bonjour discovery`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows startup keeps Config Center available even after model setup', () => {
|
||||
const script = readRepoFile('portable', 'Windows-Start.bat');
|
||||
|
||||
// The Start path now sets it in the shared launcher, for both platforms at once.
|
||||
assert.match(
|
||||
script,
|
||||
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
|
||||
'Windows-Start.bat should always open Config Center for model/channel changes',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/if not defined MODEL_CONFIGURED/,
|
||||
'Config Center should not be gated on first-time setup only',
|
||||
readRepoFile('portable', 'lib', 'start.mjs'),
|
||||
/OPENCLAW_DISABLE_BONJOUR\s*=\s*'1'/,
|
||||
'start.mjs should disable bonjour discovery',
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows gateway fallback does not force-open Dashboard', () => {
|
||||
const script = readRepoFile('portable', 'lib', 'wait-gateway.bat');
|
||||
test('startup always opens Config Center on its real port', () => {
|
||||
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
||||
|
||||
assert.match(
|
||||
script,
|
||||
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
|
||||
'wait-gateway.bat should return users to Config Center on timeout',
|
||||
start,
|
||||
/openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/,
|
||||
'start.mjs should always open Config Center for model/channel changes',
|
||||
);
|
||||
// Mac-Start.command used to hardcode 18788 and opened a dead page whenever the
|
||||
// config server fell back to another port. The port now comes from runtime.json.
|
||||
assert.match(
|
||||
start,
|
||||
/configServerPort/,
|
||||
'the Config Center port must come from runtime.json, not a hardcoded default',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/#token=uclaw/,
|
||||
'fallback should not push configured users straight into Dashboard',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
lineOf(script, ':ready') + '\n' + lineOf(script, 'exit /b 0'),
|
||||
/start ""/,
|
||||
'ready fallback should not open duplicate browser tabs',
|
||||
start,
|
||||
/openUrl\(`http:\/\/127\.0\.0\.1:18788/,
|
||||
'Config Center must not be opened on a hardcoded port',
|
||||
);
|
||||
});
|
||||
|
||||
test('portable launchers route configured model hosts around the system proxy', () => {
|
||||
const winStart = readRepoFile('portable', 'Windows-Start.bat');
|
||||
test('gateway fallback returns users to Config Center, not the Dashboard', () => {
|
||||
// Was lib/wait-gateway.bat (Windows only). Now in start.mjs, so macOS gets it too.
|
||||
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
||||
assert.match(
|
||||
winStart,
|
||||
/resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*set "NO_PROXY=/,
|
||||
'Windows-Start.bat should set NO_PROXY from resolve-no-proxy.mjs',
|
||||
start,
|
||||
/watchGatewayReady[\s\S]*openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/,
|
||||
'the fallback watcher should reopen Config Center on timeout',
|
||||
);
|
||||
const bodyStart = start.indexOf('function watchGatewayReady');
|
||||
const body = start.slice(bodyStart, start.indexOf('\n}', bodyStart));
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/#token=/,
|
||||
'the fallback should not push users straight into the Dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
const macStart = readRepoFile('portable', 'Mac-Start.command');
|
||||
test('startup routes configured model hosts around the system proxy', () => {
|
||||
// One implementation now covers both platforms; this used to be duplicated in
|
||||
// the .bat and the .command with subtly different parsing.
|
||||
assert.match(
|
||||
macStart,
|
||||
/resolve-no-proxy\.mjs[\s\S]*export NO_PROXY=/,
|
||||
'Mac-Start.command should export NO_PROXY from resolve-no-proxy.mjs',
|
||||
readRepoFile('portable', 'lib', 'start.mjs'),
|
||||
/resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*process\.env\.NO_PROXY/,
|
||||
'start.mjs should set NO_PROXY from resolve-no-proxy.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
test('startup keeps the accelerators the launchers used to wire up', () => {
|
||||
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
||||
for (const helper of ['portable-cache.mjs', 'prewarm.mjs', 'check-update.mjs', 'loading.html']) {
|
||||
assert.match(start, new RegExp(helper.replace('.', '\\.')), `start.mjs should still use ${helper}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the WeChat plugin install is no longer Windows-only', () => {
|
||||
// Windows-Start.bat installed it; Mac-Start.command never did, so Mac users
|
||||
// silently had no WeChat channel. It lives in the shared launcher now.
|
||||
assert.match(
|
||||
readRepoFile('portable', 'lib', 'start.mjs'),
|
||||
/openclaw-weixin/,
|
||||
'start.mjs should install the WeChat plugin on every platform',
|
||||
);
|
||||
});
|
||||
|
||||
test('every message key used by start.mjs exists in all catalogues', () => {
|
||||
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
||||
const keys = new Set();
|
||||
for (const [, key] of start.matchAll(/\bt\(\s*'([a-z0-9_.]+)'/g)) keys.add(key);
|
||||
// bail() composes .what/.why/.action from a base key.
|
||||
for (const [, base] of start.matchAll(/bail\(t,\s*'([a-z0-9_.]+)'/g)) {
|
||||
for (const part of ['what', 'why', 'action']) keys.add(`${base}.${part}`);
|
||||
}
|
||||
assert.ok(keys.size > 0, 'expected start.mjs to reference message keys');
|
||||
|
||||
for (const locale of ['en', 'zh-CN']) {
|
||||
const catalogue = JSON.parse(readRepoFile('portable', 'lib', 'messages', `${locale}.json`));
|
||||
const missing = [...keys].filter((key) => !(key in catalogue)).sort();
|
||||
assert.deepEqual(missing, [], `${locale}.json is missing keys:\n${missing.join('\n')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('message catalogues have no untranslated leftovers', () => {
|
||||
const en = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'en.json'));
|
||||
const zh = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'zh-CN.json'));
|
||||
const extra = Object.keys(zh).filter((key) => !(key in en));
|
||||
assert.deepEqual(extra, [], `zh-CN.json has keys English does not: ${extra.join(', ')}`);
|
||||
// Product names and technical identifiers are the same in every language.
|
||||
// Everything else being identical means a string was copied, not translated.
|
||||
const NOT_TRANSLATED_BY_DESIGN = new Set(['start.node_version']);
|
||||
const identical = Object.keys(en).filter(
|
||||
(key) =>
|
||||
!NOT_TRANSLATED_BY_DESIGN.has(key) &&
|
||||
zh[key] === en[key] &&
|
||||
/[A-Za-z]{4,}/.test(en[key]) &&
|
||||
!/^\{/.test(en[key])
|
||||
);
|
||||
assert.deepEqual(identical, [], `zh-CN.json still holds English text for: ${identical.join(', ')}`);
|
||||
});
|
||||
|
||||
test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Chinese on GBK Windows)', () => {
|
||||
// Non-ASCII bytes in a .bat get read as GBK by Chinese Windows cmd.exe, which
|
||||
// garbles parsing ("usebackq is not a command"). Chinese UX must live in the
|
||||
// node tools' stdout (rendered fine under chcp 65001), never in the .bat itself.
|
||||
for (const name of [
|
||||
'Windows-Start.bat',
|
||||
'Windows-IntranetFix.bat',
|
||||
'Windows-LocalModel.bat',
|
||||
'OpenClaw-Doctor.bat',
|
||||
'START HERE - Windows.bat',
|
||||
'advanced/Windows-IntranetFix.bat',
|
||||
'advanced/Windows-LocalModel.bat',
|
||||
'advanced/OpenClaw-Doctor.bat',
|
||||
]) {
|
||||
const bytes = readFileSync(join(repoRoot, 'portable', name));
|
||||
const offending = bytes.findIndex((b) => b > 0x7f);
|
||||
@@ -115,10 +175,10 @@ test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Ch
|
||||
|
||||
test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)', () => {
|
||||
for (const name of [
|
||||
'Mac-Start.command',
|
||||
'Mac-IntranetFix.command',
|
||||
'Mac-LocalModel.command',
|
||||
'Mac-OpenClaw-Doctor.command',
|
||||
'START HERE - Mac.command',
|
||||
'advanced/Mac-IntranetFix.command',
|
||||
'advanced/Mac-LocalModel.command',
|
||||
'advanced/Mac-OpenClaw-Doctor.command',
|
||||
]) {
|
||||
const bytes = readFileSync(join(repoRoot, 'portable', name));
|
||||
const cr = bytes.indexOf(0x0d);
|
||||
@@ -128,18 +188,18 @@ test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)',
|
||||
});
|
||||
|
||||
test('macOS local-model / intranet launchers call the shared cross-platform scripts', () => {
|
||||
assert.match(readRepoFile('portable', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/);
|
||||
assert.match(readRepoFile('portable', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/);
|
||||
assert.match(readRepoFile('portable', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/);
|
||||
assert.match(readRepoFile('portable', 'advanced', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/);
|
||||
assert.match(readRepoFile('portable', 'advanced', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/);
|
||||
assert.match(readRepoFile('portable', 'advanced', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/);
|
||||
});
|
||||
|
||||
test('local-model setup launcher calls setup-local-model.mjs', () => {
|
||||
const bat = readRepoFile('portable', 'Windows-LocalModel.bat');
|
||||
const bat = readRepoFile('portable', 'advanced', 'Windows-LocalModel.bat');
|
||||
assert.match(bat, /lib\\setup-local-model\.mjs/);
|
||||
});
|
||||
|
||||
test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () => {
|
||||
const bat = readRepoFile('portable', 'OpenClaw-Doctor.bat');
|
||||
const bat = readRepoFile('portable', 'advanced', 'OpenClaw-Doctor.bat');
|
||||
assert.match(bat, /OPENCLAW_MJS%" doctor --non-interactive/);
|
||||
// Must not auto-apply repairs that could overwrite user config/state.
|
||||
assert.doesNotMatch(bat, /doctor[^\n]*--fix/);
|
||||
@@ -175,13 +235,13 @@ function unescapedParenEchoesInsideBlocks(bat) {
|
||||
|
||||
test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => {
|
||||
for (const name of [
|
||||
'Windows-Start.bat',
|
||||
'Windows-IntranetFix.bat',
|
||||
'Windows-LocalModel.bat',
|
||||
'OpenClaw-Doctor.bat',
|
||||
'Windows-Diagnose.bat',
|
||||
'Windows-Menu.bat',
|
||||
'Windows-Install.bat',
|
||||
'START HERE - Windows.bat',
|
||||
'advanced/Windows-IntranetFix.bat',
|
||||
'advanced/Windows-LocalModel.bat',
|
||||
'advanced/OpenClaw-Doctor.bat',
|
||||
'advanced/Windows-Diagnose.bat',
|
||||
'advanced/Windows-Menu.bat',
|
||||
'advanced/Windows-Install.bat',
|
||||
]) {
|
||||
const bat = readRepoFile('portable', name);
|
||||
const offenders = unescapedParenEchoesInsideBlocks(bat);
|
||||
@@ -213,3 +273,50 @@ test('Electron desktop launcher disables OpenClaw bonjour discovery on Windows o
|
||||
'bonjour disable flag should not be in the unconditional env object',
|
||||
);
|
||||
});
|
||||
|
||||
test('the drive root stays down to a handful of clickable files', () => {
|
||||
// 23 clickable files in the root was the first thing anyone saw on plugging in,
|
||||
// and they had to work out which applied to their computer before anything ran.
|
||||
const CLICKABLE = /\.(bat|command|html|ps1|sh|exe|vbs)$/i;
|
||||
const root = readdirSync(join(repoRoot, 'portable'), { withFileTypes: true })
|
||||
.filter((e) => e.isFile() && CLICKABLE.test(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
root,
|
||||
['Read me first.html', 'START HERE - Mac.command', 'START HERE - Windows.bat'],
|
||||
'the drive root should hold exactly the two entry points and the guide',
|
||||
);
|
||||
});
|
||||
|
||||
test('entry-point filenames avoid characters cmd.exe treats as syntax', () => {
|
||||
// A filename with brackets breaks every echo that mentions it inside an
|
||||
// IF (...) block — the same flash-exit class as the v2.1.10 regression.
|
||||
for (const name of readdirSync(join(repoRoot, 'portable'))) {
|
||||
assert.doesNotMatch(name, /[()&^|<>]/, `${name} contains a character cmd.exe treats as syntax`);
|
||||
}
|
||||
});
|
||||
|
||||
test('tools moved into advanced/ resolve the drive root one level up', () => {
|
||||
const dir = join(repoRoot, 'portable', 'advanced');
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!/\.(command|sh)$/.test(name)) continue;
|
||||
const body = readFileSync(join(dir, name), 'utf8');
|
||||
if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue;
|
||||
assert.match(
|
||||
body,
|
||||
/(UCLAW_DIR|SCRIPT_DIR)="\$\(cd "\$\(dirname "\$0"\)\/\.\." && pwd\)"/,
|
||||
`${name} must resolve the drive root as one level up`,
|
||||
);
|
||||
}
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.bat')) continue;
|
||||
const body = readFileSync(join(dir, name), 'utf8');
|
||||
if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue;
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/set "(UCLAW_DIR|SCRIPT_DIR)=%~dp0"/,
|
||||
`${name} still treats its own folder as the drive root`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user