@lark-apaas/miaoda-cli 0.1.1 → 0.1.2

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.
@@ -45,15 +45,23 @@ const output_1 = require("../../../utils/output");
45
45
  const render_1 = require("../../../utils/render");
46
46
  const error_1 = require("../../../utils/error");
47
47
  const shared_1 = require("../../../cli/commands/shared");
48
+ const colors_1 = require("../../../utils/colors");
48
49
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
49
50
  /**
50
- * 判断 src 是本地文件还是远程引用:
51
- * - src 本地 fs 可访问 upload(dst remote)
52
- * - 其他 → download(src 是 /path 或 file_name,由 resolveRemotePath 解析)
53
- *
54
- * `cp` 语义要求 src 必须存在;本地不存在就认为用户指向远程。不需要给 dst 猜方向。
51
+ * 判断 src 是本地路径还是远程引用——按 PRD `miaoda file cp` 规则的优先级:
52
+ * 1. `./` / `../` / `~/` 开头 + 裸文件名(不含 `/`)→ 本地路径(即使不存在)
53
+ * handleUpload 把"不存在"准确抛成 FILE_SRC_NOT_FOUND,
54
+ * 避免回退到远程 download 输出 `FILE_NOT_FOUND` + "Run miaoda file ls"
55
+ * 这种与用户意图(上传)背离的引导。
56
+ * 2. `/` 开头:fs.existsSync 兜底——存在即本地(绝对路径上传),
57
+ * 不存在即远程 path(download,由 resolveRemotePath 处理)。
55
58
  */
56
59
  function isLocalSrc(src) {
60
+ if (src.startsWith("./") || src.startsWith("../") || src.startsWith("~/"))
61
+ return true;
62
+ if (!src.includes("/"))
63
+ return true;
64
+ // `/` 开头:可能是本地绝对路径,也可能是远程 path;交给 fs 探测。
57
65
  const expanded = expandHome(src);
58
66
  try {
59
67
  return node_fs_1.default.existsSync(expanded);
@@ -104,23 +112,37 @@ async function resolveRemotePath(appId, input) {
104
112
  async function handleUpload(appId, localRaw, remoteRaw, rename) {
105
113
  const localPath = expandHome(localRaw);
106
114
  if (!node_fs_1.default.existsSync(localPath) || !node_fs_1.default.statSync(localPath).isFile()) {
107
- throw new error_1.AppError("FILE_SRC_NOT_FOUND", `Local file '${localRaw}' does not exist`);
115
+ throw new error_1.AppError("FILE_SRC_NOT_FOUND", `Local file '${localRaw}' does not exist`, {
116
+ // 引导本地路径自检;远程下载请用 `/path` 形态,避免裸名/相对路径误入上传分支
117
+ next_actions: ["Check the local file path; use `/path` prefix for remote download."],
118
+ });
108
119
  }
109
120
  const stat = node_fs_1.default.statSync(localPath);
110
121
  if (stat.size > MAX_UPLOAD_BYTES) {
111
122
  throw new error_1.AppError("FILE_SIZE_EXCEEDED", `File size ${(0, render_1.formatSize)(stat.size)} exceeds the 100 MB upload limit`, {
112
- next_actions: [
113
- "Split the file, or use the web console for large uploads.",
114
- ],
123
+ next_actions: ["Split the file, or use the web console for large uploads."],
115
124
  });
116
125
  }
117
- const fileName = rename ?? node_path_1.default.basename(localPath);
118
- let remotePath = remoteRaw;
119
- if (remoteRaw.endsWith("/")) {
120
- remotePath = remoteRaw + fileName;
126
+ // PRD:path 末段**始终**由平台生成 16 ID 确保唯一,不受 dst 形态或
127
+ // --rename 影响;用户指定的 file_name 仅作为显示名(同目录下允许重名)。
128
+ // 因此 CLI 端把 dst 拆成"目录前缀 + file_name"两段:
129
+ // - dst / 结尾或为空 → 整段当目录前缀,file_name 用 --rename 或本地 basename
130
+ // 例:cp ./logo.png /imgs/ → path=/imgs/<GID>.png, file_name=logo.png
131
+ // - dst 不以 / 结尾 → 末段当 file_name,前段(含尾 /)当目录前缀
132
+ // 例:cp ./1.jpg /post-covers/cover.jpg → path=/post-covers/<GID>.jpg, file_name=cover.jpg
133
+ // 这样无论用户怎么写 dst,都走服务端目录前缀模式(PreUpload `filePath`
134
+ // 末尾带 /),保证 path 全局唯一性,不会因用户指定了完整路径而退化成
135
+ // 完整对象 key 模式。--rename 始终覆盖 file_name 推断结果(PRD 优先级)。
136
+ let fileName;
137
+ let remotePath;
138
+ if (remoteRaw === "" || remoteRaw.endsWith("/")) {
139
+ fileName = rename ?? node_path_1.default.basename(localPath);
140
+ remotePath = remoteRaw;
121
141
  }
122
- else if (remoteRaw === "/") {
123
- remotePath = "/" + fileName;
142
+ else {
143
+ const lastSlash = remoteRaw.lastIndexOf("/");
144
+ fileName = rename ?? remoteRaw.slice(lastSlash + 1);
145
+ remotePath = remoteRaw.slice(0, lastSlash + 1);
124
146
  }
125
147
  const contentType = detectMime(localPath);
126
148
  const result = await api.file.uploadFile({
@@ -147,7 +169,7 @@ async function handleUpload(appId, localRaw, remoteRaw, rename) {
147
169
  const tty = (0, render_1.isStdoutTty)();
148
170
  const lines = [];
149
171
  if (tty) {
150
- lines.push(`✓ Uploaded ${node_path_1.default.basename(localPath)} → ${result.path}`);
172
+ lines.push(colors_1.c.success(`✓ Uploaded ${node_path_1.default.basename(localPath)} → ${result.path}`));
151
173
  lines.push(` file_name: ${result.file_name}`);
152
174
  lines.push(` path: ${result.path}`);
153
175
  lines.push(` size: ${(0, render_1.formatSize)(result.size)} (${String(result.size)} bytes)`);
@@ -204,7 +226,7 @@ async function handleDownload(appId, remoteRaw, localRaw) {
204
226
  }
205
227
  const tty = (0, render_1.isStdoutTty)();
206
228
  if (tty) {
207
- (0, output_1.emit)(`✓ Downloaded ${baseName} → ${localTarget} (${(0, render_1.formatSize)(writtenBytes)})`);
229
+ (0, output_1.emit)(colors_1.c.success(`✓ Downloaded ${baseName} → ${localTarget} (${(0, render_1.formatSize)(writtenBytes)})`));
208
230
  }
209
231
  else {
210
232
  (0, output_1.emit)(`OK Downloaded ${baseName} -> ${localTarget} (${String(writtenBytes)} bytes)`);
@@ -101,9 +101,7 @@ async function handleFileLs(opts) {
101
101
  info.type,
102
102
  (0, render_1.formatTime)(info.uploaded_at, tty),
103
103
  ]);
104
- const table = tty
105
- ? (0, render_1.renderAlignedTable)(headers, rows)
106
- : (0, render_1.renderTsv)(headers, rows);
104
+ const table = tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows);
107
105
  const hint = result.has_more && result.next_cursor
108
106
  ? `\n— ${String(result.items.length)} results. Next: --cursor ${result.next_cursor}`
109
107
  : "";
@@ -43,6 +43,7 @@ const error_1 = require("../../../utils/error");
43
43
  const shared_1 = require("../../../cli/commands/shared");
44
44
  const index_1 = require("../../../api/file/index");
45
45
  const render_1 = require("../../../utils/render");
46
+ const colors_1 = require("../../../utils/colors");
46
47
  const node_readline_1 = __importDefault(require("node:readline"));
47
48
  const MAX_BATCH = 100;
48
49
  /**
@@ -169,7 +170,7 @@ async function handleFileRm(paths, opts) {
169
170
  results.push({
170
171
  status: "ok",
171
172
  input: entry?.input ?? p,
172
- file_name: entry?.file_name ?? (p.split("/").pop() ?? p),
173
+ file_name: entry?.file_name ?? p.split("/").pop() ?? p,
173
174
  path: p,
174
175
  });
175
176
  }
@@ -228,9 +229,9 @@ async function handleFileRm(paths, opts) {
228
229
  const lines = [];
229
230
  for (const r of results) {
230
231
  if (r.status === "ok")
231
- lines.push(`✓ Deleted ${r.input}`);
232
+ lines.push(colors_1.c.success(`✓ Deleted ${r.input}`));
232
233
  else
233
- lines.push(`✗ ${r.input}: ${r.error.message}`);
234
+ lines.push(colors_1.c.fail(`✗ ${r.input}: ${r.error.message}`));
234
235
  }
235
236
  if (failCount === 0) {
236
237
  lines.push(`Deleted ${String(okCount)} of ${String(totalCount)} files`);
@@ -82,7 +82,9 @@ function parsePluginName(input) {
82
82
  function readPackageJson() {
83
83
  const pkgPath = getPackageJsonPath();
84
84
  if (!node_fs_1.default.existsSync(pkgPath)) {
85
- throw new error_1.AppError("PKG_JSON_NOT_FOUND", "package.json not found in current directory", { next_actions: ["在应用项目根目录运行"] });
85
+ throw new error_1.AppError("PKG_JSON_NOT_FOUND", "package.json not found in current directory", {
86
+ next_actions: ["在应用项目根目录运行"],
87
+ });
86
88
  }
87
89
  const content = node_fs_1.default.readFileSync(pkgPath, "utf-8");
88
90
  return JSON.parse(content);
@@ -189,9 +191,14 @@ function installMissingDeps(deps) {
189
191
  if (deps.length === 0)
190
192
  return;
191
193
  (0, logger_1.log)("plugin", `Installing missing dependencies: ${deps.join(", ")}`);
192
- const result = (0, node_child_process_1.spawnSync)("npm", ["install", ...deps, "--no-save", "--no-package-lock"], { cwd: getProjectRoot(), stdio: "inherit" });
194
+ const result = (0, node_child_process_1.spawnSync)("npm", ["install", ...deps, "--no-save", "--no-package-lock"], {
195
+ cwd: getProjectRoot(),
196
+ stdio: "inherit",
197
+ });
193
198
  if (result.error) {
194
- throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed: ${result.error.message}`, { next_actions: ["确认本机已安装 npm,可 --verbose 查看执行详情"] });
199
+ throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed: ${result.error.message}`, {
200
+ next_actions: ["确认本机已安装 npm,可 --verbose 查看执行详情"],
201
+ });
195
202
  }
196
203
  if (result.status !== 0) {
197
204
  throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed with exit code ${String(result.status)}`, { next_actions: ["检查上方 npm 输出日志定位具体错误"] });
@@ -200,7 +207,9 @@ function installMissingDeps(deps) {
200
207
  function npmInstall(tgzPath) {
201
208
  const result = (0, node_child_process_1.spawnSync)("npm", ["install", tgzPath, "--no-save", "--no-package-lock", "--ignore-scripts"], { cwd: getProjectRoot(), stdio: "inherit" });
202
209
  if (result.error) {
203
- throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed: ${result.error.message}`, { next_actions: ["确认本机已安装 npm,可 --verbose 查看执行详情"] });
210
+ throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed: ${result.error.message}`, {
211
+ next_actions: ["确认本机已安装 npm,可 --verbose 查看执行详情"],
212
+ });
204
213
  }
205
214
  if (result.status !== 0) {
206
215
  throw new error_1.AppError("INTERNAL_NPM_FAILED", `npm install failed with exit code ${String(result.status)}`, { next_actions: ["检查上方 npm 输出日志定位具体错误"] });
@@ -232,7 +241,9 @@ function listCapabilityIds() {
232
241
  function readCapability(id) {
233
242
  const filePath = node_path_1.default.join(getCapabilitiesDir(), `${id}.json`);
234
243
  if (!node_fs_1.default.existsSync(filePath)) {
235
- throw new error_1.AppError("CAPABILITY_NOT_FOUND", `Capability not found: ${id}`, { next_actions: ["运行 miaoda plugin list 查看所有可用 capability id"] });
244
+ throw new error_1.AppError("CAPABILITY_NOT_FOUND", `Capability not found: ${id}`, {
245
+ next_actions: ["运行 miaoda plugin list 查看所有可用 capability id"],
246
+ });
236
247
  }
237
248
  try {
238
249
  const content = node_fs_1.default.readFileSync(filePath, "utf-8");
@@ -240,7 +251,9 @@ function readCapability(id) {
240
251
  }
241
252
  catch (error) {
242
253
  if (error instanceof SyntaxError) {
243
- throw new error_1.AppError("INVALID_JSON", `Invalid JSON in capability file: ${id}.json`, { next_actions: [`检查 server/capabilities/${id}.json 的 JSON 语法`] });
254
+ throw new error_1.AppError("INVALID_JSON", `Invalid JSON in capability file: ${id}.json`, {
255
+ next_actions: [`检查 server/capabilities/${id}.json 的 JSON 语法`],
256
+ });
244
257
  }
245
258
  throw error;
246
259
  }
@@ -303,7 +316,9 @@ async function loadPlugin(pluginKey) {
303
316
  }
304
317
  catch (error) {
305
318
  if (error.code === "MODULE_NOT_FOUND") {
306
- throw new error_1.AppError("PLUGIN_NOT_FOUND", `Plugin not installed: ${pluginKey}`, { next_actions: [`运行 miaoda plugin install ${pluginKey}`] });
319
+ throw new error_1.AppError("PLUGIN_NOT_FOUND", `Plugin not installed: ${pluginKey}`, {
320
+ next_actions: [`运行 miaoda plugin install ${pluginKey}`],
321
+ });
307
322
  }
308
323
  throw new error_1.AppError("INTERNAL_PLUGIN_LOAD_FAILED", `Failed to load plugin ${pluginKey}: ${error instanceof Error ? error.message : String(error)}`);
309
324
  }
@@ -314,8 +329,7 @@ async function hydrateCapability(capability) {
314
329
  if (manifest.actions.length === 0) {
315
330
  throw new error_1.AppError("INTERNAL_PLUGIN_LOAD_FAILED", `Plugin ${capability.pluginKey} has no actions defined`);
316
331
  }
317
- const hasDynamic = manifest.actions.some((action) => isDynamicSchema(action.inputSchema) ||
318
- isDynamicSchema(action.outputSchema));
332
+ const hasDynamic = manifest.actions.some((action) => isDynamicSchema(action.inputSchema) || isDynamicSchema(action.outputSchema));
319
333
  let pluginInstance = null;
320
334
  if (hasDynamic) {
321
335
  const plugin = await loadPlugin(capability.pluginKey);
@@ -44,7 +44,9 @@ const output_1 = require("../../../utils/output");
44
44
  const error_1 = require("../../../utils/error");
45
45
  const logger_1 = require("../../../utils/logger");
46
46
  const plugin_local_1 = require("./plugin-local");
47
- const log = (msg) => { (0, logger_1.log)("plugin", msg); };
47
+ const log = (msg) => {
48
+ (0, logger_1.log)("plugin", msg);
49
+ };
48
50
  // ── Install ──
49
51
  function syncActionPluginsRecord(name, version) {
50
52
  const plugins = (0, plugin_local_1.readActionPlugins)();
@@ -63,7 +65,9 @@ async function installOne(nameWithVersion) {
63
65
  if (actualVersion === requestedVersion) {
64
66
  log(`${name}@${requestedVersion} already installed`);
65
67
  syncActionPluginsRecord(name, actualVersion);
66
- api.plugin.reportCreateInstanceEvent(name, actualVersion).catch(() => { });
68
+ api.plugin.reportCreateInstanceEvent(name, actualVersion).catch(() => {
69
+ /* fire-and-forget */
70
+ });
67
71
  return { name, version: actualVersion, success: true, skipped: true };
68
72
  }
69
73
  }
@@ -74,7 +78,9 @@ async function installOne(nameWithVersion) {
74
78
  if (actualVersion === targetVersion) {
75
79
  log(`${name} already up to date (${actualVersion})`);
76
80
  syncActionPluginsRecord(name, actualVersion);
77
- api.plugin.reportCreateInstanceEvent(name, actualVersion).catch(() => { });
81
+ api.plugin.reportCreateInstanceEvent(name, actualVersion).catch(() => {
82
+ /* fire-and-forget */
83
+ });
78
84
  return { name, version: actualVersion, success: true, skipped: true };
79
85
  }
80
86
  log(`Found newer version: ${targetVersion} (installed: ${actualVersion ?? "none"})`);
@@ -106,8 +112,12 @@ async function installOne(nameWithVersion) {
106
112
  (0, plugin_local_1.writeActionPlugins)(plugins);
107
113
  const source = fromCache ? "from cache" : "downloaded";
108
114
  log(`Installed ${name}@${installedVersion} (${source})`);
109
- api.plugin.reportInstallEvent(name, installedVersion).catch(() => { });
110
- api.plugin.reportCreateInstanceEvent(name, installedVersion).catch(() => { });
115
+ api.plugin.reportInstallEvent(name, installedVersion).catch(() => {
116
+ /* fire-and-forget */
117
+ });
118
+ api.plugin.reportCreateInstanceEvent(name, installedVersion).catch(() => {
119
+ /* fire-and-forget */
120
+ });
111
121
  return { name, version: installedVersion, success: true };
112
122
  }
113
123
  catch (error) {
@@ -193,7 +203,9 @@ async function handlePluginUpdate(opts) {
193
203
  function handlePluginRemove(opts) {
194
204
  const { name } = (0, plugin_local_1.parsePluginName)(opts.name);
195
205
  if (!(0, plugin_local_1.isPluginInstalled)(name)) {
196
- throw new error_1.AppError("PLUGIN_NOT_FOUND", `Plugin ${name} is not installed`, { next_actions: ["运行 miaoda plugin list-packages 查看已安装插件"] });
206
+ throw new error_1.AppError("PLUGIN_NOT_FOUND", `Plugin ${name} is not installed`, {
207
+ next_actions: ["运行 miaoda plugin list-packages 查看已安装插件"],
208
+ });
197
209
  }
198
210
  (0, plugin_local_1.removePluginDirectory)(name);
199
211
  const plugins = (0, plugin_local_1.readActionPlugins)();
@@ -263,7 +275,9 @@ async function handlePluginInit() {
263
275
  // ── List (capability configs) ──
264
276
  async function handlePluginList(opts) {
265
277
  if (!(0, plugin_local_1.capabilitiesDirExists)()) {
266
- throw new error_1.AppError("CAPABILITIES_DIR_NOT_FOUND", "server/capabilities directory not found", { next_actions: ["当前目录必须是含 server/capabilities/ 的应用项目"] });
278
+ throw new error_1.AppError("CAPABILITIES_DIR_NOT_FOUND", "server/capabilities directory not found", {
279
+ next_actions: ["当前目录必须是含 server/capabilities/ 的应用项目"],
280
+ });
267
281
  }
268
282
  if (opts.id) {
269
283
  const capability = (0, plugin_local_1.readCapability)(opts.id);
package/dist/cli/help.js CHANGED
@@ -115,7 +115,9 @@ class MiaodaHelp extends commander_1.Help {
115
115
  out.push("Usage:", ` ${helper.commandUsage(cmd)}`, "");
116
116
  // 3. Commands(仅父级命令组有,spec 要求 Commands 在 Flags 前)
117
117
  // spec 不展示 Arguments 段,参数说明放在 description 文本里
118
- const subs = helper.visibleCommands(cmd).map((c) => formatItem(helper.subcommandTerm(c), helper.subcommandDescription(c)));
118
+ const subs = helper
119
+ .visibleCommands(cmd)
120
+ .map((c) => formatItem(helper.subcommandTerm(c), helper.subcommandDescription(c)));
119
121
  if (subs.length) {
120
122
  out.push("Commands:", formatList(subs), "");
121
123
  }
@@ -125,7 +127,8 @@ class MiaodaHelp extends commander_1.Help {
125
127
  // - `-h, --help` 永远不放 Flags 段,统一放 Global Flags(spec 约定)
126
128
  const isParent = subs.length > 0;
127
129
  if (!isRoot && !isParent) {
128
- const opts = helper.visibleOptions(cmd)
130
+ const opts = helper
131
+ .visibleOptions(cmd)
129
132
  .filter((o) => !isHelpOption(o))
130
133
  .map((o) => formatItem(helper.optionTerm(o), helper.optionDescription(o)));
131
134
  if (opts.length) {
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * 终端彩色高亮的语义层封装。
4
+ *
5
+ * 三层结构(自上而下:稳定→灵活):
6
+ *
7
+ * 1. **ColorAdapter**(公共契约):6 个语义方法 string → string,业务代码
8
+ * 只接触这层,对底层库 0 感知。
9
+ * 2. **ColorFactory**(适配器签名):`(useColor: boolean) => ColorAdapter`,
10
+ * 把"是否染色"参数化,便于按需构造。一个三方库对应一个 factory 实现。
11
+ * 3. **当前底层库实现**:picocolors([picocolors npm](https://www.npmjs.com/package/picocolors),~3KB)。
12
+ * 切换到 chalk / kleur 等只需新增一个 ColorFactory 实现,并改最下方的
13
+ * `factory =` 一行赋值,业务代码与 ColorAdapter contract 0 改动。
14
+ *
15
+ * # 色谱(对齐 spec)
16
+ *
17
+ * - header 表头 / 命令名 / key 标签:bold + cyan
18
+ * - highlight 强调值(表名等):cyan
19
+ * - success ✓ Uploaded / ✓ Deleted:green
20
+ * - fail ✗ / Error::red
21
+ * - warn ⚠ Approaching quota:yellow
22
+ * - muted NULL / 辅助 hint / 次要信息:dim + gray
23
+ *
24
+ * # 染色判定(NO_COLOR / FORCE_COLOR / TTY,[no-color.org](https://no-color.org/) 业界标准)
25
+ *
26
+ * - `NO_COLOR=1` 强制关颜色(任何非空值生效)
27
+ * - `FORCE_COLOR=1` 强制开颜色(管道 / CI 场景)
28
+ * - 否则按 `process.stdout.isTTY` 判定
29
+ *
30
+ * 每次调用 `c.xxx(s)` 时按当前状态实时构造 adapter——避免模块加载时缓存的
31
+ * `process.stdout.isTTY` 与测试 / pipe 切换脱节。
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.c = void 0;
35
+ exports.shouldColorize = shouldColorize;
36
+ const picocolors_1 = require("picocolors");
37
+ // ──────────────────────────────────────────────────────────────────────
38
+ // 2. 库无关的染色判定:哪个 factory 都能复用
39
+ // ──────────────────────────────────────────────────────────────────────
40
+ /**
41
+ * 当前是否应当染色。读取的是**调用时**的状态,而非模块加载时缓存——
42
+ * 测试经常 `Object.defineProperty(process.stdout, "isTTY", ...)` 实时切换,
43
+ * 这里直接每次问 process。
44
+ *
45
+ * 优先级:FORCE_COLOR > NO_COLOR > TTY。
46
+ */
47
+ function shouldColorize() {
48
+ const env = process.env;
49
+ const noColor = env.NO_COLOR != null && env.NO_COLOR !== "";
50
+ const forceColor = env.FORCE_COLOR != null && env.FORCE_COLOR !== "";
51
+ const isTTY = process.stdout.isTTY === true;
52
+ return forceColor || (!noColor && isTTY);
53
+ }
54
+ /** picocolors 实现:当前生产用 factory。~3KB,业界 CLI 标准之一。 */
55
+ const picocolorsFactory = (useColor) => {
56
+ const x = (0, picocolors_1.createColors)(useColor);
57
+ return {
58
+ header: (s) => x.bold(x.cyan(s)),
59
+ highlight: (s) => x.cyan(s),
60
+ success: (s) => x.green(s),
61
+ fail: (s) => x.red(s),
62
+ warn: (s) => x.yellow(s),
63
+ muted: (s) => x.dim(x.gray(s)),
64
+ };
65
+ };
66
+ // ──────────────────────────────────────────────────────────────────────
67
+ // 4. 唯一切换点:换库改这一行(其它都不用动)
68
+ // ──────────────────────────────────────────────────────────────────────
69
+ //
70
+ // 切换示例:
71
+ //
72
+ // import chalk, { Chalk } from "chalk";
73
+ // const chalkFactory: ColorFactory = (useColor) => {
74
+ // const k = new Chalk({ level: useColor ? 1 : 0 });
75
+ // return {
76
+ // header: (s) => k.bold.cyan(s),
77
+ // highlight: (s) => k.cyan(s),
78
+ // success: (s) => k.green(s),
79
+ // fail: (s) => k.red(s),
80
+ // warn: (s) => k.yellow(s),
81
+ // muted: (s) => k.dim.gray(s),
82
+ // };
83
+ // };
84
+ // const factory: ColorFactory = chalkFactory; // ← 改这里
85
+ //
86
+ const factory = picocolorsFactory;
87
+ // ──────────────────────────────────────────────────────────────────────
88
+ // 5. 公共出口:业务代码 import { c } from "../utils/colors"
89
+ // ──────────────────────────────────────────────────────────────────────
90
+ /** 语义染色器:业务代码唯一接触的对象。底层库切换 0 感知。 */
91
+ exports.c = {
92
+ header: (s) => factory(shouldColorize()).header(s),
93
+ highlight: (s) => factory(shouldColorize()).highlight(s),
94
+ success: (s) => factory(shouldColorize()).success(s),
95
+ fail: (s) => factory(shouldColorize()).fail(s),
96
+ warn: (s) => factory(shouldColorize()).warn(s),
97
+ muted: (s) => factory(shouldColorize()).muted(s),
98
+ };
@@ -6,6 +6,14 @@ class AppError extends Error {
6
6
  retryable;
7
7
  next_actions;
8
8
  statement_index;
9
+ total_statements;
10
+ /**
11
+ * 多语句失败时由 api.db.execSql 透传服务端 results(已成功的 statement 原始结构)。
12
+ * 由 db sql handler 转成 PRD 友好的 `completed` 数组 + 推断 `rolled_back` 后挂回到本对象。
13
+ */
14
+ partial_results;
15
+ completed;
16
+ rolled_back;
9
17
  constructor(code, message, opts) {
10
18
  super(message);
11
19
  this.name = "AppError";
@@ -21,6 +29,9 @@ class AppError extends Error {
21
29
  retryable: this.retryable,
22
30
  next_actions: this.next_actions,
23
31
  statement_index: this.statement_index,
32
+ total_statements: this.total_statements,
33
+ completed: this.completed,
34
+ rolled_back: this.rolled_back,
24
35
  };
25
36
  }
26
37
  }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * 模糊匹配:基于 Levenshtein 编辑距离的"did-you-mean"建议器。
4
+ *
5
+ * 用于 CLI 错误路径的拼写纠错提示——当用户输入的 token(关键字 / 表名 / 列名)
6
+ * 与某个有效候选词"接近"时,给出 "Did you mean X?" 类的 hint。
7
+ *
8
+ * # 设计取舍
9
+ *
10
+ * - **算法**:Damerau-Levenshtein DP(在标准 Levenshtein 基础上把"相邻字符
11
+ * 交换"算 1 步编辑——SQL 用户最常见的拼写错误就是 FORM/FROM、SELCT/SELECT
12
+ * 这类 transposition;纯 Levenshtein 把它们算 2 步会过严,触发不到 hint)。
13
+ * 约 30 行二维 DP 实现,不引第三方库;业界 CLI 拼写纠错事实标准。
14
+ * - **大小写不敏感**:CLI 用户混用 SELECT / select 是常态,统一 lowercase 比较
15
+ * - **阈值按候选词长度自适应**:避免短词过度匹配("id" 不应该建议成 "in")
16
+ * len ≤ 4:max 1 编辑(短词严)
17
+ * len 5-8:max 2 编辑
18
+ * len ≥ 9:max 3 编辑(长词宽)
19
+ * - **同分多候选**:返第一个;调用方负责候选词列表的稳定排序
20
+ * - **找不到不强凑**:阈值外返 null,不"硬贴"无关建议
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.levenshtein = levenshtein;
24
+ exports.suggest = suggest;
25
+ /**
26
+ * 计算两个字符串的 Damerau-Levenshtein 编辑距离(不区分大小写)。
27
+ *
28
+ * 在标准 Levenshtein(增 / 删 / 替换)基础上加"相邻字符交换"作为单步编辑。
29
+ * 二维 DP 实现(transposition 需要回看 dp[i-2][j-2],所以不再用单行优化)。
30
+ * 候选词通常 < 30 字符,二维 DP 的常数空间开销可忽略。
31
+ */
32
+ function levenshtein(a, b) {
33
+ const aa = a.toLowerCase();
34
+ const bb = b.toLowerCase();
35
+ const m = aa.length;
36
+ const n = bb.length;
37
+ if (m === 0)
38
+ return n;
39
+ if (n === 0)
40
+ return m;
41
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
42
+ for (let i = 0; i <= m; i++)
43
+ dp[i][0] = i;
44
+ for (let j = 0; j <= n; j++)
45
+ dp[0][j] = j;
46
+ for (let i = 1; i <= m; i++) {
47
+ for (let j = 1; j <= n; j++) {
48
+ const cost = aa[i - 1] === bb[j - 1] ? 0 : 1;
49
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, // 删除
50
+ dp[i][j - 1] + 1, // 插入
51
+ dp[i - 1][j - 1] + cost);
52
+ // transposition: 当前字符 = 对方前一字符 && 我方前一字符 = 对方当前字符
53
+ // 例:aa="form", bb="from",i=2,j=2 时 aa[1]=o=bb[0]=f? 不命中;i=3,j=3
54
+ // 时 aa[2]=r=bb[1]=r? bb[2]=o, aa[1]=o → 命中 → dp[3][3] = dp[1][1] + 1 = 1
55
+ if (i > 1 && j > 1 && aa[i - 1] === bb[j - 2] && aa[i - 2] === bb[j - 1]) {
56
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
57
+ }
58
+ }
59
+ }
60
+ return dp[m][n];
61
+ }
62
+ /** 默认阈值:候选词越短匹配越严,越长越宽。避免 "id" 错配 "in" 这种 false positive。 */
63
+ function defaultThreshold(len) {
64
+ if (len <= 4)
65
+ return 1;
66
+ if (len <= 8)
67
+ return 2;
68
+ return 3;
69
+ }
70
+ /**
71
+ * 在候选词列表里找与 input 最相近的一个。
72
+ *
73
+ * 距离 ≤ 阈值时按"距离最小"返回;多个候选词相同距离时返"列表中最先出现"的;
74
+ * 阈值外没有命中返 null(调用方按 null 静默不加 hint,不要强凑)。
75
+ */
76
+ function suggest(input, candidates, opts) {
77
+ if (!input || candidates.length === 0)
78
+ return null;
79
+ const threshold = opts?.maxDistance ?? defaultThreshold;
80
+ let best = null;
81
+ let bestDist = Number.POSITIVE_INFINITY;
82
+ for (const cand of candidates) {
83
+ const d = levenshtein(input, cand);
84
+ const limit = threshold(cand.length);
85
+ if (d <= limit && d < bestDist) {
86
+ best = cand;
87
+ bestDist = d;
88
+ }
89
+ }
90
+ return best;
91
+ }
@@ -7,6 +7,27 @@ exports.emitOk = emitOk;
7
7
  exports.emitPaged = emitPaged;
8
8
  const config_1 = require("./config");
9
9
  const error_1 = require("./error");
10
+ const colors_1 = require("./colors");
11
+ /**
12
+ * 服务端错误码 → CLI 兜底 hint 文案。
13
+ *
14
+ * 服务端错误协议只有 `{code, msg}` 两个字段(dataloom InnerExecuteSQL 等 IDL
15
+ * 没有 hint / next_actions 通道),所以服务端给的错误本身永远没有 hint。
16
+ * 这里是 CLI 展示层为常见错误码补一份 spec 一致的 actionable 引导,按错误码
17
+ * 落到 next_actions。
18
+ *
19
+ * 仅在 next_actions 已经为空时介入——保留 handler / enrichSqlError 自己塞过的
20
+ * 具体 hint(如 did-you-mean、shared.resolveAppId 等),它们优先级更高。
21
+ */
22
+ const SERVER_ERROR_HINTS = {
23
+ // SELECT 结果集超过 1000 行硬拦:spec 多行引导。
24
+ // key 用 BIZ_ERR_MAP 映射后的语义 code(不是原始服务端 k_dl_xxx),保持
25
+ // 与 help / 文档里宣传的 RESULT_SET_TOO_LARGE 一致。
26
+ RESULT_SET_TOO_LARGE: [
27
+ "Add `LIMIT <n>` to your SQL to narrow the result.",
28
+ "For counts, use `SELECT count(*) FROM ...`; for aggregation, use GROUP BY with filters.",
29
+ ],
30
+ };
10
31
  function isJsonMode() {
11
32
  const cfg = (0, config_1.getConfig)();
12
33
  return cfg.output === "json" || Boolean(cfg.json);
@@ -36,23 +57,56 @@ function emit(data) {
36
57
  */
37
58
  function emitError(err) {
38
59
  const info = toErrorInfo(err);
60
+ // 没人给过 next_actions 才走错误码兜底;handler 已经塞过具体 hint 时不覆盖
61
+ const hints = info.next_actions && info.next_actions.length > 0
62
+ ? info.next_actions
63
+ : (SERVER_ERROR_HINTS[info.code] ?? []);
39
64
  if (isJsonMode()) {
40
65
  const errObj = {
41
66
  code: info.code,
42
67
  message: info.message,
43
68
  };
44
- if (info.next_actions && info.next_actions.length > 0) {
45
- errObj.hint = info.next_actions.join(" ");
69
+ if (hints.length > 0) {
70
+ // JSON 输出压平成单行,更便于机器消费(脚本 / agent 拼字符串)
71
+ errObj.hint = hints.join(" ");
46
72
  }
47
73
  if (typeof info.statement_index === "number") {
48
74
  errObj.statement_index = info.statement_index;
49
75
  }
76
+ // PRD 多语句失败 envelope 额外字段:completed / rolled_back
77
+ if (Array.isArray(info.completed)) {
78
+ errObj.completed = info.completed;
79
+ }
80
+ if (typeof info.rolled_back === "boolean") {
81
+ errObj.rolled_back = info.rolled_back;
82
+ }
50
83
  process.stderr.write(JSON.stringify({ error: errObj }) + "\n");
51
84
  }
52
85
  else {
53
- process.stderr.write(`Error: ${info.message}\n`);
54
- if (info.next_actions && info.next_actions.length > 0) {
55
- process.stderr.write(` hint: ${info.next_actions.join(" ")}\n`);
86
+ // stderr 染色:picocolors 默认按 stdout.isTTY 判断;stderr 通常也是 tty,
87
+ // 这里复用 stdout 探测保持简单(stderr only-pipe 的极端场景留作后续优化)
88
+ // 多语句失败时 Error 行末尾追加 "(at statement K of N)",与 PRD spec 对齐
89
+ let errorLine = `${colors_1.c.fail("Error:")} ${info.message}`;
90
+ if (typeof info.statement_index === "number") {
91
+ const k = info.statement_index + 1;
92
+ const n = info.total_statements;
93
+ errorLine +=
94
+ typeof n === "number" && n > 0
95
+ ? ` (at statement ${String(k)} of ${String(n)})`
96
+ : ` (at statement ${String(k)})`;
97
+ }
98
+ process.stderr.write(errorLine + "\n");
99
+ // 多行 hint:第一行带 "hint:" 标签,后续行用 8 空格缩进对齐 " hint: " 之后的列。
100
+ // 对应 spec 期望的格式:
101
+ // Error: ...
102
+ // hint: 第一条建议
103
+ // 第二条建议
104
+ if (hints.length > 0) {
105
+ const [first, ...rest] = hints;
106
+ process.stderr.write(` ${colors_1.c.muted("hint:")} ${first}\n`);
107
+ for (const line of rest) {
108
+ process.stderr.write(` ${line}\n`);
109
+ }
56
110
  }
57
111
  }
58
112
  }