trackops 1.0.1 → 2.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/README.md +292 -272
- package/bin/trackops.js +108 -50
- package/lib/config.js +267 -38
- package/lib/control.js +534 -480
- package/lib/env.js +244 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +170 -47
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +1075 -0
- package/lib/opera.js +524 -125
- package/lib/preferences.js +74 -0
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/runtime-state.js +144 -0
- package/lib/server.js +1004 -521
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +418 -132
- package/locales/es.json +418 -132
- package/package.json +8 -9
- package/scripts/postinstall-locale.js +21 -0
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +570 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +89 -0
- package/skills/trackops/SKILL.md +89 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +73 -0
- package/skills/trackops/references/troubleshooting.md +49 -0
- package/skills/trackops/references/workflow.md +26 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +203 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/opera/agent.md +10 -9
- package/templates/opera/architecture/dependency-graph.md +24 -0
- package/templates/opera/architecture/runtime-automation.md +24 -0
- package/templates/opera/architecture/runtime-operations.md +34 -0
- package/templates/opera/en/agent.md +27 -0
- package/templates/opera/en/architecture/dependency-graph.md +24 -0
- package/templates/opera/en/architecture/runtime-automation.md +24 -0
- package/templates/opera/en/architecture/runtime-operations.md +34 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/reviews/delivery-audit.md +18 -0
- package/templates/opera/en/reviews/integration-audit.md +18 -0
- package/templates/opera/en/router.md +49 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/opera/reviews/delivery-audit.md +18 -0
- package/templates/opera/reviews/integration-audit.md +18 -0
- package/templates/opera/router.md +15 -5
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/opera-contract-auditor/SKILL.md +38 -0
- package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -0
- package/templates/skills/opera-policy-guard/SKILL.md +26 -0
- package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/project-starter-skill/SKILL.md +89 -164
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +104 -0
- package/ui/css/panels.css +956 -953
- package/ui/index.html +1 -1
- package/ui/js/api.js +211 -194
- package/ui/js/app.js +200 -199
- package/ui/js/i18n.js +14 -0
- package/ui/js/onboarding.js +439 -437
- package/ui/js/state.js +130 -129
- package/ui/js/utils.js +175 -172
- package/ui/js/views/board.js +255 -254
- package/ui/js/views/execution.js +256 -256
- package/ui/js/views/insights.js +340 -339
- package/ui/js/views/overview.js +366 -361
- package/ui/js/views/settings.js +340 -202
- package/ui/js/views/sidebar.js +131 -132
- package/ui/js/views/skills.js +163 -162
- package/ui/js/views/tasks.js +406 -405
- package/ui/js/views/topbar.js +239 -183
- package/templates/etapa/agent.md +0 -26
- package/templates/etapa/genesis.md +0 -94
- package/templates/etapa/references/autonomy-and-recovery.md +0 -117
- package/templates/etapa/references/etapa-cycle.md +0 -193
- package/templates/etapa/registry.md +0 -28
- package/templates/etapa/router.md +0 -39
package/lib/server.js
CHANGED
|
@@ -1,101 +1,405 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const http = require("http");
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const net = require("net");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawn, spawnSync } = require("child_process");
|
|
9
|
+
|
|
8
10
|
const config = require("./config");
|
|
9
11
|
const control = require("./control");
|
|
12
|
+
const env = require("./env");
|
|
10
13
|
const registry = require("./registry");
|
|
11
|
-
const { t, setLocale } = require("./i18n");
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
".
|
|
26
|
-
".
|
|
27
|
-
".
|
|
28
|
-
".
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
14
|
+
const { t, setLocale, getMessages } = require("./i18n");
|
|
15
|
+
const { normalizeLocale } = require("./locale");
|
|
16
|
+
const runtimeState = require("./runtime-state");
|
|
17
|
+
|
|
18
|
+
const UI_DIR = path.join(__dirname, "..", "ui");
|
|
19
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
20
|
+
const DEFAULT_PORT = 4173;
|
|
21
|
+
const PORT_SEARCH_LIMIT = 100;
|
|
22
|
+
const ORPHAN_TIMEOUT_MS = Number(process.env.OPS_COMMAND_ORPHAN_TIMEOUT_MS || 120000);
|
|
23
|
+
const MAX_COMMAND_RUNTIME_MS = Number(process.env.OPS_COMMAND_MAX_RUNTIME_MS || 600000);
|
|
24
|
+
const sessions = new Map();
|
|
25
|
+
const VIRTUAL_INTERFACE_MARKERS = ["tailscale", "vethernet", "docker", "vbox", "vmware", "hyper-v", "loopback", "virtual", "wsl"];
|
|
26
|
+
|
|
27
|
+
const MIME_TYPES = {
|
|
28
|
+
".css": "text/css; charset=utf-8",
|
|
29
|
+
".html": "text/html; charset=utf-8",
|
|
30
|
+
".js": "text/javascript; charset=utf-8",
|
|
31
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
32
|
+
".json": "application/json; charset=utf-8",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".woff2": "font/woff2",
|
|
35
|
+
".woff": "font/woff",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* ── helpers ── */
|
|
39
|
+
|
|
40
|
+
function sendJson(res, statusCode, payload) {
|
|
41
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
42
|
+
res.end(JSON.stringify(payload));
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
function sendText(res, statusCode, message) {
|
|
39
46
|
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
40
47
|
res.end(message);
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
chunks.push(chunk);
|
|
51
|
-
});
|
|
52
|
-
req.on("end", () => {
|
|
53
|
-
if (!chunks.length) { resolve({}); return; }
|
|
54
|
-
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
55
|
-
catch (_e) { reject(new Error(t("server.invalidJson"))); }
|
|
56
|
-
});
|
|
57
|
-
req.on("error", reject);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function slugify(value) {
|
|
62
|
-
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function toList(value) {
|
|
66
|
-
if (Array.isArray(value)) return value.map((i) => String(i).trim()).filter(Boolean);
|
|
67
|
-
return String(value || "").split(/\r?\n|,/).map((i) => i.trim()).filter(Boolean);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/* ── project resolution ── */
|
|
71
|
-
|
|
72
|
-
let startupRoot = null;
|
|
73
|
-
|
|
74
|
-
function ensureCurrentProjectRegistered() {
|
|
75
|
-
if (!startupRoot) return null;
|
|
76
|
-
try { return registry.registerProject(startupRoot); }
|
|
77
|
-
catch (_e) { return null; }
|
|
50
|
+
function readJsonFileSafe(filePath) {
|
|
51
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
54
|
+
} catch (_error) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
78
57
|
}
|
|
79
|
-
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
58
|
+
|
|
59
|
+
function parseBody(req) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
req.on("data", (chunk) => {
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > 1024 * 1024) { reject(new Error(t("server.payloadTooLarge", { limit: "1 MB" }))); req.destroy(); return; }
|
|
66
|
+
chunks.push(chunk);
|
|
67
|
+
});
|
|
68
|
+
req.on("end", () => {
|
|
69
|
+
if (!chunks.length) { resolve({}); return; }
|
|
70
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
71
|
+
catch (_e) { reject(new Error(t("server.invalidJson"))); }
|
|
72
|
+
});
|
|
73
|
+
req.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function slugify(value) {
|
|
78
|
+
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toList(value) {
|
|
82
|
+
if (Array.isArray(value)) return value.map((i) => String(i).trim()).filter(Boolean);
|
|
83
|
+
return String(value || "").split(/\r?\n|,/).map((i) => i.trim()).filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseDashboardArgs(args = []) {
|
|
87
|
+
const options = {
|
|
88
|
+
port: null,
|
|
89
|
+
host: null,
|
|
90
|
+
public: false,
|
|
91
|
+
strictPort: false,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
95
|
+
const arg = args[i];
|
|
96
|
+
if ((arg === "--port" || arg === "-p") && args[i + 1]) {
|
|
97
|
+
options.port = args[i + 1];
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg.startsWith("--port=")) {
|
|
102
|
+
options.port = arg.slice("--port=".length);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if ((arg === "--host" || arg === "-H") && args[i + 1]) {
|
|
106
|
+
options.host = args[i + 1];
|
|
107
|
+
i += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (arg.startsWith("--host=")) {
|
|
111
|
+
options.host = arg.slice("--host=".length);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (arg === "--public") {
|
|
115
|
+
options.public = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg === "--strict-port") {
|
|
119
|
+
options.strictPort = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return options;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parsePortValue(value) {
|
|
127
|
+
const port = Number(value);
|
|
128
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
129
|
+
throw new Error(t("server.invalidPort", { value: String(value) }));
|
|
130
|
+
}
|
|
131
|
+
return port;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isLoopbackHost(host) {
|
|
135
|
+
const normalized = String(host || "").trim().toLowerCase();
|
|
136
|
+
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isWildcardHost(host) {
|
|
140
|
+
const normalized = String(host || "").trim().toLowerCase();
|
|
141
|
+
return normalized === "0.0.0.0" || normalized === "::";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPrivateIpv4(address) {
|
|
145
|
+
const parts = String(address || "").split(".").map(Number);
|
|
146
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
147
|
+
if (parts[0] === 10) return true;
|
|
148
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
149
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getLocalUrlHost(host) {
|
|
154
|
+
if (isWildcardHost(host) || isLoopbackHost(host)) return "localhost";
|
|
155
|
+
return host;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveDashboardConfig(args = [], env = process.env) {
|
|
159
|
+
const options = parseDashboardArgs(args);
|
|
160
|
+
const host = options.host || env.OPS_UI_HOST || (options.public ? "0.0.0.0" : DEFAULT_HOST);
|
|
161
|
+
const startPort = options.port != null
|
|
162
|
+
? parsePortValue(options.port)
|
|
163
|
+
: env.OPS_UI_PORT
|
|
164
|
+
? parsePortValue(env.OPS_UI_PORT)
|
|
165
|
+
: DEFAULT_PORT;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
host,
|
|
169
|
+
startPort,
|
|
170
|
+
strictPort: options.strictPort,
|
|
171
|
+
publicMode: options.public || isWildcardHost(host) || !isLoopbackHost(host),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPortAvailable(host, port) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
const tester = net.createServer();
|
|
178
|
+
|
|
179
|
+
tester.once("error", () => {
|
|
180
|
+
resolve(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
tester.once("listening", () => {
|
|
184
|
+
tester.close(() => resolve(true));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
tester.listen(port, host);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function findAvailablePort({ host, startPort, strict }) {
|
|
192
|
+
if (strict) {
|
|
193
|
+
const available = await isPortAvailable(host, startPort);
|
|
194
|
+
if (!available) {
|
|
195
|
+
throw new Error(t("server.portBusyStrict", { port: startPort }));
|
|
196
|
+
}
|
|
197
|
+
return { port: startPort, requestedPort: startPort, fallbackUsed: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (let offset = 0; offset < PORT_SEARCH_LIMIT; offset += 1) {
|
|
201
|
+
const port = startPort + offset;
|
|
202
|
+
if (port > 65535) break;
|
|
203
|
+
// Probe a temporary bind before creating the real server to avoid immediate EADDRINUSE failures.
|
|
204
|
+
if (await isPortAvailable(host, port)) {
|
|
205
|
+
return { port, requestedPort: startPort, fallbackUsed: port !== startPort };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw new Error(t("server.portSearchExhausted", { port: startPort, limit: PORT_SEARCH_LIMIT }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildNetworkCandidates() {
|
|
213
|
+
const interfaces = os.networkInterfaces();
|
|
214
|
+
const candidates = [];
|
|
215
|
+
|
|
216
|
+
for (const [name, addresses] of Object.entries(interfaces)) {
|
|
217
|
+
for (const address of addresses || []) {
|
|
218
|
+
if (!address || address.internal || address.family !== "IPv4") continue;
|
|
219
|
+
const normalizedName = String(name || "").toLowerCase();
|
|
220
|
+
const isVirtual = VIRTUAL_INTERFACE_MARKERS.some((marker) => normalizedName.includes(marker));
|
|
221
|
+
const isPrivate = isPrivateIpv4(address.address);
|
|
222
|
+
let priority = 3;
|
|
223
|
+
if (isPrivate && !isVirtual) priority = 0;
|
|
224
|
+
else if (isPrivate) priority = 1;
|
|
225
|
+
else priority = 2;
|
|
226
|
+
candidates.push({
|
|
227
|
+
name,
|
|
228
|
+
address: address.address,
|
|
229
|
+
priority,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return candidates.sort((a, b) => {
|
|
235
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
236
|
+
if (a.name !== b.name) return a.name.localeCompare(b.name);
|
|
237
|
+
return a.address.localeCompare(b.address);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function collectReachableUrls({ host, port, publicMode }) {
|
|
242
|
+
const localUrl = `http://${getLocalUrlHost(host)}:${port}`;
|
|
243
|
+
const networkUrls = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
|
|
246
|
+
function add(url) {
|
|
247
|
+
if (seen.has(url)) return;
|
|
248
|
+
seen.add(url);
|
|
249
|
+
networkUrls.push(url);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (publicMode) {
|
|
253
|
+
if (!isWildcardHost(host) && !isLoopbackHost(host)) {
|
|
254
|
+
add(`http://${host}:${port}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
buildNetworkCandidates().forEach((candidate) => {
|
|
258
|
+
add(`http://${candidate.address}:${port}`);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { localUrl, networkUrls };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function copyTextToClipboard(text) {
|
|
266
|
+
const commands = process.platform === "win32"
|
|
267
|
+
? [["clip"]]
|
|
268
|
+
: process.platform === "darwin"
|
|
269
|
+
? [["pbcopy"]]
|
|
270
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
271
|
+
|
|
272
|
+
let sawNonAvailabilityError = false;
|
|
273
|
+
|
|
274
|
+
for (const [command, ...args] of commands) {
|
|
275
|
+
const result = spawnSync(command, args, {
|
|
276
|
+
input: text,
|
|
277
|
+
encoding: "utf8",
|
|
278
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
279
|
+
windowsHide: true,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!result.error && result.status === 0) {
|
|
283
|
+
return { copied: true, reason: null };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (result.error && result.error.code !== "ENOENT") {
|
|
287
|
+
sawNonAvailabilityError = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { copied: false, reason: sawNonAvailabilityError ? "failed" : "unavailable" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderStartupBanner(info) {
|
|
295
|
+
const lines = [
|
|
296
|
+
t("server.bannerTitle"),
|
|
297
|
+
"",
|
|
298
|
+
t("server.localUrl", { url: info.urls.localUrl }),
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
if (info.publicMode) {
|
|
302
|
+
if (info.urls.networkUrls.length > 0) {
|
|
303
|
+
lines.push(t("server.networkUrl", { url: info.urls.networkUrls[0] }));
|
|
304
|
+
info.urls.networkUrls.slice(1).forEach((url) => lines.push(t("server.networkAltUrl", { url })));
|
|
305
|
+
} else {
|
|
306
|
+
lines.push(t("server.noNetworkAddress"));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
lines.push("");
|
|
311
|
+
|
|
312
|
+
if (info.fallbackUsed) {
|
|
313
|
+
lines.push(t("server.portFallback", { requestedPort: info.requestedPort, actualPort: info.port }));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (info.clipboard.copied) {
|
|
317
|
+
lines.push(t("server.copiedToClipboard"));
|
|
318
|
+
} else if (info.clipboard.reason === "failed") {
|
|
319
|
+
lines.push(t("server.clipboardUnavailable"));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (info.defaultProject) {
|
|
323
|
+
lines.push(t("server.defaultProject", { name: info.defaultProject.name, id: info.defaultProject.id }));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return lines.join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function listenServer(server, host, port) {
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
function onError(error) {
|
|
332
|
+
server.off("listening", onListening);
|
|
333
|
+
reject(error);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onListening() {
|
|
337
|
+
server.off("error", onError);
|
|
338
|
+
resolve(server.address());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
server.once("error", onError);
|
|
342
|
+
server.once("listening", onListening);
|
|
343
|
+
server.listen(port, host);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ── project resolution ── */
|
|
348
|
+
|
|
349
|
+
let startupRoot = null;
|
|
350
|
+
|
|
351
|
+
function ensureCurrentProjectRegistered() {
|
|
352
|
+
if (!startupRoot) return null;
|
|
353
|
+
try { return registry.registerProject(startupRoot); }
|
|
354
|
+
catch (_e) { return null; }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function resolveProjectEntry(projectRef) {
|
|
358
|
+
const current = ensureCurrentProjectRegistered();
|
|
359
|
+
const entry = registry.resolveProject(projectRef, startupRoot) || current;
|
|
360
|
+
if (!entry) throw new Error(t("server.projectNotResolved"));
|
|
361
|
+
return entry;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function loadControlApi(projectRoot) {
|
|
365
|
+
return control.forProject(projectRoot);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function buildI18nPayload(controlState) {
|
|
369
|
+
const phases = config.getPhases(controlState);
|
|
370
|
+
const locale = config.getLocale(controlState);
|
|
371
|
+
const statusLabels = {};
|
|
372
|
+
for (const s of control.STATUS_ORDER) {
|
|
373
|
+
statusLabels[s] = control.statusLabel(s);
|
|
374
|
+
}
|
|
375
|
+
return { locale, statusLabels, phases, messages: getMessages(locale) };
|
|
85
376
|
}
|
|
86
377
|
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
378
|
+
function buildOperaState(projectRoot, controlState) {
|
|
379
|
+
const context = config.ensureContext(projectRoot);
|
|
380
|
+
const operaBootstrap = require("./opera-bootstrap");
|
|
381
|
+
const bootstrapState =
|
|
382
|
+
controlState.meta?.opera?.bootstrap ||
|
|
383
|
+
operaBootstrap.detectLegacyBootstrap(context, controlState) ||
|
|
384
|
+
operaBootstrap.createAwaitingBootstrapState(context);
|
|
385
|
+
const qualityReport = readJsonFileSafe(path.join(context.paths.bootstrapDir, "quality-report.json"));
|
|
386
|
+
const localeDoctor = runtimeState.doctorLocale(controlState.meta?.locale || null);
|
|
90
387
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
388
|
+
return {
|
|
389
|
+
installed: config.isOperaInstalled(controlState),
|
|
390
|
+
version: controlState.meta?.opera?.version || null,
|
|
391
|
+
model: controlState.meta?.opera?.model || null,
|
|
392
|
+
stableTag: controlState.meta?.opera?.stableTag || null,
|
|
393
|
+
contractVersion: controlState.meta?.opera?.contractVersion || null,
|
|
394
|
+
contractReadiness: controlState.meta?.opera?.contractReadiness || qualityReport?.contractReadiness || "hypothesis",
|
|
395
|
+
qualityStatus: controlState.meta?.opera?.qualityStatus || qualityReport?.status || null,
|
|
396
|
+
qualityReport,
|
|
397
|
+
legacyStatus: controlState.meta?.opera?.legacyStatus || bootstrapState?.status || "supported",
|
|
398
|
+
localeSource: localeDoctor.source,
|
|
399
|
+
bootstrap: bootstrapState,
|
|
400
|
+
contractFile: context.paths.contractFile,
|
|
401
|
+
policyFile: context.paths.autonomyPolicyFile,
|
|
402
|
+
};
|
|
99
403
|
}
|
|
100
404
|
|
|
101
405
|
function getStatePayload(projectRef) {
|
|
@@ -103,490 +407,669 @@ function getStatePayload(projectRef) {
|
|
|
103
407
|
const api = loadControlApi(project.root);
|
|
104
408
|
const controlState = api.loadControl();
|
|
105
409
|
const runtime = api.refreshRepoRuntime({ quiet: true });
|
|
410
|
+
const envState = env.auditEnvironment(project.root, controlState);
|
|
411
|
+
const operaState = buildOperaState(project.root, controlState);
|
|
106
412
|
|
|
107
413
|
return {
|
|
108
414
|
project,
|
|
109
415
|
control: controlState,
|
|
110
416
|
derived: api.derive(controlState),
|
|
111
417
|
runtime,
|
|
418
|
+
env: envState,
|
|
419
|
+
opera: operaState,
|
|
112
420
|
docsDirty: api.getDocDrift(controlState),
|
|
113
421
|
i18n: buildI18nPayload(controlState),
|
|
114
422
|
generatedAt: new Date().toISOString(),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function persist(projectRoot) {
|
|
119
|
-
const api = loadControlApi(projectRoot);
|
|
120
|
-
const controlState = api.loadControl();
|
|
121
|
-
api.saveControl(controlState);
|
|
122
|
-
api.syncDocs(controlState);
|
|
123
|
-
api.refreshRepoRuntime({ quiet: true });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const blocker = String(payload.blocker || "").trim();
|
|
183
|
-
if (blocker) task.blocker = blocker;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
session.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
session.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function persist(projectRoot) {
|
|
427
|
+
const api = loadControlApi(projectRoot);
|
|
428
|
+
const controlState = api.loadControl();
|
|
429
|
+
api.saveControl(controlState);
|
|
430
|
+
api.syncDocs(controlState);
|
|
431
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function updateProjectLocale(projectRoot, locale) {
|
|
435
|
+
const nextLocale = normalizeLocale(locale);
|
|
436
|
+
if (!nextLocale) {
|
|
437
|
+
throw new Error(t("server.invalidLocale", { value: String(locale || "") }));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const api = loadControlApi(projectRoot);
|
|
441
|
+
const controlState = api.loadControl();
|
|
442
|
+
controlState.meta = controlState.meta || {};
|
|
443
|
+
controlState.meta.locale = nextLocale;
|
|
444
|
+
|
|
445
|
+
api.saveControl(controlState);
|
|
446
|
+
|
|
447
|
+
if (config.isOperaInstalled(controlState)) {
|
|
448
|
+
const opera = require("./opera");
|
|
449
|
+
opera.installStructure(projectRoot, controlState, nextLocale, { rewriteLocalizedTemplates: true });
|
|
450
|
+
api.saveControl(controlState);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
api.syncDocs(controlState);
|
|
454
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
455
|
+
setLocale(nextLocale);
|
|
456
|
+
return controlState;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ── task operations ── */
|
|
460
|
+
|
|
461
|
+
function makeTaskId(controlState, seed) {
|
|
462
|
+
const base = slugify(seed) || `task-${Date.now()}`;
|
|
463
|
+
const existing = new Set(controlState.tasks.map((t) => t.id));
|
|
464
|
+
if (!existing.has(base)) return base;
|
|
465
|
+
let idx = 2;
|
|
466
|
+
while (existing.has(`${base}-${idx}`)) idx += 1;
|
|
467
|
+
return `${base}-${idx}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function createTask(projectRoot, payload) {
|
|
471
|
+
const api = loadControlApi(projectRoot);
|
|
472
|
+
const controlState = api.loadControl();
|
|
473
|
+
const title = String(payload.title || "").trim();
|
|
474
|
+
if (!title) throw new Error(t("server.titleRequired"));
|
|
475
|
+
|
|
476
|
+
const task = {
|
|
477
|
+
id: makeTaskId(controlState, payload.id || title),
|
|
478
|
+
title,
|
|
479
|
+
phase: payload.phase || config.getPhases(controlState)[0]?.id || "E",
|
|
480
|
+
stream: String(payload.stream || "Operations").trim(),
|
|
481
|
+
priority: payload.priority || "P1",
|
|
482
|
+
status: payload.status || "pending",
|
|
483
|
+
required: payload.required !== false,
|
|
484
|
+
dependsOn: toList(payload.dependsOn),
|
|
485
|
+
summary: String(payload.summary || "").trim(),
|
|
486
|
+
acceptance: toList(payload.acceptance),
|
|
487
|
+
history: [{ at: new Date().toISOString(), action: "create", note: t("server.taskCreatedNote") }],
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const blocker = String(payload.blocker || "").trim();
|
|
491
|
+
if (blocker) task.blocker = blocker;
|
|
492
|
+
controlState.tasks.push(task);
|
|
493
|
+
api.saveControl(controlState);
|
|
494
|
+
api.syncDocs(controlState);
|
|
495
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
496
|
+
return task;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function patchTask(projectRoot, taskId, payload) {
|
|
500
|
+
const api = loadControlApi(projectRoot);
|
|
501
|
+
const controlState = api.loadControl();
|
|
502
|
+
const task = controlState.tasks.find((t) => t.id === taskId);
|
|
503
|
+
if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
|
|
504
|
+
|
|
505
|
+
if (typeof payload.title === "string") task.title = payload.title.trim() || task.title;
|
|
506
|
+
if (typeof payload.phase === "string") task.phase = payload.phase;
|
|
507
|
+
if (typeof payload.stream === "string") task.stream = payload.stream.trim() || task.stream;
|
|
508
|
+
if (typeof payload.priority === "string") task.priority = payload.priority;
|
|
509
|
+
if (typeof payload.status === "string") task.status = payload.status;
|
|
510
|
+
if (typeof payload.required === "boolean") task.required = payload.required;
|
|
511
|
+
if (payload.summary !== undefined) task.summary = String(payload.summary || "").trim();
|
|
512
|
+
if (payload.dependsOn !== undefined) task.dependsOn = toList(payload.dependsOn);
|
|
513
|
+
if (payload.acceptance !== undefined) task.acceptance = toList(payload.acceptance);
|
|
514
|
+
|
|
515
|
+
const blocker = String(payload.blocker || "").trim();
|
|
516
|
+
if (blocker) task.blocker = blocker;
|
|
517
|
+
else delete task.blocker;
|
|
518
|
+
|
|
519
|
+
task.history = task.history || [];
|
|
520
|
+
task.history.push({ at: new Date().toISOString(), action: "edit", note: String(payload.note || t("server.taskEditedNote")).trim() });
|
|
521
|
+
|
|
522
|
+
api.saveControl(controlState);
|
|
523
|
+
api.syncDocs(controlState);
|
|
524
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
525
|
+
return task;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ── sessions (command execution) ── */
|
|
529
|
+
|
|
530
|
+
function emitSession(res, payload) { res.write(`data: ${JSON.stringify(payload)}\n\n`); }
|
|
531
|
+
|
|
532
|
+
function cleanupSession(session) {
|
|
533
|
+
if (session.killTimer) { clearTimeout(session.killTimer); session.killTimer = null; }
|
|
534
|
+
if (session.maxRuntimeTimer) { clearTimeout(session.maxRuntimeTimer); session.maxRuntimeTimer = null; }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function terminateSession(session, reason) {
|
|
538
|
+
if (!session || session.status !== "running" || !session.process) return;
|
|
539
|
+
cleanupSession(session);
|
|
540
|
+
session.status = "terminated";
|
|
541
|
+
session.exitCode = 1;
|
|
542
|
+
session.output += `\n[ops] ${reason}\n`;
|
|
543
|
+
try { session.process.kill(); } catch (_e) { /* noop */ }
|
|
544
|
+
session.listeners.forEach((res) => {
|
|
545
|
+
emitSession(res, { type: "done", status: session.status, exitCode: session.exitCode, output: session.output, projectId: session.projectId });
|
|
546
|
+
res.end();
|
|
547
|
+
});
|
|
548
|
+
session.listeners.clear();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function scheduleOrphanTermination(session) {
|
|
552
|
+
if (!session || session.status !== "running" || session.listeners.size > 0) return;
|
|
553
|
+
cleanupSession(session);
|
|
554
|
+
session.killTimer = setTimeout(() => terminateSession(session, "orphan timeout"), ORPHAN_TIMEOUT_MS);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function createSession(commandText, project) {
|
|
558
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
559
|
+
const session = {
|
|
560
|
+
id, projectId: project.id, projectName: project.name, projectRoot: project.root,
|
|
561
|
+
command: commandText, startedAt: new Date().toISOString(),
|
|
562
|
+
status: "running", exitCode: null, output: "", listeners: new Set(),
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
|
|
566
|
+
const shellArgs = process.platform === "win32"
|
|
567
|
+
? ["-NoLogo", "-NoProfile", "-Command", commandText]
|
|
568
|
+
: ["-lc", commandText];
|
|
569
|
+
|
|
570
|
+
const child = spawn(shell, shellArgs, { cwd: project.root, env: process.env });
|
|
571
|
+
session.process = child;
|
|
572
|
+
session.killTimer = null;
|
|
573
|
+
session.maxRuntimeTimer = setTimeout(() => terminateSession(session, "max runtime exceeded"), MAX_COMMAND_RUNTIME_MS);
|
|
574
|
+
sessions.set(id, session);
|
|
575
|
+
|
|
576
|
+
function pushChunk(type, chunk) {
|
|
577
|
+
const text = chunk.toString("utf8");
|
|
578
|
+
session.output += text;
|
|
579
|
+
session.listeners.forEach((res) => emitSession(res, { type, chunk: text, status: session.status, projectId: session.projectId }));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
child.stdout.on("data", (c) => pushChunk("stdout", c));
|
|
583
|
+
child.stderr.on("data", (c) => pushChunk("stderr", c));
|
|
584
|
+
child.on("close", (code) => {
|
|
585
|
+
cleanupSession(session);
|
|
586
|
+
session.status = "completed";
|
|
587
|
+
session.exitCode = code;
|
|
588
|
+
session.listeners.forEach((res) => {
|
|
589
|
+
emitSession(res, { type: "done", status: session.status, exitCode: code, output: session.output, projectId: session.projectId });
|
|
590
|
+
res.end();
|
|
591
|
+
});
|
|
592
|
+
session.listeners.clear();
|
|
593
|
+
});
|
|
594
|
+
child.on("error", (err) => {
|
|
595
|
+
cleanupSession(session);
|
|
596
|
+
session.status = "failed";
|
|
597
|
+
session.exitCode = 1;
|
|
598
|
+
session.output += `${err.message}\n`;
|
|
599
|
+
session.listeners.forEach((res) => {
|
|
600
|
+
emitSession(res, { type: "done", status: session.status, exitCode: 1, output: session.output, projectId: session.projectId });
|
|
601
|
+
res.end();
|
|
602
|
+
});
|
|
603
|
+
session.listeners.clear();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
return session;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function serveSessionStream(res, session) {
|
|
610
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
611
|
+
emitSession(res, { type: "snapshot", status: session.status, exitCode: session.exitCode, command: session.command, output: session.output, projectId: session.projectId });
|
|
612
|
+
if (session.status !== "running") { res.end(); return; }
|
|
613
|
+
cleanupSession(session);
|
|
614
|
+
session.listeners.add(res);
|
|
615
|
+
res.on("close", () => { session.listeners.delete(res); scheduleOrphanTermination(session); });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/* ── static files ── */
|
|
619
|
+
|
|
620
|
+
function serveStatic(res, pathname) {
|
|
621
|
+
const safePath = pathname === "/" ? "/index.html" : pathname;
|
|
622
|
+
const normalized = path.normalize(safePath).replace(/^(\.\.[\\/])+/, "");
|
|
623
|
+
const filePath = path.join(UI_DIR, normalized);
|
|
624
|
+
if (!filePath.startsWith(UI_DIR)) { sendText(res, 403, "Forbidden."); return; }
|
|
625
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { sendText(res, 404, "Not found."); return; }
|
|
626
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
627
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
628
|
+
fs.createReadStream(filePath).pipe(res);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/* ── API handler ── */
|
|
632
|
+
|
|
633
|
+
async function handleApi(req, res, url) {
|
|
634
|
+
try {
|
|
635
|
+
if (req.method === "GET" && url.pathname === "/api/projects") {
|
|
636
|
+
const current = ensureCurrentProjectRegistered();
|
|
637
|
+
sendJson(res, 200, { ok: true, currentProjectId: current?.id || null, registryFile: registry.REGISTRY_FILE, projects: registry.listProjects() });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (req.method === "POST" && url.pathname === "/api/projects/register") {
|
|
642
|
+
const body = await parseBody(req);
|
|
643
|
+
const project = registry.registerProject(body.root || startupRoot || process.cwd());
|
|
644
|
+
sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (req.method === "POST" && url.pathname === "/api/projects/install") {
|
|
649
|
+
const body = await parseBody(req);
|
|
650
|
+
if (!body.root) { sendJson(res, 400, { ok: false, error: "Project path required." }); return; }
|
|
651
|
+
try {
|
|
652
|
+
const initMod = require("./init");
|
|
653
|
+
const result = initMod.initProject(body.root, { locale: body.locale || null });
|
|
654
|
+
if (body.withOpera) {
|
|
655
|
+
const opera = require("./opera");
|
|
656
|
+
await opera.install(result.root, {
|
|
657
|
+
locale: body.locale || null,
|
|
658
|
+
bootstrap: body.bootstrap !== false,
|
|
659
|
+
interactive: false,
|
|
660
|
+
answers: body.bootstrapAnswers || {},
|
|
661
|
+
bootstrapMode: body.bootstrapMode || "auto",
|
|
662
|
+
technicalLevel: body.technicalLevel || null,
|
|
663
|
+
projectState: body.projectState || null,
|
|
664
|
+
docsState: body.docsState || null,
|
|
665
|
+
decisionOwnership: body.decisionOwnership || null,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
321
668
|
const project = registry.registerProject(result.root);
|
|
322
669
|
sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
|
|
323
|
-
} catch (err) {
|
|
324
|
-
sendJson(res, 500, { ok: false, error: err.message });
|
|
325
|
-
}
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
330
|
-
sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (req.method === "POST" && url.pathname === "/api/
|
|
335
|
-
const body = await parseBody(req);
|
|
336
|
-
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
337
|
-
const
|
|
338
|
-
sendJson(res,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
670
|
+
} catch (err) {
|
|
671
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
677
|
+
sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (req.method === "POST" && url.pathname === "/api/projects/locale") {
|
|
682
|
+
const body = await parseBody(req);
|
|
683
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
684
|
+
const controlState = updateProjectLocale(project.root, body.locale);
|
|
685
|
+
sendJson(res, 200, {
|
|
686
|
+
ok: true,
|
|
687
|
+
locale: config.getLocale(controlState),
|
|
688
|
+
state: getStatePayload(project.id),
|
|
689
|
+
});
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (req.method === "POST" && url.pathname === "/api/tasks") {
|
|
694
|
+
const body = await parseBody(req);
|
|
695
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
696
|
+
const task = createTask(project.root, body);
|
|
697
|
+
sendJson(res, 201, { ok: true, task, state: getStatePayload(project.id) });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
702
|
+
if (req.method === "PUT" && taskMatch) {
|
|
703
|
+
const body = await parseBody(req);
|
|
704
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
705
|
+
const task = patchTask(project.root, decodeURIComponent(taskMatch[1]), body);
|
|
706
|
+
sendJson(res, 200, { ok: true, task, state: getStatePayload(project.id) });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const actionMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/action$/);
|
|
711
|
+
if (req.method === "POST" && actionMatch) {
|
|
712
|
+
const body = await parseBody(req);
|
|
713
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
714
|
+
const action = String(body.action || "").trim();
|
|
715
|
+
if (!action) { sendJson(res, 400, { ok: false, error: "Action required." }); return; }
|
|
716
|
+
const api = loadControlApi(project.root);
|
|
717
|
+
const controlState = api.loadControl();
|
|
718
|
+
api.updateTask(controlState, action, decodeURIComponent(actionMatch[1]), body.note || "");
|
|
719
|
+
sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
724
|
+
const body = await parseBody(req);
|
|
725
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
726
|
+
const api = loadControlApi(project.root);
|
|
727
|
+
const controlState = api.loadControl();
|
|
728
|
+
api.syncDocs(controlState);
|
|
729
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
730
|
+
sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
|
|
348
731
|
return;
|
|
349
732
|
}
|
|
350
733
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const body = await parseBody(req);
|
|
354
|
-
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
355
|
-
const action = String(body.action || "").trim();
|
|
356
|
-
if (!action) { sendJson(res, 400, { ok: false, error: "Action required." }); return; }
|
|
734
|
+
if (req.method === "GET" && url.pathname === "/api/env") {
|
|
735
|
+
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
357
736
|
const api = loadControlApi(project.root);
|
|
358
737
|
const controlState = api.loadControl();
|
|
359
|
-
|
|
360
|
-
sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
|
|
738
|
+
sendJson(res, 200, env.auditEnvironment(project.root, controlState));
|
|
361
739
|
return;
|
|
362
740
|
}
|
|
363
741
|
|
|
364
|
-
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
742
|
+
if (req.method === "POST" && url.pathname === "/api/env/sync") {
|
|
365
743
|
const body = await parseBody(req);
|
|
366
744
|
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
367
745
|
const api = loadControlApi(project.root);
|
|
368
746
|
const controlState = api.loadControl();
|
|
369
|
-
|
|
747
|
+
const result = env.syncEnvironment(project.root, controlState);
|
|
748
|
+
api.syncDocs(api.loadControl());
|
|
370
749
|
api.refreshRepoRuntime({ quiet: true });
|
|
371
|
-
sendJson(res, 200,
|
|
750
|
+
sendJson(res, 200, result);
|
|
372
751
|
return;
|
|
373
752
|
}
|
|
374
753
|
|
|
375
|
-
if (req.method === "
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
sendJson(res,
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
754
|
+
if (req.method === "GET" && url.pathname === "/api/opera/bootstrap") {
|
|
755
|
+
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
756
|
+
const api = loadControlApi(project.root);
|
|
757
|
+
const controlState = api.loadControl();
|
|
758
|
+
const operaState = buildOperaState(project.root, controlState);
|
|
759
|
+
const bootstrap = operaState.bootstrap;
|
|
760
|
+
sendJson(res, 200, {
|
|
761
|
+
ok: true,
|
|
762
|
+
mode: bootstrap.mode || null,
|
|
763
|
+
status: bootstrap.status || "awaiting_intake",
|
|
764
|
+
technicalLevel: bootstrap.technicalLevel || null,
|
|
765
|
+
projectState: bootstrap.projectState || null,
|
|
766
|
+
documentationState: bootstrap.documentationState || null,
|
|
767
|
+
decisionOwnership: bootstrap.decisionOwnership || null,
|
|
768
|
+
handoffFiles: bootstrap.handoffFiles || null,
|
|
769
|
+
intakeFiles: bootstrap.intakeFiles || null,
|
|
770
|
+
reviewFiles: bootstrap.reviewFiles || null,
|
|
771
|
+
contractVersion: operaState.contractVersion,
|
|
772
|
+
contractReadiness: operaState.contractReadiness,
|
|
773
|
+
legacyStatus: operaState.legacyStatus,
|
|
774
|
+
qualityReport: operaState.qualityReport,
|
|
775
|
+
});
|
|
390
776
|
return;
|
|
391
777
|
}
|
|
392
778
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
779
|
+
if (req.method === "GET" && url.pathname === "/api/opera/handoff") {
|
|
780
|
+
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
781
|
+
const context = config.ensureContext(project.root);
|
|
782
|
+
const operaBootstrap = require("./opera-bootstrap");
|
|
783
|
+
const files = operaBootstrap.bootstrapFilePaths(context);
|
|
784
|
+
let handoffJson = null;
|
|
785
|
+
if (fs.existsSync(files.json)) {
|
|
786
|
+
try {
|
|
787
|
+
handoffJson = JSON.parse(fs.readFileSync(files.json, "utf8"));
|
|
788
|
+
} catch (_error) {
|
|
789
|
+
handoffJson = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
sendJson(res, 200, {
|
|
793
|
+
ok: true,
|
|
794
|
+
markdownFile: files.markdown,
|
|
795
|
+
jsonFile: files.json,
|
|
796
|
+
openQuestionsFile: files.openQuestions,
|
|
797
|
+
qualityReportFile: files.qualityReport,
|
|
798
|
+
markdown: fs.existsSync(files.markdown) ? fs.readFileSync(files.markdown, "utf8") : "",
|
|
799
|
+
json: handoffJson,
|
|
800
|
+
openQuestions: fs.existsSync(files.openQuestions) ? fs.readFileSync(files.openQuestions, "utf8") : "",
|
|
801
|
+
qualityReport: readJsonFileSafe(files.qualityReport),
|
|
802
|
+
});
|
|
398
803
|
return;
|
|
399
804
|
}
|
|
400
805
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
806
|
+
if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/intake") {
|
|
807
|
+
const body = await parseBody(req);
|
|
808
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
809
|
+
const opera = require("./opera");
|
|
810
|
+
const profile = await opera.runBootstrap(project.root, {
|
|
811
|
+
interactive: false,
|
|
812
|
+
bootstrapMode: body.bootstrapMode || "auto",
|
|
813
|
+
technicalLevel: body.technicalLevel || null,
|
|
814
|
+
projectState: body.projectState || null,
|
|
815
|
+
docsState: body.documentationState || body.docsState || null,
|
|
816
|
+
decisionOwnership: body.decisionOwnership || null,
|
|
817
|
+
answers: body.answers || {},
|
|
818
|
+
});
|
|
819
|
+
sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
|
|
407
820
|
return;
|
|
408
821
|
}
|
|
409
822
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (req.method === "GET" && url.pathname === "/api/time") {
|
|
823
|
+
if (req.method === "GET" && url.pathname === "/api/opera/status") {
|
|
413
824
|
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
414
825
|
const api = loadControlApi(project.root);
|
|
415
826
|
const controlState = api.loadControl();
|
|
416
|
-
|
|
417
|
-
sendJson(res, 200, { ok: true, entries });
|
|
827
|
+
sendJson(res, 200, { ok: true, ...buildOperaState(project.root, controlState) });
|
|
418
828
|
return;
|
|
419
829
|
}
|
|
420
830
|
|
|
421
|
-
if (req.method === "POST" && url.pathname === "/api/
|
|
831
|
+
if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/resume") {
|
|
422
832
|
const body = await parseBody(req);
|
|
423
833
|
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
const entry = {
|
|
428
|
-
id: entryId,
|
|
429
|
-
taskId: String(body.taskId || "").trim(),
|
|
430
|
-
taskTitle: String(body.taskTitle || "").trim(),
|
|
431
|
-
startedAt: new Date().toISOString(),
|
|
432
|
-
stoppedAt: null,
|
|
433
|
-
durationMs: 0,
|
|
434
|
-
};
|
|
435
|
-
if (!controlState.timeEntries) controlState.timeEntries = [];
|
|
436
|
-
controlState.timeEntries.push(entry);
|
|
437
|
-
api.saveControl(controlState);
|
|
438
|
-
sendJson(res, 201, { ok: true, entry });
|
|
834
|
+
const opera = require("./opera");
|
|
835
|
+
const profile = await opera.runBootstrap(project.root, { interactive: false, resume: true });
|
|
836
|
+
sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
|
|
439
837
|
return;
|
|
440
838
|
}
|
|
441
839
|
|
|
442
|
-
if (req.method === "POST" && url.pathname === "/api/
|
|
443
|
-
const body = await parseBody(req);
|
|
444
|
-
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
840
|
+
if (req.method === "POST" && url.pathname === "/api/commands") {
|
|
841
|
+
const body = await parseBody(req);
|
|
842
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
843
|
+
const commandText = String(body.command || "").trim();
|
|
844
|
+
if (!commandText) { sendJson(res, 400, { ok: false, error: t("server.commandRequired") }); return; }
|
|
845
|
+
const session = createSession(commandText, project);
|
|
846
|
+
sendJson(res, 201, { ok: true, session: { id: session.id, command: session.command, startedAt: session.startedAt, status: session.status, projectId: session.projectId, projectName: session.projectName } });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const sessionInfoMatch = url.pathname.match(/^\/api\/commands\/([^/]+)$/);
|
|
851
|
+
if (req.method === "GET" && sessionInfoMatch) {
|
|
852
|
+
const session = sessions.get(decodeURIComponent(sessionInfoMatch[1]));
|
|
853
|
+
if (!session) { sendJson(res, 404, { ok: false, error: t("server.sessionNotFound") }); return; }
|
|
854
|
+
sendJson(res, 200, { ok: true, session });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const sessionStreamMatch = url.pathname.match(/^\/api\/commands\/([^/]+)\/stream$/);
|
|
859
|
+
if (req.method === "GET" && sessionStreamMatch) {
|
|
860
|
+
const session = sessions.get(decodeURIComponent(sessionStreamMatch[1]));
|
|
861
|
+
if (!session) { sendText(res, 404, t("server.sessionNotFound")); return; }
|
|
862
|
+
serveSessionStream(res, session);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const sessionCancelMatch = url.pathname.match(/^\/api\/commands\/([^/]+)\/cancel$/);
|
|
867
|
+
if (req.method === "POST" && sessionCancelMatch) {
|
|
868
|
+
const session = sessions.get(decodeURIComponent(sessionCancelMatch[1]));
|
|
869
|
+
if (!session) { sendJson(res, 404, { ok: false, error: t("server.sessionNotFound") }); return; }
|
|
870
|
+
terminateSession(session, "manually cancelled");
|
|
871
|
+
sendJson(res, 200, { ok: true, session });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/* ── Time Tracking ── */
|
|
876
|
+
|
|
877
|
+
if (req.method === "GET" && url.pathname === "/api/time") {
|
|
878
|
+
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
879
|
+
const api = loadControlApi(project.root);
|
|
880
|
+
const controlState = api.loadControl();
|
|
881
|
+
const entries = controlState.timeEntries || [];
|
|
882
|
+
sendJson(res, 200, { ok: true, entries });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (req.method === "POST" && url.pathname === "/api/time/start") {
|
|
887
|
+
const body = await parseBody(req);
|
|
888
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
889
|
+
const api = loadControlApi(project.root);
|
|
890
|
+
const controlState = api.loadControl();
|
|
891
|
+
const entryId = `te-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
892
|
+
const entry = {
|
|
893
|
+
id: entryId,
|
|
894
|
+
taskId: String(body.taskId || "").trim(),
|
|
895
|
+
taskTitle: String(body.taskTitle || "").trim(),
|
|
896
|
+
startedAt: new Date().toISOString(),
|
|
897
|
+
stoppedAt: null,
|
|
898
|
+
durationMs: 0,
|
|
899
|
+
};
|
|
900
|
+
if (!controlState.timeEntries) controlState.timeEntries = [];
|
|
901
|
+
controlState.timeEntries.push(entry);
|
|
902
|
+
api.saveControl(controlState);
|
|
903
|
+
sendJson(res, 201, { ok: true, entry });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (req.method === "POST" && url.pathname === "/api/time/stop") {
|
|
908
|
+
const body = await parseBody(req);
|
|
909
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
910
|
+
const api = loadControlApi(project.root);
|
|
911
|
+
const controlState = api.loadControl();
|
|
912
|
+
const entries = controlState.timeEntries || [];
|
|
913
|
+
const entry = entries.find(e => e.id === body.entryId);
|
|
914
|
+
if (!entry) { sendJson(res, 404, { ok: false, error: "Entry not found." }); return; }
|
|
915
|
+
entry.stoppedAt = new Date().toISOString();
|
|
916
|
+
entry.durationMs = new Date(entry.stoppedAt) - new Date(entry.startedAt);
|
|
917
|
+
api.saveControl(controlState);
|
|
918
|
+
sendJson(res, 200, { ok: true, entry });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/* ── Skills Hub ── */
|
|
923
|
+
|
|
459
924
|
if (req.method === "GET" && url.pathname === "/api/skills/local") {
|
|
460
925
|
const project = resolveProjectEntry(url.searchParams.get("project"));
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
let
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
{ id: "
|
|
499
|
-
{ id: "
|
|
500
|
-
{ id: "
|
|
501
|
-
{ id: "
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
926
|
+
const context = config.ensureContext(project.root);
|
|
927
|
+
const skillsDir = fs.existsSync(context.paths.skillsDir) ? context.paths.skillsDir : null;
|
|
928
|
+
|
|
929
|
+
const skills = [];
|
|
930
|
+
if (skillsDir) {
|
|
931
|
+
try {
|
|
932
|
+
const dirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
933
|
+
.filter(dirent => dirent.isDirectory())
|
|
934
|
+
.map(dirent => dirent.name);
|
|
935
|
+
|
|
936
|
+
for (const d of dirs) {
|
|
937
|
+
const skillMdPath = path.join(skillsDir, d, "SKILL.md");
|
|
938
|
+
let description = "";
|
|
939
|
+
let title = d;
|
|
940
|
+
if (fs.existsSync(skillMdPath)) {
|
|
941
|
+
const content = fs.readFileSync(skillMdPath, "utf-8");
|
|
942
|
+
// Parse basic YAML frontmatter for title/description if exists
|
|
943
|
+
const titleMatch = content.match(/title:\s*(.+)/i) || content.match(/name:\s*(.+)/i);
|
|
944
|
+
const descMatch = content.match(/description:\s*(.+)/i) || content.match(/desc:\s*(.+)/i);
|
|
945
|
+
if (titleMatch) title = titleMatch[1].replace(/['"]/g, '');
|
|
946
|
+
if (descMatch) description = descMatch[1].replace(/['"]/g, '');
|
|
947
|
+
}
|
|
948
|
+
skills.push({ id: d, title, description, path: skillMdPath });
|
|
949
|
+
}
|
|
950
|
+
} catch (err) {
|
|
951
|
+
console.error("Error reading skills dir:", err);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
sendJson(res, 200, { ok: true, skills });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (req.method === "GET" && url.pathname === "/api/skills/discover") {
|
|
959
|
+
// Mocked recommendations/catalog for skills.sh integration
|
|
960
|
+
// Ideally this calls a raw json from github
|
|
961
|
+
const catalog = [
|
|
962
|
+
{ id: "changelog-updater", title: "Changelog Updater", description: "Mantiene automatizado el CHANGELOG basado en commits.", url: "https://skills.sh/changelog-updater.md" },
|
|
963
|
+
{ id: "commiter", title: "Git Commiter", description: "Genera mensajes de commit strictos siguiendo Conventional Commits y Emojis.", url: "https://skills.sh/commiter.md" },
|
|
964
|
+
{ id: "project-starter-skill", title: "Project Starter", description: "Skill para discovery y estructuracion inicial guiada con TrackOps y OPERA.", url: "https://skills.sh/project-starter.md" },
|
|
965
|
+
{ id: "tdd-master", title: "TDD Master", description: "Fuerza el ciclo Red-Green-Refactor en las implementaciones.", url: "https://skills.sh/tdd-master.md" },
|
|
966
|
+
{ id: "e2e-tester", title: "E2E Tester", description: "Plantillas y comandos para frameworks de Test End-to-End.", url: "https://skills.sh/e2e-tester.md" }
|
|
967
|
+
];
|
|
968
|
+
// Simulate network wait
|
|
969
|
+
setTimeout(() => {
|
|
970
|
+
sendJson(res, 200, { ok: true, catalog });
|
|
971
|
+
}, 400);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
511
975
|
if (req.method === "POST" && url.pathname === "/api/skills/install") {
|
|
512
|
-
const body = await parseBody(req);
|
|
513
|
-
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
514
|
-
const skillId = body.skillId;
|
|
515
|
-
|
|
516
|
-
if (!skillId) { sendJson(res, 400, { ok: false, error: "Missing skillId parameter" }); return; }
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!fs.existsSync(agentsPath) && fs.existsSync(path.join(project.root, ".agent"))) {
|
|
521
|
-
agentsPath = path.join(project.root, ".agent");
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const skillsDir = path.join(agentsPath, "skills");
|
|
525
|
-
if (!fs.existsSync(agentsPath)) fs.mkdirSync(agentsPath, { recursive: true });
|
|
976
|
+
const body = await parseBody(req);
|
|
977
|
+
const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
|
|
978
|
+
const skillId = body.skillId;
|
|
979
|
+
|
|
980
|
+
if (!skillId) { sendJson(res, 400, { ok: false, error: "Missing skillId parameter" }); return; }
|
|
981
|
+
|
|
982
|
+
const context = config.ensureContext(project.root);
|
|
983
|
+
const skillsDir = context.paths.skillsDir;
|
|
526
984
|
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
|
|
527
|
-
|
|
528
|
-
const targetSkillDir = path.join(skillsDir, skillId);
|
|
529
|
-
if (!fs.existsSync(targetSkillDir)) fs.mkdirSync(targetSkillDir, { recursive: true });
|
|
530
|
-
|
|
531
|
-
const targetMdPath = path.join(targetSkillDir, "SKILL.md");
|
|
532
|
-
const templateContent = `---
|
|
533
|
-
name: ${skillId}
|
|
534
|
-
description: Skill instalada desde skills.sh
|
|
535
|
-
---
|
|
536
|
-
|
|
537
|
-
# ${skillId}
|
|
538
|
-
|
|
539
|
-
Instructions para el agente relativas a esta skill...
|
|
540
|
-
`;
|
|
541
|
-
|
|
542
|
-
fs.writeFileSync(targetMdPath, templateContent, "utf-8");
|
|
543
|
-
sendJson(res, 201, { ok: true, message: "Instalado correctamente", path: targetMdPath });
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
sendJson(res, 404, { ok: false, error: "API route not found." });
|
|
549
|
-
} catch (error) {
|
|
550
|
-
sendJson(res, 500, { ok: false, error: error.message });
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/* ── server start ── */
|
|
555
|
-
|
|
556
|
-
function run() {
|
|
985
|
+
|
|
986
|
+
const targetSkillDir = path.join(skillsDir, skillId);
|
|
987
|
+
if (!fs.existsSync(targetSkillDir)) fs.mkdirSync(targetSkillDir, { recursive: true });
|
|
988
|
+
|
|
989
|
+
const targetMdPath = path.join(targetSkillDir, "SKILL.md");
|
|
990
|
+
const templateContent = `---
|
|
991
|
+
name: ${skillId}
|
|
992
|
+
description: Skill instalada desde skills.sh
|
|
993
|
+
---
|
|
994
|
+
|
|
995
|
+
# ${skillId}
|
|
996
|
+
|
|
997
|
+
Instructions para el agente relativas a esta skill...
|
|
998
|
+
`;
|
|
999
|
+
|
|
1000
|
+
fs.writeFileSync(targetMdPath, templateContent, "utf-8");
|
|
1001
|
+
sendJson(res, 201, { ok: true, message: "Instalado correctamente", path: targetMdPath });
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
sendJson(res, 404, { ok: false, error: "API route not found." });
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
sendJson(res, 500, { ok: false, error: error.message });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/* ── server start ── */
|
|
1013
|
+
|
|
1014
|
+
async function run(args = []) {
|
|
557
1015
|
startupRoot = config.resolveProjectRoot() || process.cwd();
|
|
558
|
-
|
|
559
|
-
try {
|
|
560
|
-
const ctrl = config.loadControl(startupRoot);
|
|
561
|
-
setLocale(config.getLocale(ctrl));
|
|
562
|
-
} catch (_e) {
|
|
563
|
-
setLocale("es");
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
const ctrl = config.loadControl(startupRoot);
|
|
1019
|
+
setLocale(config.getLocale(ctrl));
|
|
1020
|
+
} catch (_e) {
|
|
1021
|
+
setLocale("es");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const dashboardConfig = resolveDashboardConfig(args, process.env);
|
|
1025
|
+
const portSelection = await findAvailablePort({
|
|
1026
|
+
host: dashboardConfig.host,
|
|
1027
|
+
startPort: dashboardConfig.startPort,
|
|
1028
|
+
strict: dashboardConfig.strictPort,
|
|
1029
|
+
});
|
|
1030
|
+
const resolvedPort = portSelection.port;
|
|
1031
|
+
const fallbackBaseHost = getLocalUrlHost(dashboardConfig.host);
|
|
1032
|
+
|
|
1033
|
+
const server = http.createServer((req, res) => {
|
|
1034
|
+
const url = new URL(req.url, `http://${req.headers.host || `${fallbackBaseHost}:${resolvedPort}`}`);
|
|
1035
|
+
if (url.pathname.startsWith("/api/")) { handleApi(req, res, url); return; }
|
|
1036
|
+
serveStatic(res, url.pathname);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
function shutdown() {
|
|
1040
|
+
sessions.forEach((s) => { if (s.status === "running") terminateSession(s, "dashboard shutdown"); });
|
|
1041
|
+
}
|
|
1042
|
+
process.on("SIGINT", shutdown);
|
|
1043
|
+
process.on("SIGTERM", shutdown);
|
|
1044
|
+
process.on("exit", shutdown);
|
|
1045
|
+
|
|
1046
|
+
await listenServer(server, dashboardConfig.host, resolvedPort);
|
|
1047
|
+
const current = ensureCurrentProjectRegistered();
|
|
1048
|
+
const urls = collectReachableUrls({
|
|
1049
|
+
host: dashboardConfig.host,
|
|
1050
|
+
port: resolvedPort,
|
|
1051
|
+
publicMode: dashboardConfig.publicMode,
|
|
1052
|
+
});
|
|
1053
|
+
const clipboard = copyTextToClipboard(urls.localUrl);
|
|
1054
|
+
|
|
1055
|
+
console.log(renderStartupBanner({
|
|
1056
|
+
port: resolvedPort,
|
|
1057
|
+
requestedPort: portSelection.requestedPort,
|
|
1058
|
+
fallbackUsed: portSelection.fallbackUsed,
|
|
1059
|
+
publicMode: dashboardConfig.publicMode,
|
|
1060
|
+
urls,
|
|
1061
|
+
clipboard,
|
|
1062
|
+
defaultProject: current,
|
|
1063
|
+
}));
|
|
1064
|
+
|
|
1065
|
+
return server;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (require.main === module) {
|
|
1069
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
1070
|
+
console.error(error.message);
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
module.exports = { run };
|