kcode-pi 0.1.4 → 0.1.6
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.
- package/README.md +42 -0
- package/dist/cli/kcode.d.ts +1 -0
- package/dist/cli/kcode.js +27 -4
- package/dist/context/project-context.js +1 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/extensions/kingdee-harness.ts +11 -1
- package/extensions/kingdee-tools.ts +4 -3
- package/package.json +1 -1
- package/src/cli/kcode.ts +29 -4
- package/src/context/project-context.ts +1 -0
- package/src/official/kingdee-skills.ts +549 -38
- package/src/platform/path.ts +38 -0
- package/src/tools/build-debug.ts +2 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ KCode 不要求你单独安装全局 `pi` 命令,也不会修改用户全局 P
|
|
|
15
15
|
- Node.js `>=22.19.0`
|
|
16
16
|
- npm
|
|
17
17
|
- Windows 推荐使用 Windows Terminal
|
|
18
|
+
- 不需要安装 Python。KCode 随包工具使用 Node/TypeScript 实现;只有你自己的业务项目或外部脚本明确依赖 Python 时才需要另行安装。
|
|
18
19
|
|
|
19
20
|
全局安装:
|
|
20
21
|
|
|
@@ -60,6 +61,12 @@ kcode context --refresh
|
|
|
60
61
|
kcode doctor
|
|
61
62
|
```
|
|
62
63
|
|
|
64
|
+
查看当前 KCode 版本:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
kcode version
|
|
68
|
+
```
|
|
69
|
+
|
|
63
70
|
启动工作环境:
|
|
64
71
|
|
|
65
72
|
```powershell
|
|
@@ -235,6 +242,16 @@ kcode context --refresh
|
|
|
235
242
|
kcode doctor
|
|
236
243
|
```
|
|
237
244
|
|
|
245
|
+
### `kcode version`
|
|
246
|
+
|
|
247
|
+
显示当前 KCode package 版本、安装路径、随包 Pi CLI 版本和 Node 版本。
|
|
248
|
+
|
|
249
|
+
```powershell
|
|
250
|
+
kcode version
|
|
251
|
+
kcode --version
|
|
252
|
+
kcode -v
|
|
253
|
+
```
|
|
254
|
+
|
|
238
255
|
### `kcode start`
|
|
239
256
|
|
|
240
257
|
启动 KCode 工作环境。
|
|
@@ -394,11 +411,19 @@ kd_build 按产品画像执行或 dry-run 构建
|
|
|
394
411
|
kd_debug 分析金蝶日志和堆栈
|
|
395
412
|
```
|
|
396
413
|
|
|
414
|
+
这些工具不依赖本机 Python:
|
|
415
|
+
|
|
416
|
+
- `kd_ksql_lint` 是内置 Node 静态检查器。
|
|
417
|
+
- `kd_cosmic_config` 使用 Node 读取并校验 `ok-cosmic.json`。
|
|
418
|
+
- `kd_cosmic_metadata` 使用 `ok-cosmic.json` 中的统一路由 API,并在当前项目 `.pi/kd/official-skills/` 下维护 JSON 缓存。
|
|
419
|
+
- `kd_cosmic_api` 查询随包金蝶知识库;需要精确方法签名时,仍要结合当前项目 SDK、编译输出或红绿证据确认。
|
|
420
|
+
|
|
397
421
|
## 升级
|
|
398
422
|
|
|
399
423
|
查看当前安装版本:
|
|
400
424
|
|
|
401
425
|
```powershell
|
|
426
|
+
kcode version
|
|
402
427
|
npm ls -g kcode-pi --depth=0
|
|
403
428
|
```
|
|
404
429
|
|
|
@@ -514,6 +539,23 @@ notepad $env:USERPROFILE\.pi\agent\settings.json
|
|
|
514
539
|
}
|
|
515
540
|
```
|
|
516
541
|
|
|
542
|
+
### Windows 下出现 `/mnt/d/...` 找不到文件
|
|
543
|
+
|
|
544
|
+
现象:
|
|
545
|
+
|
|
546
|
+
```text
|
|
547
|
+
read /mnt/d/projects/xxx/src/main/java/Foo.java
|
|
548
|
+
ENOENT: no such file or directory, access 'D:\mnt\d\projects\xxx\src\main\java\Foo.java'
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
原因是当前运行环境是 Windows,但 LLM 或工具把 Windows 路径误写成了 WSL/MSYS 风格路径。正确做法:
|
|
552
|
+
|
|
553
|
+
- 优先使用项目相对路径,例如 `code/fi/module/src/main/java/.../Foo.java`。
|
|
554
|
+
- 如果必须使用绝对路径,在 Windows 下使用 `D:\projects\xxx\...`,不要使用 `/mnt/d/...` 或 `/d/...`。
|
|
555
|
+
- 发现项目结构变化后运行 `kcode context --refresh`,让项目常驻上下文重新记录真实源码根。
|
|
556
|
+
|
|
557
|
+
KCode Harness 会拦截常见 `/mnt/<drive>/...` 和 `/<drive>/...` 文件工具调用,并提示改用 Windows 路径或项目相对路径。
|
|
558
|
+
|
|
517
559
|
### 仍然加载旧版本 KCode
|
|
518
560
|
|
|
519
561
|
先检查当前项目配置:
|
package/dist/cli/kcode.d.ts
CHANGED
|
@@ -12,5 +12,6 @@ export declare function runKcodeCli(args: string[], cwd?: string): KcodeCliResul
|
|
|
12
12
|
export declare function initProject(cwd: string): KcodeCliResult;
|
|
13
13
|
export declare function context(cwd: string, args: string[]): KcodeCliResult;
|
|
14
14
|
export declare function doctor(cwd: string): KcodeCliResult;
|
|
15
|
+
export declare function version(): KcodeCliResult;
|
|
15
16
|
export declare function start(cwd: string, piArgs: string[]): KcodeCliResult;
|
|
16
17
|
export declare function resolvePiCliCommand(piArgs?: string[]): PiCliCommand | undefined;
|
package/dist/cli/kcode.js
CHANGED
|
@@ -6,7 +6,9 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import { ensureProjectContext, writeProjectContext } from "../context/project-context.js";
|
|
7
7
|
const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
|
-
const
|
|
9
|
+
const packageMetadata = readPackageMetadata(packageRoot);
|
|
10
|
+
const packageName = packageMetadata.name ?? "kcode-pi";
|
|
11
|
+
const packageVersion = packageMetadata.version ?? "unknown";
|
|
10
12
|
export function runKcodeCli(args, cwd = process.cwd()) {
|
|
11
13
|
const command = args[0] ?? "help";
|
|
12
14
|
switch (command) {
|
|
@@ -16,6 +18,10 @@ export function runKcodeCli(args, cwd = process.cwd()) {
|
|
|
16
18
|
return context(cwd, args.slice(1));
|
|
17
19
|
case "doctor":
|
|
18
20
|
return doctor(cwd);
|
|
21
|
+
case "version":
|
|
22
|
+
case "--version":
|
|
23
|
+
case "-v":
|
|
24
|
+
return version();
|
|
19
25
|
case "start":
|
|
20
26
|
return start(cwd, args.slice(1));
|
|
21
27
|
case "help":
|
|
@@ -57,6 +63,7 @@ export function doctor(cwd) {
|
|
|
57
63
|
const settingsPath = projectSettingsPath(cwd);
|
|
58
64
|
lines.push(`Node:${node.status === 0 ? node.stdout.trim() : "未找到"}`);
|
|
59
65
|
lines.push(`Pi CLI:${formatPiCliStatus(piCli, pi)}`);
|
|
66
|
+
lines.push(`KCode version:${packageName}@${packageVersion}`);
|
|
60
67
|
lines.push(`KCode package:${packageRoot}`);
|
|
61
68
|
lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,请先运行 kcode init"}`);
|
|
62
69
|
lines.push(`项目上下文:${existsSync(join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md")) ? join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md") : "未创建,请运行 kcode context"}`);
|
|
@@ -70,6 +77,19 @@ export function doctor(cwd) {
|
|
|
70
77
|
output: lines.join("\n"),
|
|
71
78
|
};
|
|
72
79
|
}
|
|
80
|
+
export function version() {
|
|
81
|
+
const piCli = resolvePiCliCommand(["--version"]);
|
|
82
|
+
const pi = piCli ? spawnSync(piCli.command, piCli.args, { encoding: "utf8" }) : undefined;
|
|
83
|
+
return {
|
|
84
|
+
exitCode: 0,
|
|
85
|
+
output: [
|
|
86
|
+
`${packageName}@${packageVersion}`,
|
|
87
|
+
`KCode package:${packageRoot}`,
|
|
88
|
+
`Pi CLI:${formatPiCliStatus(piCli, pi)}`,
|
|
89
|
+
`Node:${process.version}`,
|
|
90
|
+
].join("\n"),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
73
93
|
export function start(cwd, piArgs) {
|
|
74
94
|
const init = initProject(cwd);
|
|
75
95
|
const piCli = resolvePiCliCommand(piArgs);
|
|
@@ -127,12 +147,14 @@ function isSameKcodePackage(candidate, currentPackage) {
|
|
|
127
147
|
return candidateName === packageName;
|
|
128
148
|
}
|
|
129
149
|
function readPackageName(packagePath) {
|
|
150
|
+
return readPackageMetadata(packagePath).name;
|
|
151
|
+
}
|
|
152
|
+
function readPackageMetadata(packagePath) {
|
|
130
153
|
try {
|
|
131
|
-
|
|
132
|
-
return packageJson.name;
|
|
154
|
+
return JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8"));
|
|
133
155
|
}
|
|
134
156
|
catch {
|
|
135
|
-
return
|
|
157
|
+
return {};
|
|
136
158
|
}
|
|
137
159
|
}
|
|
138
160
|
function packageNameFromPath(path) {
|
|
@@ -182,6 +204,7 @@ function helpText() {
|
|
|
182
204
|
" kcode init 初始化当前项目的 .pi/settings.json",
|
|
183
205
|
" kcode context 生成或刷新 .pi/kd/PROJECT_CONTEXT.md",
|
|
184
206
|
" kcode doctor 检查 Node、随包 Pi CLI、KCode package 和项目级配置",
|
|
207
|
+
" kcode version 显示 KCode、随包 Pi CLI 和 Node 版本",
|
|
185
208
|
" kcode start 初始化项目配置后启动 KCode 工作环境",
|
|
186
209
|
].join("\n");
|
|
187
210
|
}
|
|
@@ -58,6 +58,7 @@ export function generateProjectContext(cwd) {
|
|
|
58
58
|
"- This file is project memory for KCode. Read it before planning or editing code.",
|
|
59
59
|
"- Do not create demo/sample/scaffold code for business requirements.",
|
|
60
60
|
"- Do not assume module layout. Follow the actual paths below and verify target files before editing.",
|
|
61
|
+
"- Use project-relative paths when calling file tools. On Windows, do not rewrite paths to /mnt/<drive>/... or /<drive>/...; use Windows paths only when an absolute path is necessary.",
|
|
61
62
|
"- If this file is stale, regenerate with `kcode context --refresh` before planning.",
|
|
62
63
|
"- Write product code only after the Harness reaches `execute` and PLAN.md names the real target path.",
|
|
63
64
|
"",
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -15,6 +15,7 @@ import { readArtifact } from "../src/harness/artifacts.ts";
|
|
|
15
15
|
import { flagshipWriteBlockReason, isSourceLikePath, planWriteBlockReason } from "../src/harness/path-policy.ts";
|
|
16
16
|
import { tddProductionWriteBlockReason } from "../src/harness/tdd-policy.ts";
|
|
17
17
|
import { readProjectContext } from "../src/context/project-context.ts";
|
|
18
|
+
import { windowsPathHint } from "../src/platform/path.ts";
|
|
18
19
|
|
|
19
20
|
const KINGDEE_INTENT_PATTERN =
|
|
20
21
|
/金蝶|苍穹|星瀚|星空|旗舰|企业版|单据|表单|列表|插件|操作插件|校验器|反写|转换|工作流|基础资料|动态对象|DynamicObject|BOS|Cosmic|IronPython|Python\s*插件|py\s*插件|kd_|KSQL/i;
|
|
@@ -104,6 +105,7 @@ function workflowPromptForRun(cwd: string, run: NonNullable<ReturnType<typeof re
|
|
|
104
105
|
"",
|
|
105
106
|
phaseGuidance[run.phase],
|
|
106
107
|
"必须先理解当前业务项目已有目录、模块、包名、基类和本地封装,再决定文件位置和实现方式。",
|
|
108
|
+
"路径规则:在 Windows 工作区内,优先使用项目相对路径;如需绝对路径必须使用 `D:\\...` 这类 Windows 路径,禁止把路径改写成 `/mnt/d/...`、`/d/...` 等 WSL/MSYS 风格路径。",
|
|
107
109
|
"execute 阶段只能写 PLAN.md 明确列出的源码文件;如果目标文件不在计划内,必须先回到 plan 更新 PLAN.md。",
|
|
108
110
|
"写生产源码前必须先有红灯证据 evidence/tdd-red.md;红绿证据可以是 API/基类/方法签名、元数据、编译、既有测试框架或外部接口最小验证,不要为了测试引入额外 jar。",
|
|
109
111
|
].join("\n");
|
|
@@ -179,9 +181,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
181
|
});
|
|
180
182
|
|
|
181
183
|
pi.on("tool_call", (event, ctx) => {
|
|
184
|
+
const input = event.input as Record<string, unknown>;
|
|
185
|
+
const path = typeof input.path === "string" ? input.path : undefined;
|
|
186
|
+
const hint = path ? windowsPathHint(path) : undefined;
|
|
187
|
+
if (hint && ["read", "write", "edit"].includes(event.toolName)) {
|
|
188
|
+
const reason = `当前是 Windows 工作区,不能使用 WSL/MSYS 路径 ${path}。请改用项目相对路径,或使用 Windows 路径 ${hint}。`;
|
|
189
|
+
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
190
|
+
return { block: true, reason };
|
|
191
|
+
}
|
|
192
|
+
|
|
182
193
|
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
|
|
183
194
|
|
|
184
|
-
const path = typeof event.input.path === "string" ? event.input.path : undefined;
|
|
185
195
|
const reason = codeWriteBlockReason(ctx.cwd, path) ?? flagshipWriteBlockReason(readActiveRun(ctx.cwd), path, ctx.cwd);
|
|
186
196
|
if (!reason) return undefined;
|
|
187
197
|
|
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
type OfficialEvidenceFile,
|
|
18
18
|
isCosmicFamily,
|
|
19
19
|
ksqlLintCommand,
|
|
20
|
-
|
|
20
|
+
runOfficialCommand,
|
|
21
21
|
writeOfficialEvidence,
|
|
22
22
|
} from "../src/official/kingdee-skills.ts";
|
|
23
|
+
import { resolveWorkspacePath } from "../src/platform/path.ts";
|
|
23
24
|
|
|
24
25
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const knowledgePath = join(extensionDir, "..", "knowledge");
|
|
@@ -83,7 +84,7 @@ async function runOrDryRun(
|
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
const result = await
|
|
87
|
+
const result = await runOfficialCommand(command);
|
|
87
88
|
const evidencePath = evidenceFile ? writeOfficialEvidence(ctx.cwd, evidenceFile, result) : undefined;
|
|
88
89
|
return {
|
|
89
90
|
content: [{ type: "text" as const, text: formatCommandResult(result) }],
|
|
@@ -194,7 +195,7 @@ const kdCheckTool = defineTool({
|
|
|
194
195
|
let source = "inline";
|
|
195
196
|
|
|
196
197
|
if (!code && params.path) {
|
|
197
|
-
const filePath =
|
|
198
|
+
const filePath = resolveWorkspacePath(ctx.cwd, params.path);
|
|
198
199
|
code = readFileSync(filePath, "utf8");
|
|
199
200
|
source = params.path;
|
|
200
201
|
}
|
package/package.json
CHANGED
package/src/cli/kcode.ts
CHANGED
|
@@ -17,7 +17,9 @@ interface PiSettings {
|
|
|
17
17
|
|
|
18
18
|
const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
|
-
const
|
|
20
|
+
const packageMetadata = readPackageMetadata(packageRoot);
|
|
21
|
+
const packageName = packageMetadata.name ?? "kcode-pi";
|
|
22
|
+
const packageVersion = packageMetadata.version ?? "unknown";
|
|
21
23
|
|
|
22
24
|
export interface PiCliCommand {
|
|
23
25
|
command: string;
|
|
@@ -36,6 +38,10 @@ export function runKcodeCli(args: string[], cwd = process.cwd()): KcodeCliResult
|
|
|
36
38
|
return context(cwd, args.slice(1));
|
|
37
39
|
case "doctor":
|
|
38
40
|
return doctor(cwd);
|
|
41
|
+
case "version":
|
|
42
|
+
case "--version":
|
|
43
|
+
case "-v":
|
|
44
|
+
return version();
|
|
39
45
|
case "start":
|
|
40
46
|
return start(cwd, args.slice(1));
|
|
41
47
|
case "help":
|
|
@@ -84,6 +90,7 @@ export function doctor(cwd: string): KcodeCliResult {
|
|
|
84
90
|
|
|
85
91
|
lines.push(`Node:${node.status === 0 ? node.stdout.trim() : "未找到"}`);
|
|
86
92
|
lines.push(`Pi CLI:${formatPiCliStatus(piCli, pi)}`);
|
|
93
|
+
lines.push(`KCode version:${packageName}@${packageVersion}`);
|
|
87
94
|
lines.push(`KCode package:${packageRoot}`);
|
|
88
95
|
lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,请先运行 kcode init"}`);
|
|
89
96
|
lines.push(`项目上下文:${existsSync(join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md")) ? join(cwd, ".pi", "kd", "PROJECT_CONTEXT.md") : "未创建,请运行 kcode context"}`);
|
|
@@ -100,6 +107,20 @@ export function doctor(cwd: string): KcodeCliResult {
|
|
|
100
107
|
};
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
export function version(): KcodeCliResult {
|
|
111
|
+
const piCli = resolvePiCliCommand(["--version"]);
|
|
112
|
+
const pi = piCli ? spawnSync(piCli.command, piCli.args, { encoding: "utf8" }) : undefined;
|
|
113
|
+
return {
|
|
114
|
+
exitCode: 0,
|
|
115
|
+
output: [
|
|
116
|
+
`${packageName}@${packageVersion}`,
|
|
117
|
+
`KCode package:${packageRoot}`,
|
|
118
|
+
`Pi CLI:${formatPiCliStatus(piCli, pi)}`,
|
|
119
|
+
`Node:${process.version}`,
|
|
120
|
+
].join("\n"),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
export function start(cwd: string, piArgs: string[]): KcodeCliResult {
|
|
104
125
|
const init = initProject(cwd);
|
|
105
126
|
const piCli = resolvePiCliCommand(piArgs);
|
|
@@ -168,11 +189,14 @@ function isSameKcodePackage(candidate: string, currentPackage: string): boolean
|
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
function readPackageName(packagePath: string): string | undefined {
|
|
192
|
+
return readPackageMetadata(packagePath).name;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readPackageMetadata(packagePath: string): { name?: string; version?: string } {
|
|
171
196
|
try {
|
|
172
|
-
|
|
173
|
-
return packageJson.name;
|
|
197
|
+
return JSON.parse(readFileSync(join(packagePath, "package.json"), "utf8")) as { name?: string; version?: string };
|
|
174
198
|
} catch {
|
|
175
|
-
return
|
|
199
|
+
return {};
|
|
176
200
|
}
|
|
177
201
|
}
|
|
178
202
|
|
|
@@ -229,6 +253,7 @@ function helpText(): string {
|
|
|
229
253
|
" kcode init 初始化当前项目的 .pi/settings.json",
|
|
230
254
|
" kcode context 生成或刷新 .pi/kd/PROJECT_CONTEXT.md",
|
|
231
255
|
" kcode doctor 检查 Node、随包 Pi CLI、KCode package 和项目级配置",
|
|
256
|
+
" kcode version 显示 KCode、随包 Pi CLI 和 Node 版本",
|
|
232
257
|
" kcode start 初始化项目配置后启动 KCode 工作环境",
|
|
233
258
|
].join("\n");
|
|
234
259
|
}
|
|
@@ -70,6 +70,7 @@ export function generateProjectContext(cwd: string): string {
|
|
|
70
70
|
"- This file is project memory for KCode. Read it before planning or editing code.",
|
|
71
71
|
"- Do not create demo/sample/scaffold code for business requirements.",
|
|
72
72
|
"- Do not assume module layout. Follow the actual paths below and verify target files before editing.",
|
|
73
|
+
"- Use project-relative paths when calling file tools. On Windows, do not rewrite paths to /mnt/<drive>/... or /<drive>/...; use Windows paths only when an absolute path is necessary.",
|
|
73
74
|
"- If this file is stale, regenerate with `kcode context --refresh` before planning.",
|
|
74
75
|
"- Write product code only after the Harness reaches `execute` and PLAN.md names the real target path.",
|
|
75
76
|
"",
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ProductProfile } from "../product/profile.ts";
|
|
7
7
|
import { readActiveRun } from "../harness/state.ts";
|
|
8
8
|
import { runArtifactPath } from "../harness/paths.ts";
|
|
9
|
+
import { searchKnowledge } from "../knowledge/search.ts";
|
|
10
|
+
import { formatSearchResults } from "../knowledge/format.ts";
|
|
11
|
+
import { resolveWorkspacePath } from "../platform/path.ts";
|
|
9
12
|
|
|
10
13
|
const execFileAsync = promisify(execFile);
|
|
11
14
|
|
|
@@ -25,6 +28,11 @@ export interface CommandResult {
|
|
|
25
28
|
stderr: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
export interface OfficialCommand {
|
|
32
|
+
display: string;
|
|
33
|
+
run: () => Promise<CommandResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export type OfficialEvidenceFile = "cosmic-config.txt" | "cosmic-metadata.json" | "cosmic-api.txt" | "ksql-lint.txt";
|
|
29
37
|
|
|
30
38
|
const SKILL_DIRS: Record<OfficialSkillKey, string> = {
|
|
@@ -63,25 +71,6 @@ export async function ensureOfficialSkillRoot(_cwd: string, skill: OfficialSkill
|
|
|
63
71
|
throw new Error(`Official skill directory not found for ${skill}. Checked: ${officialSkillsSourceRoots().join(", ")}`);
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
export function resolveWorkspacePath(cwd: string, path: string): string {
|
|
67
|
-
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function pythonExecutable(): string {
|
|
71
|
-
return process.env.KCODE_PYTHON ?? (process.platform === "win32" ? "python" : "python3");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildPythonCommand(cwd: string, scriptPath: string, args: string[]): CommandSpec {
|
|
75
|
-
const executable = pythonExecutable();
|
|
76
|
-
const fullArgs = [scriptPath, ...args];
|
|
77
|
-
return {
|
|
78
|
-
executable,
|
|
79
|
-
args: fullArgs,
|
|
80
|
-
cwd,
|
|
81
|
-
display: formatCommand(executable, fullArgs),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
74
|
export async function runCommand(command: CommandSpec, timeoutMs = 120_000): Promise<CommandResult> {
|
|
86
75
|
try {
|
|
87
76
|
const result = await execFileAsync(command.executable, command.args, {
|
|
@@ -113,11 +102,15 @@ export async function runCommand(command: CommandSpec, timeoutMs = 120_000): Pro
|
|
|
113
102
|
}
|
|
114
103
|
}
|
|
115
104
|
|
|
116
|
-
export async function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
105
|
+
export async function runOfficialCommand(command: OfficialCommand): Promise<CommandResult> {
|
|
106
|
+
return command.run();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function cosmicConfigCommand(cwd: string, config?: string): Promise<OfficialCommand> {
|
|
110
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
111
|
+
const configPath = resolveConfigPath(cwd, config);
|
|
112
|
+
const display = formatCommand("kcode-node:cosmic-config", config ? ["--config", configPath] : []);
|
|
113
|
+
return officialCommand(display, () => runCosmicConfig(cwd, configPath));
|
|
121
114
|
}
|
|
122
115
|
|
|
123
116
|
export async function cosmicMetadataCommand(
|
|
@@ -131,16 +124,16 @@ export async function cosmicMetadataCommand(
|
|
|
131
124
|
op?: boolean;
|
|
132
125
|
showDetail?: boolean;
|
|
133
126
|
},
|
|
134
|
-
): Promise<
|
|
135
|
-
|
|
136
|
-
const script = join(root, "scripts", "cosmic-form-metadata.py");
|
|
127
|
+
): Promise<OfficialCommand> {
|
|
128
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
137
129
|
const args = withConfig(["get", params.form], cwd, params.config);
|
|
138
130
|
if (params.sql) args.push("--sql");
|
|
139
131
|
if (params.op) args.push("--op");
|
|
140
132
|
if (params.showDetail) args.push("--show-detail");
|
|
141
133
|
if (params.fuzzy) args.push("--fuzzy", ...splitTerms(params.fuzzy));
|
|
142
134
|
if (params.typeFilter) args.push("--type", params.typeFilter);
|
|
143
|
-
|
|
135
|
+
const configPath = resolveConfigPath(cwd, params.config);
|
|
136
|
+
return officialCommand(formatCommand("kcode-node:cosmic-metadata", args), () => runCosmicMetadata(cwd, configPath, params));
|
|
144
137
|
}
|
|
145
138
|
|
|
146
139
|
export async function cosmicApiCommand(
|
|
@@ -152,19 +145,18 @@ export async function cosmicApiCommand(
|
|
|
152
145
|
method?: string;
|
|
153
146
|
compact?: boolean;
|
|
154
147
|
},
|
|
155
|
-
): Promise<
|
|
156
|
-
|
|
157
|
-
const script = join(root, "scripts", "cosmic-api-knowledge.py");
|
|
148
|
+
): Promise<OfficialCommand> {
|
|
149
|
+
await ensureOfficialSkillRoot(cwd, "ok-cosmic");
|
|
158
150
|
const args = withConfig([params.mode, params.query], cwd, params.config);
|
|
159
151
|
if (params.method) args.push("--method", params.method);
|
|
160
152
|
if (params.compact) args.push("--compact");
|
|
161
|
-
return
|
|
153
|
+
return officialCommand(formatCommand("kcode-node:cosmic-api", args), () => runCosmicApi(cwd, params));
|
|
162
154
|
}
|
|
163
155
|
|
|
164
|
-
export async function ksqlLintCommand(cwd: string, path: string): Promise<
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
return
|
|
156
|
+
export async function ksqlLintCommand(cwd: string, path: string): Promise<OfficialCommand> {
|
|
157
|
+
await ensureOfficialSkillRoot(cwd, "ok-ksql");
|
|
158
|
+
const resolvedPath = resolveWorkspacePath(cwd, path);
|
|
159
|
+
return officialCommand(formatCommand("kcode-node:ksql-lint", [resolvedPath]), () => runKsqlLint(resolvedPath));
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
export function formatCommandResult(result: CommandResult): string {
|
|
@@ -208,6 +200,525 @@ function formatJsonEvidence(evidenceFile: OfficialEvidenceFile, result: CommandR
|
|
|
208
200
|
)}\n`;
|
|
209
201
|
}
|
|
210
202
|
|
|
203
|
+
function officialCommand(display: string, run: () => Promise<Omit<CommandResult, "command">>): OfficialCommand {
|
|
204
|
+
return {
|
|
205
|
+
display,
|
|
206
|
+
run: async () => {
|
|
207
|
+
try {
|
|
208
|
+
const result = await run();
|
|
209
|
+
return { command: display, ...result };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
command: display,
|
|
213
|
+
exitCode: 1,
|
|
214
|
+
stdout: "",
|
|
215
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveConfigPath(cwd: string, config?: string): string {
|
|
223
|
+
return config ? resolveWorkspacePath(cwd, config) : resolve(cwd, "ok-cosmic.json");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readJsonObject(path: string): Record<string, unknown> {
|
|
227
|
+
const data = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
228
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
229
|
+
throw new Error(`配置文件格式错误: ${path} 必须包含 JSON Object`);
|
|
230
|
+
}
|
|
231
|
+
return data as Record<string, unknown>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function runCosmicConfig(_cwd: string, configPath: string): Promise<Omit<CommandResult, "command">> {
|
|
235
|
+
const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = [];
|
|
236
|
+
issues.push({ level: "OK", key: "runtime.node", message: `Node ${process.version}` });
|
|
237
|
+
|
|
238
|
+
let config: Record<string, unknown> | undefined;
|
|
239
|
+
try {
|
|
240
|
+
if (!existsSync(configPath)) throw new Error(`找不到配置文件: ${configPath}`);
|
|
241
|
+
config = readJsonObject(configPath);
|
|
242
|
+
issues.push({ level: "OK", key: "__file__", message: `已找到配置文件: ${configPath}` });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
issues.push({ level: "ERROR", key: "__file__", message: error instanceof Error ? error.message : String(error) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (config) issues.push(...validateCosmicConfig(config, dirname(configPath)));
|
|
248
|
+
const errors = issues.filter((issue) => issue.level === "ERROR").length;
|
|
249
|
+
const warnings = issues.filter((issue) => issue.level === "WARNING").length;
|
|
250
|
+
const lines = issues.map((issue) => `[${issue.level}] ${issue.key}: ${issue.message}`);
|
|
251
|
+
lines.push(`[SUMMARY] errors=${errors} warnings=${warnings}`);
|
|
252
|
+
return { exitCode: errors ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateCosmicConfig(config: Record<string, unknown>, baseDir: string): Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> {
|
|
256
|
+
const issues: Array<{ level: "OK" | "WARNING" | "ERROR"; key: string; message: string }> = [];
|
|
257
|
+
const graph = objectValue(config.graph);
|
|
258
|
+
if (!graph) {
|
|
259
|
+
issues.push({ level: "ERROR", key: "graph", message: "缺少 `graph` 配置对象。" });
|
|
260
|
+
} else {
|
|
261
|
+
const dbPath = stringValue(graph.dbPath);
|
|
262
|
+
if (!dbPath) {
|
|
263
|
+
issues.push({ level: "ERROR", key: "graph.dbPath", message: "缺少必填项 `graph.dbPath`。" });
|
|
264
|
+
} else {
|
|
265
|
+
const resolved = resolveWorkspacePath(baseDir, dbPath);
|
|
266
|
+
issues.push({
|
|
267
|
+
level: existsSync(resolved) ? "OK" : "WARNING",
|
|
268
|
+
key: "graph.dbPath",
|
|
269
|
+
message: existsSync(resolved) ? `知识库路径: ${resolved}` : `graph.dbPath 指向的文件不存在: ${resolved}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const route = objectValue(config.route);
|
|
275
|
+
const routeUrl = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || "";
|
|
276
|
+
if (!route && !routeUrl) {
|
|
277
|
+
issues.push({ level: "WARNING", key: "route", message: "缺少 `route` 配置节,统一路由在线查询不可用。" });
|
|
278
|
+
} else if (!routeUrl) {
|
|
279
|
+
issues.push({ level: "WARNING", key: "route.apiUrl", message: "`route.apiUrl` 为空,统一路由在线查询不可用。" });
|
|
280
|
+
} else {
|
|
281
|
+
issues.push({ level: "OK", key: "route.apiUrl", message: "统一路由 API 已配置。" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const extensionRepos = config.extensionRepos;
|
|
285
|
+
if (extensionRepos !== undefined) {
|
|
286
|
+
if (!Array.isArray(extensionRepos)) {
|
|
287
|
+
issues.push({ level: "ERROR", key: "extensionRepos", message: "`extensionRepos` 必须是字符串数组。" });
|
|
288
|
+
} else {
|
|
289
|
+
extensionRepos.forEach((raw, index) => {
|
|
290
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
291
|
+
const key = `extensionRepos[${index}]`;
|
|
292
|
+
if (!value) {
|
|
293
|
+
issues.push({ level: "ERROR", key, message: `${key} 必须是非空字符串路径。` });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const resolved = resolveWorkspacePath(baseDir, value);
|
|
297
|
+
issues.push({
|
|
298
|
+
level: existsSync(resolved) ? "OK" : "WARNING",
|
|
299
|
+
key,
|
|
300
|
+
message: existsSync(resolved) ? `扩展代码库: ${resolved}` : `扩展代码库路径不存在或不是目录: ${resolved}`,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return issues;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runCosmicMetadata(
|
|
310
|
+
cwd: string,
|
|
311
|
+
configPath: string,
|
|
312
|
+
params: { form: string; fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean },
|
|
313
|
+
): Promise<Omit<CommandResult, "command">> {
|
|
314
|
+
const config = readJsonObject(configPath);
|
|
315
|
+
const cache = readMetadataCache(cwd);
|
|
316
|
+
const targets = params.form.split(/[,,]/).map((item) => item.trim()).filter(Boolean);
|
|
317
|
+
if (targets.length === 0) return { exitCode: 1, stdout: "", stderr: "必须提供 formId 或中文单据名。" };
|
|
318
|
+
|
|
319
|
+
const outputs: string[] = [];
|
|
320
|
+
for (const target of targets) {
|
|
321
|
+
const cached = findCachedMetadata(cache, target);
|
|
322
|
+
const payload = cached ?? (await fetchMetadata(config, target));
|
|
323
|
+
const formId = stringValue(objectValue(payload.form)?.formId) || stringValue(payload.formId) || target;
|
|
324
|
+
cache[formId] = { payload, updatedAt: Date.now() };
|
|
325
|
+
outputs.push(formatMetadata(target, payload, params, cached ? "cache" : "route"));
|
|
326
|
+
}
|
|
327
|
+
writeMetadataCache(cwd, cache);
|
|
328
|
+
return { exitCode: 0, stdout: `${outputs.join("\n\n---\n\n")}\n`, stderr: "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function fetchMetadata(config: Record<string, unknown>, target: string): Promise<Record<string, unknown>> {
|
|
332
|
+
const route = objectValue(config.route);
|
|
333
|
+
const routeUrl = routeUrlFromConfig(route);
|
|
334
|
+
if (!routeUrl) {
|
|
335
|
+
throw new Error("未配置表单元数据查询 API。请在 ok-cosmic.json 的 route.apiUrl 中配置统一路由,或设置 COSMIC_ROUTE_API。");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const hasCjk = /[\u3400-\u9fff]/.test(target);
|
|
339
|
+
const response = await postRoute(routeUrl, route, {
|
|
340
|
+
data: {
|
|
341
|
+
type: "meta",
|
|
342
|
+
reqData: {
|
|
343
|
+
entityId: hasCjk ? "" : target,
|
|
344
|
+
formId: hasCjk ? "" : target,
|
|
345
|
+
billName: hasCjk ? target : "",
|
|
346
|
+
full: true,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (response.status === false) throw new Error(`接口请求失败: ${stringValue(response.message) || "未知错误"}`);
|
|
351
|
+
const data = response.status === true && response.data !== undefined ? unwrapRoutePayload(response.data) : unwrapRoutePayload(response);
|
|
352
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("元数据接口返回格式不是 JSON Object。");
|
|
353
|
+
return data as Record<string, unknown>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function postRoute(url: string, route: Record<string, unknown> | undefined, body: unknown): Promise<Record<string, unknown>> {
|
|
357
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
358
|
+
const token = stringValue(route?.apiToken) || stringValue(route?.token) || process.env.COSMIC_ROUTE_TOKEN || "";
|
|
359
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
360
|
+
const timeoutSeconds = Number(route?.timeoutSeconds ?? process.env.COSMIC_ROUTE_TIMEOUT ?? 10);
|
|
361
|
+
const controller = new AbortController();
|
|
362
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSeconds) * 1000);
|
|
363
|
+
try {
|
|
364
|
+
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
|
|
365
|
+
const text = await response.text();
|
|
366
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
367
|
+
const parsed = JSON.parse(text) as unknown;
|
|
368
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("远程接口返回的根对象不是 JSON Object。");
|
|
369
|
+
return parsed as Record<string, unknown>;
|
|
370
|
+
} finally {
|
|
371
|
+
clearTimeout(timeout);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function routeUrlFromConfig(route: Record<string, unknown> | undefined): string {
|
|
376
|
+
let url = stringValue(route?.apiUrl) || process.env.COSMIC_ROUTE_API || process.env.COSMIC_RUNTIME_ROUTE_API || "";
|
|
377
|
+
const sign = stringValue(route?.openApiSign) || stringValue(route?.openapiSign) || process.env.COSMIC_ROUTE_OPEN_API_SIGN || process.env.COSMIC_OPEN_API_SIGN || "";
|
|
378
|
+
if (url && sign && !/[?&]openApiSign=/.test(url)) url += `${url.includes("?") ? "&" : "?"}openApiSign=${encodeURIComponent(sign)}`;
|
|
379
|
+
return url;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function unwrapRoutePayload(value: unknown): unknown {
|
|
383
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
384
|
+
const obj = value as Record<string, unknown>;
|
|
385
|
+
if (obj.form || obj.formFields || obj.entityFields || obj.code === "MULTI_MATCH" || obj.code === "BILL_NOT_FOUND") return obj;
|
|
386
|
+
for (const key of ["data", "result", "respData", "response"]) {
|
|
387
|
+
const nested = unwrapRoutePayload(obj[key]);
|
|
388
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested;
|
|
389
|
+
}
|
|
390
|
+
return obj;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
type MetadataCache = Record<string, { payload: Record<string, unknown>; updatedAt: number }>;
|
|
394
|
+
|
|
395
|
+
function metadataCachePath(cwd: string): string {
|
|
396
|
+
return join(officialSkillsCacheRoot(cwd), "cosmic-form-metadata-cache.json");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readMetadataCache(cwd: string): MetadataCache {
|
|
400
|
+
const path = metadataCachePath(cwd);
|
|
401
|
+
if (!existsSync(path)) return {};
|
|
402
|
+
try {
|
|
403
|
+
return JSON.parse(readFileSync(path, "utf8")) as MetadataCache;
|
|
404
|
+
} catch {
|
|
405
|
+
return {};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function writeMetadataCache(cwd: string, cache: MetadataCache): void {
|
|
410
|
+
const path = metadataCachePath(cwd);
|
|
411
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
412
|
+
writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function findCachedMetadata(cache: MetadataCache, target: string): Record<string, unknown> | undefined {
|
|
416
|
+
const direct = cache[target]?.payload;
|
|
417
|
+
if (direct) return direct;
|
|
418
|
+
for (const entry of Object.values(cache)) {
|
|
419
|
+
const form = objectValue(entry.payload.form);
|
|
420
|
+
const names = [form?.formId, form?.id, form?.formName, form?.name, form?.title].map(stringValue);
|
|
421
|
+
if (names.includes(target)) return entry.payload;
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatMetadata(
|
|
427
|
+
target: string,
|
|
428
|
+
payload: Record<string, unknown>,
|
|
429
|
+
params: { fuzzy?: string; typeFilter?: string; sql?: boolean; op?: boolean; showDetail?: boolean },
|
|
430
|
+
source: "cache" | "route",
|
|
431
|
+
): string {
|
|
432
|
+
const form = objectValue(payload.form) ?? {};
|
|
433
|
+
const fields = [...arrayObjects(payload.formFields), ...arrayObjects(payload.entityFields)];
|
|
434
|
+
const operations = arrayObjects(payload.operateMetas).length ? arrayObjects(payload.operateMetas) : arrayObjects(payload.buttons);
|
|
435
|
+
const formName = stringValue(form.formName) || stringValue(form.name) || stringValue(form.title) || target;
|
|
436
|
+
const formId = stringValue(form.formId) || stringValue(form.id) || stringValue(form.key) || target;
|
|
437
|
+
const dbName = stringValue(form.dbName) || "-";
|
|
438
|
+
const dbTable = stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-";
|
|
439
|
+
|
|
440
|
+
if (params.op) {
|
|
441
|
+
const ops = filterObjects(operations, params.fuzzy);
|
|
442
|
+
return [`## [Op] 操作查询: ${formName} (${formId})`, "", "| 名称 | 标识 | 类型 |", "| :--- | :--- | :--- |", ...ops.map((op) => `| ${stringValue(op.name) || stringValue(op.opName) || "-"} | \`${stringValue(op.key) || stringValue(op.opKey) || "-"}\` | ${stringValue(op.type) || stringValue(op.opType) || "-"} |`)].join("\n");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const filtered = filterObjects(fields, params.fuzzy).filter((field) => {
|
|
446
|
+
if (!params.typeFilter) return true;
|
|
447
|
+
return matchesText(stringValue(field.type), params.typeFilter);
|
|
448
|
+
});
|
|
449
|
+
const selected = filtered.length ? filtered : fields.slice(0, 120);
|
|
450
|
+
const lines = [
|
|
451
|
+
`## [Meta] ${formName} (${formId})`,
|
|
452
|
+
`**来源**: ${source === "cache" ? "KCode 本地 JSON 缓存" : "统一路由 API"}`,
|
|
453
|
+
`**表**: dbName=\`${dbName}\`, dbTable=\`${dbTable}\``,
|
|
454
|
+
"",
|
|
455
|
+
];
|
|
456
|
+
if (params.sql) {
|
|
457
|
+
lines.push("| 名称 | 标识 | 类型 | 表名 | 数据库字段 |");
|
|
458
|
+
lines.push("| :--- | :--- | :--- | :--- | :--- |");
|
|
459
|
+
for (const field of selected) {
|
|
460
|
+
lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | \`${fieldTable(field, form)}\` | \`${stringValue(field.dbKey) || "-"}\` |`);
|
|
461
|
+
}
|
|
462
|
+
return lines.join("\n");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
lines.push("| 名称 | 标识 | 类型 | 附加信息 |");
|
|
466
|
+
lines.push("| :--- | :--- | :--- | :--- |");
|
|
467
|
+
for (const field of selected) {
|
|
468
|
+
const detail = params.showDetail ? fieldDetail(field) : stringValue(field.dbKey) || stringValue(field.refType) || "-";
|
|
469
|
+
lines.push(`| ${stringValue(field.name) || "-"} | \`${stringValue(field.key) || "-"}\` | ${stringValue(field.type) || "-"} | ${detail} |`);
|
|
470
|
+
}
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runCosmicApi(cwd: string, params: { mode: "search" | "search-method" | "detail"; query: string; method?: string; compact?: boolean }): Promise<Omit<CommandResult, "command">> {
|
|
475
|
+
const knowledgePath = join(packageRoot, "knowledge");
|
|
476
|
+
const query = params.mode === "detail" && params.method ? `${params.query} ${params.method}` : params.query;
|
|
477
|
+
const results = searchKnowledge(query, { scopes: ["cosmic", "cangqiong", "xinghan", "flagship"], topK: params.compact ? 5 : 10, minScore: 1 }, knowledgePath);
|
|
478
|
+
const header = [
|
|
479
|
+
`KCode Node Cosmic API query (${params.mode})`,
|
|
480
|
+
"说明: 当前 npm 包不再调用 Python/SQLite 脚本;这里查询随包金蝶知识库。需要精确方法签名时,请优先结合项目 SDK/编译输出做红绿验证。",
|
|
481
|
+
"",
|
|
482
|
+
].join("\n");
|
|
483
|
+
const stdout = `${header}${formatSearchResults(query, results, knowledgePath)}\n`;
|
|
484
|
+
return Promise.resolve({ exitCode: results.length ? 0 : 1, stdout, stderr: results.length ? "" : "未在随包知识库找到匹配 API 线索。" });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function runKsqlLint(path: string): Promise<Omit<CommandResult, "command">> {
|
|
488
|
+
if (!existsSync(path)) return Promise.resolve({ exitCode: 2, stdout: "", stderr: `${path}:1: ERROR: 文件不存在。\n` });
|
|
489
|
+
const rawSql = readFileSync(path, "utf8");
|
|
490
|
+
const findings = [...lintTimestamps(path, rawSql), ...iterStatements(stripCommentsAndLiterals(rawSql)).flatMap(lintStatement)].sort((a, b) => a.line - b.line || a.severity.localeCompare(b.severity));
|
|
491
|
+
const lines = findings.map((finding) => `${path}:${finding.line}: ${finding.severity}: ${finding.message}`);
|
|
492
|
+
const errorCount = findings.filter((finding) => finding.severity === "ERROR").length;
|
|
493
|
+
const warnCount = findings.filter((finding) => finding.severity === "WARN").length;
|
|
494
|
+
lines.push(`SUMMARY: ${errorCount} error(s), ${warnCount} warning(s)`);
|
|
495
|
+
return Promise.resolve({ exitCode: errorCount ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface KsqlFinding {
|
|
499
|
+
severity: "ERROR" | "WARN";
|
|
500
|
+
line: number;
|
|
501
|
+
message: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
interface KsqlStatement {
|
|
505
|
+
text: string;
|
|
506
|
+
line: number;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function stripCommentsAndLiterals(sql: string): string {
|
|
510
|
+
let out = "";
|
|
511
|
+
let i = 0;
|
|
512
|
+
let state: "normal" | "line" | "block" | "single" | "double" | "dollar" = "normal";
|
|
513
|
+
let dollarTag = "";
|
|
514
|
+
while (i < sql.length) {
|
|
515
|
+
const ch = sql[i];
|
|
516
|
+
const next = sql[i + 1] ?? "";
|
|
517
|
+
if (state === "normal") {
|
|
518
|
+
if (ch === "-" && next === "-") {
|
|
519
|
+
out += " ";
|
|
520
|
+
i += 2;
|
|
521
|
+
state = "line";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (ch === "/" && next === "*") {
|
|
525
|
+
out += " ";
|
|
526
|
+
i += 2;
|
|
527
|
+
state = "block";
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (ch === "'") {
|
|
531
|
+
out += " ";
|
|
532
|
+
i++;
|
|
533
|
+
state = "single";
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (ch === '"') {
|
|
537
|
+
out += " ";
|
|
538
|
+
i++;
|
|
539
|
+
state = "double";
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (ch === "$") {
|
|
543
|
+
const match = sql.slice(i).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
|
544
|
+
if (match) {
|
|
545
|
+
dollarTag = match[0];
|
|
546
|
+
out += " ".repeat(dollarTag.length);
|
|
547
|
+
i += dollarTag.length;
|
|
548
|
+
state = "dollar";
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
out += ch;
|
|
553
|
+
i++;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (state === "line") {
|
|
557
|
+
out += ch === "\n" ? "\n" : " ";
|
|
558
|
+
i++;
|
|
559
|
+
if (ch === "\n") state = "normal";
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (state === "block") {
|
|
563
|
+
if (ch === "*" && next === "/") {
|
|
564
|
+
out += " ";
|
|
565
|
+
i += 2;
|
|
566
|
+
state = "normal";
|
|
567
|
+
} else {
|
|
568
|
+
out += ch === "\n" ? "\n" : " ";
|
|
569
|
+
i++;
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (state === "single" || state === "double") {
|
|
574
|
+
const quote = state === "single" ? "'" : '"';
|
|
575
|
+
if (ch === quote && next === quote) {
|
|
576
|
+
out += " ";
|
|
577
|
+
i += 2;
|
|
578
|
+
} else {
|
|
579
|
+
out += ch === "\n" ? "\n" : " ";
|
|
580
|
+
i++;
|
|
581
|
+
if (ch === quote) state = "normal";
|
|
582
|
+
}
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (sql.startsWith(dollarTag, i)) {
|
|
586
|
+
out += " ".repeat(dollarTag.length);
|
|
587
|
+
i += dollarTag.length;
|
|
588
|
+
state = "normal";
|
|
589
|
+
dollarTag = "";
|
|
590
|
+
} else {
|
|
591
|
+
out += ch === "\n" ? "\n" : " ";
|
|
592
|
+
i++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function iterStatements(maskedSql: string): KsqlStatement[] {
|
|
599
|
+
const statements: KsqlStatement[] = [];
|
|
600
|
+
let start = 0;
|
|
601
|
+
let startLine = 1;
|
|
602
|
+
let line = 1;
|
|
603
|
+
for (let i = 0; i < maskedSql.length; i++) {
|
|
604
|
+
const ch = maskedSql[i];
|
|
605
|
+
if (ch === ";") {
|
|
606
|
+
const text = maskedSql.slice(start, i + 1);
|
|
607
|
+
if (text.trim()) statements.push({ text, line: startLine });
|
|
608
|
+
start = i + 1;
|
|
609
|
+
startLine = line;
|
|
610
|
+
}
|
|
611
|
+
if (ch === "\n") {
|
|
612
|
+
line++;
|
|
613
|
+
if (!maskedSql.slice(start, i).trim()) startLine = line;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const tail = maskedSql.slice(start);
|
|
617
|
+
if (tail.trim()) statements.push({ text: tail, line: startLine });
|
|
618
|
+
return statements;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function lintStatement(stmt: KsqlStatement): KsqlFinding[] {
|
|
622
|
+
const findings: KsqlFinding[] = [];
|
|
623
|
+
const compact = stmt.text.split(/\s+/).filter(Boolean).join(" ");
|
|
624
|
+
const first = compact.match(/^\s*(\w+)/)?.[1]?.toUpperCase() ?? "";
|
|
625
|
+
if ((first === "UPDATE" || first === "DELETE") && !hasToken(stmt.text, "WHERE")) {
|
|
626
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: `${first} 语句缺少 WHERE,禁止生成无范围更新/删除。` });
|
|
627
|
+
}
|
|
628
|
+
const selectStarMatches = [...stmt.text.matchAll(/\bSELECT\s+\*/gi)];
|
|
629
|
+
if (selectStarMatches.length) {
|
|
630
|
+
const isBackup = /\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b/i.test(stmt.text);
|
|
631
|
+
if (isBackup && hasToken(stmt.text, "WHERE")) {
|
|
632
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "备份语句必须整表备份,SELECT * INTO bak_... 不允许带 WHERE。" });
|
|
633
|
+
} else if (!isBackup) {
|
|
634
|
+
for (const match of selectStarMatches) {
|
|
635
|
+
findings.push({ severity: "ERROR", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "查询/验证语句禁止 SELECT *;只有整表备份 SELECT * INTO bak_... 例外。" });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (/\bSELECT\s+\*\s+INTO\b/i.test(stmt.text) && !/\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b/i.test(stmt.text)) {
|
|
640
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "备份表名必须形如 bak_<原表或业务缩写>_<yyyyMMddHHmm>。" });
|
|
641
|
+
}
|
|
642
|
+
for (const match of stmt.text.matchAll(/\bEXISTS\b/gi)) {
|
|
643
|
+
findings.push({ severity: "WARN", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "SQL 可读性偏好:成员关系/半连接默认使用 IN,只有 IN 改变语义时才保留 EXISTS 并说明原因。" });
|
|
644
|
+
}
|
|
645
|
+
if (/\bUPDATE\b.+\bJOIN\b/i.test(compact)) {
|
|
646
|
+
findings.push({ severity: "WARN", line: stmt.line, message: "PostgreSQL 多表更新优先使用 UPDATE ... FROM ... WHERE ...,不要使用 MySQL 风格 UPDATE ... JOIN。" });
|
|
647
|
+
}
|
|
648
|
+
if (/=\s*NULL\b|\bNULL\s*=/i.test(stmt.text)) {
|
|
649
|
+
findings.push({ severity: "ERROR", line: stmt.line, message: "NULL 判断必须使用 IS NULL / IS NOT NULL,不能使用 = NULL。" });
|
|
650
|
+
}
|
|
651
|
+
if (/<>|!=/.test(stmt.text) && hasToken(stmt.text, "NULL")) {
|
|
652
|
+
findings.push({ severity: "WARN", line: stmt.line, message: "涉及 NULL 的不等比较需确认语义;PostgreSQL 可优先使用 IS DISTINCT FROM。" });
|
|
653
|
+
}
|
|
654
|
+
return findings;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function lintTimestamps(path: string, rawSql: string): KsqlFinding[] {
|
|
658
|
+
const findings: KsqlFinding[] = [];
|
|
659
|
+
const backupTimestamps = new Set([...rawSql.matchAll(/\bbak_[a-zA-Z0-9_]+_(\d{12})\b/gi)].map((match) => match[1]));
|
|
660
|
+
const filenameTs = path.match(/ksql_[^/\\]*_(\d{12})\.txt$/i)?.[1];
|
|
661
|
+
const headerTimestamps = new Set([...rawSql.matchAll(/备份表时间戳[::]\s*(\d{12})/g)].map((match) => match[1]));
|
|
662
|
+
if (backupTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "同一 SQL 文件中出现多个备份表时间戳;桌面文件、备份表和文件头时间戳必须一致。" });
|
|
663
|
+
if (filenameTs && backupTimestamps.size && !backupTimestamps.has(filenameTs)) {
|
|
664
|
+
findings.push({ severity: "ERROR", line: 1, message: `文件名时间戳 ${filenameTs} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` });
|
|
665
|
+
}
|
|
666
|
+
if (headerTimestamps.size > 1) findings.push({ severity: "ERROR", line: 1, message: "文件头出现多个不同的备份表时间戳。" });
|
|
667
|
+
if (headerTimestamps.size && backupTimestamps.size && [...headerTimestamps].some((ts) => !backupTimestamps.has(ts))) {
|
|
668
|
+
findings.push({ severity: "ERROR", line: 1, message: `文件头时间戳 ${[...headerTimestamps].sort().join(", ")} 与备份表时间戳 ${[...backupTimestamps].sort().join(", ")} 不一致。` });
|
|
669
|
+
}
|
|
670
|
+
return findings;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function hasToken(text: string, token: string): boolean {
|
|
674
|
+
return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function lineOfOffset(text: string, baseLine: number, offset: number): number {
|
|
678
|
+
return baseLine + (text.slice(0, offset).match(/\n/g)?.length ?? 0);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
|
682
|
+
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
686
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function stringValue(value: unknown): string {
|
|
690
|
+
return typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function filterObjects(values: Record<string, unknown>[], fuzzy?: string): Record<string, unknown>[] {
|
|
694
|
+
const terms = fuzzy ? splitTerms(fuzzy) : [];
|
|
695
|
+
if (!terms.length) return values;
|
|
696
|
+
return values.filter((value) => terms.some((term) => matchesText(Object.values(value).map(stringValue).join("|"), term)));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function matchesText(text: string, term: string): boolean {
|
|
700
|
+
try {
|
|
701
|
+
return new RegExp(term, "i").test(text);
|
|
702
|
+
} catch {
|
|
703
|
+
return text.toLowerCase().includes(term.toLowerCase());
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function fieldTable(field: Record<string, unknown>, form: Record<string, unknown>): string {
|
|
708
|
+
return stringValue(field.dbTableName) || stringValue(field.dbTableKey) || stringValue(form.dbTableName) || stringValue(form.dbTableKey) || "-";
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function fieldDetail(field: Record<string, unknown>): string {
|
|
712
|
+
const parts = [];
|
|
713
|
+
const extMap = objectValue(field.extMap);
|
|
714
|
+
if (extMap) parts.push(`枚举: ${Object.entries(extMap).map(([key, value]) => `${key}:${stringValue(value)}`).join(", ")}`);
|
|
715
|
+
const refType = stringValue(field.refType);
|
|
716
|
+
if (refType) parts.push(`refType: ${refType}`);
|
|
717
|
+
const dbKey = stringValue(field.dbKey);
|
|
718
|
+
if (dbKey) parts.push(`dbKey: ${dbKey}`);
|
|
719
|
+
return parts.join(";") || "-";
|
|
720
|
+
}
|
|
721
|
+
|
|
211
722
|
function withConfig(args: string[], cwd: string, config?: string): string[] {
|
|
212
723
|
if (!config) return args;
|
|
213
724
|
return ["--config", resolveWorkspacePath(cwd, config), ...args];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
|
|
4
|
+
|
|
5
|
+
export function resolveWorkspacePath(cwd: string, inputPath: string): string {
|
|
6
|
+
const normalized = normalizeExternalPath(inputPath);
|
|
7
|
+
return isAbsolute(normalized) || WINDOWS_DRIVE_RE.test(normalized) ? resolve(normalized) : resolve(cwd, normalized);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeExternalPath(inputPath: string): string {
|
|
11
|
+
let value = inputPath.trim();
|
|
12
|
+
if (value.startsWith("file://")) {
|
|
13
|
+
try {
|
|
14
|
+
value = new URL(value).pathname;
|
|
15
|
+
} catch {
|
|
16
|
+
// Keep original value when it is not a valid file URL.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
const wsl = value.match(/^\/mnt\/([a-zA-Z])\/(.*)$/);
|
|
22
|
+
if (wsl) return `${wsl[1].toUpperCase()}:\\${wsl[2].replace(/\//g, "\\")}`;
|
|
23
|
+
|
|
24
|
+
const msys = value.match(/^\/([a-zA-Z])\/(.*)$/);
|
|
25
|
+
if (msys) return `${msys[1].toUpperCase()}:\\${msys[2].replace(/\//g, "\\")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasUnixDrivePath(inputPath: string): boolean {
|
|
32
|
+
return /^\/mnt\/[a-zA-Z]\//.test(inputPath.trim()) || /^\/[a-zA-Z]\//.test(inputPath.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function windowsPathHint(inputPath: string): string | undefined {
|
|
36
|
+
if (process.platform !== "win32" || !hasUnixDrivePath(inputPath)) return undefined;
|
|
37
|
+
return normalizeExternalPath(inputPath);
|
|
38
|
+
}
|
package/src/tools/build-debug.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { extname, join } from "node:path";
|
|
3
3
|
import type { ProductProfile } from "../product/profile.ts";
|
|
4
|
-
import { type CommandSpec, formatCommandResult,
|
|
4
|
+
import { type CommandSpec, formatCommandResult, runCommand } from "../official/kingdee-skills.ts";
|
|
5
|
+
import { resolveWorkspacePath } from "../platform/path.ts";
|
|
5
6
|
|
|
6
7
|
export interface BuildPlan {
|
|
7
8
|
profile: string;
|