trackops 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.
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/bin/trackops.js +103 -0
- package/lib/config.js +97 -0
- package/lib/control.js +575 -0
- package/lib/i18n.js +53 -0
- package/lib/init.js +200 -0
- package/lib/opera.js +202 -0
- package/lib/registry.js +182 -0
- package/lib/server.js +451 -0
- package/lib/skills.js +159 -0
- package/locales/en.json +142 -0
- package/locales/es.json +142 -0
- package/package.json +46 -0
- package/templates/etapa/agent.md +26 -0
- package/templates/etapa/genesis.md +94 -0
- package/templates/etapa/references/autonomy-and-recovery.md +117 -0
- package/templates/etapa/references/etapa-cycle.md +193 -0
- package/templates/etapa/registry.md +28 -0
- package/templates/etapa/router.md +39 -0
- package/templates/hooks/post-checkout +2 -0
- package/templates/hooks/post-commit +2 -0
- package/templates/hooks/post-merge +2 -0
- package/templates/opera/agent.md +26 -0
- package/templates/opera/genesis.md +94 -0
- package/templates/opera/references/autonomy-and-recovery.md +117 -0
- package/templates/opera/references/opera-cycle.md +193 -0
- package/templates/opera/registry.md +28 -0
- package/templates/opera/router.md +39 -0
- package/templates/skills/changelog-updater/SKILL.md +69 -0
- package/templates/skills/commiter/SKILL.md +99 -0
- package/templates/skills/project-starter-skill/SKILL.md +202 -0
- package/templates/skills/project-starter-skill/references/opera-cycle.md +193 -0
- package/ui/app.js +950 -0
- package/ui/index.html +356 -0
- package/ui/styles.css +688 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { spawn } = require("child_process");
|
|
7
|
+
|
|
8
|
+
const config = require("./config");
|
|
9
|
+
const control = require("./control");
|
|
10
|
+
const registry = require("./registry");
|
|
11
|
+
const { t, setLocale } = require("./i18n");
|
|
12
|
+
|
|
13
|
+
const UI_DIR = path.join(__dirname, "..", "ui");
|
|
14
|
+
const HOST = process.env.OPS_UI_HOST || "127.0.0.1";
|
|
15
|
+
const PORT = Number(process.env.OPS_UI_PORT || 4173);
|
|
16
|
+
const ORPHAN_TIMEOUT_MS = Number(process.env.OPS_COMMAND_ORPHAN_TIMEOUT_MS || 120000);
|
|
17
|
+
const MAX_COMMAND_RUNTIME_MS = Number(process.env.OPS_COMMAND_MAX_RUNTIME_MS || 600000);
|
|
18
|
+
const sessions = new Map();
|
|
19
|
+
|
|
20
|
+
const MIME_TYPES = {
|
|
21
|
+
".css": "text/css; charset=utf-8",
|
|
22
|
+
".html": "text/html; charset=utf-8",
|
|
23
|
+
".js": "text/javascript; charset=utf-8",
|
|
24
|
+
".json": "application/json; charset=utf-8",
|
|
25
|
+
".svg": "image/svg+xml",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/* ── helpers ── */
|
|
29
|
+
|
|
30
|
+
function sendJson(res, statusCode, payload) {
|
|
31
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
32
|
+
res.end(JSON.stringify(payload));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sendText(res, statusCode, message) {
|
|
36
|
+
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
37
|
+
res.end(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseBody(req) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
req.on("data", (chunk) => {
|
|
45
|
+
size += chunk.length;
|
|
46
|
+
if (size > 1024 * 1024) { reject(new Error(t("server.payloadTooLarge", { limit: "1 MB" }))); req.destroy(); return; }
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
});
|
|
49
|
+
req.on("end", () => {
|
|
50
|
+
if (!chunks.length) { resolve({}); return; }
|
|
51
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
52
|
+
catch (_e) { reject(new Error(t("server.invalidJson"))); }
|
|
53
|
+
});
|
|
54
|
+
req.on("error", reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function slugify(value) {
|
|
59
|
+
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toList(value) {
|
|
63
|
+
if (Array.isArray(value)) return value.map((i) => String(i).trim()).filter(Boolean);
|
|
64
|
+
return String(value || "").split(/\r?\n|,/).map((i) => i.trim()).filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── project resolution ── */
|
|
68
|
+
|
|
69
|
+
let startupRoot = null;
|
|
70
|
+
|
|
71
|
+
function ensureCurrentProjectRegistered() {
|
|
72
|
+
if (!startupRoot) return null;
|
|
73
|
+
try { return registry.registerProject(startupRoot); }
|
|
74
|
+
catch (_e) { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveProjectEntry(projectRef) {
|
|
78
|
+
const current = ensureCurrentProjectRegistered();
|
|
79
|
+
const entry = registry.resolveProject(projectRef, startupRoot) || current;
|
|
80
|
+
if (!entry) throw new Error(t("server.projectNotResolved"));
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadControlApi(projectRoot) {
|
|
85
|
+
return control.forProject(projectRoot);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildI18nPayload(controlState) {
|
|
89
|
+
const phases = config.getPhases(controlState);
|
|
90
|
+
const locale = config.getLocale(controlState);
|
|
91
|
+
const statusLabels = {};
|
|
92
|
+
for (const s of control.STATUS_ORDER) {
|
|
93
|
+
statusLabels[s] = control.statusLabel(s);
|
|
94
|
+
}
|
|
95
|
+
return { locale, statusLabels, phases };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getStatePayload(projectRef) {
|
|
99
|
+
const project = resolveProjectEntry(projectRef);
|
|
100
|
+
const api = loadControlApi(project.root);
|
|
101
|
+
const controlState = api.loadControl();
|
|
102
|
+
const runtime = api.refreshRepoRuntime({ quiet: true });
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
project,
|
|
106
|
+
control: controlState,
|
|
107
|
+
derived: api.derive(controlState),
|
|
108
|
+
runtime,
|
|
109
|
+
docsDirty: api.getDocDrift(controlState),
|
|
110
|
+
i18n: buildI18nPayload(controlState),
|
|
111
|
+
generatedAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function persist(projectRoot) {
|
|
116
|
+
const api = loadControlApi(projectRoot);
|
|
117
|
+
const controlState = api.loadControl();
|
|
118
|
+
api.saveControl(controlState);
|
|
119
|
+
api.syncDocs(controlState);
|
|
120
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ── task operations ── */
|
|
124
|
+
|
|
125
|
+
function makeTaskId(controlState, seed) {
|
|
126
|
+
const base = slugify(seed) || `task-${Date.now()}`;
|
|
127
|
+
const existing = new Set(controlState.tasks.map((t) => t.id));
|
|
128
|
+
if (!existing.has(base)) return base;
|
|
129
|
+
let idx = 2;
|
|
130
|
+
while (existing.has(`${base}-${idx}`)) idx += 1;
|
|
131
|
+
return `${base}-${idx}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createTask(projectRoot, payload) {
|
|
135
|
+
const api = loadControlApi(projectRoot);
|
|
136
|
+
const controlState = api.loadControl();
|
|
137
|
+
const title = String(payload.title || "").trim();
|
|
138
|
+
if (!title) throw new Error(t("server.titleRequired"));
|
|
139
|
+
|
|
140
|
+
const task = {
|
|
141
|
+
id: makeTaskId(controlState, payload.id || title),
|
|
142
|
+
title,
|
|
143
|
+
phase: payload.phase || config.getPhases(controlState)[0]?.id || "E",
|
|
144
|
+
stream: String(payload.stream || "Operations").trim(),
|
|
145
|
+
priority: payload.priority || "P1",
|
|
146
|
+
status: payload.status || "pending",
|
|
147
|
+
required: payload.required !== false,
|
|
148
|
+
dependsOn: toList(payload.dependsOn),
|
|
149
|
+
summary: String(payload.summary || "").trim(),
|
|
150
|
+
acceptance: toList(payload.acceptance),
|
|
151
|
+
history: [{ at: new Date().toISOString(), action: "create", note: t("server.taskCreatedNote") }],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const blocker = String(payload.blocker || "").trim();
|
|
155
|
+
if (blocker) task.blocker = blocker;
|
|
156
|
+
controlState.tasks.push(task);
|
|
157
|
+
api.saveControl(controlState);
|
|
158
|
+
api.syncDocs(controlState);
|
|
159
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
160
|
+
return task;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function patchTask(projectRoot, taskId, payload) {
|
|
164
|
+
const api = loadControlApi(projectRoot);
|
|
165
|
+
const controlState = api.loadControl();
|
|
166
|
+
const task = controlState.tasks.find((t) => t.id === taskId);
|
|
167
|
+
if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
|
|
168
|
+
|
|
169
|
+
if (typeof payload.title === "string") task.title = payload.title.trim() || task.title;
|
|
170
|
+
if (typeof payload.phase === "string") task.phase = payload.phase;
|
|
171
|
+
if (typeof payload.stream === "string") task.stream = payload.stream.trim() || task.stream;
|
|
172
|
+
if (typeof payload.priority === "string") task.priority = payload.priority;
|
|
173
|
+
if (typeof payload.status === "string") task.status = payload.status;
|
|
174
|
+
if (typeof payload.required === "boolean") task.required = payload.required;
|
|
175
|
+
if (payload.summary !== undefined) task.summary = String(payload.summary || "").trim();
|
|
176
|
+
if (payload.dependsOn !== undefined) task.dependsOn = toList(payload.dependsOn);
|
|
177
|
+
if (payload.acceptance !== undefined) task.acceptance = toList(payload.acceptance);
|
|
178
|
+
|
|
179
|
+
const blocker = String(payload.blocker || "").trim();
|
|
180
|
+
if (blocker) task.blocker = blocker;
|
|
181
|
+
else delete task.blocker;
|
|
182
|
+
|
|
183
|
+
task.history = task.history || [];
|
|
184
|
+
task.history.push({ at: new Date().toISOString(), action: "edit", note: String(payload.note || t("server.taskEditedNote")).trim() });
|
|
185
|
+
|
|
186
|
+
api.saveControl(controlState);
|
|
187
|
+
api.syncDocs(controlState);
|
|
188
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
189
|
+
return task;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ── sessions (command execution) ── */
|
|
193
|
+
|
|
194
|
+
function emitSession(res, payload) { res.write(`data: ${JSON.stringify(payload)}\n\n`); }
|
|
195
|
+
|
|
196
|
+
function cleanupSession(session) {
|
|
197
|
+
if (session.killTimer) { clearTimeout(session.killTimer); session.killTimer = null; }
|
|
198
|
+
if (session.maxRuntimeTimer) { clearTimeout(session.maxRuntimeTimer); session.maxRuntimeTimer = null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function terminateSession(session, reason) {
|
|
202
|
+
if (!session || session.status !== "running" || !session.process) return;
|
|
203
|
+
cleanupSession(session);
|
|
204
|
+
session.status = "terminated";
|
|
205
|
+
session.exitCode = 1;
|
|
206
|
+
session.output += `\n[ops] ${reason}\n`;
|
|
207
|
+
try { session.process.kill(); } catch (_e) { /* noop */ }
|
|
208
|
+
session.listeners.forEach((res) => {
|
|
209
|
+
emitSession(res, { type: "done", status: session.status, exitCode: session.exitCode, output: session.output, projectId: session.projectId });
|
|
210
|
+
res.end();
|
|
211
|
+
});
|
|
212
|
+
session.listeners.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function scheduleOrphanTermination(session) {
|
|
216
|
+
if (!session || session.status !== "running" || session.listeners.size > 0) return;
|
|
217
|
+
cleanupSession(session);
|
|
218
|
+
session.killTimer = setTimeout(() => terminateSession(session, "orphan timeout"), ORPHAN_TIMEOUT_MS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function createSession(commandText, project) {
|
|
222
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
223
|
+
const session = {
|
|
224
|
+
id, projectId: project.id, projectName: project.name, projectRoot: project.root,
|
|
225
|
+
command: commandText, startedAt: new Date().toISOString(),
|
|
226
|
+
status: "running", exitCode: null, output: "", listeners: new Set(),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
|
|
230
|
+
const shellArgs = process.platform === "win32"
|
|
231
|
+
? ["-NoLogo", "-NoProfile", "-Command", commandText]
|
|
232
|
+
: ["-lc", commandText];
|
|
233
|
+
|
|
234
|
+
const child = spawn(shell, shellArgs, { cwd: project.root, env: process.env });
|
|
235
|
+
session.process = child;
|
|
236
|
+
session.killTimer = null;
|
|
237
|
+
session.maxRuntimeTimer = setTimeout(() => terminateSession(session, "max runtime exceeded"), MAX_COMMAND_RUNTIME_MS);
|
|
238
|
+
sessions.set(id, session);
|
|
239
|
+
|
|
240
|
+
function pushChunk(type, chunk) {
|
|
241
|
+
const text = chunk.toString("utf8");
|
|
242
|
+
session.output += text;
|
|
243
|
+
session.listeners.forEach((res) => emitSession(res, { type, chunk: text, status: session.status, projectId: session.projectId }));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
child.stdout.on("data", (c) => pushChunk("stdout", c));
|
|
247
|
+
child.stderr.on("data", (c) => pushChunk("stderr", c));
|
|
248
|
+
child.on("close", (code) => {
|
|
249
|
+
cleanupSession(session);
|
|
250
|
+
session.status = "completed";
|
|
251
|
+
session.exitCode = code;
|
|
252
|
+
session.listeners.forEach((res) => {
|
|
253
|
+
emitSession(res, { type: "done", status: session.status, exitCode: code, output: session.output, projectId: session.projectId });
|
|
254
|
+
res.end();
|
|
255
|
+
});
|
|
256
|
+
session.listeners.clear();
|
|
257
|
+
});
|
|
258
|
+
child.on("error", (err) => {
|
|
259
|
+
cleanupSession(session);
|
|
260
|
+
session.status = "failed";
|
|
261
|
+
session.exitCode = 1;
|
|
262
|
+
session.output += `${err.message}\n`;
|
|
263
|
+
session.listeners.forEach((res) => {
|
|
264
|
+
emitSession(res, { type: "done", status: session.status, exitCode: 1, output: session.output, projectId: session.projectId });
|
|
265
|
+
res.end();
|
|
266
|
+
});
|
|
267
|
+
session.listeners.clear();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return session;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function serveSessionStream(res, session) {
|
|
274
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
275
|
+
emitSession(res, { type: "snapshot", status: session.status, exitCode: session.exitCode, command: session.command, output: session.output, projectId: session.projectId });
|
|
276
|
+
if (session.status !== "running") { res.end(); return; }
|
|
277
|
+
cleanupSession(session);
|
|
278
|
+
session.listeners.add(res);
|
|
279
|
+
res.on("close", () => { session.listeners.delete(res); scheduleOrphanTermination(session); });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ── static files ── */
|
|
283
|
+
|
|
284
|
+
function serveStatic(res, pathname) {
|
|
285
|
+
const safePath = pathname === "/" ? "/index.html" : pathname;
|
|
286
|
+
const normalized = path.normalize(safePath).replace(/^(\.\.[\\/])+/, "");
|
|
287
|
+
const filePath = path.join(UI_DIR, normalized);
|
|
288
|
+
if (!filePath.startsWith(UI_DIR)) { sendText(res, 403, "Forbidden."); return; }
|
|
289
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { sendText(res, 404, "Not found."); return; }
|
|
290
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
291
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
292
|
+
fs.createReadStream(filePath).pipe(res);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ── API handler ── */
|
|
296
|
+
|
|
297
|
+
async function handleApi(req, res, url) {
|
|
298
|
+
try {
|
|
299
|
+
if (req.method === "GET" && url.pathname === "/api/projects") {
|
|
300
|
+
const current = ensureCurrentProjectRegistered();
|
|
301
|
+
sendJson(res, 200, { ok: true, currentProjectId: current?.id || null, registryFile: registry.REGISTRY_FILE, projects: registry.listProjects() });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (req.method === "POST" && url.pathname === "/api/projects/register") {
|
|
306
|
+
const body = await parseBody(req);
|
|
307
|
+
const project = registry.registerProject(body.root || startupRoot || process.cwd());
|
|
308
|
+
sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (req.method === "POST" && url.pathname === "/api/projects/install") {
|
|
313
|
+
const body = await parseBody(req);
|
|
314
|
+
if (!body.root) { sendJson(res, 400, { ok: false, error: "Project path required." }); return; }
|
|
315
|
+
try {
|
|
316
|
+
const initMod = require("./init");
|
|
317
|
+
const result = initMod.initProject(body.root, {});
|
|
318
|
+
const project = registry.registerProject(result.root);
|
|
319
|
+
sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
|
|
320
|
+
} catch (err) {
|
|
321
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
327
|
+
sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (req.method === "POST" && url.pathname === "/api/tasks") {
|
|
332
|
+
const body = await parseBody(req);
|
|
333
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
334
|
+
const task = createTask(project.root, body);
|
|
335
|
+
sendJson(res, 201, { ok: true, task, state: getStatePayload(project.id) });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
340
|
+
if (req.method === "PUT" && taskMatch) {
|
|
341
|
+
const body = await parseBody(req);
|
|
342
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
343
|
+
const task = patchTask(project.root, decodeURIComponent(taskMatch[1]), body);
|
|
344
|
+
sendJson(res, 200, { ok: true, task, state: getStatePayload(project.id) });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const actionMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/action$/);
|
|
349
|
+
if (req.method === "POST" && actionMatch) {
|
|
350
|
+
const body = await parseBody(req);
|
|
351
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
352
|
+
const action = String(body.action || "").trim();
|
|
353
|
+
if (!action) { sendJson(res, 400, { ok: false, error: "Action required." }); return; }
|
|
354
|
+
const api = loadControlApi(project.root);
|
|
355
|
+
const controlState = api.loadControl();
|
|
356
|
+
api.updateTask(controlState, action, decodeURIComponent(actionMatch[1]), body.note || "");
|
|
357
|
+
sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
362
|
+
const body = await parseBody(req);
|
|
363
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
364
|
+
const api = loadControlApi(project.root);
|
|
365
|
+
const controlState = api.loadControl();
|
|
366
|
+
api.syncDocs(controlState);
|
|
367
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
368
|
+
sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (req.method === "POST" && url.pathname === "/api/commands") {
|
|
373
|
+
const body = await parseBody(req);
|
|
374
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
375
|
+
const commandText = String(body.command || "").trim();
|
|
376
|
+
if (!commandText) { sendJson(res, 400, { ok: false, error: t("server.commandRequired") }); return; }
|
|
377
|
+
const session = createSession(commandText, project);
|
|
378
|
+
sendJson(res, 201, { ok: true, session: { id: session.id, command: session.command, startedAt: session.startedAt, status: session.status, projectId: session.projectId, projectName: session.projectName } });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const sessionInfoMatch = url.pathname.match(/^\/api\/commands\/([^/]+)$/);
|
|
383
|
+
if (req.method === "GET" && sessionInfoMatch) {
|
|
384
|
+
const session = sessions.get(decodeURIComponent(sessionInfoMatch[1]));
|
|
385
|
+
if (!session) { sendJson(res, 404, { ok: false, error: t("server.sessionNotFound") }); return; }
|
|
386
|
+
sendJson(res, 200, { ok: true, session });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const sessionStreamMatch = url.pathname.match(/^\/api\/commands\/([^/]+)\/stream$/);
|
|
391
|
+
if (req.method === "GET" && sessionStreamMatch) {
|
|
392
|
+
const session = sessions.get(decodeURIComponent(sessionStreamMatch[1]));
|
|
393
|
+
if (!session) { sendText(res, 404, t("server.sessionNotFound")); return; }
|
|
394
|
+
serveSessionStream(res, session);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const sessionCancelMatch = url.pathname.match(/^\/api\/commands\/([^/]+)\/cancel$/);
|
|
399
|
+
if (req.method === "POST" && sessionCancelMatch) {
|
|
400
|
+
const session = sessions.get(decodeURIComponent(sessionCancelMatch[1]));
|
|
401
|
+
if (!session) { sendJson(res, 404, { ok: false, error: t("server.sessionNotFound") }); return; }
|
|
402
|
+
terminateSession(session, "manually cancelled");
|
|
403
|
+
sendJson(res, 200, { ok: true, session });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
sendJson(res, 404, { ok: false, error: "API route not found." });
|
|
408
|
+
} catch (error) {
|
|
409
|
+
sendJson(res, 500, { ok: false, error: error.message });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ── server start ── */
|
|
414
|
+
|
|
415
|
+
function run() {
|
|
416
|
+
startupRoot = config.resolveProjectRoot() || process.cwd();
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const ctrl = config.loadControl(startupRoot);
|
|
420
|
+
setLocale(config.getLocale(ctrl));
|
|
421
|
+
} catch (_e) {
|
|
422
|
+
setLocale("es");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
ensureCurrentProjectRegistered();
|
|
426
|
+
|
|
427
|
+
const server = http.createServer((req, res) => {
|
|
428
|
+
const url = new URL(req.url, `http://${req.headers.host || `${HOST}:${PORT}`}`);
|
|
429
|
+
if (url.pathname.startsWith("/api/")) { handleApi(req, res, url); return; }
|
|
430
|
+
serveStatic(res, url.pathname);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
function shutdown() {
|
|
434
|
+
sessions.forEach((s) => { if (s.status === "running") terminateSession(s, "dashboard shutdown"); });
|
|
435
|
+
}
|
|
436
|
+
process.on("SIGINT", shutdown);
|
|
437
|
+
process.on("SIGTERM", shutdown);
|
|
438
|
+
process.on("exit", shutdown);
|
|
439
|
+
|
|
440
|
+
server.listen(PORT, HOST, () => {
|
|
441
|
+
const current = ensureCurrentProjectRegistered();
|
|
442
|
+
console.log(t("server.ready", { host: HOST, port: PORT }));
|
|
443
|
+
if (current) console.log(t("server.defaultProject", { name: current.name, id: current.id }));
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (require.main === module) {
|
|
448
|
+
run();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
module.exports = { run };
|
package/lib/skills.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const config = require("./config");
|
|
7
|
+
const { t, setLocale } = require("./i18n");
|
|
8
|
+
|
|
9
|
+
const SKILLS_TEMPLATES_DIR = path.join(__dirname, "..", "templates", "skills");
|
|
10
|
+
|
|
11
|
+
function copyDirRecursive(src, dest) {
|
|
12
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
13
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
14
|
+
const srcPath = path.join(src, entry.name);
|
|
15
|
+
const destPath = path.join(dest, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
copyDirRecursive(srcPath, destPath);
|
|
18
|
+
} else {
|
|
19
|
+
fs.copyFileSync(srcPath, destPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseFrontmatter(content) {
|
|
25
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
26
|
+
if (!match) return {};
|
|
27
|
+
const fm = {};
|
|
28
|
+
for (const line of match[1].split("\n")) {
|
|
29
|
+
const sep = line.indexOf(":");
|
|
30
|
+
if (sep > 0) {
|
|
31
|
+
const key = line.slice(0, sep).trim().replace(/^["']|["']$/g, "");
|
|
32
|
+
const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, "");
|
|
33
|
+
fm[key] = val;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return fm;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getSkillsDir(root) {
|
|
40
|
+
return path.join(root, ".agents", "skills");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getRegistryPath(root) {
|
|
44
|
+
return path.join(root, ".agent", "skills", "_registry.md");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function catalogSkills() {
|
|
48
|
+
if (!fs.existsSync(SKILLS_TEMPLATES_DIR)) return [];
|
|
49
|
+
return fs.readdirSync(SKILLS_TEMPLATES_DIR, { withFileTypes: true })
|
|
50
|
+
.filter((e) => e.isDirectory())
|
|
51
|
+
.map((e) => {
|
|
52
|
+
const skillMd = path.join(SKILLS_TEMPLATES_DIR, e.name, "SKILL.md");
|
|
53
|
+
if (!fs.existsSync(skillMd)) return null;
|
|
54
|
+
const fm = parseFrontmatter(fs.readFileSync(skillMd, "utf8"));
|
|
55
|
+
return { name: e.name, description: fm.description || "", version: fm.version || "1.0" };
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function installedSkills(root) {
|
|
61
|
+
const skillsDir = getSkillsDir(root);
|
|
62
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
63
|
+
return fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, "SKILL.md")))
|
|
65
|
+
.map((e) => {
|
|
66
|
+
const fm = parseFrontmatter(fs.readFileSync(path.join(skillsDir, e.name, "SKILL.md"), "utf8"));
|
|
67
|
+
return { name: e.name, description: fm.description || "", version: fm.version || "1.0" };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateRegistry(root) {
|
|
72
|
+
const registryPath = getRegistryPath(root);
|
|
73
|
+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
74
|
+
const skills = installedSkills(root);
|
|
75
|
+
const lines = ["# Skills Registry", "", "| Skill | Version | Description |", "|-------|---------|-------------|"];
|
|
76
|
+
for (const s of skills) {
|
|
77
|
+
lines.push(`| ${s.name} | ${s.version} | ${s.description} |`);
|
|
78
|
+
}
|
|
79
|
+
fs.writeFileSync(registryPath, lines.join("\n") + "\n", "utf8");
|
|
80
|
+
|
|
81
|
+
// Also update control meta
|
|
82
|
+
const controlFile = config.controlFilePath(root);
|
|
83
|
+
if (fs.existsSync(controlFile)) {
|
|
84
|
+
try {
|
|
85
|
+
const control = config.loadControl(root);
|
|
86
|
+
if (control.meta.opera) {
|
|
87
|
+
control.meta.opera.skills = skills.map((s) => s.name);
|
|
88
|
+
config.saveControl(root, control);
|
|
89
|
+
}
|
|
90
|
+
} catch (_e) { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function installSkill(root, skillName) {
|
|
95
|
+
const control = config.loadControl(root);
|
|
96
|
+
setLocale(config.getLocale(control));
|
|
97
|
+
|
|
98
|
+
const templateDir = path.join(SKILLS_TEMPLATES_DIR, skillName);
|
|
99
|
+
if (!fs.existsSync(templateDir)) {
|
|
100
|
+
throw new Error(t("skill.notFound", { name: skillName }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const targetDir = path.join(getSkillsDir(root), skillName);
|
|
104
|
+
if (fs.existsSync(path.join(targetDir, "SKILL.md"))) {
|
|
105
|
+
console.log(t("skill.alreadyInstalled", { name: skillName }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
copyDirRecursive(templateDir, targetDir);
|
|
110
|
+
updateRegistry(root);
|
|
111
|
+
console.log(t("skill.installed", { name: skillName }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function removeSkill(root, skillName) {
|
|
115
|
+
const control = config.loadControl(root);
|
|
116
|
+
setLocale(config.getLocale(control));
|
|
117
|
+
|
|
118
|
+
const targetDir = path.join(getSkillsDir(root), skillName);
|
|
119
|
+
if (!fs.existsSync(targetDir)) {
|
|
120
|
+
console.log(t("skill.notInstalled", { name: skillName }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
125
|
+
updateRegistry(root);
|
|
126
|
+
console.log(t("skill.removed", { name: skillName }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── CLI commands ── */
|
|
130
|
+
|
|
131
|
+
function cmdInstall(root, skillName) {
|
|
132
|
+
if (!skillName) { console.error("Skill name required."); process.exit(1); }
|
|
133
|
+
installSkill(root, skillName);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cmdList(root) {
|
|
137
|
+
const skills = installedSkills(root);
|
|
138
|
+
if (!skills.length) { console.log("No skills installed."); return; }
|
|
139
|
+
console.log(t("skill.listTitle"));
|
|
140
|
+
for (const s of skills) {
|
|
141
|
+
console.log(` ${s.name} (v${s.version}) — ${s.description}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cmdRemove(root, skillName) {
|
|
146
|
+
if (!skillName) { console.error("Skill name required."); process.exit(1); }
|
|
147
|
+
removeSkill(root, skillName);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cmdCatalog() {
|
|
151
|
+
const skills = catalogSkills();
|
|
152
|
+
if (!skills.length) { console.log("No skills available in catalog."); return; }
|
|
153
|
+
console.log(t("skill.catalogTitle"));
|
|
154
|
+
for (const s of skills) {
|
|
155
|
+
console.log(` ${s.name} (v${s.version}) — ${s.description}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { installSkill, removeSkill, installedSkills, catalogSkills, updateRegistry, cmdInstall, cmdList, cmdRemove, cmdCatalog };
|