@yinuo-ngm/server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/lib/app.d.ts +2 -0
  2. package/lib/app.js +65 -0
  3. package/lib/common/api.d.ts +2 -0
  4. package/lib/common/api.js +13 -0
  5. package/lib/common/editor.d.ts +5 -0
  6. package/lib/common/editor.js +69 -0
  7. package/lib/env.d.ts +7 -0
  8. package/lib/env.js +16 -0
  9. package/lib/index.d.ts +1 -0
  10. package/lib/index.js +20 -0
  11. package/lib/plugins/core.plugin.d.ts +9 -0
  12. package/lib/plugins/core.plugin.js +16 -0
  13. package/lib/plugins/error-handler.plugin.d.ts +6 -0
  14. package/lib/plugins/error-handler.plugin.js +113 -0
  15. package/lib/plugins/request-id.plugin.d.ts +3 -0
  16. package/lib/plugins/request-id.plugin.js +14 -0
  17. package/lib/plugins/routes.plugin.d.ts +3 -0
  18. package/lib/plugins/routes.plugin.js +17 -0
  19. package/lib/plugins/static.plugin.d.ts +2 -0
  20. package/lib/plugins/static.plugin.js +24 -0
  21. package/lib/plugins/success-handle.plugin.d.ts +3 -0
  22. package/lib/plugins/success-handle.plugin.js +30 -0
  23. package/lib/plugins/ws/topics/index.d.ts +1 -0
  24. package/lib/plugins/ws/topics/index.js +17 -0
  25. package/lib/plugins/ws/topics/syslog.ws.d.ts +9 -0
  26. package/lib/plugins/ws/topics/syslog.ws.js +28 -0
  27. package/lib/plugins/ws/topics/task.ws.d.ts +12 -0
  28. package/lib/plugins/ws/topics/task.ws.js +75 -0
  29. package/lib/plugins/ws/ws.context.d.ts +12 -0
  30. package/lib/plugins/ws/ws.context.js +35 -0
  31. package/lib/plugins/ws/ws.plugin.d.ts +3 -0
  32. package/lib/plugins/ws/ws.plugin.js +76 -0
  33. package/lib/plugins/ws/ws.router.d.ts +20 -0
  34. package/lib/plugins/ws/ws.router.js +67 -0
  35. package/lib/routes/config.routes.d.ts +2 -0
  36. package/lib/routes/config.routes.js +65 -0
  37. package/lib/routes/dashboard.routes.d.ts +2 -0
  38. package/lib/routes/dashboard.routes.js +33 -0
  39. package/lib/routes/deps.route.d.ts +2 -0
  40. package/lib/routes/deps.route.js +25 -0
  41. package/lib/routes/fs.routes.d.ts +2 -0
  42. package/lib/routes/fs.routes.js +29 -0
  43. package/lib/routes/index.d.ts +9 -0
  44. package/lib/routes/index.js +22 -0
  45. package/lib/routes/project.routes.d.ts +2 -0
  46. package/lib/routes/project.routes.js +196 -0
  47. package/lib/routes/rss.routes.d.ts +2 -0
  48. package/lib/routes/rss.routes.js +53 -0
  49. package/lib/routes/system.routes.d.ts +2 -0
  50. package/lib/routes/system.routes.js +28 -0
  51. package/lib/routes/task.routes.d.ts +2 -0
  52. package/lib/routes/task.routes.js +48 -0
  53. package/package.json +29 -0
  54. package/src/app.ts +76 -0
  55. package/src/common/api.ts +12 -0
  56. package/src/common/editor.ts +49 -0
  57. package/src/env.ts +12 -0
  58. package/src/index.ts +21 -0
  59. package/src/plugins/core.plugin.ts +34 -0
  60. package/src/plugins/error-handler.plugin.ts +168 -0
  61. package/src/plugins/request-id.plugin.ts +14 -0
  62. package/src/plugins/routes.plugin.ts +24 -0
  63. package/src/plugins/static.plugin.ts +30 -0
  64. package/src/plugins/success-handle.plugin.ts +33 -0
  65. package/src/plugins/ws/topics/index.ts +1 -0
  66. package/src/plugins/ws/topics/syslog.ws.ts +36 -0
  67. package/src/plugins/ws/topics/task.ws.ts +96 -0
  68. package/src/plugins/ws/ws.context.ts +32 -0
  69. package/src/plugins/ws/ws.plugin.ts +103 -0
  70. package/src/plugins/ws/ws.router.ts +79 -0
  71. package/src/routes/config.routes.ts +86 -0
  72. package/src/routes/dashboard.routes.ts +43 -0
  73. package/src/routes/deps.route.ts +51 -0
  74. package/src/routes/fs.routes.ts +31 -0
  75. package/src/routes/index.ts +19 -0
  76. package/src/routes/project.routes.ts +265 -0
  77. package/src/routes/rss.routes.ts +58 -0
  78. package/src/routes/system.routes.ts +32 -0
  79. package/src/routes/task.routes.ts +85 -0
  80. package/tsconfig.json +13 -0
@@ -0,0 +1,265 @@
1
+ import { AppError } from "@yinuo-ngm/core";
2
+ import { type FastifyInstance } from "fastify";
3
+ import * as path from "path";
4
+ import { openFolder } from "../common/editor";
5
+
6
+ /**
7
+ * Project routes
8
+ */
9
+ export default async function projectRoutes(fastify: FastifyInstance) {
10
+ /**
11
+ * 列出所有项目
12
+ * POST /list
13
+ */
14
+ fastify.get("/list", async () => {
15
+ const projects = await fastify.core.project.list();
16
+ return projects;
17
+ });
18
+
19
+ /**
20
+ * 更新项目(编辑)
21
+ */
22
+ fastify.post("/update/:id", async (req, reply) => {
23
+ const { id } = req.params as { id: string };
24
+ const body = req.body as Partial<{
25
+ name: string;
26
+ env: Record<string, string>;
27
+ scripts: Record<string, string>;
28
+ }>;
29
+ try {
30
+ // 明确禁止修改的字段
31
+ if ((body as any).root) {
32
+ reply.code(400);
33
+ return { message: "root is immutable" };
34
+ }
35
+ const updated = await fastify.core.project.update(id, {
36
+ ...(body.name !== undefined ? { name: body.name } : {}),
37
+ ...(body.env !== undefined ? { env: body.env } : {}),
38
+ ...(body.scripts !== undefined ? { scripts: body.scripts } : {}),
39
+ });
40
+ return updated;
41
+ } catch (err) {
42
+ if (err instanceof Error) {
43
+ reply.code(400);
44
+ return { message: err.message };
45
+ }
46
+ }
47
+ });
48
+
49
+ /**
50
+ * 获取项目详情
51
+ */
52
+ fastify.get("/getInfo/:id", async (req,) => {
53
+ const { id } = req.params as { id: string };
54
+ const project = await fastify.core.project.get(id);
55
+ return project;
56
+ });
57
+
58
+ /**
59
+ * 删除项目
60
+ */
61
+ fastify.get("/delete/:id", async (req) => {
62
+ const { id } = req.params as { id: string };
63
+ await fastify.core.project.remove(id);
64
+ return { id };
65
+ });
66
+
67
+ /**
68
+ * 检查路径是否合法 / 是否已存在项目
69
+ */
70
+ fastify.post("/check", async (req) => {
71
+ const body = req.body as { rootPath: string };
72
+ return fastify.core.project.checkRoot(body.rootPath);
73
+ });
74
+
75
+ /**
76
+ * 扫描项目(只读)
77
+ * POST /detect
78
+ */
79
+ fastify.post("/detect", async (req) => {
80
+ const body = req.body as { rootPath: string };
81
+ const root = path.resolve(body.rootPath);
82
+
83
+ // 直接复用 core.project.scan
84
+ const meta = await fastify.core.project.scan(root);
85
+
86
+ return {
87
+ framework: meta.framework ?? "unknown",
88
+ hasPackageJson: !!meta.scripts && Object.keys(meta.scripts).length > 0,
89
+ scripts: meta.scripts ?? {},
90
+ recommendedScript:
91
+ meta.scripts?.dev
92
+ ? "dev"
93
+ : meta.scripts?.start
94
+ ? "start"
95
+ : Object.keys(meta.scripts ?? {})[0],
96
+ hasGit: meta.hasGit ?? false,
97
+ };
98
+ });
99
+
100
+ /**
101
+ * 导入已有项目
102
+ */
103
+ fastify.post("/import", async (req) => {
104
+ const body = req.body as {
105
+ root: string;
106
+ name?: string;
107
+ syncTasks?: boolean;
108
+ };
109
+ const project = await fastify.core.project.importProject({
110
+ root: body.root,
111
+ name: body.name,
112
+ });
113
+ // 要自动 sync task,这里加
114
+ if (body.syncTasks === true) {
115
+ await fastify.core.task.refreshByProject(project.id);
116
+ }
117
+ return {
118
+ id: project.id,
119
+ };
120
+ });
121
+
122
+ /**
123
+ * 检查 root 是否是一个可导入的项目
124
+ */
125
+ fastify.post("/checkImport", async (req) => {
126
+ const body = req.body as { root: string };
127
+ return fastify.core.project.checkImport(body.root);
128
+ });
129
+
130
+ /**
131
+ * 创建新项目(暂不接 scaffold)
132
+ */
133
+ fastify.post("/create", async (req) => {
134
+ // const body = req.body as {
135
+ // name: string;
136
+ // root: string;
137
+ // scripts?: Record<string, string>;
138
+ // syncTasks?: boolean;
139
+ // };
140
+
141
+ // const chk = await fastify.core.project.checkRoot(body.root);
142
+ // if (!chk.ok) return chk;
143
+
144
+ // const project = await fastify.core.project.create({
145
+ // name: body.name,
146
+ // root: chk.root,
147
+ // scripts: body.scripts,
148
+ // });
149
+
150
+ // let syncedSpecs: any[] | undefined;
151
+ // if (body.syncTasks === true) {
152
+ // syncedSpecs = await fastify.core.task.syncSpecsFromProjectScripts(
153
+ // project.id,
154
+ // project.root,
155
+ // project.scripts ?? {}
156
+ // );
157
+ // }
158
+ // return { id: project.id, syncedSpecs };
159
+ const body = req.body as {
160
+ name: string;
161
+ root: string;
162
+ };
163
+ const project = await fastify.core.project.create({
164
+ name: body.name,
165
+ root: body.root,
166
+ });
167
+ return { id: project.id };
168
+ });
169
+
170
+
171
+ /**
172
+ * 设置收藏
173
+ * POST /projects/favorite/:id
174
+ * body: { isFavorite: boolean }
175
+ */
176
+ fastify.post("/favorite/:id", async (req, reply) => {
177
+ const { id } = req.params as { id: string };
178
+ const body = req.body as { isFavorite?: boolean };
179
+ if (typeof body?.isFavorite !== "boolean") {
180
+ reply.code(400);
181
+ return { message: "isFavorite must be boolean" };
182
+ }
183
+ const updated = await fastify.core.project.setFavorite(id, body.isFavorite);
184
+ return updated;
185
+ });
186
+
187
+ /**
188
+ * 切换收藏
189
+ * POST /projects/favorite/:id/toggle
190
+ */
191
+ fastify.post("/favorite/:id/toggle", async (req) => {
192
+ const { id } = req.params as { id: string };
193
+ const updated = await fastify.core.project.toggleFavorite(id);
194
+ return updated;
195
+ });
196
+
197
+ fastify.post("/lastOpened/:id", async (req) => {
198
+ const { id } = req.params as { id: string };
199
+ const body = req.body as { timestamp: number };
200
+ if (typeof body?.timestamp !== "number") {
201
+ throw new AppError("INVALID_TIMESTAMP", "timestamp must be a number");
202
+ }
203
+ const updated = await fastify.core.project.setLastOpened(id, body.timestamp);
204
+ return updated;
205
+ });
206
+
207
+ fastify.post("/rename/:id", async (req) => {
208
+ const { id } = req.params as { id: string };
209
+ const body = req.body as { name: string };
210
+ if (typeof body?.name !== "string" || body.name.trim() === "") {
211
+ throw new AppError("INVALID_NAME", "name must be a non-empty string");
212
+ }
213
+ const updated = await fastify.core.project.rename(id, body.name.trim());
214
+ return updated;
215
+ })
216
+ fastify.post("/edit/:id", async (req) => {
217
+ const { id } = req.params as { id: string };
218
+ const body = req.body as { name: string; description?: string; repoPageUrl?: string; };
219
+ if (typeof body?.name !== "string" || body.name.trim() === "") {
220
+ throw new AppError("INVALID_NAME", "name must be a non-empty string");
221
+ }
222
+ const updated = await fastify.core.project.edit(id, { name: body.name.trim(), description: body.description?.trim(), repoPageUrl: body.repoPageUrl?.trim() });
223
+ return updated;
224
+ })
225
+
226
+
227
+ /**
228
+ * 在编辑器打开项目
229
+ * POST /projects/openInEditor/:id
230
+ * body: { editor?: "code" | "system" }
231
+ */
232
+ fastify.post("/openInEditor/:id", async (req) => {
233
+ try {
234
+ const { id } = req.params as { id: string };
235
+ const body = req.body as { editor?: "code" | "system" };
236
+ const p = await fastify.core.project.get(id);
237
+ const editor = body?.editor || "code";
238
+ await openFolder(p.root, { editor });
239
+ return { ok: true };
240
+ } catch (e: any) {
241
+ throw new AppError("EDITOR_LAUNCH_FAILED", e?.message || "openInEditor failed");
242
+ }
243
+ });
244
+
245
+ fastify.post("/bootstrap/cli", async (req) => {
246
+ const body = req.body as any;
247
+ return await fastify.core.bootstrap.bootstrapByCli(body);
248
+ });
249
+
250
+ fastify.post("/bootstrap/git", async (req) => {
251
+ const body = req.body as any;
252
+ return await fastify.core.bootstrap.bootstrapByGit(body);
253
+ });
254
+
255
+ fastify.post("/bootstrap/pickRoot", async (req) => {
256
+ const body = req.body as any;
257
+ const taskId = String(body?.taskId ?? "").trim();
258
+ const pickedRoot = String(body?.pickedRoot ?? "").trim();
259
+ if (!taskId) throw new AppError("BAD_REQUEST", "taskId is required");
260
+ if (!pickedRoot) throw new AppError("BAD_REQUEST", "pickedRoot is required");
261
+ const r = await fastify.core.bootstrap.pickWorkspaceRoot({ taskId, pickedRoot });
262
+ return r;
263
+ });
264
+ }
265
+
@@ -0,0 +1,58 @@
1
+ import { AppError } from "@yinuo-ngm/core";
2
+ import type { FastifyInstance } from "fastify";
3
+ import Parser from "rss-parser";
4
+
5
+ type CacheEntry = { expireAt: number; payload: any };
6
+ const cache = new Map<string, CacheEntry>();
7
+
8
+ const parser = new Parser({
9
+ timeout: 15000, // 15s
10
+ });
11
+ export default async function rssRoutes(app: FastifyInstance) {
12
+ app.get("/preview", async (req) => {
13
+ const q = req.query as { url?: string; limit?: string; force?: string; cacheSec?: string };
14
+ const url = (q.url ?? "").trim();
15
+ if (!url) {
16
+ // return reply.code(400).send({ ok: false, message: "url required" });
17
+ throw new AppError("INVALID_RSS_URL", "url required", { query: q });
18
+ }
19
+ // limit between 1 and 100, default 20
20
+ const limit = Math.min(Math.max(parseInt(q.limit ?? "20", 10) || 20, 1), 100);
21
+ // force fetch, default false
22
+ const force = (q.force ?? "0") === "1";
23
+ // cache duration between 30 and 3600 seconds, default 2400
24
+ const cacheSec = Math.min(Math.max(parseInt(q.cacheSec ?? "2400", 10) || 2400, 30), 3600);
25
+ const key = `${url}|${limit}`;
26
+ const now = Date.now();
27
+ if (!force) {
28
+ const hit = cache.get(key);
29
+ if (hit && hit.expireAt > now) return hit.payload;
30
+ }
31
+ try {
32
+ const feed = await parser.parseURL(url);
33
+ const items = (feed.items ?? []).slice(0, limit).map((it) => ({
34
+ title: it.title ?? "",
35
+ link: (it.link ?? it.guid ?? "").toString(),
36
+ pubDate: it.isoDate ?? (it.pubDate ? new Date(it.pubDate).toISOString() : undefined),
37
+ author: (it.creator ?? it.author ?? "")?.toString() || undefined,
38
+ summary: (it.contentSnippet ?? it.content ?? "")?.toString() || undefined,
39
+ }));
40
+ const payload = {
41
+ title: feed.title,
42
+ description: feed.description,
43
+ link: feed.link,
44
+ items,
45
+ fetchedAt: new Date().toISOString(),
46
+ };
47
+ cache.set(key, { expireAt: now + cacheSec * 1000, payload });
48
+ return payload;
49
+ } catch (e: any) {
50
+ // return reply.code(500).send({
51
+ // ok: false,
52
+ // message: "RSS_FETCH_FAILED",
53
+ // detail: e?.message || String(e),
54
+ // });
55
+ throw new AppError(`RSS_FETCH_FAILED`, e?.message || String(e), { url });
56
+ }
57
+ });
58
+ }
@@ -0,0 +1,32 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import { env } from "../env";
3
+
4
+ export default async function systemRoutes(fastify: FastifyInstance) {
5
+ fastify.get("/health", async () => (
6
+ {
7
+ ts: Date.now(),
8
+ name: "ngm-server",
9
+ pid: process.pid,
10
+ uptime: process.uptime(),
11
+ version: process.env.npm_package_version,
12
+ dataDir: env.dataDir
13
+ }
14
+ ));
15
+
16
+ fastify.post("/shutdown", async () => {
17
+ fastify.log.info("Shutdown requested via /shutdown");
18
+
19
+ // 异步关闭,避免阻塞响应
20
+ setTimeout(async () => {
21
+ try {
22
+ await fastify.close(); // ← 会触发 onClose
23
+ process.exit(0);
24
+ } catch (e) {
25
+ console.error("Graceful shutdown failed", e);
26
+ process.exit(1);
27
+ }
28
+ }, 50);
29
+
30
+ return { ok: true };
31
+ });
32
+ }
@@ -0,0 +1,85 @@
1
+ import { AppError } from "@yinuo-ngm/core";
2
+ import type { FastifyInstance } from "fastify";
3
+ /** 判断该项目是否已有 specs(用于懒加载) */
4
+ async function ensureSpecs(fastify: FastifyInstance, projectId: string) {
5
+ const specs = await fastify.core.task.listSpecsByProject(projectId);
6
+ if (specs.length === 0) {
7
+ await fastify.core.task.refreshByProject(projectId);
8
+ }
9
+ }
10
+
11
+ export default async function taskRoutes(fastify: FastifyInstance) {
12
+
13
+ /**
14
+ * 获取 task views(spec + runtime 聚合)
15
+ * 懒加载,如果该项目未 refresh 过则自动 refresh
16
+ * GET /views/:projectId
17
+ */
18
+ fastify.get("/list/:projectId", async (req) => {
19
+ const { projectId } = req.params as { projectId: string };
20
+ await ensureSpecs(fastify, projectId);
21
+ return fastify.core.task.listViewsByProject(projectId);
22
+ });
23
+
24
+ /**
25
+ * 刷新(从 ProjectService.get(projectId) 的 scripts 重新生成 specs)
26
+ * 返回 views,前端可直接渲染
27
+ * POST /refresh/:projectId
28
+ */
29
+ fastify.post("/refresh/:projectId", async (req) => {
30
+ const { projectId } = req.params as { projectId: string };
31
+ // refreshByProject 内部会从 ProjectService.get() 拉 root/scripts/pm 并更新 specs
32
+ return await fastify.core.task.refreshByProject(projectId);
33
+ });
34
+
35
+ /**
36
+ * 启动任务(唯一方式)
37
+ * POST /start
38
+ * body: { taskId: string }
39
+ */
40
+ fastify.post("/start", async (req) => {
41
+ const body = req.body as { taskId?: string };
42
+ const taskId = body?.taskId?.trim();
43
+ if (!taskId) throw new AppError("TASK_ID_REQUIRED", "taskId is required", { body });
44
+ return await fastify.core.task.start(taskId);
45
+ });
46
+
47
+
48
+ /**
49
+ * 停止任务
50
+ * POST /stop
51
+ * body: { taskId: string }
52
+ */
53
+ fastify.post("/stop", async (req) => {
54
+ const body = req.body as { taskId?: string };
55
+ const taskId = body?.taskId?.trim();
56
+ if (!taskId) throw new AppError("TASK_ID_REQUIRED", "taskId is required", { body });
57
+ return await fastify.core.task.stop(taskId);
58
+ });
59
+
60
+ /**
61
+ * 查询任务状态
62
+ * GET /status/:taskId
63
+ */
64
+ fastify.get("/status/:taskId", async (req) => {
65
+ const { taskId } = req.params as { taskId: string };
66
+ return await fastify.core.task.status(taskId);
67
+ });
68
+
69
+ /** 列出所有活跃的任务(running / stopping) */
70
+ fastify.get("/active", async () => {
71
+ return await fastify.core.task.listActive();
72
+ });
73
+
74
+ /**
75
+ * 拉取某次运行的任务日志
76
+ * GET /log/run/:runId?tail=200
77
+ */
78
+ fastify.get("/log/run/:runId", async (req) => {
79
+ const { runId } = req.params as { runId: string };
80
+ const { tail } = req.query as { tail?: string };
81
+ const limit = Math.min(Math.max(Number(tail) || 200, 1), 5000);
82
+ return await fastify.core.task.getTailLogsByRun(runId, limit);
83
+ });
84
+ }
85
+
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@core": ["core/src/index.ts"],
9
+ "@core/*": ["core/src/*"]
10
+ }
11
+ },
12
+ "references": [{ "path": "../core" }]
13
+ }