@simoonfish/df-cli 1.0.6 → 1.0.7

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.
@@ -13,6 +13,8 @@ declare class ApiClient {
13
13
  private url;
14
14
  get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T>;
15
15
  post<T>(path: string, body?: unknown): Promise<T>;
16
+ put<T>(path: string, body?: unknown): Promise<T>;
17
+ del<T>(path: string): Promise<T>;
16
18
  verifyKey(): Promise<{
17
19
  valid: boolean;
18
20
  user_id: number | null;
@@ -48,6 +50,42 @@ declare class ApiClient {
48
50
  action: string;
49
51
  detail?: string;
50
52
  }): Promise<unknown>;
53
+ createKnowledgeBase(data: {
54
+ name: string;
55
+ path_in_repo?: string;
56
+ description?: string;
57
+ }): Promise<{
58
+ code: number;
59
+ data: any;
60
+ }>;
61
+ searchKnowledgeBases(keyword: string): Promise<{
62
+ code: number;
63
+ data: any[];
64
+ }>;
65
+ bindWorkspaceKnowledgeBase(wsId: number, kbIds: number[]): Promise<any>;
66
+ createKnowledgeNode(data: {
67
+ base_id: number;
68
+ parent_id?: number | null;
69
+ title: string;
70
+ type: string;
71
+ content?: string;
72
+ sort_order?: number;
73
+ path?: string;
74
+ }): Promise<{
75
+ code: number;
76
+ data: any;
77
+ }>;
78
+ updateKnowledgeNode(nodeId: number, data: {
79
+ title?: string;
80
+ content?: string;
81
+ parent_id?: number | null;
82
+ sort_order?: number;
83
+ path?: string;
84
+ }): Promise<{
85
+ code: number;
86
+ data: any;
87
+ }>;
88
+ deleteKnowledgeNode(nodeId: number): Promise<any>;
51
89
  }
52
90
  export declare const api: ApiClient;
53
91
  export {};
@@ -53,6 +53,27 @@ class ApiClient {
53
53
  const json = await res.json();
54
54
  return json;
55
55
  }
56
+ async put(path, body) {
57
+ const res = await fetch(this.url(path), {
58
+ method: "PUT",
59
+ headers: this.headers(),
60
+ body: body ? JSON.stringify(body) : undefined,
61
+ });
62
+ if (!res.ok)
63
+ throw new Error(`API ${res.status}: ${res.statusText}`);
64
+ const json = await res.json();
65
+ return json;
66
+ }
67
+ async del(path) {
68
+ const res = await fetch(this.url(path), {
69
+ method: "DELETE",
70
+ headers: this.headers(),
71
+ });
72
+ if (!res.ok)
73
+ throw new Error(`API ${res.status}: ${res.statusText}`);
74
+ const json = await res.json();
75
+ return json;
76
+ }
56
77
  async verifyKey() {
57
78
  return this.post("/auth/api-keys/verify");
58
79
  }
@@ -77,5 +98,25 @@ class ApiClient {
77
98
  async syncReport(data) {
78
99
  return this.post("/clients/sync-report", data);
79
100
  }
101
+ // ── Knowledge Base CRUD (for upload) ─────────────────────────
102
+ async createKnowledgeBase(data) {
103
+ return this.post("/knowledge-bases", data);
104
+ }
105
+ async searchKnowledgeBases(keyword) {
106
+ return this.get("/knowledge-bases", { keyword, pageSize: 50 });
107
+ }
108
+ async bindWorkspaceKnowledgeBase(wsId, kbIds) {
109
+ return this.post(`/workspaces/${wsId}/knowledge-bases`, { knowledge_base_ids: kbIds });
110
+ }
111
+ // ── Knowledge Node CRUD (for upload) ─────────────────────────
112
+ async createKnowledgeNode(data) {
113
+ return this.post("/knowledge-nodes", data);
114
+ }
115
+ async updateKnowledgeNode(nodeId, data) {
116
+ return this.put(`/knowledge-nodes/${nodeId}`, data);
117
+ }
118
+ async deleteKnowledgeNode(nodeId) {
119
+ return this.del(`/knowledge-nodes/${nodeId}`);
120
+ }
80
121
  }
81
122
  export const api = new ApiClient();
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const uploadCommand: Command;
@@ -0,0 +1,239 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { readConfig, findBinding, getKnowledgeInstallDir } from "../config.js";
7
+ import { api } from "../api/client.js";
8
+ export const uploadCommand = new Command("upload")
9
+ .description("将本地数据上传到服务端(知识库等)")
10
+ .option("--kb", "上传知识库到服务端")
11
+ .option("--kb-name <name>", "指定上传的知识库名称(仅限当前工作区已绑定的)")
12
+ .option("--strategy <mode>", "覆盖策略: incremental(默认,只增改)| mirror(以本地为准,删除远程多余内容)", "incremental")
13
+ .option("--dry-run", "预览模式,只输出将要执行的操作,不实际写入")
14
+ .action(async (opts) => {
15
+ const spinner = ora("加载配置...").start();
16
+ try {
17
+ if (!opts.kb) {
18
+ spinner.fail("请指定上传内容类型,例如 --kb 上传知识库");
19
+ process.exit(1);
20
+ }
21
+ const config = readConfig();
22
+ if (!config) {
23
+ spinner.fail("未找到配置,请先运行 devforge init");
24
+ process.exit(1);
25
+ }
26
+ const binding = findBinding(config);
27
+ if (!binding) {
28
+ spinner.fail("当前目录未绑定工作区,请先运行 devforge sync");
29
+ process.exit(1);
30
+ }
31
+ const localDir = binding.local_dir;
32
+ const strategy = opts.strategy === "mirror" ? "mirror" : "incremental";
33
+ const dryRun = !!opts.dryRun;
34
+ if (opts.kb) {
35
+ await uploadKnowledgeBases(spinner, binding.workspace_id, localDir, opts.kbName, strategy, dryRun);
36
+ }
37
+ }
38
+ catch (e) {
39
+ spinner.fail(`上传失败: ${e.message}`);
40
+ process.exit(1);
41
+ }
42
+ });
43
+ async function uploadKnowledgeBases(spinner, wsId, localDir, kbNameFilter, strategy, dryRun) {
44
+ // 1. Get workspace's KBs from server
45
+ spinner.start("获取工作区绑定的知识库...");
46
+ const wsKbsRes = await api.getWorkspaceKnowledgeBases(wsId);
47
+ const serverKbs = wsKbsRes.data || [];
48
+ if (serverKbs.length === 0) {
49
+ spinner.info("当前工作区未绑定知识库,跳过");
50
+ return;
51
+ }
52
+ spinner.succeed(`工作区已绑定 ${serverKbs.length} 个知识库`);
53
+ // 2. Read local KB directories
54
+ const kbRootDir = getKnowledgeInstallDir(localDir);
55
+ if (!existsSync(kbRootDir)) {
56
+ spinner.fail(`本地未找到知识库目录: ${kbRootDir},请先运行 devforge sync`);
57
+ process.exit(1);
58
+ }
59
+ const kbDirs = readdirSync(kbRootDir, { withFileTypes: true })
60
+ .filter((d) => d.isDirectory())
61
+ .map((d) => d.name);
62
+ if (kbDirs.length === 0) {
63
+ spinner.info("本地无可上传的知识库");
64
+ return;
65
+ }
66
+ for (const kbName of kbDirs) {
67
+ // Apply kb-name filter
68
+ if (kbNameFilter && kbName !== kbNameFilter)
69
+ continue;
70
+ const kbDir = join(kbRootDir, kbName);
71
+ const manifestPath = join(kbDir, "manifest.json");
72
+ if (!existsSync(manifestPath)) {
73
+ spinner.warn(`跳过 ${kbName}: 缺少 manifest.json`);
74
+ continue;
75
+ }
76
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
77
+ // 3. Match server KB by name
78
+ let serverKb = serverKbs.find((k) => k.name === kbName);
79
+ if (!serverKb) {
80
+ spinner.warn(`服务端未找到同名知识库 "${kbName}",将自动创建`);
81
+ if (!dryRun) {
82
+ const createRes = await api.createKnowledgeBase({ name: kbName });
83
+ serverKb = createRes.data;
84
+ // Bind to workspace
85
+ if (serverKb?.id) {
86
+ await api.bindWorkspaceKnowledgeBase(wsId, [serverKb.id]);
87
+ spinner.succeed(`已创建并绑定知识库: ${kbName} (ID: ${serverKb.id})`);
88
+ }
89
+ }
90
+ else {
91
+ console.log(chalk.yellow(` [预览] 创建知识库: ${kbName}`));
92
+ console.log(chalk.yellow(` [预览] 绑定到工作区: ${wsId}`));
93
+ continue;
94
+ }
95
+ }
96
+ else {
97
+ spinner.succeed(`已匹配知识库: ${kbName} (ID: ${serverKb.id})`);
98
+ }
99
+ if (!serverKb?.id)
100
+ continue;
101
+ // 4. Read index.json for local node tree
102
+ const indexPath = join(kbDir, "index.json");
103
+ if (!existsSync(indexPath)) {
104
+ spinner.warn(`跳过 ${kbName}: 缺少 index.json`);
105
+ continue;
106
+ }
107
+ const localNodes = JSON.parse(readFileSync(indexPath, "utf-8"));
108
+ // 5. Get server nodes
109
+ const serverNodesRes = await api.getKnowledgeNodes(serverKb.id);
110
+ const serverNodes = serverNodesRes.data || [];
111
+ // 6. Build path→node maps
112
+ const localByPath = new Map();
113
+ const serverByPath = new Map();
114
+ for (const n of localNodes) {
115
+ if (n.path)
116
+ localByPath.set(n.path, n);
117
+ }
118
+ for (const n of serverNodes) {
119
+ if (n.path)
120
+ serverByPath.set(n.path, n);
121
+ }
122
+ // Also index server nodes by title for pathless folders
123
+ const serverByTitle = new Map();
124
+ for (const n of serverNodes) {
125
+ if (n.type === "folder")
126
+ serverByTitle.set(n.title, n);
127
+ }
128
+ // 7. Execute diff sync
129
+ let created = 0, updated = 0, deleted = 0;
130
+ // 7a. Create/update local nodes
131
+ for (const localNode of localNodes) {
132
+ const serverNode = localNode.path ? serverByPath.get(localNode.path) : undefined;
133
+ if (serverNode) {
134
+ // Update existing node
135
+ const needsUpdate = localNode.title !== serverNode.title ||
136
+ localNode.sort_order !== serverNode.sort_order ||
137
+ localNode.parent_id !== serverNode.parent_id;
138
+ // For documents, also check content
139
+ let contentChanged = false;
140
+ if (localNode.type === "document") {
141
+ const localContent = readLocalDocumentContent(kbDir, localNode);
142
+ if (localContent !== (serverNode.content || "")) {
143
+ contentChanged = true;
144
+ }
145
+ }
146
+ if (needsUpdate || contentChanged) {
147
+ const payload = { title: localNode.title, sort_order: localNode.sort_order };
148
+ if (contentChanged && localNode.type === "document") {
149
+ payload.content = readLocalDocumentContent(kbDir, localNode);
150
+ }
151
+ if (!dryRun) {
152
+ await api.updateKnowledgeNode(serverNode.id, payload);
153
+ }
154
+ updated++;
155
+ const action = contentChanged ? "内容更新" : "元信息更新";
156
+ spinner.text = ` ~ ${localNode.path || localNode.title} (${action})`;
157
+ }
158
+ else {
159
+ spinner.text = ` ${localNode.path || localNode.title} (相同,跳过)`;
160
+ }
161
+ }
162
+ else {
163
+ // Create new node — need to resolve parent_id
164
+ const parentId = resolveParentId(localNode, localNodes, serverNodes);
165
+ const payload = {
166
+ base_id: serverKb.id,
167
+ parent_id: parentId,
168
+ title: localNode.title,
169
+ type: localNode.type,
170
+ sort_order: localNode.sort_order,
171
+ path: localNode.path,
172
+ };
173
+ if (localNode.type === "document") {
174
+ payload.content = readLocalDocumentContent(kbDir, localNode);
175
+ }
176
+ if (!dryRun) {
177
+ await api.createKnowledgeNode(payload);
178
+ }
179
+ created++;
180
+ spinner.text = ` + ${localNode.path || localNode.title}`;
181
+ }
182
+ }
183
+ // 7b. Delete server nodes missing locally (mirror mode only)
184
+ if (strategy === "mirror") {
185
+ for (const serverNode of serverNodes) {
186
+ const localExists = serverNode.path
187
+ ? localByPath.has(serverNode.path)
188
+ : localNodes.some((n) => n.title === serverNode.title && n.type === serverNode.type);
189
+ if (!localExists) {
190
+ if (!dryRun) {
191
+ await api.deleteKnowledgeNode(serverNode.id);
192
+ }
193
+ deleted++;
194
+ spinner.text = ` - ${serverNode.path || serverNode.title}`;
195
+ }
196
+ }
197
+ }
198
+ // 8. Report
199
+ const modeLabel = strategy === "mirror" ? "镜像" : "增量";
200
+ const dryLabel = dryRun ? " [预览]" : "";
201
+ spinner.succeed(`知识库 "${kbName}" 上传完成${dryLabel}(${modeLabel}) — +${created} ~${updated} -${deleted}`);
202
+ }
203
+ }
204
+ // ── Helpers ──────────────────────────────────────────────────
205
+ /** 读取本地文档文件内容 */
206
+ function readLocalDocumentContent(kbDir, node) {
207
+ if (node.type !== "document")
208
+ return "";
209
+ // Try path first, then title as filename
210
+ const relPath = node.path || `${node.title}.md`;
211
+ const filePath = join(kbDir, relPath);
212
+ try {
213
+ return readFileSync(filePath, "utf-8");
214
+ }
215
+ catch {
216
+ return "";
217
+ }
218
+ }
219
+ /**
220
+ * 为新建节点解析 parent_id。
221
+ * 优先用本地 index.json 中的 parent_id(指向本地 ID),映射为服务端节点 ID。
222
+ * 如果映射不到,尝试按 title 匹配服务端文件夹。
223
+ */
224
+ function resolveParentId(localNode, allLocalNodes, serverNodes) {
225
+ if (localNode.parent_id == null)
226
+ return null;
227
+ const localParent = allLocalNodes.find((n) => n.id === localNode.parent_id);
228
+ if (!localParent)
229
+ return null;
230
+ // Match by path first
231
+ if (localParent.path) {
232
+ const match = serverNodes.find((sn) => sn.path === localParent.path);
233
+ if (match)
234
+ return match.id;
235
+ }
236
+ // Fallback: match by title (for folders)
237
+ const match = serverNodes.find((sn) => sn.type === "folder" && sn.title === localParent.title);
238
+ return match?.id ?? null;
239
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { installCommand } from "./commands/install.js";
10
10
  import { listCommand } from "./commands/list.js";
11
11
  import { statusCommand } from "./commands/status.js";
12
12
  import { unbindCommand } from "./commands/unbind.js";
13
+ import { uploadCommand } from "./commands/upload.js";
13
14
  const program = new Command();
14
15
  program
15
16
  .name("devforge")
@@ -24,6 +25,7 @@ program
24
25
  .addCommand(listCommand)
25
26
  .addCommand(statusCommand)
26
27
  .addCommand(unbindCommand)
28
+ .addCommand(uploadCommand)
27
29
  .hook("preAction", (thisCommand) => {
28
30
  const opts = thisCommand.opts();
29
31
  // require api-key for most commands except version/help
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.0.6",
6
+ "version": "1.0.7",
7
7
  "description": "DevForge CLI — 将平台工作区、Skill 和知识库同步到本地开发工程",
8
8
  "bin": {
9
9
  "devforge": "./dist/index.js"