u1s1-cli 1.11.8 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/agent-setup.d.ts +5 -0
  2. package/dist/agent-setup.js +31 -7
  3. package/dist/api.js +42 -34
  4. package/dist/bench-report.js +12 -9
  5. package/dist/bench.js +34 -31
  6. package/dist/brand.d.ts +2 -1
  7. package/dist/brand.js +38 -12
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +25 -8
  10. package/dist/context-usage.js +12 -9
  11. package/dist/deploy.js +49 -45
  12. package/dist/device-auth.js +7 -5
  13. package/dist/error-humanize.d.ts +1 -0
  14. package/dist/error-humanize.js +18 -6
  15. package/dist/feedback.js +48 -31
  16. package/dist/i18n.d.ts +21 -0
  17. package/dist/i18n.js +46 -0
  18. package/dist/import/index.js +64 -28
  19. package/dist/import/skills.js +72 -41
  20. package/dist/import/util.js +3 -2
  21. package/dist/import/write.js +2 -1
  22. package/dist/index.js +139 -48
  23. package/dist/login.js +39 -28
  24. package/dist/loop.js +18 -12
  25. package/dist/mcp/client.js +15 -14
  26. package/dist/mcp/command.js +76 -48
  27. package/dist/mcp/extension.js +5 -3
  28. package/dist/mcp/tools.js +6 -5
  29. package/dist/model.js +11 -10
  30. package/dist/nudges.d.ts +8 -2
  31. package/dist/nudges.js +22 -3
  32. package/dist/search-tools.js +7 -6
  33. package/dist/shell-doctor.js +9 -8
  34. package/dist/subagent.js +5 -4
  35. package/dist/tools.js +53 -37
  36. package/dist/update.js +29 -28
  37. package/dist/usage.d.ts +1 -1
  38. package/dist/usage.js +96 -43
  39. package/dist/web.js +2 -1
  40. package/dist/workflow/runner.js +33 -29
  41. package/dist/workflow/templates.js +70 -45
  42. package/dist/workflow/tool.js +15 -12
  43. package/package.json +2 -2
  44. package/scripts/patch-pi.js +243 -278
  45. package/scripts/render-regression.mjs +55 -4
package/dist/brand.js CHANGED
@@ -1,9 +1,18 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve, sep } from "node:path";
3
+ import { activeSite } from "./config.js";
4
+ import { isEnglish, tr } from "./i18n.js";
3
5
  import { siteLinks } from "./site.js";
4
6
  export const BRAND_NAME = "u1s1";
5
7
  export const BRAND_CN = "有一说一";
6
- export const BRAND_TAGLINE = "说人话的 AI 编程搭子";
8
+ /** 中文品牌名只在中国站界面出现;国际站只叫 u1s1。 */
9
+ export function brandTagline() {
10
+ return tr("说人话的 AI 编程搭子", "the plain-spoken AI coding assistant");
11
+ }
12
+ /** 横幅里品牌名后面那一行:中国站「有一说一 · 标语」,国际站只有英文标语。 */
13
+ function brandSubtitle() {
14
+ return isEnglish() ? "Plain-spoken AI coding assistant" : `${BRAND_CN} · ${brandTagline()}`;
15
+ }
7
16
  /**
8
17
  * 中国站仪表盘。只为 embed 导出保留(Desktop App 现在只有中国站包,P3 出 intl 包时
9
18
  * 改用自带的站点配置);CLI 内部一律按当前站点取 siteLinks(activeSite()).dashboard。
@@ -30,10 +39,10 @@ export function cwdAdvice(cwd, dirs = {}) {
30
39
  const here = resolve(cwd);
31
40
  const within = (dir) => here === dir || here.startsWith(dir + sep);
32
41
  if (within(configDir)) {
33
- return "当前在 u1s1 的配置目录里(存着登录凭据和设置),模型的文件工具会直接看到它们;建议 /exit 后 cd 到你的项目文件夹再运行 u1s1";
42
+ return tr("当前在 u1s1 的配置目录里(存着登录凭据和设置),模型的文件工具会直接看到它们;建议 /exit 后 cd 到你的项目文件夹再运行 u1s1", "You're inside u1s1's config folder (it holds your sign-in credentials and settings), so the model's file tools can see them. /exit, cd to your project folder, and run u1s1 again.");
34
43
  }
35
44
  if (here === home) {
36
- return "当前在用户主目录,模型找文件会翻遍整个主目录,又慢又容易改错地方;建议 /exit 后 cd 到项目文件夹再运行 u1s1";
45
+ return tr("当前在用户主目录,模型找文件会翻遍整个主目录,又慢又容易改错地方;建议 /exit 后 cd 到项目文件夹再运行 u1s1", "You're in your home folder, so the model will search all of it: slow, and easy to edit the wrong files. /exit, cd to a project folder, and run u1s1 again.");
37
46
  }
38
47
  return undefined;
39
48
  }
@@ -72,13 +81,25 @@ function announcementLine(theme, a) {
72
81
  }
73
82
  /** 空目录首跑引导:不让新手面对空白输入框想 prompt,给几句能照抄的开场白。 */
74
83
  function starterLines(theme) {
75
- return [
84
+ if (isEnglish()) {
85
+ return [
86
+ ` ${theme.fg("muted", "Empty folder? Just describe what you want to build, for example:")}`,
87
+ ` ${theme.fg("text", "\"Build a personal landing page with a short bio\"")}`,
88
+ ` ${theme.fg("text", "\"Make a birthday card page for a friend with a little animation\"")}`,
89
+ ` ${theme.fg("text", "\"Write a small tool that renames the photos in this folder by date\"")}`,
90
+ ];
91
+ }
92
+ const lines = [
76
93
  ` ${theme.fg("muted", "这个文件夹还是空的?把想做的直接说出来就行,比如:")}`,
77
94
  ` ${theme.fg("text", "「做一个自我介绍网页,做完帮我发布出去」")}`,
78
95
  ` ${theme.fg("text", "「做一个给朋友的生日祝福页面,要有点小动画」")}`,
79
96
  ` ${theme.fg("text", "「写一个把文件夹里照片按日期重命名的小工具」")}`,
80
- ` ${theme.fg("muted", "网页做好后,输入 /exit 回到终端,运行 u1s1 deploy --public 一键上线,拿到可分享的链接发给朋友")}`,
81
97
  ];
98
+ // 国际站没有网页托管(u1s1 deploy),不提发布
99
+ if (activeSite().id !== "intl") {
100
+ lines.push(` ${theme.fg("muted", "网页做好后,输入 /exit 回到终端,运行 u1s1 deploy --public 一键上线,拿到可分享的链接发给朋友")}`);
101
+ }
102
+ return lines;
82
103
  }
83
104
  /**
84
105
  * Startup hero, responsive to terminal width:
@@ -87,13 +108,18 @@ function starterLines(theme) {
87
108
  export function renderBrandHeader(theme, input) {
88
109
  const name = theme.bold(theme.fg("text", `${BRAND_NAME} v${input.version}`)) +
89
110
  (input.notice ? ` ${theme.fg("accent", input.notice)}` : "");
90
- const brand = theme.fg("muted", `${BRAND_CN} · ${BRAND_TAGLINE}`);
91
- const dir = theme.fg("dim", `cwd: ${formatHomePath(input.cwd)}${input.account ? ` · 账号 ${input.account}` : ""}`);
92
- const hints = theme.fg("dim", "/help · Ctrl+V 图片 · Shift+Enter 换行 · Esc 中断");
111
+ const brand = theme.fg("muted", brandSubtitle());
112
+ const dir = theme.fg("dim", `cwd: ${formatHomePath(input.cwd)}${input.account ? tr(` · 账号 ${input.account}`, ` · account ${input.account}`) : ""}`);
113
+ const hints = theme.fg("dim", tr("/help · Ctrl+V 图片 · Shift+Enter 换行 · Esc 中断", "/help · Ctrl+V image · Shift+Enter newline · Esc stop"));
93
114
  const starter = input.starterTips ? starterLines(theme) : [];
94
115
  const warning = input.cwdWarning ? [` ${theme.fg("warning", "⚠")} ${theme.fg("warning", input.cwdWarning)}`] : [];
95
116
  if (input.width < ART_WIDTH + 4) {
96
- const lines = ["", ` ${theme.fg("accent", "✻")} ${name} ${theme.fg("muted", BRAND_CN)}`, ` ${dir}`, ` ${theme.fg("dim", "Ctrl+V 粘贴图片")}`];
117
+ const lines = [
118
+ "",
119
+ ` ${theme.fg("accent", "✻")} ${name}${isEnglish() ? "" : ` ${theme.fg("muted", BRAND_CN)}`}`,
120
+ ` ${dir}`,
121
+ ` ${theme.fg("dim", tr("Ctrl+V 粘贴图片", "Ctrl+V to paste images"))}`,
122
+ ];
97
123
  if (input.announcement)
98
124
  lines.push(` ${announcementLine(theme, input.announcement)}`);
99
125
  if (warning.length)
@@ -103,8 +129,8 @@ export function renderBrandHeader(theme, input) {
103
129
  return [...lines, ""];
104
130
  }
105
131
  const art = HERO_ART.map((line) => ` ${paintArt(theme, line)}`);
106
- // widest info row (hints) needs 46 cols beside the 28-col wordmark
107
- if (input.width < ART_WIDTH + 46) {
132
+ // widest info row (hints) needs 46 cols beside the 28-col wordmark (English hints are wider: 56)
133
+ if (input.width < ART_WIDTH + (isEnglish() ? 56 : 46)) {
108
134
  const lines = ["", ...art, "", ` ${name} ${brand}`, ` ${dir}`, ` ${hints}`];
109
135
  if (input.announcement)
110
136
  lines.push(` ${announcementLine(theme, input.announcement)}`);
@@ -134,6 +160,6 @@ export function printConsoleBanner(version) {
134
160
  for (const line of HERO_ART)
135
161
  console.log(` ${line}`);
136
162
  console.log("");
137
- console.log(` ${BRAND_NAME} v${version} — ${BRAND_CN},${BRAND_TAGLINE}`);
163
+ console.log(tr(` ${BRAND_NAME} v${version} — ${BRAND_CN},${brandTagline()}`, ` ${BRAND_NAME} v${version} — ${brandTagline()}`));
138
164
  console.log("");
139
165
  }
package/dist/config.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface ApiModel {
39
39
  max_tokens: number;
40
40
  /** Older gateways omit this field; missing means unknown coverage. */
41
41
  free_package_eligible?: boolean;
42
+ /** 本站默认模型(网关按站点定:中国站 deepseek-flash,国际站另配);老网关也会发。 */
43
+ is_default?: boolean;
42
44
  /**
43
45
  * 网关建议的自动压缩预算(token);缺失/null 时 CLI 用内置默认 200K。
44
46
  * 用户设置 compaction.maxContextTokens 优先级更高(见 effectiveContextWindow)。
@@ -81,6 +83,8 @@ export interface ModelDef {
81
83
  cost: ModelCost | null;
82
84
  /** 网关下发的免费用量包覆盖标记;老网关缺失时为 undefined(不派生 note)。 */
83
85
  freeEligible?: boolean;
86
+ /** 网关标的本站默认模型(is_default)。 */
87
+ isDefault?: boolean;
84
88
  /** 网关按模型下发的自动压缩预算(token);缺失时用 DEFAULT_COMPACTION_BUDGET_TOKENS。 */
85
89
  compactAtTokens?: number;
86
90
  note: string;
@@ -101,7 +105,13 @@ export declare function effectiveContextWindow(m: Pick<ModelDef, "contextWindow"
101
105
  export declare const MODELS: ModelDef[];
102
106
  /** Replace MODELS with a fresh list fetched from server (e.g. at startup). */
103
107
  export declare function setModelsFromApi(apiModels: ModelDef[]): void;
104
- export declare const DEFAULT_MODEL_ID: string;
108
+ /** 离线兜底列表的首个模型(中国站默认);没拉到目录时用它。 */
109
+ export declare const FALLBACK_DEFAULT_MODEL_ID: string;
110
+ /**
111
+ * 当前默认模型:优先网关标的 is_default(两站默认模型不同,国际站目录里没有 deepseek-flash),
112
+ * 老网关没标时退回兜底 id(在目录里的话),再退回目录第一个。
113
+ */
114
+ export declare function defaultModelId(): string;
105
115
  export declare function resolveModel(nameOrAlias: string): ModelDef | undefined;
106
116
  /** provider 注册键就是云端的端点 id(ep+uuid);models.json 清理旧条目按此形状识别。 */
107
117
  export declare const ENDPOINT_ID_RE: RegExp;
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { resetLang, setLangResolver, tr } from "./i18n.js";
7
8
  import { migrateStoredBaseUrl, SITES, siteOf } from "./site.js";
8
9
  const require = createRequire(import.meta.url);
9
10
  export const VERSION = require("../package.json").version;
@@ -80,6 +81,7 @@ export function apiModelToDef(m) {
80
81
  cacheWrite: 0,
81
82
  },
82
83
  freeEligible: typeof m.free_package_eligible === "boolean" ? m.free_package_eligible : undefined,
84
+ isDefault: m.is_default === true ? true : undefined,
83
85
  compactAtTokens: positiveInteger(m.compact_at_tokens),
84
86
  note: "",
85
87
  };
@@ -126,7 +128,8 @@ export const MODELS = [
126
128
  contextWindow: 1_048_576,
127
129
  maxTokens: 384_000,
128
130
  cost: null,
129
- note: "默认 · 免费用量包额度内免费,日常写代码首选,支持看图",
131
+ // 离线兜底列表的说明按语言现取(模块加载时还不知道站点)
132
+ get note() { return tr("默认 · 免费用量包额度内免费,日常写代码首选,支持看图", "Default · everyday coding, supports images"); },
130
133
  },
131
134
  {
132
135
  id: "x-ai/grok-4.6",
@@ -137,7 +140,7 @@ export const MODELS = [
137
140
  contextWindow: 500_000,
138
141
  maxTokens: 65_536,
139
142
  cost: null,
140
- note: "更强 · 但烧额度快约 20 倍,难题再用",
143
+ get note() { return tr("更强 · 但烧额度快约 20 倍,难题再用", "Stronger · about 20× the cost, for hard problems"); },
141
144
  },
142
145
  ];
143
146
  /**
@@ -146,21 +149,23 @@ export const MODELS = [
146
149
  * 让人切模型前就知道「这个不走免费包 / 大概贵多少」。
147
150
  */
148
151
  function deriveModelNote(m, base) {
152
+ // 国际站没有免费包:不提免费包,只给价格倍数
149
153
  if (m.freeEligible === true)
150
- return "免费用量包可抵扣";
154
+ return tr("免费用量包可抵扣", "");
151
155
  if (m.freeEligible === false) {
152
156
  const blended = m.cost ? (m.cost.input + m.cost.output) / 2 : 0;
153
157
  const baseBlended = base?.cost ? (base.cost.input + base.cost.output) / 2 : 0;
154
158
  const mult = baseBlended > 0 ? Math.round(blended / baseBlended) : 0;
155
159
  return mult >= 2
156
- ? `不走免费包 · 费用约为默认模型 ${mult} 倍`
157
- : "不走免费包,用余额或全模型包";
160
+ ? tr(`不走免费包 · 费用约为默认模型 ${mult} 倍`, `about ${mult}× the default model's price`)
161
+ : tr("不走免费包,用余额或全模型包", "");
158
162
  }
159
163
  return "";
160
164
  }
161
165
  /** Replace MODELS with a fresh list fetched from server (e.g. at startup). */
162
166
  export function setModelsFromApi(apiModels) {
163
- const base = apiModels.find((m) => m.id === DEFAULT_MODEL_ID)
167
+ const base = apiModels.find((m) => m.isDefault)
168
+ ?? apiModels.find((m) => m.id === FALLBACK_DEFAULT_MODEL_ID)
164
169
  ?? apiModels.find((m) => m.freeEligible === true);
165
170
  for (const m of apiModels) {
166
171
  if (!m.note)
@@ -169,7 +174,17 @@ export function setModelsFromApi(apiModels) {
169
174
  MODELS.length = 0;
170
175
  MODELS.push(...apiModels);
171
176
  }
172
- export const DEFAULT_MODEL_ID = MODELS[0].id;
177
+ /** 离线兜底列表的首个模型(中国站默认);没拉到目录时用它。 */
178
+ export const FALLBACK_DEFAULT_MODEL_ID = MODELS[0].id;
179
+ /**
180
+ * 当前默认模型:优先网关标的 is_default(两站默认模型不同,国际站目录里没有 deepseek-flash),
181
+ * 老网关没标时退回兜底 id(在目录里的话),再退回目录第一个。
182
+ */
183
+ export function defaultModelId() {
184
+ return MODELS.find((m) => m.isDefault)?.id
185
+ ?? (MODELS.some((m) => m.id === FALLBACK_DEFAULT_MODEL_ID) ? FALLBACK_DEFAULT_MODEL_ID : MODELS[0]?.id)
186
+ ?? FALLBACK_DEFAULT_MODEL_ID;
187
+ }
173
188
  export function resolveModel(nameOrAlias) {
174
189
  const q = nameOrAlias.trim().toLowerCase();
175
190
  return MODELS.find((m) => m.id.toLowerCase() === q || m.aliases.includes(q));
@@ -312,7 +327,7 @@ export function resolvePreferredModel(cfg) {
312
327
  if (refValid(ref))
313
328
  return ref;
314
329
  }
315
- return { provider: PROVIDER_ID, id: DEFAULT_MODEL_ID };
330
+ return { provider: PROVIDER_ID, id: defaultModelId() };
316
331
  }
317
332
  export function loadConfig() {
318
333
  return configFromFile((readJsonFile(configFile) ?? {}));
@@ -359,6 +374,7 @@ export function configFromFile(file, env = process.env, currentCnApiBase = SITES
359
374
  export function activeSite() {
360
375
  return siteOf(loadConfig());
361
376
  }
377
+ setLangResolver(() => (activeSite().id === "intl" ? "en" : "zh"));
362
378
  /**
363
379
  * 凭据文件落盘:先以 0600 写临时文件再 rename 覆盖,文件从不以默认权限
364
380
  * (umask 下常是 0644)存在过哪怕一瞬;rename 在 POSIX 上原子替换。
@@ -419,6 +435,7 @@ export function stripEnvOverrides(cfg, file, env = process.env) {
419
435
  export function saveConfig(cfg) {
420
436
  const file = (readJsonFile(configFile) ?? {});
421
437
  writePrivateFile(configFile, JSON.stringify(stripEnvOverrides(cfg, file), null, 2) + "\n");
438
+ resetLang();
422
439
  }
423
440
  /** Persist the user's preferred model to both stores. */
424
441
  export function persistPreferredModel(cfg, provider, modelId) {
@@ -1,4 +1,5 @@
1
1
  import { CUSTOM_ENDPOINTS, DEFAULT_COMPACTION_BUDGET_TOKENS, MODELS, PROVIDER_ID, } from "./config.js";
2
+ import { tr } from "./i18n.js";
2
3
  /** 1_048_576 → "1M",200_000 → "200K",184_000 → "184K",12_345 → "12.3K"。 */
3
4
  export function formatTokens(n) {
4
5
  if (n >= 1_000_000) {
@@ -23,25 +24,27 @@ export function contextEntryLines(input) {
23
24
  const def = findModelDef(input.model);
24
25
  const usage = input.usage;
25
26
  if (!usage || usage.contextWindow <= 0) {
26
- return ["当前模型没有上下文窗口信息,无法统计。"];
27
+ return [tr("当前模型没有上下文窗口信息,无法统计。", "The current model has no context window info, so usage can't be measured.")];
27
28
  }
28
29
  const budget = usage.contextWindow;
29
30
  const modelWindow = def?.contextWindow ?? 0;
30
31
  const lines = [];
31
32
  if (usage.tokens === null || usage.percent === null) {
32
- lines.push(`当前上下文:未知(刚压缩过,等下一次回复后才能统计) · 压缩预算 ${formatTokens(budget)}`);
33
+ lines.push(tr(`当前上下文:未知(刚压缩过,等下一次回复后才能统计) · 压缩预算 ${formatTokens(budget)}`, `Current context: unknown (just compacted; available after the next reply) · compaction budget ${formatTokens(budget)}`));
33
34
  }
34
35
  else {
35
- lines.push(`当前上下文:约 ${formatTokens(usage.tokens)} tokens,占压缩预算 ${formatTokens(budget)} 的 ${usage.percent.toFixed(1)}%`);
36
+ lines.push(tr(`当前上下文:约 ${formatTokens(usage.tokens)} tokens,占压缩预算 ${formatTokens(budget)} 的 ${usage.percent.toFixed(1)}%`, `Current context: ~${formatTokens(usage.tokens)} tokens, ${usage.percent.toFixed(1)}% of the ${formatTokens(budget)} compaction budget`));
36
37
  }
37
38
  const trigger = Math.max(0, budget - input.reserveTokens);
38
39
  const budgetOrigin = input.budgetSetting !== undefined
39
- ? "来自你的设置"
40
+ ? tr("来自你的设置", "from your settings")
40
41
  : def?.compactAtTokens
41
- ? "由 u1s1 按模型下发"
42
- : `默认 ${formatTokens(DEFAULT_COMPACTION_BUDGET_TOKENS)}`;
43
- const windowNote = modelWindow > budget ? `(模型窗口 ${formatTokens(modelWindow)},预算${budgetOrigin})` : `(${budgetOrigin})`;
44
- lines.push(`超过约 ${formatTokens(trigger)} tokens 会自动压缩 ${windowNote};/compact 可随时手动压缩`);
45
- lines.push("改预算:~/.u1s1/agent/settings.json 里 compaction.maxContextTokens(0 = 不限,按模型窗口);只影响自动压缩时机,不改模型能力");
42
+ ? tr("由 u1s1 按模型下发", "set by u1s1 for this model")
43
+ : tr(`默认 ${formatTokens(DEFAULT_COMPACTION_BUDGET_TOKENS)}`, `default ${formatTokens(DEFAULT_COMPACTION_BUDGET_TOKENS)}`);
44
+ const windowNote = modelWindow > budget
45
+ ? tr(`(模型窗口 ${formatTokens(modelWindow)},预算${budgetOrigin})`, `(model window ${formatTokens(modelWindow)}; budget ${budgetOrigin})`)
46
+ : `(${budgetOrigin})`;
47
+ lines.push(tr(`超过约 ${formatTokens(trigger)} tokens 会自动压缩 ${windowNote};/compact 可随时手动压缩`, `Auto-compacts above ~${formatTokens(trigger)} tokens ${windowNote}; run /compact to compact any time`));
48
+ lines.push(tr("改预算:~/.u1s1/agent/settings.json 里 compaction.maxContextTokens(0 = 不限,按模型窗口);只影响自动压缩时机,不改模型能力", "To change the budget, set compaction.maxContextTokens in ~/.u1s1/agent/settings.json (0 = no limit, use the model window). It only affects when auto-compaction kicks in, not what the model can do."));
46
49
  return lines;
47
50
  }
package/dist/deploy.js CHANGED
@@ -4,6 +4,7 @@ import { VERSION, u1s1Dir } from "./config.js";
4
4
  import { authorizedFetch } from "./device-auth.js";
5
5
  import { readJsonResponseCapped } from "./api.js";
6
6
  import { markNudge } from "./nudges.js";
7
+ import { tr } from "./i18n.js";
7
8
  import { askLine } from "./prompt.js";
8
9
  import { sendTelemetryEvent } from "./telemetry.js";
9
10
  import { siteLinks, siteOf } from "./site.js";
@@ -68,10 +69,10 @@ function resolveSiteDir(explicit) {
68
69
  if (explicit) {
69
70
  const dir = resolve(explicit);
70
71
  if (!existsSync(dir) || !statSync(dir).isDirectory()) {
71
- throw new Error(`目录不存在:${dir}`);
72
+ throw new Error(tr(`目录不存在:${dir}`, `Directory not found: ${dir}`));
72
73
  }
73
74
  if (!existsSync(join(dir, "index.html"))) {
74
- throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
75
+ throw new Error(tr(`${dir} 里没有 index.html,网站需要一个首页`, `${dir} has no index.html; a site needs a home page`));
75
76
  }
76
77
  return dir;
77
78
  }
@@ -82,9 +83,11 @@ function resolveSiteDir(explicit) {
82
83
  }
83
84
  if (existsSync(join(cwd, "index.html")))
84
85
  return cwd;
85
- throw new Error("这里找不到能发布的网页(index.html)。\n" +
86
+ throw new Error(tr("这里找不到能发布的网页(index.html)。\n" +
86
87
  " 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
87
- " 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
88
+ " 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录", "No publishable page (index.html) found here.\n" +
89
+ " Run u1s1 deploy in your site directory, or pass one: u1s1 deploy <dir>\n" +
90
+ " If the project needs a build step (Vite/Next etc.), build first and deploy dist/ or similar"));
88
91
  }
89
92
  export function collectFiles(root) {
90
93
  const files = [];
@@ -133,23 +136,23 @@ export function parseDeployArgs(args) {
133
136
  if (arg === "--name" || arg === "-n") {
134
137
  const value = args[++i];
135
138
  if (!value || value.startsWith("-"))
136
- throw new Error(`${arg} 后面需要站点名`);
139
+ throw new Error(tr(`${arg} 后面需要站点名`, `${arg} needs a site name`));
137
140
  parsed.name = value.toLowerCase();
138
141
  }
139
142
  else if (arg.startsWith("--name=")) {
140
143
  parsed.name = arg.slice(7).toLowerCase();
141
144
  if (!parsed.name)
142
- throw new Error("--name 后面需要站点名");
145
+ throw new Error(tr("--name 后面需要站点名", "--name needs a site name"));
143
146
  }
144
147
  else if (arg === "--public" || arg === "--private") {
145
148
  const visibility = arg.slice(2);
146
149
  if (parsed.visibility && parsed.visibility !== visibility) {
147
- throw new Error("--public 和 --private 不能同时使用");
150
+ throw new Error(tr("--public 和 --private 不能同时使用", "--public and --private can't be used together"));
148
151
  }
149
152
  parsed.visibility = visibility;
150
153
  }
151
154
  else if (arg.startsWith("-")) {
152
- throw new Error(`不认识的 deploy 参数:${arg}`);
155
+ throw new Error(tr(`不认识的 deploy 参数:${arg}`, `Unknown deploy option: ${arg}`));
153
156
  }
154
157
  else {
155
158
  parsed.dirArg = arg;
@@ -168,7 +171,7 @@ async function api(cfg, request) {
168
171
  });
169
172
  }
170
173
  catch {
171
- throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
174
+ throw new Error(tr(`连不上 ${cfg.baseUrl},检查一下网络?`, `Can't reach ${cfg.baseUrl}. Check your network?`));
172
175
  }
173
176
  const value = await readJsonResponseCapped(resp, MAX_DEPLOY_API_RESPONSE_BYTES).catch(() => null);
174
177
  const data = value && typeof value === "object" && !Array.isArray(value)
@@ -180,7 +183,7 @@ async function api(cfg, request) {
180
183
  : undefined;
181
184
  const message = typeof detail?.message === "string"
182
185
  ? detail.message.slice(0, 2_000)
183
- : `服务端返回 ${resp.status},稍后再试`;
186
+ : tr(`服务端返回 ${resp.status},稍后再试`, `Server returned ${resp.status}. Please try again later.`);
184
187
  const e = new Error(message);
185
188
  if (typeof detail?.code === "string" && detail.code.length <= 100) {
186
189
  e.code = detail.code;
@@ -188,7 +191,7 @@ async function api(cfg, request) {
188
191
  throw e;
189
192
  }
190
193
  if (!data)
191
- throw new Error("服务端部署响应格式不正确");
194
+ throw new Error(tr("服务端部署响应格式不正确", "Unexpected deploy response from the server"));
192
195
  return data;
193
196
  }
194
197
  /** 逐个上传,失败重试一次;并发数保守取 6。 */
@@ -218,12 +221,12 @@ async function uploadAll(cfg, start, files) {
218
221
  const body = value && typeof value === "object" && !Array.isArray(value) ? value : null;
219
222
  const message = body?.error && typeof body.error === "object" && typeof body.error.message === "string"
220
223
  ? body.error.message.slice(0, 2_000)
221
- : "网络错误";
222
- throw new Error(`上传 ${f.path} 失败:${message}`);
224
+ : tr("网络错误", "network error");
225
+ throw new Error(tr(`上传 ${f.path} 失败:${message}`, `Failed to upload ${f.path}: ${message}`));
223
226
  }
224
227
  void resp.body?.cancel("deploy upload completed").catch(() => { });
225
228
  done++;
226
- process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
229
+ process.stdout.write(tr(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`, `\r Uploading ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`));
227
230
  };
228
231
  const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
229
232
  for (let f = queue.shift(); f; f = queue.shift())
@@ -238,60 +241,60 @@ async function uploadAll(cfg, start, files) {
238
241
  }
239
242
  }
240
243
  async function promptSiteName(def) {
241
- return (await askLine(` 项目名(回车用 ${def},只需在你的账号内唯一):`, def)).toLowerCase();
244
+ return (await askLine(tr(` 项目名(回车用 ${def},只需在你的账号内唯一):`, ` Project name (Enter for ${def}; only needs to be unique in your account): `), def)).toLowerCase();
242
245
  }
243
246
  async function promptVisibility() {
244
247
  if (!process.stdin.isTTY)
245
248
  return "private";
246
249
  while (true) {
247
- const answer = (await askLine(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):")).toLowerCase();
250
+ const answer = (await askLine(tr(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):", " Visibility [1] Public (listed in the community) [2] Private (only you, when signed in; default): "))).toLowerCase();
248
251
  if (!answer || answer === "2" || answer === "private" || answer === "私密")
249
252
  return "private";
250
253
  if (answer === "1" || answer === "public" || answer === "公开")
251
254
  return "public";
252
- console.log(" 请输入 1 或 2");
255
+ console.log(tr(" 请输入 1 或 2", " Please enter 1 or 2"));
253
256
  }
254
257
  }
255
258
  async function listDeployments(cfg) {
256
259
  const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
257
260
  if (!sites.length) {
258
- console.log(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。");
261
+ console.log(tr(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。", " No sites deployed yet. Try u1s1 deploy in your site directory."));
259
262
  return;
260
263
  }
261
264
  console.log("");
262
265
  for (const site of sites) {
263
266
  const visibility = site.visibility === "private"
264
- ? "私密"
265
- : site.community_listed ? "公开(社区)" : "公开(未展示)";
267
+ ? tr("私密", "private")
268
+ : site.community_listed ? tr("公开(社区)", "public (community)") : tr("公开(未展示)", "public (unlisted)");
266
269
  console.log(` ${site.deployed ? "●" : "○"} ${site.url} ${visibility} · ${fmtBytes(site.total_bytes)} · ${site.updated_at} UTC`);
267
270
  }
268
271
  console.log("");
269
272
  }
270
273
  async function confirmRemoval(url) {
271
- const answer = (await askLine(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`)).toLowerCase();
274
+ const answer = (await askLine(tr(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`, ` Delete ${url}? The URL stops working immediately and the files are erased for good. Type y to confirm: `))).toLowerCase();
272
275
  return answer === "y" || answer === "yes";
273
276
  }
274
277
  async function removeDeployment(cfg, args) {
275
278
  const yes = args.some((arg) => arg === "--yes" || arg === "-y");
276
279
  const positional = args.filter((arg) => !arg.startsWith("-"));
277
280
  if (positional.length !== 1) {
278
- throw new Error("用法:u1s1 deploy remove <站点名或网址> [--yes]");
281
+ throw new Error(tr("用法:u1s1 deploy remove <站点名或网址> [--yes]", "Usage: u1s1 deploy remove <site name or URL> [--yes]"));
279
282
  }
280
283
  const reference = siteReferenceFromInput(positional[0]);
281
284
  if (!reference)
282
- throw new Error("站点名不能为空");
285
+ throw new Error(tr("站点名不能为空", "Site name can't be empty"));
283
286
  // 先按列表核对,拿到完整网址给确认提示;名字打错在这里就能提前发现
284
287
  const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
285
288
  const site = sites.find((s) => (s.slug || s.name) === reference || s.name === reference);
286
289
  if (!site) {
287
- throw new Error(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`);
290
+ throw new Error(tr(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`, `Site "${reference}" not found. Run u1s1 deploy list to see your sites`));
288
291
  }
289
292
  if (!yes) {
290
293
  if (!process.stdin.isTTY) {
291
- throw new Error("非交互环境下删除站点需要加 --yes 明确确认");
294
+ throw new Error(tr("非交互环境下删除站点需要加 --yes 明确确认", "Deleting a site non-interactively requires --yes"));
292
295
  }
293
296
  if (!(await confirmRemoval(site.url))) {
294
- console.log(" 已取消,站点保留。");
297
+ console.log(tr(" 已取消,站点保留。", " Cancelled; the site was kept."));
295
298
  return;
296
299
  }
297
300
  }
@@ -301,8 +304,8 @@ async function removeDeployment(cfg, args) {
301
304
  });
302
305
  forgetSite(site.slug || site.name);
303
306
  console.log("");
304
- console.log(` ✓ 已删除 ${site.url}`);
305
- console.log(" 同一个名字可以再次 u1s1 deploy 重新发布。");
307
+ console.log(tr(` ✓ 已删除 ${site.url}`, ` ✓ Deleted ${site.url}`));
308
+ console.log(tr(" 同一个名字可以再次 u1s1 deploy 重新发布。", " You can publish under the same name again with u1s1 deploy."));
306
309
  console.log("");
307
310
  }
308
311
  async function selectDeployment(args) {
@@ -311,12 +314,12 @@ async function selectDeployment(args) {
311
314
  const dir = resolveSiteDir(dirArg);
312
315
  const files = collectFiles(dir);
313
316
  if (!files.some((file) => file.path === "index.html")) {
314
- throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
317
+ throw new Error(tr(`${dir} 里没有 index.html,网站需要一个首页`, `${dir} has no index.html; a site needs a home page`));
315
318
  }
316
319
  const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
317
320
  console.log("");
318
- console.log(` 部署目录 ${dir}`);
319
- console.log(` 文件 ${files.length} 个,共 ${fmtBytes(totalBytes)}`);
321
+ console.log(tr(` 部署目录 ${dir}`, ` Directory ${dir}`));
322
+ console.log(tr(` 文件 ${files.length} 个,共 ${fmtBytes(totalBytes)}`, ` Files ${files.length}, ${fmtBytes(totalBytes)} total`));
320
323
  // 站点名:--name > 上次用过的 > 交互询问(默认目录名;dist 等产物目录用项目名)
321
324
  const remembered = readDeploys()[dir];
322
325
  if (!name && remembered)
@@ -364,44 +367,45 @@ async function startDeployment(cfg, initialName) {
364
367
  function validateDeploymentLimits(start, files, totalBytes) {
365
368
  const tooBig = files.filter((file) => file.bytes > start.limits.max_file_bytes);
366
369
  if (tooBig.length) {
367
- throw new Error(`这些文件超过单文件上限 ${fmtBytes(start.limits.max_file_bytes)}:\n` +
370
+ throw new Error(tr(`这些文件超过单文件上限 ${fmtBytes(start.limits.max_file_bytes)}:\n`, `These files exceed the per-file limit of ${fmtBytes(start.limits.max_file_bytes)}:\n`) +
368
371
  tooBig.map((file) => ` ${file.path}(${fmtBytes(file.bytes)})`).join("\n"));
369
372
  }
370
373
  if (files.length > start.limits.max_files || totalBytes > start.limits.max_total_bytes) {
371
- throw new Error(`超出配额:最多 ${start.limits.max_files} 个文件 / ${fmtBytes(start.limits.max_total_bytes)}。` +
372
- `当前 ${files.length} 个 / ${fmtBytes(totalBytes)}`);
374
+ throw new Error(tr(`超出配额:最多 ${start.limits.max_files} 个文件 / ${fmtBytes(start.limits.max_total_bytes)}。` +
375
+ `当前 ${files.length} 个 / ${fmtBytes(totalBytes)}`, `Over quota: at most ${start.limits.max_files} files / ${fmtBytes(start.limits.max_total_bytes)}. ` +
376
+ `This deploy has ${files.length} files / ${fmtBytes(totalBytes)}`));
373
377
  }
374
378
  }
375
379
  function printDeploymentResult(result, start, site) {
376
380
  const links = siteLinks(site);
377
- console.log(` ✅ 部署完成,${result.file_count} 个文件已上线`);
381
+ console.log(tr(` ✅ 部署完成,${result.file_count} 个文件已上线`, ` ✅ Deployed, ${result.file_count} files live`));
378
382
  console.log("");
379
383
  console.log(` ${result.visibility === "private" ? "🔒" : "🌐"} ${result.url}`);
380
384
  console.log("");
381
385
  if ((result.hostname || start.hostname || "").split(".").length > 3) {
382
- console.log(" 新 hostname 的 HTTPS 证书会由 Total TLS 自动签发,首次访问可能需要等待几分钟。");
386
+ console.log(tr(" 新 hostname 的 HTTPS 证书会由 Total TLS 自动签发,首次访问可能需要等待几分钟。", " The HTTPS certificate for a new hostname is issued automatically; the first visit may take a few minutes."));
383
387
  }
384
388
  if (result.visibility === "private") {
385
- console.log(` 私密站点仅你可见,请从 ${links.sites} 打开。`);
389
+ console.log(tr(` 私密站点仅你可见,请从 ${links.sites} 打开。`, ` Private sites are only visible to you; open them from ${links.sites}`));
386
390
  }
387
391
  else if (result.community_listed) {
388
- console.log(` 已进入 ${links.community} ,把网址发给朋友就能看。`);
392
+ console.log(tr(` 已进入 ${links.community} ,把网址发给朋友就能看。`, ` Listed on ${links.community} — share the URL with friends.`));
389
393
  }
390
394
  else {
391
- console.log(" 公开链接可直接访问;运行 u1s1 deploy --public 可明确加入作品社区。");
395
+ console.log(tr(" 公开链接可直接访问;运行 u1s1 deploy --public 可明确加入作品社区。", " The public link works for anyone; run u1s1 deploy --public to list it in the community."));
392
396
  }
393
- console.log(" 改完代码再跑一次 u1s1 deploy 即可更新。");
397
+ console.log(tr(" 改完代码再跑一次 u1s1 deploy 即可更新。", " Run u1s1 deploy again after changes to update."));
394
398
  console.log("");
395
399
  }
396
400
  export function printDeployHelp() {
397
401
  console.log("");
398
- console.log(" u1s1 deploy [目录] [--name 站点名] [--public|--private]");
402
+ console.log(tr(" u1s1 deploy [目录] [--name 站点名] [--public|--private]", " u1s1 deploy [dir] [--name site-name] [--public|--private]"));
399
403
  console.log("");
400
- console.log(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。");
401
- console.log(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。");
404
+ console.log(tr(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。", " With no directory, uses a build output dir (dist/build/out etc.) if found, else the current dir."));
405
+ console.log(tr(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。", " The first deploy asks for a name and visibility and remembers them; run it again to update."));
402
406
  console.log("");
403
- console.log(" u1s1 deploy list 查看已发布的站点");
404
- console.log(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)");
407
+ console.log(tr(" u1s1 deploy list 查看已发布的站点", " u1s1 deploy list List your sites"));
408
+ console.log(tr(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)", " u1s1 deploy remove <name-or-url> Delete a site (asks to confirm; --yes skips)"));
405
409
  console.log("");
406
410
  }
407
411
  export async function deployCommand(cfg, args) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { VERSION } from "./config.js";
4
+ import { tr } from "./i18n.js";
4
5
  const enc = new TextEncoder();
5
6
  const SIGNING_PROXY_REQUEST_LIMIT_BYTES = 32 * 1024 * 1024;
6
7
  let cachedPrivate;
@@ -42,7 +43,7 @@ async function privateKey(jwk) {
42
43
  }
43
44
  export async function dpopHeaders(cfg, method, url) {
44
45
  if (!hasDeviceCredential(cfg))
45
- throw new Error("当前安装还没有设备凭证,请重新运行 u1s1 login");
46
+ throw new Error(tr("当前安装还没有设备凭证,请重新运行 u1s1 login", "This install has no device credential yet. Run u1s1 login again."));
46
47
  const token = cfg.deviceToken;
47
48
  const header = b64url(JSON.stringify({
48
49
  typ: "dpop+jwt",
@@ -76,7 +77,7 @@ export async function authorizedFetch(cfg, input, init = {}) {
76
77
  headers.set("authorization", `Bearer ${cfg.apiKey}`);
77
78
  }
78
79
  else {
79
- throw new Error("还没有登录,请运行 u1s1 login");
80
+ throw new Error(tr("还没有登录,请运行 u1s1 login", "Not signed in. Run u1s1 login."));
80
81
  }
81
82
  return fetch(url, { ...init, headers });
82
83
  }
@@ -214,7 +215,7 @@ function clientSurface(fallback) {
214
215
  */
215
216
  export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource) {
216
217
  if (!hasDeviceCredential(cfg))
217
- throw new Error("当前安装需要重新登录,以创建设备凭证");
218
+ throw new Error(tr("当前安装需要重新登录,以创建设备凭证", "Please sign in again to create a device credential for this install."));
218
219
  const client = clientSurface(fallbackClient);
219
220
  const current = signingProxy;
220
221
  if (current && current.token === cfg.deviceToken && current.client === client) {
@@ -286,10 +287,11 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attes
286
287
  if (res.headersSent)
287
288
  return void res.destroy(error);
288
289
  res.writeHead(502, { "content-type": "application/json" });
289
- // 中文说明放前面给用户看;原始英文错误保留在括号里,pi 的重试分类正则
290
+ // 说明放前面给用户看;原始英文错误保留在括号里,pi 的重试分类正则
290
291
  // (fetch failed / timeout 等)和排查都还认得
291
292
  const raw = error instanceof Error ? error.message : "signing proxy failed";
292
- res.end(JSON.stringify({ error: { message: `连不上 u1s1 服务器,检查一下网络?(${raw})` } }));
293
+ const message = tr(`连不上 u1s1 服务器,检查一下网络?(${raw})`, `Can't reach the u1s1 server. Check your network? (${raw})`);
294
+ res.end(JSON.stringify({ error: { message } }));
293
295
  }
294
296
  });
295
297
  await new Promise((resolve, reject) => {
@@ -16,6 +16,7 @@ import { type Site } from "./site.js";
16
16
  * 额度用尽(reason=exhausted)且今天已打卡时网关还带 error.resets_at(下次可打卡时刻,
17
17
  * 即北京时间 0 点,ISO),这里换算成「距下次可打卡还有 N 小时 M 分」接在正文后:比
18
18
  * 「北京时间 0 点」更省用户脑子。今天没打卡时网关不带 resets_at(现在就能领)。
19
+ * 国际站没有打卡/免费包(网关也不带 resets_at),英文界面一律不给这句。
19
20
  */
20
21
  export declare function quotaResetHint(resetsAt: unknown, now?: number): string;
21
22
  /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */