esedre 0.1.3
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 +327 -0
- package/README.md +200 -0
- package/dist/esedre.mjs +3683 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-BqjKnasV.js +114 -0
- package/dist/web/assets/index-CezKPkES.css +2 -0
- package/dist/web/embed.js +98 -0
- package/dist/web/esedre-hero.png +0 -0
- package/dist/web/esedre-icon-inverted.svg +18 -0
- package/dist/web/esedre-icon.svg +18 -0
- package/dist/web/favicon-16x16.png +0 -0
- package/dist/web/favicon-32x32.png +0 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/favicon.svg +18 -0
- package/dist/web/index.html +18 -0
- package/package.json +61 -0
package/dist/esedre.mjs
ADDED
|
@@ -0,0 +1,3683 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/esedre.ts
|
|
4
|
+
import fs8 from "node:fs";
|
|
5
|
+
import path8 from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/storage/filesystem.ts
|
|
8
|
+
import fs3 from "node:fs";
|
|
9
|
+
import path3 from "node:path";
|
|
10
|
+
|
|
11
|
+
// src/types.ts
|
|
12
|
+
var EsedreConflictError = class extends Error {
|
|
13
|
+
constructor(ticketId, currentHash, lastHash) {
|
|
14
|
+
super(
|
|
15
|
+
`Conflict: Ticket #${ticketId} has been modified (current: ${currentHash.slice(0, 8)}, provided lastHash: ${lastHash.slice(0, 8)}). Refresh state and retry.`
|
|
16
|
+
);
|
|
17
|
+
this.ticketId = ticketId;
|
|
18
|
+
this.currentHash = currentHash;
|
|
19
|
+
this.lastHash = lastHash;
|
|
20
|
+
this.name = "EsedreConflictError";
|
|
21
|
+
}
|
|
22
|
+
ticketId;
|
|
23
|
+
currentHash;
|
|
24
|
+
lastHash;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/config.ts
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
var DEFAULT_ESEDRE_PORT = 5674;
|
|
31
|
+
var MAX_PROJECT_CODE_LENGTH = 6;
|
|
32
|
+
var PROJECT_CODE_REGEX = /^[a-zA-Z0-9]{1,6}$/;
|
|
33
|
+
var EsedreAuthorizationError = class extends Error {
|
|
34
|
+
constructor(projectCode, message) {
|
|
35
|
+
super(message || `Access Denied: Project '${projectCode}' is outside this workspace's authorized scope.`);
|
|
36
|
+
this.projectCode = projectCode;
|
|
37
|
+
this.name = "EsedreAuthorizationError";
|
|
38
|
+
}
|
|
39
|
+
projectCode;
|
|
40
|
+
};
|
|
41
|
+
function validateProjectCode(code, existingCodes) {
|
|
42
|
+
if (!code || typeof code !== "string") {
|
|
43
|
+
return { valid: false, error: "Project code is required." };
|
|
44
|
+
}
|
|
45
|
+
const trimmed = code.trim();
|
|
46
|
+
if (!PROJECT_CODE_REGEX.test(trimmed)) {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error: `Project code '${trimmed}' is invalid. It must be 1 to ${MAX_PROJECT_CODE_LENGTH} alphanumeric characters (e.g. CORE, WEB, DOCS).`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (existingCodes && existingCodes.length > 0) {
|
|
53
|
+
const lower = trimmed.toLowerCase();
|
|
54
|
+
const collision = existingCodes.find((c) => c.trim().toLowerCase() === lower);
|
|
55
|
+
if (collision && collision !== trimmed) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `Project code '${trimmed}' collides with existing project '${collision}' (case-insensitive).`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { valid: true };
|
|
63
|
+
}
|
|
64
|
+
function isProjectAuthorized(projectCode, allowedProjects) {
|
|
65
|
+
if (!allowedProjects || allowedProjects.length === 0) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const normalizedList = allowedProjects.map((p) => p.trim().toUpperCase());
|
|
69
|
+
if (normalizedList.includes("*")) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const normalized = projectCode.trim().toUpperCase();
|
|
73
|
+
return normalizedList.includes(normalized);
|
|
74
|
+
}
|
|
75
|
+
function findEsedreConfig(startDir) {
|
|
76
|
+
let current = path.resolve(startDir || process.cwd());
|
|
77
|
+
const candidates = [
|
|
78
|
+
[".esedre", "esedre.json"],
|
|
79
|
+
["esedre.json"]
|
|
80
|
+
];
|
|
81
|
+
while (true) {
|
|
82
|
+
for (const segs of candidates) {
|
|
83
|
+
const candidatePath = path.join(current, ...segs);
|
|
84
|
+
if (fs.existsSync(candidatePath)) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = fs.readFileSync(candidatePath, "utf-8");
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
return {
|
|
89
|
+
config: parsed,
|
|
90
|
+
configPath: candidatePath,
|
|
91
|
+
workspaceRoot: current
|
|
92
|
+
};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(`Failed to parse Esedre configuration at ${candidatePath}: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const parent = path.dirname(current);
|
|
99
|
+
if (parent === current) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
current = parent;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
config: {},
|
|
106
|
+
configPath: null,
|
|
107
|
+
workspaceRoot: process.cwd()
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function checkGitIgnore(workspaceRoot) {
|
|
111
|
+
const gitignorePath = path.join(workspaceRoot, ".gitignore");
|
|
112
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
113
|
+
return {
|
|
114
|
+
exists: false,
|
|
115
|
+
hasSnapshotIgnored: false,
|
|
116
|
+
gitignorePath
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
121
|
+
const lines = content.split(/\r?\n/).map((l) => l.trim());
|
|
122
|
+
const hasSnapshotIgnored = lines.some(
|
|
123
|
+
(l) => l === ".esedre/snapshot.json" || l === "/.esedre/snapshot.json" || l === "snapshot.json" || l === ".esedre/*.json"
|
|
124
|
+
);
|
|
125
|
+
return {
|
|
126
|
+
exists: true,
|
|
127
|
+
hasSnapshotIgnored,
|
|
128
|
+
gitignorePath
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return {
|
|
132
|
+
exists: true,
|
|
133
|
+
hasSnapshotIgnored: false,
|
|
134
|
+
gitignorePath
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function appendSnapshotToGitIgnore(workspaceRoot) {
|
|
139
|
+
const check = checkGitIgnore(workspaceRoot);
|
|
140
|
+
if (check.hasSnapshotIgnored) return false;
|
|
141
|
+
const entry = "\n# Esedre ticket local snapshot (read-only projection)\n.esedre/snapshot.json\n";
|
|
142
|
+
try {
|
|
143
|
+
if (check.exists) {
|
|
144
|
+
fs.appendFileSync(check.gitignorePath, entry, "utf-8");
|
|
145
|
+
} else {
|
|
146
|
+
fs.writeFileSync(check.gitignorePath, entry.trimStart(), "utf-8");
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function migrateEsedreConfig(raw, targetVersion = "0.1.0") {
|
|
154
|
+
const migrated = {
|
|
155
|
+
version: targetVersion,
|
|
156
|
+
projectCode: raw?.projectCode || void 0,
|
|
157
|
+
allowedProjects: Array.isArray(raw?.allowedProjects) ? raw.allowedProjects : void 0,
|
|
158
|
+
dataDir: Array.isArray(raw?.dataDir) ? raw.dataDir : typeof raw?.dataDir === "string" ? raw.dataDir : void 0,
|
|
159
|
+
serverUrl: raw?.serverUrl,
|
|
160
|
+
port: typeof raw?.port === "number" ? raw.port : DEFAULT_ESEDRE_PORT,
|
|
161
|
+
projects: typeof raw?.projects === "object" && raw?.projects ? raw.projects : void 0
|
|
162
|
+
};
|
|
163
|
+
return migrated;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/snapshot.ts
|
|
167
|
+
import crypto from "node:crypto";
|
|
168
|
+
import fs2 from "node:fs";
|
|
169
|
+
import path2 from "node:path";
|
|
170
|
+
function computeTicketHash(ticket) {
|
|
171
|
+
const hash = crypto.createHash("sha1");
|
|
172
|
+
const payload = JSON.stringify({
|
|
173
|
+
id: ticket.meta.id,
|
|
174
|
+
title: (ticket.meta.title || "").trim(),
|
|
175
|
+
category: ticket.meta.category,
|
|
176
|
+
status: ticket.meta.status,
|
|
177
|
+
complexity: ticket.meta.complexity || "",
|
|
178
|
+
effort: ticket.meta.estimatedEffort || "",
|
|
179
|
+
project: ticket.meta.project || "",
|
|
180
|
+
revision: ticket.meta.revision || 1,
|
|
181
|
+
completedAt: ticket.meta.completedAt || "",
|
|
182
|
+
detailRaw: (ticket.detail?.raw || "").trim(),
|
|
183
|
+
planMarkdown: (ticket.planMarkdown || "").trim()
|
|
184
|
+
});
|
|
185
|
+
hash.update(payload, "utf-8");
|
|
186
|
+
return hash.digest("hex");
|
|
187
|
+
}
|
|
188
|
+
function verifyTicketHash(currentHash, lastHash) {
|
|
189
|
+
if (!lastHash) return true;
|
|
190
|
+
const cleanLast = lastHash.trim().toLowerCase();
|
|
191
|
+
const cleanCurrent = currentHash.trim().toLowerCase();
|
|
192
|
+
if (cleanCurrent === cleanLast) return true;
|
|
193
|
+
if (cleanLast.length >= 7 && cleanCurrent.startsWith(cleanLast)) return true;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
async function generateProjectSnapshot(storage, projectCode, outputDir) {
|
|
197
|
+
const tickets = await storage.listTickets({ project: projectCode });
|
|
198
|
+
const entries = [];
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
for (const t of tickets) {
|
|
201
|
+
const lookupKey = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : t.meta.id;
|
|
202
|
+
const fullTicket = await storage.getTicket(lookupKey);
|
|
203
|
+
const plan = await storage.getPlan(lookupKey);
|
|
204
|
+
const ticketObj = fullTicket || t;
|
|
205
|
+
ticketObj.planMarkdown = plan || void 0;
|
|
206
|
+
const pCode = ticketObj.projectDescriptor?.code || ticketObj.meta.project || projectCode;
|
|
207
|
+
const ticketKey = `${pCode}-${ticketObj.meta.id}`;
|
|
208
|
+
const createdAt = ticketObj.meta.createdAt || ticketObj.meta.timestamp;
|
|
209
|
+
const updatedAt = ticketObj.meta.updatedAt || ticketObj.meta.timestamp || createdAt;
|
|
210
|
+
const completedAt = ticketObj.meta.completedAt || (ticketObj.meta.status === "Completed" ? ticketObj.meta.timestamp : void 0);
|
|
211
|
+
const revision = ticketObj.meta.revision || 1;
|
|
212
|
+
let daysSinceUpdate = 0;
|
|
213
|
+
if (updatedAt) {
|
|
214
|
+
const updateTime = new Date(updatedAt).getTime();
|
|
215
|
+
if (!isNaN(updateTime)) {
|
|
216
|
+
daysSinceUpdate = Math.max(0, Math.floor((now - updateTime) / (1e3 * 60 * 60 * 24)));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const sha1 = computeTicketHash(ticketObj);
|
|
220
|
+
entries.push({
|
|
221
|
+
id: ticketObj.meta.id,
|
|
222
|
+
ticketKey,
|
|
223
|
+
title: ticketObj.meta.title,
|
|
224
|
+
type: ticketObj.meta.type || ticketObj.meta.category || "Feature",
|
|
225
|
+
category: ticketObj.meta.type || ticketObj.meta.category || "Feature",
|
|
226
|
+
status: ticketObj.meta.status,
|
|
227
|
+
complexity: ticketObj.meta.complexity,
|
|
228
|
+
estimatedEffort: ticketObj.meta.estimatedEffort,
|
|
229
|
+
project: pCode,
|
|
230
|
+
summary: ticketObj.detail?.summary,
|
|
231
|
+
hasPlan: Boolean(plan && plan.trim().length > 0),
|
|
232
|
+
planMarkdown: plan && plan.trim().length > 0 ? plan : void 0,
|
|
233
|
+
createdAt,
|
|
234
|
+
updatedAt,
|
|
235
|
+
completedAt,
|
|
236
|
+
revision,
|
|
237
|
+
daysSinceUpdate,
|
|
238
|
+
sha1
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const snapshot = {
|
|
242
|
+
version: "0.1.0",
|
|
243
|
+
projectCode,
|
|
244
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
245
|
+
totalTickets: entries.length,
|
|
246
|
+
tickets: entries
|
|
247
|
+
};
|
|
248
|
+
const esedreDir = path2.join(outputDir, ".esedre");
|
|
249
|
+
if (!fs2.existsSync(esedreDir)) {
|
|
250
|
+
fs2.mkdirSync(esedreDir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
const snapshotPath = path2.join(esedreDir, "snapshot.json");
|
|
253
|
+
const tempSnapshotPath = `${snapshotPath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
254
|
+
try {
|
|
255
|
+
fs2.writeFileSync(tempSnapshotPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
256
|
+
fs2.renameSync(tempSnapshotPath, snapshotPath);
|
|
257
|
+
} catch {
|
|
258
|
+
fs2.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
259
|
+
if (fs2.existsSync(tempSnapshotPath)) {
|
|
260
|
+
try {
|
|
261
|
+
fs2.unlinkSync(tempSnapshotPath);
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return snapshot;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/storage/filesystem.ts
|
|
270
|
+
function isProjectMatch(code, slug, filter) {
|
|
271
|
+
const f = filter.trim().toLowerCase();
|
|
272
|
+
const c = code.trim().toLowerCase();
|
|
273
|
+
const s = slug.trim().toLowerCase();
|
|
274
|
+
if (c === f || s === f) return true;
|
|
275
|
+
if ((f === "prof" || f === "core" || f === "pasrc") && (c === "prof" || c === "core")) return true;
|
|
276
|
+
if ((f === "esedre" || f === "ese" || f === "docs") && (c === "esedre" || c === "docs")) return true;
|
|
277
|
+
if ((f === "alce" || f === "web") && (c === "alce" || c === "web")) return true;
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
function writeSafeFile(filePath, content) {
|
|
281
|
+
const dir = path3.dirname(filePath);
|
|
282
|
+
if (!fs3.existsSync(dir)) {
|
|
283
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
284
|
+
}
|
|
285
|
+
const normalized = content.replace(/\n/g, "\n");
|
|
286
|
+
const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
287
|
+
try {
|
|
288
|
+
fs3.writeFileSync(tempPath, normalized, "utf-8");
|
|
289
|
+
fs3.renameSync(tempPath, filePath);
|
|
290
|
+
} catch {
|
|
291
|
+
fs3.writeFileSync(filePath, normalized, "utf-8");
|
|
292
|
+
if (fs3.existsSync(tempPath)) {
|
|
293
|
+
try {
|
|
294
|
+
fs3.unlinkSync(tempPath);
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
var FilesystemStorageAdapter = class {
|
|
301
|
+
workspaceRoot;
|
|
302
|
+
config;
|
|
303
|
+
constructor(workspaceRoot, config) {
|
|
304
|
+
this.workspaceRoot = workspaceRoot || this.resolveWorkspaceRoot();
|
|
305
|
+
if (config) {
|
|
306
|
+
this.config = config;
|
|
307
|
+
} else {
|
|
308
|
+
try {
|
|
309
|
+
const discovered = findEsedreConfig(this.workspaceRoot);
|
|
310
|
+
this.config = discovered.config || {};
|
|
311
|
+
} catch {
|
|
312
|
+
this.config = {};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
resolveWorkspaceRoot() {
|
|
317
|
+
let current = process.cwd();
|
|
318
|
+
while (current !== path3.dirname(current)) {
|
|
319
|
+
const candidateConfig = path3.join(current, ".esedre", "esedre.json");
|
|
320
|
+
const candidateRootConfig = path3.join(current, "esedre.json");
|
|
321
|
+
const candidateTickets = path3.join(current, "src", "data", "planning", "tickets");
|
|
322
|
+
const candidateProjectsDir = path3.join(current, "src", "data", "planning", "projects", "projects.json");
|
|
323
|
+
const candidateProjects = path3.join(current, "src", "data", "planning", "projects.json");
|
|
324
|
+
if (fs3.existsSync(candidateConfig) || fs3.existsSync(candidateRootConfig) || fs3.existsSync(candidateTickets) || fs3.existsSync(candidateProjectsDir) || fs3.existsSync(candidateProjects)) {
|
|
325
|
+
return current;
|
|
326
|
+
}
|
|
327
|
+
current = path3.dirname(current);
|
|
328
|
+
}
|
|
329
|
+
return process.cwd();
|
|
330
|
+
}
|
|
331
|
+
resolveProjectLocations() {
|
|
332
|
+
const locations = [];
|
|
333
|
+
const seenCodes = /* @__PURE__ */ new Set();
|
|
334
|
+
const rawDataDirs = Array.isArray(this.config.dataDir) ? this.config.dataDir : this.config.dataDir ? [this.config.dataDir] : [];
|
|
335
|
+
for (const dDir of rawDataDirs) {
|
|
336
|
+
const resolvedHub = path3.resolve(this.workspaceRoot, dDir);
|
|
337
|
+
if (!fs3.existsSync(resolvedHub)) continue;
|
|
338
|
+
const projectsSubDir = path3.join(resolvedHub, "projects");
|
|
339
|
+
if (fs3.existsSync(projectsSubDir)) {
|
|
340
|
+
let entries = [];
|
|
341
|
+
try {
|
|
342
|
+
entries = fs3.readdirSync(projectsSubDir, { withFileTypes: true });
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
for (const ent of entries) {
|
|
346
|
+
if (!ent.isDirectory()) continue;
|
|
347
|
+
const projectDir = path3.join(projectsSubDir, ent.name);
|
|
348
|
+
const projectTicketsDir = path3.join(projectDir, "tickets");
|
|
349
|
+
let desc;
|
|
350
|
+
const pJsonPath = path3.join(projectDir, "project.json");
|
|
351
|
+
if (fs3.existsSync(pJsonPath)) {
|
|
352
|
+
try {
|
|
353
|
+
desc = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
|
|
354
|
+
} catch {
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!desc) {
|
|
358
|
+
const hubProjectsFile = path3.join(resolvedHub, "projects.json");
|
|
359
|
+
const subHubProjectsFile = path3.join(projectsSubDir, "projects.json");
|
|
360
|
+
for (const f of [hubProjectsFile, subHubProjectsFile]) {
|
|
361
|
+
if (fs3.existsSync(f)) {
|
|
362
|
+
try {
|
|
363
|
+
const hubProjs = JSON.parse(fs3.readFileSync(f, "utf-8"));
|
|
364
|
+
desc = hubProjs.find(
|
|
365
|
+
(p) => p.code.toLowerCase() === ent.name.toLowerCase() || p.slug.toLowerCase() === ent.name.toLowerCase()
|
|
366
|
+
);
|
|
367
|
+
if (desc) break;
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!desc) {
|
|
374
|
+
desc = {
|
|
375
|
+
id: locations.length + 1,
|
|
376
|
+
code: ent.name,
|
|
377
|
+
slug: ent.name.toLowerCase(),
|
|
378
|
+
name: ent.name,
|
|
379
|
+
description: `${ent.name} project`
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const codeKey = desc.code.toLowerCase();
|
|
383
|
+
if (!seenCodes.has(codeKey)) {
|
|
384
|
+
seenCodes.add(codeKey);
|
|
385
|
+
locations.push({
|
|
386
|
+
project: desc,
|
|
387
|
+
ticketsDir: projectTicketsDir,
|
|
388
|
+
sourceType: "hub",
|
|
389
|
+
hubDir: resolvedHub
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
const flatTicketsDir = path3.join(resolvedHub, "tickets");
|
|
395
|
+
const actualTicketsDir = fs3.existsSync(flatTicketsDir) ? flatTicketsDir : resolvedHub;
|
|
396
|
+
let descs = [];
|
|
397
|
+
const pJsonCandidates = [
|
|
398
|
+
path3.join(resolvedHub, "projects.json"),
|
|
399
|
+
path3.join(resolvedHub, "project.json")
|
|
400
|
+
];
|
|
401
|
+
for (const cand of pJsonCandidates) {
|
|
402
|
+
if (fs3.existsSync(cand)) {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(fs3.readFileSync(cand, "utf-8"));
|
|
405
|
+
descs = Array.isArray(parsed) ? parsed : [parsed];
|
|
406
|
+
break;
|
|
407
|
+
} catch {
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (descs.length === 0) {
|
|
412
|
+
const pCode = this.config.projectCode || "CORE";
|
|
413
|
+
descs = [{
|
|
414
|
+
id: 1,
|
|
415
|
+
code: pCode,
|
|
416
|
+
slug: pCode.toLowerCase(),
|
|
417
|
+
name: pCode,
|
|
418
|
+
description: `${pCode} project`
|
|
419
|
+
}];
|
|
420
|
+
}
|
|
421
|
+
const isShared = descs.length > 1;
|
|
422
|
+
for (const desc of descs) {
|
|
423
|
+
const codeKey = desc.code.toLowerCase();
|
|
424
|
+
if (!seenCodes.has(codeKey)) {
|
|
425
|
+
seenCodes.add(codeKey);
|
|
426
|
+
locations.push({
|
|
427
|
+
project: desc,
|
|
428
|
+
ticketsDir: actualTicketsDir,
|
|
429
|
+
sourceType: "hub",
|
|
430
|
+
hubDir: resolvedHub,
|
|
431
|
+
isSharedDir: isShared
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (this.config.projects && typeof this.config.projects === "object") {
|
|
438
|
+
for (const [code, rawPath] of Object.entries(this.config.projects)) {
|
|
439
|
+
const projectDir = path3.resolve(this.workspaceRoot, rawPath);
|
|
440
|
+
if (!fs3.existsSync(projectDir)) continue;
|
|
441
|
+
let actualTicketsDir = path3.join(projectDir, "tickets");
|
|
442
|
+
if (fs3.existsSync(path3.join(projectDir, ".esedre", "tickets"))) {
|
|
443
|
+
actualTicketsDir = path3.join(projectDir, ".esedre", "tickets");
|
|
444
|
+
} else if (fs3.existsSync(path3.join(projectDir, "tickets"))) {
|
|
445
|
+
actualTicketsDir = path3.join(projectDir, "tickets");
|
|
446
|
+
} else if (fs3.existsSync(path3.join(projectDir, "src", "data", "planning", "tickets"))) {
|
|
447
|
+
actualTicketsDir = path3.join(projectDir, "src", "data", "planning", "tickets");
|
|
448
|
+
}
|
|
449
|
+
let desc;
|
|
450
|
+
const pJsonCandidates = [
|
|
451
|
+
path3.join(projectDir, ".esedre", "project.json"),
|
|
452
|
+
path3.join(projectDir, "project.json")
|
|
453
|
+
];
|
|
454
|
+
for (const cand of pJsonCandidates) {
|
|
455
|
+
if (fs3.existsSync(cand)) {
|
|
456
|
+
try {
|
|
457
|
+
desc = JSON.parse(fs3.readFileSync(cand, "utf-8"));
|
|
458
|
+
break;
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!desc) {
|
|
464
|
+
desc = {
|
|
465
|
+
id: locations.length + 1,
|
|
466
|
+
code,
|
|
467
|
+
slug: code.toLowerCase(),
|
|
468
|
+
name: code,
|
|
469
|
+
description: `${code} project`
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const codeKey = desc.code.toLowerCase();
|
|
473
|
+
if (!seenCodes.has(codeKey)) {
|
|
474
|
+
seenCodes.add(codeKey);
|
|
475
|
+
locations.push({
|
|
476
|
+
project: desc,
|
|
477
|
+
ticketsDir: actualTicketsDir,
|
|
478
|
+
sourceType: "federated"
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (locations.length === 0) {
|
|
484
|
+
const candidateDirs = [
|
|
485
|
+
path3.join(this.workspaceRoot, ".esedre", "tickets"),
|
|
486
|
+
path3.join(this.workspaceRoot, "tickets"),
|
|
487
|
+
path3.join(this.workspaceRoot, "src", "data", "planning", "tickets")
|
|
488
|
+
];
|
|
489
|
+
const foundTicketsDir = candidateDirs.find((d) => fs3.existsSync(d)) || path3.join(this.workspaceRoot, ".esedre", "tickets");
|
|
490
|
+
let descs = [];
|
|
491
|
+
const pJsonCandidates = [
|
|
492
|
+
path3.join(this.workspaceRoot, ".esedre", "projects.json"),
|
|
493
|
+
path3.join(this.workspaceRoot, ".esedre", "project.json"),
|
|
494
|
+
path3.join(this.workspaceRoot, "projects.json"),
|
|
495
|
+
path3.join(this.workspaceRoot, "project.json"),
|
|
496
|
+
path3.join(this.workspaceRoot, "src", "data", "planning", "projects", "projects.json"),
|
|
497
|
+
path3.join(this.workspaceRoot, "src", "data", "planning", "projects.json")
|
|
498
|
+
];
|
|
499
|
+
for (const cand of pJsonCandidates) {
|
|
500
|
+
if (fs3.existsSync(cand)) {
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(fs3.readFileSync(cand, "utf-8"));
|
|
503
|
+
descs = Array.isArray(parsed) ? parsed : [parsed];
|
|
504
|
+
break;
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (descs.length === 0) {
|
|
510
|
+
if (this.config.projectCode) {
|
|
511
|
+
const pName = this.config.projectName || this.config.projectCode;
|
|
512
|
+
descs = [{
|
|
513
|
+
id: 1,
|
|
514
|
+
code: this.config.projectCode,
|
|
515
|
+
slug: this.config.projectCode.toLowerCase(),
|
|
516
|
+
name: pName,
|
|
517
|
+
description: `${pName} project`
|
|
518
|
+
}];
|
|
519
|
+
} else {
|
|
520
|
+
return locations;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const isShared = descs.length > 1;
|
|
524
|
+
for (const desc of descs) {
|
|
525
|
+
const codeKey = desc.code.toLowerCase();
|
|
526
|
+
if (!seenCodes.has(codeKey)) {
|
|
527
|
+
seenCodes.add(codeKey);
|
|
528
|
+
locations.push({
|
|
529
|
+
project: desc,
|
|
530
|
+
ticketsDir: foundTicketsDir,
|
|
531
|
+
sourceType: "in-repo",
|
|
532
|
+
isSharedDir: isShared
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return locations;
|
|
538
|
+
}
|
|
539
|
+
async getProjects() {
|
|
540
|
+
return this.resolveProjectLocations().map((l) => l.project);
|
|
541
|
+
}
|
|
542
|
+
async registerProject(input) {
|
|
543
|
+
const rawCode = input.code.trim();
|
|
544
|
+
const cleanCode = rawCode.toUpperCase();
|
|
545
|
+
const cleanName = input.name?.trim() || cleanCode;
|
|
546
|
+
const cleanDesc = input.description?.trim() || `${cleanName} project`;
|
|
547
|
+
const slug = cleanCode.toLowerCase();
|
|
548
|
+
const colors2 = input.colors || {
|
|
549
|
+
badge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-300",
|
|
550
|
+
dot: "bg-cyan-400",
|
|
551
|
+
border: "border-cyan-500/40"
|
|
552
|
+
};
|
|
553
|
+
const rawDataDirs = Array.isArray(this.config.dataDir) ? this.config.dataDir : this.config.dataDir ? [this.config.dataDir] : [];
|
|
554
|
+
let targetHub = null;
|
|
555
|
+
for (const dDir of rawDataDirs) {
|
|
556
|
+
const resolved = path3.resolve(this.workspaceRoot, dDir);
|
|
557
|
+
if (fs3.existsSync(resolved)) {
|
|
558
|
+
targetHub = resolved;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (targetHub) {
|
|
563
|
+
const hubProjectsDir = path3.join(targetHub, "projects");
|
|
564
|
+
const projectDir = path3.join(hubProjectsDir, cleanCode);
|
|
565
|
+
const ticketsDir2 = path3.join(projectDir, "tickets");
|
|
566
|
+
if (!fs3.existsSync(ticketsDir2)) {
|
|
567
|
+
fs3.mkdirSync(ticketsDir2, { recursive: true });
|
|
568
|
+
}
|
|
569
|
+
const pJsonPath2 = path3.join(projectDir, "project.json");
|
|
570
|
+
let existingPJson2 = null;
|
|
571
|
+
if (fs3.existsSync(pJsonPath2)) {
|
|
572
|
+
try {
|
|
573
|
+
existingPJson2 = JSON.parse(fs3.readFileSync(pJsonPath2, "utf-8"));
|
|
574
|
+
} catch {
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const id2 = existingPJson2?.id || (await this.getProjects()).length + 1;
|
|
578
|
+
const projectDesc2 = {
|
|
579
|
+
id: id2,
|
|
580
|
+
code: cleanCode,
|
|
581
|
+
slug,
|
|
582
|
+
name: cleanName,
|
|
583
|
+
description: cleanDesc,
|
|
584
|
+
colors: colors2
|
|
585
|
+
};
|
|
586
|
+
writeSafeFile(pJsonPath2, JSON.stringify(projectDesc2, null, 2) + "\n");
|
|
587
|
+
const hubProjectsFile = path3.join(targetHub, "projects.json");
|
|
588
|
+
if (fs3.existsSync(hubProjectsFile)) {
|
|
589
|
+
try {
|
|
590
|
+
const arr = JSON.parse(fs3.readFileSync(hubProjectsFile, "utf-8"));
|
|
591
|
+
const idx = arr.findIndex((p) => p.code.toLowerCase() === slug);
|
|
592
|
+
if (idx >= 0) {
|
|
593
|
+
arr[idx] = projectDesc2;
|
|
594
|
+
} else {
|
|
595
|
+
arr.push(projectDesc2);
|
|
596
|
+
}
|
|
597
|
+
writeSafeFile(hubProjectsFile, JSON.stringify(arr, null, 2) + "\n");
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return projectDesc2;
|
|
602
|
+
}
|
|
603
|
+
const esedreDir = path3.join(this.workspaceRoot, ".esedre");
|
|
604
|
+
const ticketsDir = path3.join(esedreDir, "tickets");
|
|
605
|
+
if (!fs3.existsSync(ticketsDir)) {
|
|
606
|
+
fs3.mkdirSync(ticketsDir, { recursive: true });
|
|
607
|
+
}
|
|
608
|
+
const pJsonPath = path3.join(esedreDir, "project.json");
|
|
609
|
+
let existingPJson = null;
|
|
610
|
+
if (fs3.existsSync(pJsonPath)) {
|
|
611
|
+
try {
|
|
612
|
+
existingPJson = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const id = existingPJson?.id || 1;
|
|
617
|
+
const projectDesc = {
|
|
618
|
+
id,
|
|
619
|
+
code: cleanCode,
|
|
620
|
+
slug,
|
|
621
|
+
name: cleanName,
|
|
622
|
+
description: cleanDesc,
|
|
623
|
+
colors: colors2
|
|
624
|
+
};
|
|
625
|
+
writeSafeFile(pJsonPath, JSON.stringify(projectDesc, null, 2) + "\n");
|
|
626
|
+
const projectsJsonPath = path3.join(esedreDir, "projects.json");
|
|
627
|
+
if (fs3.existsSync(projectsJsonPath)) {
|
|
628
|
+
try {
|
|
629
|
+
const arr = JSON.parse(fs3.readFileSync(projectsJsonPath, "utf-8"));
|
|
630
|
+
const idx = arr.findIndex((p) => p.code.toLowerCase() === slug);
|
|
631
|
+
if (idx >= 0) {
|
|
632
|
+
arr[idx] = projectDesc;
|
|
633
|
+
} else {
|
|
634
|
+
arr.push(projectDesc);
|
|
635
|
+
}
|
|
636
|
+
writeSafeFile(projectsJsonPath, JSON.stringify(arr, null, 2) + "\n");
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return projectDesc;
|
|
641
|
+
}
|
|
642
|
+
findTicketLocation(id) {
|
|
643
|
+
const locations = this.resolveProjectLocations();
|
|
644
|
+
if (locations.length === 0) return null;
|
|
645
|
+
const strId = String(id).trim();
|
|
646
|
+
const prefixMatch = strId.match(/^([a-zA-Z0-9]{1,6})[-_:](\d+)$/i);
|
|
647
|
+
if (prefixMatch) {
|
|
648
|
+
const code = prefixMatch[1].toLowerCase();
|
|
649
|
+
const num2 = parseInt(prefixMatch[2], 10);
|
|
650
|
+
const loc = locations.find((l) => isProjectMatch(l.project.code, l.project.slug, code));
|
|
651
|
+
if (!loc) return null;
|
|
652
|
+
const ticketDir = path3.join(loc.ticketsDir, String(num2));
|
|
653
|
+
const metaPath = path3.join(ticketDir, "meta.json");
|
|
654
|
+
if (fs3.existsSync(metaPath)) {
|
|
655
|
+
if (loc.isSharedDir) {
|
|
656
|
+
try {
|
|
657
|
+
const metaRaw = fs3.readFileSync(metaPath, "utf-8");
|
|
658
|
+
const meta = JSON.parse(metaRaw);
|
|
659
|
+
meta.type = meta.type || meta.category || "Feature";
|
|
660
|
+
meta.category = meta.type;
|
|
661
|
+
const mProj = (meta.project || "").toLowerCase();
|
|
662
|
+
if (mProj && mProj !== code && mProj !== loc.project.slug.toLowerCase()) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return { loc, ticketDir, id: num2 };
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const num = typeof id === "number" ? id : parseInt(strId, 10);
|
|
673
|
+
if (isNaN(num)) return null;
|
|
674
|
+
const matches = [];
|
|
675
|
+
const checkedDirs = /* @__PURE__ */ new Set();
|
|
676
|
+
for (const loc of locations) {
|
|
677
|
+
const ticketDir = path3.join(loc.ticketsDir, String(num));
|
|
678
|
+
const metaPath = path3.join(ticketDir, "meta.json");
|
|
679
|
+
if (!fs3.existsSync(metaPath)) continue;
|
|
680
|
+
if (loc.isSharedDir) {
|
|
681
|
+
if (checkedDirs.has(ticketDir)) continue;
|
|
682
|
+
checkedDirs.add(ticketDir);
|
|
683
|
+
try {
|
|
684
|
+
const metaRaw = fs3.readFileSync(metaPath, "utf-8");
|
|
685
|
+
const meta = JSON.parse(metaRaw);
|
|
686
|
+
const matchedLoc = locations.find(
|
|
687
|
+
(l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && (l.project.code.toLowerCase() === meta.project.toLowerCase() || l.project.slug.toLowerCase() === meta.project.toLowerCase())
|
|
688
|
+
) || loc;
|
|
689
|
+
matches.push({ loc: matchedLoc, ticketDir, id: num });
|
|
690
|
+
} catch {
|
|
691
|
+
matches.push({ loc, ticketDir, id: num });
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
matches.push({ loc, ticketDir, id: num });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (this.config.projectCode) {
|
|
698
|
+
const prefLower = this.config.projectCode.toLowerCase();
|
|
699
|
+
const preferredMatch = matches.find(
|
|
700
|
+
(m) => m.loc.project.code.toLowerCase() === prefLower || m.loc.project.slug.toLowerCase() === prefLower
|
|
701
|
+
);
|
|
702
|
+
if (preferredMatch) return preferredMatch;
|
|
703
|
+
}
|
|
704
|
+
if (matches.length === 1) {
|
|
705
|
+
return matches[0];
|
|
706
|
+
}
|
|
707
|
+
if (matches.length > 1) {
|
|
708
|
+
const codes = matches.map((m) => m.loc.project.code);
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Ambiguous ticket #${num}: exists in multiple projects (${codes.join(", ")}). Please specify as <ProjectCode>-${num} (e.g. ${codes[0]}-${num}).`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
async listTickets(filter) {
|
|
716
|
+
const locations = this.resolveProjectLocations();
|
|
717
|
+
const tickets = [];
|
|
718
|
+
const processedSharedTickets = /* @__PURE__ */ new Set();
|
|
719
|
+
for (const loc of locations) {
|
|
720
|
+
if (filter?.project && filter.project !== "all") {
|
|
721
|
+
const pLower = filter.project.toLowerCase();
|
|
722
|
+
if (!isProjectMatch(loc.project.code, loc.project.slug, filter.project)) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!fs3.existsSync(loc.ticketsDir)) continue;
|
|
727
|
+
let entries = [];
|
|
728
|
+
try {
|
|
729
|
+
entries = fs3.readdirSync(loc.ticketsDir, { withFileTypes: true });
|
|
730
|
+
} catch {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
for (const entry of entries) {
|
|
734
|
+
if (!entry.isDirectory()) continue;
|
|
735
|
+
const num = parseInt(entry.name, 10);
|
|
736
|
+
if (isNaN(num)) continue;
|
|
737
|
+
const ticketDir = path3.join(loc.ticketsDir, entry.name);
|
|
738
|
+
const metaPath = path3.join(ticketDir, "meta.json");
|
|
739
|
+
if (!fs3.existsSync(metaPath)) continue;
|
|
740
|
+
if (loc.isSharedDir) {
|
|
741
|
+
const key = `${loc.ticketsDir}:${entry.name}`;
|
|
742
|
+
if (processedSharedTickets.has(key)) continue;
|
|
743
|
+
processedSharedTickets.add(key);
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
const metaRaw = fs3.readFileSync(metaPath, "utf-8");
|
|
747
|
+
const meta = JSON.parse(metaRaw);
|
|
748
|
+
meta.id = meta.id ?? num;
|
|
749
|
+
meta.type = meta.type || meta.category || "Feature";
|
|
750
|
+
meta.category = meta.type;
|
|
751
|
+
let effectiveLoc = loc;
|
|
752
|
+
if (loc.isSharedDir) {
|
|
753
|
+
const found = locations.find(
|
|
754
|
+
(l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && (l.project.code.toLowerCase() === meta.project.toLowerCase() || l.project.slug.toLowerCase() === meta.project.toLowerCase())
|
|
755
|
+
);
|
|
756
|
+
if (found) effectiveLoc = found;
|
|
757
|
+
}
|
|
758
|
+
meta.project = effectiveLoc.project.code;
|
|
759
|
+
meta.projectId = effectiveLoc.project.id;
|
|
760
|
+
if (filter?.project && filter.project !== "all") {
|
|
761
|
+
const pLower = filter.project.toLowerCase();
|
|
762
|
+
if (!isProjectMatch(meta.project, effectiveLoc.project.slug, filter.project)) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (filter?.status && meta.status !== filter.status) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (filter?.category && meta.category !== filter.category) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (filter?.search) {
|
|
773
|
+
const term = filter.search.toLowerCase();
|
|
774
|
+
const matchesTitle = meta.title.toLowerCase().includes(term);
|
|
775
|
+
const matchesNum = String(meta.id).includes(term) || `${meta.project}-${meta.id}`.toLowerCase().includes(term);
|
|
776
|
+
if (!matchesTitle && !matchesNum) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
let detail;
|
|
781
|
+
const detailPath = path3.join(ticketDir, "detail.md");
|
|
782
|
+
if (fs3.existsSync(detailPath)) {
|
|
783
|
+
const raw = fs3.readFileSync(detailPath, "utf-8");
|
|
784
|
+
detail = this.parseDetailMarkdown(raw);
|
|
785
|
+
}
|
|
786
|
+
let planMarkdown;
|
|
787
|
+
const planPath = path3.join(ticketDir, "implementation_plan.md");
|
|
788
|
+
if (fs3.existsSync(planPath)) {
|
|
789
|
+
planMarkdown = fs3.readFileSync(planPath, "utf-8");
|
|
790
|
+
}
|
|
791
|
+
let comments = [];
|
|
792
|
+
const commentsPath = path3.join(ticketDir, "comments.json");
|
|
793
|
+
if (fs3.existsSync(commentsPath)) {
|
|
794
|
+
try {
|
|
795
|
+
comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const ticketObj = {
|
|
800
|
+
meta,
|
|
801
|
+
detail,
|
|
802
|
+
planMarkdown,
|
|
803
|
+
comments,
|
|
804
|
+
projectDescriptor: effectiveLoc.project
|
|
805
|
+
};
|
|
806
|
+
ticketObj.sha1 = computeTicketHash(ticketObj);
|
|
807
|
+
ticketObj.lastHash = ticketObj.sha1;
|
|
808
|
+
tickets.push(ticketObj);
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return tickets.sort((a, b) => {
|
|
814
|
+
if (a.meta.project !== b.meta.project) {
|
|
815
|
+
return (a.meta.project || "").localeCompare(b.meta.project || "");
|
|
816
|
+
}
|
|
817
|
+
return a.meta.id - b.meta.id;
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
async getTicket(id) {
|
|
821
|
+
const locInfo = this.findTicketLocation(id);
|
|
822
|
+
if (!locInfo) return null;
|
|
823
|
+
const metaPath = path3.join(locInfo.ticketDir, "meta.json");
|
|
824
|
+
if (!fs3.existsSync(metaPath)) return null;
|
|
825
|
+
try {
|
|
826
|
+
const metaRaw = fs3.readFileSync(metaPath, "utf-8");
|
|
827
|
+
const meta = JSON.parse(metaRaw);
|
|
828
|
+
meta.id = meta.id ?? locInfo.id;
|
|
829
|
+
meta.project = locInfo.loc.project.code;
|
|
830
|
+
meta.projectId = locInfo.loc.project.id;
|
|
831
|
+
let detail;
|
|
832
|
+
const detailPath = path3.join(locInfo.ticketDir, "detail.md");
|
|
833
|
+
if (fs3.existsSync(detailPath)) {
|
|
834
|
+
const raw = fs3.readFileSync(detailPath, "utf-8");
|
|
835
|
+
detail = this.parseDetailMarkdown(raw);
|
|
836
|
+
}
|
|
837
|
+
let planMarkdown;
|
|
838
|
+
const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
|
|
839
|
+
if (fs3.existsSync(planPath)) {
|
|
840
|
+
planMarkdown = fs3.readFileSync(planPath, "utf-8");
|
|
841
|
+
}
|
|
842
|
+
let comments = [];
|
|
843
|
+
const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
|
|
844
|
+
if (fs3.existsSync(commentsPath)) {
|
|
845
|
+
try {
|
|
846
|
+
comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const ticket = {
|
|
851
|
+
meta,
|
|
852
|
+
detail,
|
|
853
|
+
planMarkdown,
|
|
854
|
+
comments,
|
|
855
|
+
projectDescriptor: locInfo.loc.project
|
|
856
|
+
};
|
|
857
|
+
ticket.sha1 = computeTicketHash(ticket);
|
|
858
|
+
ticket.lastHash = ticket.sha1;
|
|
859
|
+
return ticket;
|
|
860
|
+
} catch {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
parseDetailMarkdown(raw) {
|
|
865
|
+
const titleMatch = raw.match(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?([^\n]+)/im);
|
|
866
|
+
const title = titleMatch ? titleMatch[1].trim() : "Untitled Ticket";
|
|
867
|
+
const typeMatch = raw.match(/\*\*(?:Type|Category)\*\*:\s*([^\n]+)/i);
|
|
868
|
+
const type = typeMatch ? typeMatch[1].trim() : "Feature";
|
|
869
|
+
const category = type;
|
|
870
|
+
const complexityMatch = raw.match(/\*\*Complexity\*\*:\s*([^\n]+)/i);
|
|
871
|
+
const complexity = complexityMatch ? complexityMatch[1].trim() : "Medium";
|
|
872
|
+
const effortMatch = raw.match(/\*\*Estimated Effort\*\*:\s*([^\n]+)/i);
|
|
873
|
+
const estimatedEffort = effortMatch ? effortMatch[1].trim() : "2.0 - 4.0 hours";
|
|
874
|
+
let summary;
|
|
875
|
+
const summaryMatch = raw.match(/(?:##|###)\s*(?:Summary|Rationale)\s*\n([\s\S]*?)(?=\n##|\n###|\n#|$)/i);
|
|
876
|
+
if (summaryMatch) {
|
|
877
|
+
summary = summaryMatch[1].trim();
|
|
878
|
+
}
|
|
879
|
+
const breakdown = [];
|
|
880
|
+
const breakdownMatch = raw.match(/(?:##|###)\s*Feature Breakdown\s*\n([\s\S]*?)(?=\n##|\n###|\n#|$)/i);
|
|
881
|
+
if (breakdownMatch) {
|
|
882
|
+
const lines = breakdownMatch[1].split("\n");
|
|
883
|
+
for (const line of lines) {
|
|
884
|
+
const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
|
|
885
|
+
if (itemMatch) breakdown.push(itemMatch[1].trim());
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const technicalDetails = [];
|
|
889
|
+
const techMatch = raw.match(/###\s*(?:Technical Details?|Architecture)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|$)/i);
|
|
890
|
+
if (techMatch) {
|
|
891
|
+
const lines = techMatch[1].split("\n");
|
|
892
|
+
for (const line of lines) {
|
|
893
|
+
const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
|
|
894
|
+
if (itemMatch) technicalDetails.push(itemMatch[1].trim());
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const openQuestions = [];
|
|
898
|
+
const questionsMatch = raw.match(/###\s*(?:Open Questions?|Technical Detail & Open Questions?|Decisions)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|$)/i);
|
|
899
|
+
if (questionsMatch) {
|
|
900
|
+
const lines = questionsMatch[1].split("\n");
|
|
901
|
+
for (const line of lines) {
|
|
902
|
+
const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
|
|
903
|
+
if (itemMatch) openQuestions.push(itemMatch[1].trim());
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
title,
|
|
908
|
+
type,
|
|
909
|
+
category,
|
|
910
|
+
complexity,
|
|
911
|
+
estimatedEffort,
|
|
912
|
+
summary,
|
|
913
|
+
breakdown,
|
|
914
|
+
technicalDetails,
|
|
915
|
+
openQuestions,
|
|
916
|
+
raw
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
async createTicket(input) {
|
|
920
|
+
const locations = this.resolveProjectLocations();
|
|
921
|
+
if (locations.length === 0) {
|
|
922
|
+
throw new Error("No projects registered in workspace. Please configure projects before creating tickets.");
|
|
923
|
+
}
|
|
924
|
+
const requestedIdentifier = input.projectCode || input.projectId || this.config.projectCode;
|
|
925
|
+
let targetLoc;
|
|
926
|
+
if (requestedIdentifier) {
|
|
927
|
+
const lower = String(requestedIdentifier).trim().toLowerCase();
|
|
928
|
+
targetLoc = locations.find(
|
|
929
|
+
(l) => l.project.code.toLowerCase() === lower || l.project.slug.toLowerCase() === lower || String(l.project.id) === lower || l.project.name.toLowerCase() === lower
|
|
930
|
+
);
|
|
931
|
+
if (!targetLoc) {
|
|
932
|
+
throw new Error(`Project '${requestedIdentifier}' is invalid or not registered in this workspace.`);
|
|
933
|
+
}
|
|
934
|
+
} else if (locations.length === 1) {
|
|
935
|
+
targetLoc = locations[0];
|
|
936
|
+
} else {
|
|
937
|
+
const availableCodes = locations.map((l) => l.project.code).join(", ");
|
|
938
|
+
throw new Error(`Project is required to create a ticket (available: ${availableCodes}).`);
|
|
939
|
+
}
|
|
940
|
+
if (!fs3.existsSync(targetLoc.ticketsDir)) {
|
|
941
|
+
fs3.mkdirSync(targetLoc.ticketsDir, { recursive: true });
|
|
942
|
+
}
|
|
943
|
+
const existing = fs3.readdirSync(targetLoc.ticketsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !isNaN(parseInt(d.name, 10))).map((d) => parseInt(d.name, 10));
|
|
944
|
+
const nextId = existing.length > 0 ? Math.max(...existing) + 1 : 1;
|
|
945
|
+
const ticketDir = path3.join(targetLoc.ticketsDir, String(nextId));
|
|
946
|
+
fs3.mkdirSync(ticketDir, { recursive: true });
|
|
947
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
948
|
+
const effectiveType = input.type || input.category || "Feature";
|
|
949
|
+
const meta = {
|
|
950
|
+
id: nextId,
|
|
951
|
+
title: input.title.slice(0, 48).trim(),
|
|
952
|
+
type: effectiveType,
|
|
953
|
+
category: effectiveType,
|
|
954
|
+
complexity: input.complexity || "Medium",
|
|
955
|
+
estimatedEffort: input.estimatedEffort || input.effort || "2.0 - 4.0 hours",
|
|
956
|
+
submittedBy: input.submittedBy || "Developer",
|
|
957
|
+
timestamp: now,
|
|
958
|
+
createdAt: now,
|
|
959
|
+
updatedAt: now,
|
|
960
|
+
revision: 1,
|
|
961
|
+
isActivePlanning: false,
|
|
962
|
+
status: "Planned",
|
|
963
|
+
projectId: targetLoc.project.id,
|
|
964
|
+
project: targetLoc.project.code
|
|
965
|
+
};
|
|
966
|
+
const detailMd = `# Ticket #${nextId}: ${meta.title}
|
|
967
|
+
**Category**: ${meta.category}
|
|
968
|
+
**Complexity**: ${meta.complexity}
|
|
969
|
+
**Estimated Effort**: ${meta.estimatedEffort}
|
|
970
|
+
|
|
971
|
+
### Summary
|
|
972
|
+
${input.summary || "Summary to be defined."}
|
|
973
|
+
|
|
974
|
+
### Feature Breakdown
|
|
975
|
+
1. **Initial Requirement**:
|
|
976
|
+
- Details to be populated during planning.
|
|
977
|
+
|
|
978
|
+
### Technical Details & Architecture
|
|
979
|
+
- Architecture specifications to be documented.
|
|
980
|
+
|
|
981
|
+
### Open Questions & Decisions
|
|
982
|
+
- None recorded at initialization.
|
|
983
|
+
`;
|
|
984
|
+
writeSafeFile(path3.join(ticketDir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
985
|
+
writeSafeFile(path3.join(ticketDir, "detail.md"), detailMd);
|
|
986
|
+
const created = await this.getTicket(`${targetLoc.project.code}-${nextId}`);
|
|
987
|
+
return created;
|
|
988
|
+
}
|
|
989
|
+
async updateTicket(id, updates, lastHash) {
|
|
990
|
+
const existing = await this.getTicket(id);
|
|
991
|
+
if (!existing) {
|
|
992
|
+
throw new Error(`Ticket #${id} does not exist`);
|
|
993
|
+
}
|
|
994
|
+
if (lastHash !== void 0) {
|
|
995
|
+
const currentHash = existing.sha1 || computeTicketHash(existing);
|
|
996
|
+
if (!verifyTicketHash(currentHash, lastHash)) {
|
|
997
|
+
throw new EsedreConflictError(id, currentHash, lastHash);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const locInfo = this.findTicketLocation(id);
|
|
1001
|
+
if (!locInfo) {
|
|
1002
|
+
throw new Error(`Ticket #${id} could not be located on disk`);
|
|
1003
|
+
}
|
|
1004
|
+
const metaPath = path3.join(locInfo.ticketDir, "meta.json");
|
|
1005
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1006
|
+
const isNowCompleted = updates.status === "Completed";
|
|
1007
|
+
const completedAt = isNowCompleted ? existing.meta.completedAt || now : updates.status && updates.status !== "Completed" ? void 0 : existing.meta.completedAt;
|
|
1008
|
+
const revision = (existing.meta.revision || 1) + 1;
|
|
1009
|
+
const updatedMeta = {
|
|
1010
|
+
...existing.meta,
|
|
1011
|
+
...updates,
|
|
1012
|
+
id: locInfo.id,
|
|
1013
|
+
updatedAt: now,
|
|
1014
|
+
completedAt,
|
|
1015
|
+
revision
|
|
1016
|
+
};
|
|
1017
|
+
writeSafeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + "\n");
|
|
1018
|
+
const newType = updates.type || updates.category;
|
|
1019
|
+
if (newType) {
|
|
1020
|
+
updatedMeta.type = newType;
|
|
1021
|
+
updatedMeta.category = newType;
|
|
1022
|
+
}
|
|
1023
|
+
if (existing.detail && (updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
|
|
1024
|
+
let content = existing.detail.raw;
|
|
1025
|
+
if (updates.title) {
|
|
1026
|
+
content = content.replace(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im, `# Ticket #${locInfo.id}: ${updatedMeta.title}`);
|
|
1027
|
+
}
|
|
1028
|
+
if (updates.type || updates.category) {
|
|
1029
|
+
content = content.replace(/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i, `**Type**: ${updatedMeta.type}`);
|
|
1030
|
+
}
|
|
1031
|
+
if (updates.complexity) {
|
|
1032
|
+
content = content.replace(/\*\*Complexity\*\*:\s*[^\n]+/i, `**Complexity**: ${updatedMeta.complexity}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (updates.estimatedEffort) {
|
|
1035
|
+
content = content.replace(/\*\*Estimated Effort\*\*:\s*[^\n]+/i, `**Estimated Effort**: ${updatedMeta.estimatedEffort}`);
|
|
1036
|
+
}
|
|
1037
|
+
writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
|
|
1038
|
+
}
|
|
1039
|
+
return await this.getTicket(`${locInfo.loc.project.code}-${locInfo.id}`);
|
|
1040
|
+
}
|
|
1041
|
+
async getPlan(id) {
|
|
1042
|
+
const locInfo = this.findTicketLocation(id);
|
|
1043
|
+
if (!locInfo) return null;
|
|
1044
|
+
const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
|
|
1045
|
+
if (!fs3.existsSync(planPath)) return null;
|
|
1046
|
+
return fs3.readFileSync(planPath, "utf-8");
|
|
1047
|
+
}
|
|
1048
|
+
async savePlan(id, planMarkdown, lastHash) {
|
|
1049
|
+
const ticket = await this.getTicket(id);
|
|
1050
|
+
if (!ticket) {
|
|
1051
|
+
throw new Error(`Ticket #${id} does not exist`);
|
|
1052
|
+
}
|
|
1053
|
+
if (lastHash !== void 0) {
|
|
1054
|
+
const currentHash = ticket.sha1 || computeTicketHash(ticket);
|
|
1055
|
+
if (!verifyTicketHash(currentHash, lastHash)) {
|
|
1056
|
+
throw new EsedreConflictError(id, currentHash, lastHash);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const locInfo = this.findTicketLocation(id);
|
|
1060
|
+
if (!locInfo) {
|
|
1061
|
+
throw new Error(`Ticket #${id} could not be located on disk`);
|
|
1062
|
+
}
|
|
1063
|
+
const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
|
|
1064
|
+
writeSafeFile(planPath, planMarkdown);
|
|
1065
|
+
const metaPath = path3.join(locInfo.ticketDir, "meta.json");
|
|
1066
|
+
if (fs3.existsSync(metaPath)) {
|
|
1067
|
+
try {
|
|
1068
|
+
const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
|
|
1069
|
+
meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1070
|
+
meta.revision = (meta.revision || 1) + 1;
|
|
1071
|
+
writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async addComment(id, comment) {
|
|
1077
|
+
const ticket = await this.getTicket(id);
|
|
1078
|
+
if (!ticket) {
|
|
1079
|
+
throw new Error(`Ticket #${id} does not exist`);
|
|
1080
|
+
}
|
|
1081
|
+
const locInfo = this.findTicketLocation(id);
|
|
1082
|
+
if (!locInfo) {
|
|
1083
|
+
throw new Error(`Ticket #${id} could not be located on disk`);
|
|
1084
|
+
}
|
|
1085
|
+
const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
|
|
1086
|
+
let comments = [];
|
|
1087
|
+
if (fs3.existsSync(commentsPath)) {
|
|
1088
|
+
try {
|
|
1089
|
+
comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
|
|
1090
|
+
} catch {
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const newComment = {
|
|
1094
|
+
id: `${locInfo.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1095
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1096
|
+
author: comment.author || "User",
|
|
1097
|
+
text: comment.text.trim()
|
|
1098
|
+
};
|
|
1099
|
+
comments.push(newComment);
|
|
1100
|
+
writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
|
|
1101
|
+
return newComment;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// src/securityFilter.ts
|
|
1106
|
+
var SecurityFilter = class {
|
|
1107
|
+
constructor(target, config = {}) {
|
|
1108
|
+
this.target = target;
|
|
1109
|
+
this.config = config;
|
|
1110
|
+
}
|
|
1111
|
+
target;
|
|
1112
|
+
config;
|
|
1113
|
+
async getProjects() {
|
|
1114
|
+
const projs = await this.target.getProjects();
|
|
1115
|
+
if (!this.config.allowedProjects || this.config.allowedProjects.length === 0) {
|
|
1116
|
+
return projs;
|
|
1117
|
+
}
|
|
1118
|
+
return projs.filter((p) => isProjectAuthorized(p.code, this.config.allowedProjects));
|
|
1119
|
+
}
|
|
1120
|
+
async registerProject(input) {
|
|
1121
|
+
const proj = await this.target.registerProject(input);
|
|
1122
|
+
if (this.config.allowedProjects && !this.config.allowedProjects.includes("*")) {
|
|
1123
|
+
const upper = proj.code.toUpperCase();
|
|
1124
|
+
if (!this.config.allowedProjects.some((p) => p.toUpperCase() === upper)) {
|
|
1125
|
+
this.config.allowedProjects.push(proj.code);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return proj;
|
|
1129
|
+
}
|
|
1130
|
+
async listTickets(filter) {
|
|
1131
|
+
if (filter?.project && !isProjectAuthorized(filter.project, this.config.allowedProjects)) {
|
|
1132
|
+
throw new EsedreAuthorizationError(filter.project);
|
|
1133
|
+
}
|
|
1134
|
+
const tickets = await this.target.listTickets(filter);
|
|
1135
|
+
if (!this.config.allowedProjects || this.config.allowedProjects.length === 0) {
|
|
1136
|
+
return tickets;
|
|
1137
|
+
}
|
|
1138
|
+
return tickets.filter((t) => {
|
|
1139
|
+
const code = t.projectDescriptor?.code || t.meta.project;
|
|
1140
|
+
if (!code) return true;
|
|
1141
|
+
return isProjectAuthorized(code, this.config.allowedProjects);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
async getTicket(id) {
|
|
1145
|
+
const ticket = await this.target.getTicket(id);
|
|
1146
|
+
if (!ticket) return null;
|
|
1147
|
+
const code = ticket.projectDescriptor?.code || ticket.meta.project;
|
|
1148
|
+
if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
|
|
1149
|
+
throw new EsedreAuthorizationError(code);
|
|
1150
|
+
}
|
|
1151
|
+
return ticket;
|
|
1152
|
+
}
|
|
1153
|
+
async createTicket(input) {
|
|
1154
|
+
const targetCode = input.projectCode || this.config.projectCode;
|
|
1155
|
+
if (targetCode && !isProjectAuthorized(targetCode, this.config.allowedProjects)) {
|
|
1156
|
+
throw new EsedreAuthorizationError(targetCode);
|
|
1157
|
+
}
|
|
1158
|
+
return this.target.createTicket(input);
|
|
1159
|
+
}
|
|
1160
|
+
async updateTicket(id, updates, lastHash) {
|
|
1161
|
+
const existing = await this.target.getTicket(id);
|
|
1162
|
+
if (existing) {
|
|
1163
|
+
const code = existing.projectDescriptor?.code || existing.meta.project;
|
|
1164
|
+
if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
|
|
1165
|
+
throw new EsedreAuthorizationError(code);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (updates.project && !isProjectAuthorized(updates.project, this.config.allowedProjects)) {
|
|
1169
|
+
throw new EsedreAuthorizationError(updates.project);
|
|
1170
|
+
}
|
|
1171
|
+
return this.target.updateTicket(id, updates, lastHash);
|
|
1172
|
+
}
|
|
1173
|
+
async getPlan(id) {
|
|
1174
|
+
const ticket = await this.target.getTicket(id);
|
|
1175
|
+
if (ticket) {
|
|
1176
|
+
const code = ticket.projectDescriptor?.code || ticket.meta.project;
|
|
1177
|
+
if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
|
|
1178
|
+
throw new EsedreAuthorizationError(code);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return this.target.getPlan(id);
|
|
1182
|
+
}
|
|
1183
|
+
async savePlan(id, planMarkdown, lastHash) {
|
|
1184
|
+
const ticket = await this.target.getTicket(id);
|
|
1185
|
+
if (ticket) {
|
|
1186
|
+
const code = ticket.projectDescriptor?.code || ticket.meta.project;
|
|
1187
|
+
if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
|
|
1188
|
+
throw new EsedreAuthorizationError(code);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return this.target.savePlan(id, planMarkdown, lastHash);
|
|
1192
|
+
}
|
|
1193
|
+
async addComment(id, comment) {
|
|
1194
|
+
const ticket = await this.target.getTicket(id);
|
|
1195
|
+
if (ticket) {
|
|
1196
|
+
const code = ticket.projectDescriptor?.code || ticket.meta.project;
|
|
1197
|
+
if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
|
|
1198
|
+
throw new EsedreAuthorizationError(code);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return this.target.addComment(id, comment);
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// src/utils/formatter.ts
|
|
1206
|
+
var isColorSupported = !process.env.NO_COLOR && (process.stdout.isTTY || process.env.FORCE_COLOR);
|
|
1207
|
+
var colors = {
|
|
1208
|
+
reset: isColorSupported ? "\x1B[0m" : "",
|
|
1209
|
+
bold: isColorSupported ? "\x1B[1m" : "",
|
|
1210
|
+
dim: isColorSupported ? "\x1B[2m" : "",
|
|
1211
|
+
cyan: isColorSupported ? "\x1B[36m" : "",
|
|
1212
|
+
green: isColorSupported ? "\x1B[32m" : "",
|
|
1213
|
+
yellow: isColorSupported ? "\x1B[33m" : "",
|
|
1214
|
+
magenta: isColorSupported ? "\x1B[35m" : "",
|
|
1215
|
+
red: isColorSupported ? "\x1B[31m" : "",
|
|
1216
|
+
blue: isColorSupported ? "\x1B[34m" : ""
|
|
1217
|
+
};
|
|
1218
|
+
function formatTicketListTable(tickets) {
|
|
1219
|
+
if (tickets.length === 0) {
|
|
1220
|
+
return `${colors.dim}No tickets found matching criteria.${colors.reset}`;
|
|
1221
|
+
}
|
|
1222
|
+
const rows = tickets.map((t) => {
|
|
1223
|
+
const id = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : `#${t.meta.id}`;
|
|
1224
|
+
const project = t.projectDescriptor?.code || "CORE";
|
|
1225
|
+
const category = t.meta.category;
|
|
1226
|
+
const status = t.meta.status;
|
|
1227
|
+
const title = t.meta.title;
|
|
1228
|
+
return { id, project, category, status, title };
|
|
1229
|
+
});
|
|
1230
|
+
const idWidth = Math.max(4, ...rows.map((r) => r.id.length));
|
|
1231
|
+
const projWidth = Math.max(7, ...rows.map((r) => r.project.length));
|
|
1232
|
+
const catWidth = Math.max(8, ...rows.map((r) => r.category.length));
|
|
1233
|
+
const statusWidth = Math.max(14, ...rows.map((r) => r.status.length));
|
|
1234
|
+
const header = `${colors.bold}${pad("ID", idWidth)} ${pad("Project", projWidth)} ${pad("Category", catWidth)} ${pad("Status", statusWidth)} Title${colors.reset}`;
|
|
1235
|
+
const divider = `${colors.dim}${"-".repeat(idWidth)} ${"-".repeat(projWidth)} ${"-".repeat(catWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(40)}${colors.reset}`;
|
|
1236
|
+
const formattedRows = rows.map((r) => {
|
|
1237
|
+
const statusColored = colorStatus(r.status);
|
|
1238
|
+
const catColored = colorCategory(r.category);
|
|
1239
|
+
const projColored = `${colors.magenta}${r.project}${colors.reset}`;
|
|
1240
|
+
return `${colors.bold}${pad(r.id, idWidth)}${colors.reset} ${pad(projColored, projWidth + (isColorSupported ? colors.magenta.length + colors.reset.length : 0))} ${pad(catColored, catWidth + (isColorSupported ? 9 : 0))} ${pad(statusColored, statusWidth + (isColorSupported ? 9 : 0))} ${r.title}`;
|
|
1241
|
+
});
|
|
1242
|
+
return [header, divider, ...formattedRows].join("\n");
|
|
1243
|
+
}
|
|
1244
|
+
function formatTicketDetail(ticket) {
|
|
1245
|
+
const { meta, detail, comments, planMarkdown, projectDescriptor } = ticket;
|
|
1246
|
+
const lines = [];
|
|
1247
|
+
lines.push(`${colors.bold}${colors.cyan}Ticket #${meta.project ? `${meta.project}-${meta.id}` : meta.id}: ${meta.title}${colors.reset}`);
|
|
1248
|
+
lines.push(`${colors.dim}${"=".repeat(60)}${colors.reset}`);
|
|
1249
|
+
lines.push(`${colors.bold}Project:${colors.reset} ${projectDescriptor ? `${projectDescriptor.code} - ${projectDescriptor.name}` : "Default"}`);
|
|
1250
|
+
lines.push(`${colors.bold}Category:${colors.reset} ${colorCategory(meta.category)}`);
|
|
1251
|
+
lines.push(`${colors.bold}Status:${colors.reset} ${colorStatus(meta.status)}`);
|
|
1252
|
+
lines.push(`${colors.bold}Complexity:${colors.reset} ${meta.complexity || "Medium"}`);
|
|
1253
|
+
lines.push(`${colors.bold}Effort:${colors.reset} ${meta.estimatedEffort || "N/A"}`);
|
|
1254
|
+
lines.push(`${colors.bold}Submitted By:${colors.reset} ${meta.submittedBy || "Unknown"}`);
|
|
1255
|
+
if (meta.featureFlag) {
|
|
1256
|
+
lines.push(`${colors.bold}Feature Flag:${colors.reset} ${colors.yellow}${meta.featureFlag}${colors.reset}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (detail?.summary) {
|
|
1259
|
+
lines.push("");
|
|
1260
|
+
lines.push(`${colors.bold}Summary:${colors.reset}`);
|
|
1261
|
+
lines.push(detail.summary);
|
|
1262
|
+
}
|
|
1263
|
+
if (detail?.breakdown && detail.breakdown.length > 0) {
|
|
1264
|
+
lines.push("");
|
|
1265
|
+
lines.push(`${colors.bold}Feature Breakdown:${colors.reset}`);
|
|
1266
|
+
for (const b of detail.breakdown) {
|
|
1267
|
+
lines.push(` \u2022 ${b}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (detail?.technicalDetails && detail.technicalDetails.length > 0) {
|
|
1271
|
+
lines.push("");
|
|
1272
|
+
lines.push(`${colors.bold}Technical Details:${colors.reset}`);
|
|
1273
|
+
for (const t of detail.technicalDetails) {
|
|
1274
|
+
lines.push(` \u2022 ${t}`);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (detail?.openQuestions && detail.openQuestions.length > 0) {
|
|
1278
|
+
lines.push("");
|
|
1279
|
+
lines.push(`${colors.bold}Open Decisions & Questions:${colors.reset}`);
|
|
1280
|
+
for (const q of detail.openQuestions) {
|
|
1281
|
+
lines.push(` \u2022 ${q}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
lines.push("");
|
|
1285
|
+
lines.push(`${colors.bold}Implementation Plan:${colors.reset} ${planMarkdown ? `${colors.green}Available (${planMarkdown.split("\n").length} lines)${colors.reset}` : `${colors.dim}None recorded${colors.reset}`}`);
|
|
1286
|
+
if (comments.length > 0) {
|
|
1287
|
+
lines.push("");
|
|
1288
|
+
lines.push(`${colors.bold}Comments (${comments.length}):${colors.reset}`);
|
|
1289
|
+
for (const c of comments) {
|
|
1290
|
+
lines.push(` ${colors.dim}[${c.timestamp.slice(0, 10)}]${colors.reset} ${colors.bold}${c.author}:${colors.reset} ${c.text}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return lines.join("\n");
|
|
1294
|
+
}
|
|
1295
|
+
function pad(str, width) {
|
|
1296
|
+
return str.padEnd(width, " ");
|
|
1297
|
+
}
|
|
1298
|
+
function colorStatus(status) {
|
|
1299
|
+
switch (status) {
|
|
1300
|
+
case "Completed":
|
|
1301
|
+
return `${colors.green}${status}${colors.reset}`;
|
|
1302
|
+
case "In Development":
|
|
1303
|
+
return `${colors.green}${status}${colors.reset}`;
|
|
1304
|
+
case "Rejected":
|
|
1305
|
+
return `${colors.red}${status}${colors.reset}`;
|
|
1306
|
+
default:
|
|
1307
|
+
return `${colors.cyan}${status}${colors.reset}`;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
function colorCategory(category) {
|
|
1311
|
+
switch (category) {
|
|
1312
|
+
case "Feature":
|
|
1313
|
+
return `${colors.blue}${category}${colors.reset}`;
|
|
1314
|
+
case "Platform":
|
|
1315
|
+
return `${colors.magenta}${category}${colors.reset}`;
|
|
1316
|
+
case "Tools":
|
|
1317
|
+
return `${colors.yellow}${category}${colors.reset}`;
|
|
1318
|
+
case "Bug":
|
|
1319
|
+
return `${colors.red}${category}${colors.reset}`;
|
|
1320
|
+
case "Idea":
|
|
1321
|
+
return `${colors.green}${category}${colors.reset}`;
|
|
1322
|
+
default:
|
|
1323
|
+
return category;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// src/mcp/server.ts
|
|
1328
|
+
import readline from "node:readline";
|
|
1329
|
+
var EsedreMcpServer = class {
|
|
1330
|
+
storage;
|
|
1331
|
+
constructor(storage) {
|
|
1332
|
+
this.storage = storage;
|
|
1333
|
+
}
|
|
1334
|
+
start() {
|
|
1335
|
+
const rl = readline.createInterface({
|
|
1336
|
+
input: process.stdin,
|
|
1337
|
+
output: process.stdout,
|
|
1338
|
+
terminal: false
|
|
1339
|
+
});
|
|
1340
|
+
process.stderr.write("[Esedre MCP Server] Initialized. Awaiting JSON-RPC messages on stdin...\n");
|
|
1341
|
+
rl.on("line", async (line) => {
|
|
1342
|
+
const trimmed = line.trim();
|
|
1343
|
+
if (!trimmed) return;
|
|
1344
|
+
try {
|
|
1345
|
+
const req = JSON.parse(trimmed);
|
|
1346
|
+
const res = await this.handleRequest(req);
|
|
1347
|
+
if (res && req.id !== void 0 && req.id !== null) {
|
|
1348
|
+
process.stdout.write(JSON.stringify(res) + "\n");
|
|
1349
|
+
}
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
process.stderr.write(`[Esedre MCP Error] Failed to parse or process line: ${err.message}
|
|
1352
|
+
`);
|
|
1353
|
+
const errorRes = {
|
|
1354
|
+
jsonrpc: "2.0",
|
|
1355
|
+
id: null,
|
|
1356
|
+
error: { code: -32700, message: "Parse error: " + err.message }
|
|
1357
|
+
};
|
|
1358
|
+
process.stdout.write(JSON.stringify(errorRes) + "\n");
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
rl.on("close", () => {
|
|
1362
|
+
process.stderr.write("[Esedre MCP Server] Stdio closed. Exiting.\n");
|
|
1363
|
+
process.exit(0);
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
async handleRequest(req) {
|
|
1367
|
+
const id = req.id ?? null;
|
|
1368
|
+
switch (req.method) {
|
|
1369
|
+
case "initialize": {
|
|
1370
|
+
return {
|
|
1371
|
+
jsonrpc: "2.0",
|
|
1372
|
+
id,
|
|
1373
|
+
result: {
|
|
1374
|
+
protocolVersion: "2024-11-05",
|
|
1375
|
+
capabilities: {
|
|
1376
|
+
tools: {},
|
|
1377
|
+
resources: {}
|
|
1378
|
+
},
|
|
1379
|
+
serverInfo: {
|
|
1380
|
+
name: "esedre",
|
|
1381
|
+
version: "0.1.0"
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
case "notifications/initialized": {
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
case "ping": {
|
|
1390
|
+
return {
|
|
1391
|
+
jsonrpc: "2.0",
|
|
1392
|
+
id,
|
|
1393
|
+
result: {}
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
case "tools/list": {
|
|
1397
|
+
return {
|
|
1398
|
+
jsonrpc: "2.0",
|
|
1399
|
+
id,
|
|
1400
|
+
result: {
|
|
1401
|
+
tools: this.getToolDefinitions()
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
case "tools/call": {
|
|
1406
|
+
const toolName = req.params?.name;
|
|
1407
|
+
const args = req.params?.arguments || {};
|
|
1408
|
+
try {
|
|
1409
|
+
const content = await this.executeTool(toolName, args);
|
|
1410
|
+
return {
|
|
1411
|
+
jsonrpc: "2.0",
|
|
1412
|
+
id,
|
|
1413
|
+
result: {
|
|
1414
|
+
content: [
|
|
1415
|
+
{
|
|
1416
|
+
type: "text",
|
|
1417
|
+
text: typeof content === "string" ? content : JSON.stringify(content, null, 2)
|
|
1418
|
+
}
|
|
1419
|
+
]
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
if (err instanceof EsedreConflictError || err.name === "EsedreConflictError") {
|
|
1424
|
+
return {
|
|
1425
|
+
jsonrpc: "2.0",
|
|
1426
|
+
id,
|
|
1427
|
+
error: {
|
|
1428
|
+
code: -32e3,
|
|
1429
|
+
message: err.message,
|
|
1430
|
+
data: {
|
|
1431
|
+
ticketId: err.ticketId,
|
|
1432
|
+
currentHash: err.currentHash,
|
|
1433
|
+
lastHash: err.lastHash
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
jsonrpc: "2.0",
|
|
1440
|
+
id,
|
|
1441
|
+
error: {
|
|
1442
|
+
code: -32603,
|
|
1443
|
+
message: `Error executing ${toolName}: ${err.message}`
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
case "resources/list": {
|
|
1449
|
+
const tickets = await this.storage.listTickets();
|
|
1450
|
+
const resources = tickets.map((t) => ({
|
|
1451
|
+
uri: `esedre://tickets/${t.meta.id}`,
|
|
1452
|
+
name: `Ticket #${t.meta.id}: ${t.meta.title}`,
|
|
1453
|
+
description: `[${t.projectDescriptor?.code || t.meta.project || "UNASSIGNED"}] ${t.meta.type || t.meta.category} : ${t.meta.status}`,
|
|
1454
|
+
mimeType: "text/markdown"
|
|
1455
|
+
}));
|
|
1456
|
+
return {
|
|
1457
|
+
jsonrpc: "2.0",
|
|
1458
|
+
id,
|
|
1459
|
+
result: { resources }
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
case "resources/read": {
|
|
1463
|
+
const uri = req.params?.uri;
|
|
1464
|
+
if (!uri) {
|
|
1465
|
+
return {
|
|
1466
|
+
jsonrpc: "2.0",
|
|
1467
|
+
id,
|
|
1468
|
+
error: { code: -32602, message: "Resource URI is required" }
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
const match = uri.match(/^esedre:\/\/tickets\/([a-zA-Z0-9_-]+)$/);
|
|
1473
|
+
if (match) {
|
|
1474
|
+
const ticketId = match[1];
|
|
1475
|
+
const ticket = await this.storage.getTicket(ticketId);
|
|
1476
|
+
if (!ticket) {
|
|
1477
|
+
return {
|
|
1478
|
+
jsonrpc: "2.0",
|
|
1479
|
+
id,
|
|
1480
|
+
error: { code: -32602, message: `Ticket #${ticketId} not found` }
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const markdown = ticket.detail?.raw || `# Ticket #${ticket.meta.id}: ${ticket.meta.title}
|
|
1484
|
+
|
|
1485
|
+
Status: ${ticket.meta.status}`;
|
|
1486
|
+
return {
|
|
1487
|
+
jsonrpc: "2.0",
|
|
1488
|
+
id,
|
|
1489
|
+
result: {
|
|
1490
|
+
contents: [
|
|
1491
|
+
{
|
|
1492
|
+
uri,
|
|
1493
|
+
mimeType: "text/markdown",
|
|
1494
|
+
text: markdown
|
|
1495
|
+
}
|
|
1496
|
+
]
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
return {
|
|
1502
|
+
jsonrpc: "2.0",
|
|
1503
|
+
id,
|
|
1504
|
+
error: { code: -32603, message: err.message }
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
jsonrpc: "2.0",
|
|
1509
|
+
id,
|
|
1510
|
+
error: { code: -32602, message: `Unsupported URI scheme: ${uri}` }
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
default: {
|
|
1514
|
+
return {
|
|
1515
|
+
jsonrpc: "2.0",
|
|
1516
|
+
id,
|
|
1517
|
+
error: {
|
|
1518
|
+
code: -32601,
|
|
1519
|
+
message: `Method not found: ${req.method}`
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
getToolDefinitions() {
|
|
1526
|
+
return [
|
|
1527
|
+
{
|
|
1528
|
+
name: "esedre_list_tickets",
|
|
1529
|
+
description: "List roadmap tickets with optional project code, status, category, or search query.",
|
|
1530
|
+
inputSchema: {
|
|
1531
|
+
type: "object",
|
|
1532
|
+
properties: {
|
|
1533
|
+
project: { type: "string", description: "Project code (e.g. CORE, WEB, DOCS)" },
|
|
1534
|
+
status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"], description: "Ticket status" },
|
|
1535
|
+
category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket category" },
|
|
1536
|
+
search: { type: "string", description: "Search keywords in title or ID" }
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
name: "esedre_get_ticket",
|
|
1542
|
+
description: "Retrieve full details for a ticket including metadata, summary, feature breakdown, open decisions, and comments.",
|
|
1543
|
+
inputSchema: {
|
|
1544
|
+
type: "object",
|
|
1545
|
+
properties: {
|
|
1546
|
+
ticketId: { type: "integer", description: "Numeric ticket ID (e.g. 96, 101)" }
|
|
1547
|
+
},
|
|
1548
|
+
required: ["ticketId"]
|
|
1549
|
+
}
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
name: "esedre_get_plan",
|
|
1553
|
+
description: "Retrieve the active implementation plan markdown for a ticket.",
|
|
1554
|
+
inputSchema: {
|
|
1555
|
+
type: "object",
|
|
1556
|
+
properties: {
|
|
1557
|
+
ticketId: { type: "integer", description: "Numeric ticket ID" }
|
|
1558
|
+
},
|
|
1559
|
+
required: ["ticketId"]
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
name: "esedre_save_plan",
|
|
1564
|
+
description: "Save or update the implementation plan markdown for a ticket.",
|
|
1565
|
+
inputSchema: {
|
|
1566
|
+
type: "object",
|
|
1567
|
+
properties: {
|
|
1568
|
+
ticketId: { type: "integer", description: "Numeric ticket ID" },
|
|
1569
|
+
planMarkdown: { type: "string", description: "Implementation plan markdown content" },
|
|
1570
|
+
lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
|
|
1571
|
+
},
|
|
1572
|
+
required: ["ticketId", "planMarkdown"]
|
|
1573
|
+
}
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
name: "esedre_create_ticket",
|
|
1577
|
+
description: "Create a new roadmap ticket in Esedre.",
|
|
1578
|
+
inputSchema: {
|
|
1579
|
+
type: "object",
|
|
1580
|
+
properties: {
|
|
1581
|
+
title: { type: "string", description: "Ticket title (max 48 characters)" },
|
|
1582
|
+
project: { type: "string", description: "Target project code (e.g. CORE, WEB, DOCS)" },
|
|
1583
|
+
category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket category" },
|
|
1584
|
+
complexity: { type: "string", description: "Complexity (e.g. Low, Medium, High)" },
|
|
1585
|
+
effort: { type: "string", description: "Estimated effort (e.g. 2.0 \u2013 4.0 hours)" },
|
|
1586
|
+
summary: { type: "string", description: "Initial feature summary" },
|
|
1587
|
+
author: { type: "string", description: "Submitting author name" }
|
|
1588
|
+
},
|
|
1589
|
+
required: ["title", "category"]
|
|
1590
|
+
}
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
name: "esedre_update_ticket",
|
|
1594
|
+
description: "Update an existing ticket state (status, title, active planning flag, or feature flag).",
|
|
1595
|
+
inputSchema: {
|
|
1596
|
+
type: "object",
|
|
1597
|
+
properties: {
|
|
1598
|
+
ticketId: { type: "integer", description: "Numeric ticket ID" },
|
|
1599
|
+
status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"] },
|
|
1600
|
+
title: { type: "string", description: "New title (max 48 characters)" },
|
|
1601
|
+
inDevelopment: { type: "boolean", description: "Active development toggle" },
|
|
1602
|
+
featureFlag: { type: "string", description: "Feature flag name" },
|
|
1603
|
+
lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
|
|
1604
|
+
},
|
|
1605
|
+
required: ["ticketId"]
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
name: "esedre_add_comment",
|
|
1610
|
+
description: "Append a developer or companion agent comment to a ticket.",
|
|
1611
|
+
inputSchema: {
|
|
1612
|
+
type: "object",
|
|
1613
|
+
properties: {
|
|
1614
|
+
ticketId: { type: "integer", description: "Numeric ticket ID" },
|
|
1615
|
+
text: { type: "string", description: "Comment message" },
|
|
1616
|
+
author: { type: "string", description: "Author name (e.g. Antigravity, Developer)" }
|
|
1617
|
+
},
|
|
1618
|
+
required: ["ticketId", "text"]
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
];
|
|
1622
|
+
}
|
|
1623
|
+
async executeTool(name, args) {
|
|
1624
|
+
const canonical = name.replace(/^esedre_/, "");
|
|
1625
|
+
switch (canonical) {
|
|
1626
|
+
case "list_tickets": {
|
|
1627
|
+
const tickets = await this.storage.listTickets({
|
|
1628
|
+
project: args.project,
|
|
1629
|
+
status: args.status,
|
|
1630
|
+
category: args.category,
|
|
1631
|
+
search: args.search
|
|
1632
|
+
});
|
|
1633
|
+
return tickets.map((t) => ({
|
|
1634
|
+
id: t.meta.id,
|
|
1635
|
+
title: t.meta.title,
|
|
1636
|
+
category: t.meta.category,
|
|
1637
|
+
status: t.meta.status,
|
|
1638
|
+
complexity: t.meta.complexity,
|
|
1639
|
+
effort: t.meta.estimatedEffort,
|
|
1640
|
+
project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
|
|
1641
|
+
sha1: t.sha1 || t.meta.sha1
|
|
1642
|
+
}));
|
|
1643
|
+
}
|
|
1644
|
+
case "get_ticket": {
|
|
1645
|
+
const id = parseInt(String(args.ticketId), 10);
|
|
1646
|
+
const ticket = await this.storage.getTicket(id);
|
|
1647
|
+
if (!ticket) throw new Error(`Ticket #${id} not found`);
|
|
1648
|
+
return ticket;
|
|
1649
|
+
}
|
|
1650
|
+
case "get_plan": {
|
|
1651
|
+
const id = parseInt(String(args.ticketId), 10);
|
|
1652
|
+
const plan = await this.storage.getPlan(id);
|
|
1653
|
+
return { ticketId: id, planMarkdown: plan || null };
|
|
1654
|
+
}
|
|
1655
|
+
case "save_plan": {
|
|
1656
|
+
const id = parseInt(String(args.ticketId), 10);
|
|
1657
|
+
await this.storage.savePlan(id, args.planMarkdown, args.lastHash);
|
|
1658
|
+
return { success: true, message: `Implementation plan saved for Ticket #${id}` };
|
|
1659
|
+
}
|
|
1660
|
+
case "create_ticket": {
|
|
1661
|
+
const created = await this.storage.createTicket({
|
|
1662
|
+
title: args.title,
|
|
1663
|
+
type: args.type || args.category,
|
|
1664
|
+
category: args.type || args.category,
|
|
1665
|
+
projectCode: args.project,
|
|
1666
|
+
complexity: args.complexity,
|
|
1667
|
+
estimatedEffort: args.effort,
|
|
1668
|
+
summary: args.summary,
|
|
1669
|
+
submittedBy: args.author || "Agent"
|
|
1670
|
+
});
|
|
1671
|
+
return created;
|
|
1672
|
+
}
|
|
1673
|
+
case "update_ticket": {
|
|
1674
|
+
const id = parseInt(String(args.ticketId), 10);
|
|
1675
|
+
const updates = {};
|
|
1676
|
+
if (args.status) updates.status = args.status;
|
|
1677
|
+
if (args.title) updates.title = args.title;
|
|
1678
|
+
if (args.inDevelopment !== void 0) updates.isActivePlanning = Boolean(args.inDevelopment);
|
|
1679
|
+
if (args.featureFlag) updates.featureFlag = args.featureFlag;
|
|
1680
|
+
const updated = await this.storage.updateTicket(id, updates, args.lastHash);
|
|
1681
|
+
return updated;
|
|
1682
|
+
}
|
|
1683
|
+
case "add_comment": {
|
|
1684
|
+
const id = parseInt(String(args.ticketId), 10);
|
|
1685
|
+
const comment = await this.storage.addComment(id, {
|
|
1686
|
+
text: args.text,
|
|
1687
|
+
author: args.author || "Agent"
|
|
1688
|
+
});
|
|
1689
|
+
return comment;
|
|
1690
|
+
}
|
|
1691
|
+
default:
|
|
1692
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
// src/upgrade.ts
|
|
1698
|
+
import crypto2 from "node:crypto";
|
|
1699
|
+
import fs5 from "node:fs";
|
|
1700
|
+
import path5 from "node:path";
|
|
1701
|
+
|
|
1702
|
+
// src/server/daemon.ts
|
|
1703
|
+
import fs4 from "node:fs";
|
|
1704
|
+
import path4 from "node:path";
|
|
1705
|
+
import os from "node:os";
|
|
1706
|
+
import http from "node:http";
|
|
1707
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
1708
|
+
import { fileURLToPath } from "node:url";
|
|
1709
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
1710
|
+
var __dirname = path4.dirname(__filename);
|
|
1711
|
+
function getGlobalEsedreDir() {
|
|
1712
|
+
return path4.join(os.homedir(), ".esedre");
|
|
1713
|
+
}
|
|
1714
|
+
function ensureGlobalEsedreStore() {
|
|
1715
|
+
const globalDir = getGlobalEsedreDir();
|
|
1716
|
+
const runDir = path4.join(globalDir, "run");
|
|
1717
|
+
const logsDir = path4.join(globalDir, "logs");
|
|
1718
|
+
try {
|
|
1719
|
+
fs4.mkdirSync(runDir, { recursive: true });
|
|
1720
|
+
fs4.mkdirSync(logsDir, { recursive: true });
|
|
1721
|
+
try {
|
|
1722
|
+
const runEntries = fs4.readdirSync(runDir);
|
|
1723
|
+
for (const entry of runEntries) {
|
|
1724
|
+
if (entry.startsWith(".probe-")) {
|
|
1725
|
+
fs4.unlinkSync(path4.join(runDir, entry));
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
} catch {
|
|
1729
|
+
}
|
|
1730
|
+
const testProbe = path4.join(runDir, `.probe-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
1731
|
+
try {
|
|
1732
|
+
fs4.writeFileSync(testProbe, "ok", "utf-8");
|
|
1733
|
+
return { ok: true, path: globalDir };
|
|
1734
|
+
} finally {
|
|
1735
|
+
if (fs4.existsSync(testProbe)) {
|
|
1736
|
+
try {
|
|
1737
|
+
fs4.unlinkSync(testProbe);
|
|
1738
|
+
} catch {
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
return { ok: false, path: globalDir, error: err.message };
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
function resolveStoreDir(workspaceRoot) {
|
|
1747
|
+
const globalStore = ensureGlobalEsedreStore();
|
|
1748
|
+
if (globalStore.ok) {
|
|
1749
|
+
return globalStore.path;
|
|
1750
|
+
}
|
|
1751
|
+
if (workspaceRoot) {
|
|
1752
|
+
const localStore = path4.join(workspaceRoot, ".esedre");
|
|
1753
|
+
try {
|
|
1754
|
+
fs4.mkdirSync(path4.join(localStore, "run"), { recursive: true });
|
|
1755
|
+
fs4.mkdirSync(path4.join(localStore, "logs"), { recursive: true });
|
|
1756
|
+
return localStore;
|
|
1757
|
+
} catch {
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return globalStore.path;
|
|
1761
|
+
}
|
|
1762
|
+
function getDaemonStateFile(port, workspaceRoot) {
|
|
1763
|
+
const store = resolveStoreDir(workspaceRoot);
|
|
1764
|
+
return path4.join(store, "run", `daemon-${port}.json`);
|
|
1765
|
+
}
|
|
1766
|
+
function getDaemonLogFile(port, workspaceRoot) {
|
|
1767
|
+
const store = resolveStoreDir(workspaceRoot);
|
|
1768
|
+
return path4.join(store, "logs", `daemon-${port}.log`);
|
|
1769
|
+
}
|
|
1770
|
+
function getDaemonState(port, workspaceRoot) {
|
|
1771
|
+
const stateFile = getDaemonStateFile(port, workspaceRoot);
|
|
1772
|
+
if (!fs4.existsSync(stateFile)) {
|
|
1773
|
+
return null;
|
|
1774
|
+
}
|
|
1775
|
+
try {
|
|
1776
|
+
const raw = fs4.readFileSync(stateFile, "utf-8").trim();
|
|
1777
|
+
if (!raw) {
|
|
1778
|
+
fs4.unlinkSync(stateFile);
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
const state = JSON.parse(raw);
|
|
1782
|
+
if (!state || typeof state.pid !== "number" || typeof state.port !== "number") {
|
|
1783
|
+
fs4.unlinkSync(stateFile);
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
return state;
|
|
1787
|
+
} catch {
|
|
1788
|
+
try {
|
|
1789
|
+
fs4.unlinkSync(stateFile);
|
|
1790
|
+
} catch {
|
|
1791
|
+
}
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function isProcessAlive(pid) {
|
|
1796
|
+
try {
|
|
1797
|
+
process.kill(pid, 0);
|
|
1798
|
+
return true;
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
return err.code === "EPERM";
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
function pingDaemon(port, timeoutMs = 200) {
|
|
1804
|
+
return new Promise((resolve) => {
|
|
1805
|
+
const req = http.get(
|
|
1806
|
+
`http://127.0.0.1:${port}/api/planning/projects`,
|
|
1807
|
+
{ timeout: timeoutMs },
|
|
1808
|
+
(res) => {
|
|
1809
|
+
let body = "";
|
|
1810
|
+
res.on("data", (chunk) => {
|
|
1811
|
+
body += chunk;
|
|
1812
|
+
});
|
|
1813
|
+
res.on("end", () => {
|
|
1814
|
+
if (res.statusCode === 200) {
|
|
1815
|
+
try {
|
|
1816
|
+
const data = JSON.parse(body);
|
|
1817
|
+
if (Array.isArray(data)) {
|
|
1818
|
+
const projects = data.map((p) => p.code || p.name).filter(Boolean);
|
|
1819
|
+
resolve({ responding: true, isEsedre: true, projects });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
} catch {
|
|
1823
|
+
}
|
|
1824
|
+
resolve({ responding: true, isEsedre: false });
|
|
1825
|
+
} else {
|
|
1826
|
+
resolve({ responding: true, isEsedre: false });
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
);
|
|
1831
|
+
req.on("timeout", () => {
|
|
1832
|
+
req.destroy();
|
|
1833
|
+
resolve({ responding: false, isEsedre: false });
|
|
1834
|
+
});
|
|
1835
|
+
req.on("error", () => {
|
|
1836
|
+
resolve({ responding: false, isEsedre: false });
|
|
1837
|
+
});
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
async function startDaemon(options = {}) {
|
|
1841
|
+
const port = options.port || 5674;
|
|
1842
|
+
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
1843
|
+
const stateFile = getDaemonStateFile(port, workspaceRoot);
|
|
1844
|
+
const logFile = getDaemonLogFile(port, workspaceRoot);
|
|
1845
|
+
const existingState = getDaemonState(port, workspaceRoot);
|
|
1846
|
+
if (existingState) {
|
|
1847
|
+
if (isProcessAlive(existingState.pid)) {
|
|
1848
|
+
const ping = await pingDaemon(port, 400);
|
|
1849
|
+
if (ping.responding && ping.isEsedre) {
|
|
1850
|
+
if (!options.quiet) {
|
|
1851
|
+
console.log(`\x1B[36m\u26A1 Esedre daemon already running on http://localhost:${port} (PID ${existingState.pid})\x1B[0m`);
|
|
1852
|
+
}
|
|
1853
|
+
return existingState;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
try {
|
|
1857
|
+
fs4.unlinkSync(stateFile);
|
|
1858
|
+
} catch {
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
const portPing = await pingDaemon(port, 200);
|
|
1862
|
+
if (portPing.responding && portPing.isEsedre) {
|
|
1863
|
+
if (!options.quiet) {
|
|
1864
|
+
console.log(`\x1B[36m\u26A1 Esedre server already active on http://localhost:${port}\x1B[0m`);
|
|
1865
|
+
}
|
|
1866
|
+
return {
|
|
1867
|
+
pid: process.pid,
|
|
1868
|
+
port,
|
|
1869
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1870
|
+
version: "0.1.0",
|
|
1871
|
+
workspaceRoot,
|
|
1872
|
+
logFile
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
resolveStoreDir(workspaceRoot);
|
|
1876
|
+
fs4.mkdirSync(path4.dirname(logFile), { recursive: true });
|
|
1877
|
+
fs4.mkdirSync(path4.dirname(stateFile), { recursive: true });
|
|
1878
|
+
const logFd = fs4.openSync(logFile, "a");
|
|
1879
|
+
const isEsedreArgv1 = process.argv[1] && /esedre/i.test(path4.basename(process.argv[1]));
|
|
1880
|
+
const cliCandidates = [
|
|
1881
|
+
isEsedreArgv1 ? process.argv[1] : void 0,
|
|
1882
|
+
path4.resolve(workspaceRoot, "dist", "esedre.mjs"),
|
|
1883
|
+
path4.resolve(workspaceRoot, "../esedre/dist", "esedre.mjs"),
|
|
1884
|
+
path4.resolve(__dirname, "esedre.mjs"),
|
|
1885
|
+
path4.resolve(__dirname, "..", "dist", "esedre.mjs"),
|
|
1886
|
+
path4.resolve(__dirname, "..", "..", "dist", "esedre.mjs"),
|
|
1887
|
+
path4.resolve(__dirname, "..", "..", "bin", "esedre.js"),
|
|
1888
|
+
path4.resolve(__dirname, "..", "..", "bin", "esedre.ts"),
|
|
1889
|
+
path4.resolve(__dirname, "..", "esedre.mjs"),
|
|
1890
|
+
path4.resolve(__dirname, "../../../esedre/dist", "esedre.mjs")
|
|
1891
|
+
].filter(Boolean);
|
|
1892
|
+
const cliPath = cliCandidates.find((c) => fs4.existsSync(c) && (c.endsWith(".mjs") || c.endsWith(".js") || c.endsWith(".ts"))) || cliCandidates[0];
|
|
1893
|
+
if (!fs4.existsSync(cliPath)) {
|
|
1894
|
+
throw new Error(`Could not locate Esedre executable. Searched candidates: ${cliCandidates.join(", ")}`);
|
|
1895
|
+
}
|
|
1896
|
+
const child = spawn(process.execPath, [cliPath, "serve", "--port", String(port)], {
|
|
1897
|
+
detached: true,
|
|
1898
|
+
stdio: ["ignore", logFd, logFd],
|
|
1899
|
+
cwd: workspaceRoot,
|
|
1900
|
+
env: { ...process.env },
|
|
1901
|
+
windowsHide: true
|
|
1902
|
+
});
|
|
1903
|
+
child.unref();
|
|
1904
|
+
const pid = child.pid;
|
|
1905
|
+
if (!pid) {
|
|
1906
|
+
throw new Error("Failed to spawn Esedre daemon background process.");
|
|
1907
|
+
}
|
|
1908
|
+
const startTime = Date.now();
|
|
1909
|
+
let ready = false;
|
|
1910
|
+
while (Date.now() - startTime < 3500) {
|
|
1911
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1912
|
+
const ping = await pingDaemon(port, 150);
|
|
1913
|
+
if (ping.responding && ping.isEsedre) {
|
|
1914
|
+
ready = true;
|
|
1915
|
+
break;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (!ready) {
|
|
1919
|
+
try {
|
|
1920
|
+
if (process.platform === "win32") {
|
|
1921
|
+
spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { shell: false });
|
|
1922
|
+
} else {
|
|
1923
|
+
process.kill(pid, "SIGTERM");
|
|
1924
|
+
}
|
|
1925
|
+
} catch {
|
|
1926
|
+
}
|
|
1927
|
+
throw new Error(`Esedre daemon failed to start on port ${port} within timeout. Check logs at: ${logFile}`);
|
|
1928
|
+
}
|
|
1929
|
+
const state = {
|
|
1930
|
+
pid,
|
|
1931
|
+
port,
|
|
1932
|
+
uiPort: port + 1,
|
|
1933
|
+
apiPort: port + 2,
|
|
1934
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1935
|
+
version: "0.1.0",
|
|
1936
|
+
workspaceRoot,
|
|
1937
|
+
logFile
|
|
1938
|
+
};
|
|
1939
|
+
const tmpState = `${stateFile}.tmp.${Date.now()}`;
|
|
1940
|
+
fs4.writeFileSync(tmpState, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
1941
|
+
try {
|
|
1942
|
+
fs4.renameSync(tmpState, stateFile);
|
|
1943
|
+
} catch {
|
|
1944
|
+
fs4.writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
1945
|
+
try {
|
|
1946
|
+
fs4.unlinkSync(tmpState);
|
|
1947
|
+
} catch {
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (!options.quiet) {
|
|
1951
|
+
console.log(`\x1B[32m\u2714 Started Esedre background daemon on http://localhost:${port} (PID ${pid})\x1B[0m`);
|
|
1952
|
+
console.log(` \u2022 Web UI: http://localhost:${port}`);
|
|
1953
|
+
console.log(` \u2022 Logs: ${logFile}`);
|
|
1954
|
+
}
|
|
1955
|
+
return state;
|
|
1956
|
+
}
|
|
1957
|
+
async function stopDaemon(options = {}) {
|
|
1958
|
+
const port = options.port || 5674;
|
|
1959
|
+
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
1960
|
+
const stateFile = getDaemonStateFile(port, workspaceRoot);
|
|
1961
|
+
const state = getDaemonState(port, workspaceRoot);
|
|
1962
|
+
let pid = state?.pid;
|
|
1963
|
+
if (!pid) {
|
|
1964
|
+
const ping = await pingDaemon(port, 200);
|
|
1965
|
+
if (!ping.responding) {
|
|
1966
|
+
if (!options.quiet) {
|
|
1967
|
+
console.log(`\x1B[33m\u25CB Esedre daemon is not running on port ${port}.\x1B[0m`);
|
|
1968
|
+
}
|
|
1969
|
+
return false;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
if (pid && isProcessAlive(pid)) {
|
|
1973
|
+
try {
|
|
1974
|
+
if (process.platform === "win32") {
|
|
1975
|
+
spawnSync("taskkill", ["/pid", String(pid), "/f", "/t"], { shell: false, stdio: "ignore" });
|
|
1976
|
+
} else {
|
|
1977
|
+
process.kill(pid, "SIGTERM");
|
|
1978
|
+
}
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
console.warn(`Warning terminating PID ${pid}: ${err.message}`);
|
|
1981
|
+
}
|
|
1982
|
+
const stopStart = Date.now();
|
|
1983
|
+
while (isProcessAlive(pid) && Date.now() - stopStart < 2e3) {
|
|
1984
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
try {
|
|
1988
|
+
if (fs4.existsSync(stateFile)) {
|
|
1989
|
+
fs4.unlinkSync(stateFile);
|
|
1990
|
+
}
|
|
1991
|
+
} catch {
|
|
1992
|
+
}
|
|
1993
|
+
if (!options.quiet) {
|
|
1994
|
+
console.log(`\x1B[32m\u2714 Stopped Esedre daemon on port ${port}${pid ? ` (PID ${pid})` : ""}\x1B[0m`);
|
|
1995
|
+
}
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1998
|
+
async function getDaemonStatus(options = {}) {
|
|
1999
|
+
const port = options.port || 5674;
|
|
2000
|
+
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
2001
|
+
const state = getDaemonState(port, workspaceRoot);
|
|
2002
|
+
const stateFile = getDaemonStateFile(port, workspaceRoot);
|
|
2003
|
+
const logFile = getDaemonLogFile(port, workspaceRoot);
|
|
2004
|
+
const ping = await pingDaemon(port, 300);
|
|
2005
|
+
if (state && isProcessAlive(state.pid) && ping.responding && ping.isEsedre) {
|
|
2006
|
+
const startedMs = Date.parse(state.startedAt) || Date.now();
|
|
2007
|
+
const uptimeSeconds = Math.max(0, Math.floor((Date.now() - startedMs) / 1e3));
|
|
2008
|
+
return {
|
|
2009
|
+
running: true,
|
|
2010
|
+
pid: state.pid,
|
|
2011
|
+
port: state.port,
|
|
2012
|
+
uptimeSeconds,
|
|
2013
|
+
startedAt: state.startedAt,
|
|
2014
|
+
workspaceRoot: state.workspaceRoot,
|
|
2015
|
+
projects: ping.projects,
|
|
2016
|
+
logFile: state.logFile || logFile
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
if (state && !isProcessAlive(state.pid)) {
|
|
2020
|
+
try {
|
|
2021
|
+
fs4.unlinkSync(stateFile);
|
|
2022
|
+
} catch {
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return {
|
|
2026
|
+
running: ping.responding && ping.isEsedre,
|
|
2027
|
+
port,
|
|
2028
|
+
projects: ping.projects,
|
|
2029
|
+
logFile
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
function printDaemonLogs(options = {}) {
|
|
2033
|
+
const port = options.port || 5674;
|
|
2034
|
+
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
2035
|
+
const logFile = getDaemonLogFile(port, workspaceRoot);
|
|
2036
|
+
if (!fs4.existsSync(logFile)) {
|
|
2037
|
+
console.log(`\x1B[33mNo logs found at: ${logFile}\x1B[0m`);
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
const content = fs4.readFileSync(logFile, "utf-8");
|
|
2041
|
+
const count = options.lines || 40;
|
|
2042
|
+
const allLines = content.split(/\r?\n/);
|
|
2043
|
+
const tail = allLines.slice(-count).join("\n");
|
|
2044
|
+
console.log(`\x1B[36m--- Esedre Daemon Logs (${logFile}) ---\x1B[0m
|
|
2045
|
+
`);
|
|
2046
|
+
console.log(tail);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// src/upgrade.ts
|
|
2050
|
+
var CURRENT_ESEDRE_VERSION = "0.1.3";
|
|
2051
|
+
function computeNormalizedHash(content) {
|
|
2052
|
+
const normalized = content.replace(/\r\n/g, "\n").trim();
|
|
2053
|
+
return crypto2.createHash("sha1").update(normalized, "utf-8").digest("hex");
|
|
2054
|
+
}
|
|
2055
|
+
function classifyContent(currentContent, latestTemplate, historicHashes) {
|
|
2056
|
+
if (!currentContent || currentContent.trim().length === 0) {
|
|
2057
|
+
return "MISSING";
|
|
2058
|
+
}
|
|
2059
|
+
const currentHash = computeNormalizedHash(currentContent);
|
|
2060
|
+
const latestHash = computeNormalizedHash(latestTemplate);
|
|
2061
|
+
if (currentHash === latestHash) {
|
|
2062
|
+
return "LATEST";
|
|
2063
|
+
}
|
|
2064
|
+
if (historicHashes.includes(currentHash)) {
|
|
2065
|
+
return "HISTORIC_DEFAULT";
|
|
2066
|
+
}
|
|
2067
|
+
return "CUSTOMIZED";
|
|
2068
|
+
}
|
|
2069
|
+
var WRAPPER_CMD = `@echo off
|
|
2070
|
+
REM Esedre Autonomous Ticketing & Project Planning Engine Wrapper (Windows CMD)
|
|
2071
|
+
setlocal
|
|
2072
|
+
where ese >nul 2>nul
|
|
2073
|
+
if %ERRORLEVEL% equ 0 (
|
|
2074
|
+
ese %*
|
|
2075
|
+
goto :done
|
|
2076
|
+
)
|
|
2077
|
+
where esedre >nul 2>nul
|
|
2078
|
+
if %ERRORLEVEL% equ 0 (
|
|
2079
|
+
esedre %*
|
|
2080
|
+
goto :done
|
|
2081
|
+
)
|
|
2082
|
+
if exist "%~dp0..\\..\\esedre\\dist\\esedre.mjs" (
|
|
2083
|
+
node "%~dp0..\\..\\esedre\\dist\\esedre.mjs" %*
|
|
2084
|
+
goto :done
|
|
2085
|
+
)
|
|
2086
|
+
if exist "%~dp0..\\esedre\\dist\\esedre.mjs" (
|
|
2087
|
+
node "%~dp0..\\esedre\\dist\\esedre.mjs" %*
|
|
2088
|
+
goto :done
|
|
2089
|
+
)
|
|
2090
|
+
if exist "%~dp0..\\node_modules\\.bin\\ese.cmd" (
|
|
2091
|
+
call "%~dp0..\\node_modules\\.bin\\ese.cmd" %*
|
|
2092
|
+
goto :done
|
|
2093
|
+
)
|
|
2094
|
+
if exist "%~dp0..\\node_modules\\.bin\\esedre.cmd" (
|
|
2095
|
+
call "%~dp0..\\node_modules\\.bin\\esedre.cmd" %*
|
|
2096
|
+
goto :done
|
|
2097
|
+
)
|
|
2098
|
+
npx --yes esedre %*
|
|
2099
|
+
|
|
2100
|
+
:done
|
|
2101
|
+
endlocal
|
|
2102
|
+
exit /b %ERRORLEVEL%
|
|
2103
|
+
`;
|
|
2104
|
+
var WRAPPER_PS1 = `# Esedre Autonomous Ticketing & Project Planning Engine Wrapper (PowerShell)
|
|
2105
|
+
$ErrorActionPreference = "Stop"
|
|
2106
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
2107
|
+
|
|
2108
|
+
if (Get-Command "ese" -ErrorAction SilentlyContinue) {
|
|
2109
|
+
& ese @args
|
|
2110
|
+
exit $LASTEXITCODE
|
|
2111
|
+
}
|
|
2112
|
+
if (Get-Command "esedre" -ErrorAction SilentlyContinue) {
|
|
2113
|
+
& esedre @args
|
|
2114
|
+
exit $LASTEXITCODE
|
|
2115
|
+
}
|
|
2116
|
+
$siblingEsedre = Join-Path $scriptDir "..\\..\\esedre\\dist\\esedre.mjs"
|
|
2117
|
+
if (Test-Path $siblingEsedre) {
|
|
2118
|
+
& node $siblingEsedre @args
|
|
2119
|
+
exit $LASTEXITCODE
|
|
2120
|
+
}
|
|
2121
|
+
$distEsedre = Join-Path $scriptDir "..\\esedre\\dist\\esedre.mjs"
|
|
2122
|
+
if (Test-Path $distEsedre) {
|
|
2123
|
+
& node $distEsedre @args
|
|
2124
|
+
exit $LASTEXITCODE
|
|
2125
|
+
}
|
|
2126
|
+
$localBin = Join-Path $scriptDir "..\\node_modules\\.bin\\ese.cmd"
|
|
2127
|
+
if (Test-Path $localBin) {
|
|
2128
|
+
& $localBin @args
|
|
2129
|
+
exit $LASTEXITCODE
|
|
2130
|
+
}
|
|
2131
|
+
$localEsedreBin = Join-Path $scriptDir "..\\node_modules\\.bin\\esedre.cmd"
|
|
2132
|
+
if (Test-Path $localEsedreBin) {
|
|
2133
|
+
& $localEsedreBin @args
|
|
2134
|
+
exit $LASTEXITCODE
|
|
2135
|
+
}
|
|
2136
|
+
& npx --yes esedre @args
|
|
2137
|
+
exit $LASTEXITCODE
|
|
2138
|
+
`;
|
|
2139
|
+
var WRAPPER_BASH = `#!/usr/bin/env bash
|
|
2140
|
+
# Esedre Autonomous Ticketing & Project Planning Engine Wrapper (POSIX)
|
|
2141
|
+
set -e
|
|
2142
|
+
DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
|
2143
|
+
|
|
2144
|
+
if command -v ese >/dev/null 2>&1; then
|
|
2145
|
+
exec ese "$@"
|
|
2146
|
+
fi
|
|
2147
|
+
if command -v esedre >/dev/null 2>&1; then
|
|
2148
|
+
exec esedre "$@"
|
|
2149
|
+
fi
|
|
2150
|
+
if [ -f "$DIR/../../esedre/dist/esedre.mjs" ]; then
|
|
2151
|
+
exec node "$DIR/../../esedre/dist/esedre.mjs" "$@"
|
|
2152
|
+
fi
|
|
2153
|
+
if [ -f "$DIR/../esedre/dist/esedre.mjs" ]; then
|
|
2154
|
+
exec node "$DIR/../esedre/dist/esedre.mjs" "$@"
|
|
2155
|
+
fi
|
|
2156
|
+
if [ -f "$DIR/../node_modules/.bin/ese" ]; then
|
|
2157
|
+
exec "$DIR/../node_modules/.bin/ese" "$@"
|
|
2158
|
+
fi
|
|
2159
|
+
if [ -f "$DIR/../node_modules/.bin/esedre" ]; then
|
|
2160
|
+
exec "$DIR/../node_modules/.bin/esedre" "$@"
|
|
2161
|
+
fi
|
|
2162
|
+
exec npx --yes esedre "$@"
|
|
2163
|
+
`;
|
|
2164
|
+
var ESE_WRAPPER_CMD = `@echo off
|
|
2165
|
+
REM Ese CLI short alias wrapper for Esedre (Windows CMD)
|
|
2166
|
+
call "%~dp0esedre.cmd" %*
|
|
2167
|
+
`;
|
|
2168
|
+
var ESE_WRAPPER_PS1 = `# Ese CLI short alias wrapper for Esedre (PowerShell)
|
|
2169
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
2170
|
+
& (Join-Path $scriptDir "esedre.ps1") @args
|
|
2171
|
+
exit $LASTEXITCODE
|
|
2172
|
+
`;
|
|
2173
|
+
var ESE_WRAPPER_BASH = `#!/usr/bin/env bash
|
|
2174
|
+
# Ese CLI short alias wrapper for Esedre (POSIX)
|
|
2175
|
+
DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
|
2176
|
+
exec "$DIR/esedre" "$@"
|
|
2177
|
+
`;
|
|
2178
|
+
var ESEDRE_SKILL_TEMPLATE = '---\nname: esedre\ndescription: Tooling and workflow reference for interacting with the Esedre developer ticketing and roadmap system. Use this skill whenever inspecting planned work, viewing ticket specs, updating implementation plans, or posting developer notes.\n---\n\n# Esedre Planning & Roadmap Workflow\n\nEsedre is the developer ticketing and companion LLM coding agent coordination platform. The engine codebase resides in the standalone repository `aellinsar/esedre` (`../esedre`), and operates on decoupled ticket repositories (such as `aellinsar/esedre-data` configured in `.esedre/esedre.json`). In `ProfessorArwamSleepCenter`, interact with tickets via the compiled bridge artifact `tools/esedre.mjs` (`node tools/esedre.mjs <command>`), in-repo shell wrappers in `.esedre/` (`.esedre/ese`), or the global `ese` CLI.\n\n## 0. Instant Zero-Latency Context (`.esedre/snapshot.json`)\n\nBefore querying tickets over the network or CLI, check `.esedre/snapshot.json` in the workspace root. It provides a local read-only projection containing all active and completed tickets, summaries, implementation plans, revision numbers, completion dates (`completedAt`), and staleness metrics (`daysSinceUpdate`).\n\n## 1. Model Context Protocol (MCP) Tools\n\nWhen Esedre MCP is active in your agent session (`.agents/mcp_config.json`), use these tools directly:\n\n| Tool | Purpose | Key Arguments |\n|---|---|---|\n| `esedre_list_tickets` | List roadmap tickets with filters | `project` (e.g. `Profe`, `Esedre`, `Alce`), `status`, `type`, `search` |\n| `esedre_get_ticket` | Full specification, summary, comments, revision & sha1 | `ticketId` (numeric or compound e.g. `Profe-35`) |\n| `esedre_get_plan` | Active implementation plan markdown | `ticketId` (numeric or compound) |\n| `esedre_save_plan` | Save implementation plan markdown with OCC | `ticketId`, `planMarkdown`, `lastHash` |\n| `esedre_create_ticket` | Mint a new ticket with auto sequential ID | `title` (max 48 chars), `type`, `project`, `effort`, `summary` |\n| `esedre_update_ticket` | Update ticket attributes with OCC | `ticketId`, `status`, `title`, `inDevelopment`, `featureFlag`, `lastHash` |\n\n## 2. Core CLI Commands (Fallback via `run_command`)\n\nBoth `esedre` and `ese` work interchangeably:\n\n### List Tickets\n```bash\n.esedre/ese list [-p|--project <code|all>] [-s|--status <status>] [-t|--type <type>] [-q|--search <q>] [--json]\n```\n\n### Read Ticket Details\n```bash\n.esedre/ese get <ticketId> [--json]\n```\n\n### Read or Update Implementation Plans\n```bash\n.esedre/ese plan <ticketId>\n.esedre/ese plan <ticketId> --file <planFilePath>\n.esedre/ese plan <ticketId> --set "<markdownContent>" [--last-hash <hash>]\n```\n\n### Create a Ticket\n```bash\n.esedre/ese create --title "..." [-p|--project Profe] [-t|--type Feature] [--complexity Medium] [--effort "2.0 - 4.0 hours"] [--json]\n```\n\n### Update Ticket Status\n```bash\n.esedre/ese update <ticketId> --status "In Development"\n.esedre/ese update <ticketId> --status "Completed"\n```\n\n### Append Comments or Notes\n```bash\n.esedre/ese comment <ticketId> --text "Verified implementation." --author "Antigravity"\n```\n\n### Regenerate Snapshot\n```bash\n.esedre/ese snapshot\n```\n\n### Background Daemon Management\n```bash\n# Start background daemon on port 5674 (idempotent)\n.esedre/ese daemon start [--port 5674] [--quiet] [--json]\n\n# Check daemon health and diagnostics\n.esedre/ese daemon status [--port 5674] [--json]\n\n# View recent daemon activity logs\n.esedre/ese daemon logs\n\n# Stop background daemon\n.esedre/ese daemon stop [--port 5674] [--quiet] [--json]\n```\n\n### Foreground Gateway Server\n```bash\n.esedre/ese serve [--port 5674]\n```\n- Starts the unified gateway on port 5674, routing `/app` to Esedre UI (port 5675) and `/api` to Esedre REST API (port 5676).\n- Transparently rewrites `/` to `/app/`.\n\n### Workspace Configuration & Vite Proxy Setup\n```bash\n.esedre/ese configure [--project <code>] [--name <name>] [--port <n>] [--proxy] [--no-proxy] [-y]\n```\n- Automatically detects `vite.config.ts`/`vite.config.js`. In interactive mode or with `--proxy`, configures a reverse proxy for `/esedre` targeting port 5674 (`http://127.0.0.1:5674`).\n- If Vite is absent or declined, displays the recommended reverse proxy block for embedding the planner UI in the host app.\n\n## 3. Embedded View & Theme Token Contract\n\nWhen embedding `<PlannedWorkView />` inside a host app (e.g. `PlannedWorkModal.tsx`):\n\n### Zero-Effort Drop-in (Default Fallback)\n`PlannedWorkView` incorporates an internal CSS fallback bridge (`.esedre-host-bridge` / `.esedre-theme-root` with `ESEDRE_THEME_FALLBACK_CSS`).\nIf an embedding application supplies **zero CSS variables**, the component automatically falls back to clean, high-contrast light or dark themes matching the user\'s OS preference (`prefers-color-scheme: dark`) or host `.dark` / `[data-theme="night"]` classes.\n\n### Theme Token Customization\nIf the host application declares any or all of the 12 core design tokens on `:root` or an ancestor container, `PlannedWorkView` automatically adopts them:\n- **Surface**: `--bg-surface`, `--bg-surface-elevated`, `--bg-input`\n- **Borders**: `--border-subtle`, `--border-strong`, `--border-accent`\n- **Text**: `--text-primary`, `--text-secondary`, `--text-muted`\n- **Accent**: `--accent-primary`, `--accent-bg-subtle`, `--accent-border-subtle`\n\n## 4. Safety & Invariants\n- **Always use `--json`** for CLI programmatic inspection.\n- **Optimistic Concurrency**: Writes support `--last-hash <hash>` to prevent overwriting concurrent updates.\n- **Universal Type Naming**: Always use `type` (`Feature`, `Platform`, `Tools`, `Idea`, `Bug`). The legacy name `category` is deprecated.\n- **Compound IDs for Multi-Project Portfolios**: In `project=all`, ticket IDs are `${projectCode}-${id}` (e.g. `Profe-1`, `Esedre-1`). DOM anchors strictly use `feature-card-${projectCode}-${id}`.\n- **Never delete tickets** directly via filesystem.\n- **Agent Project Allow-List**: Providing isolation of projects planning that you don\'t want an agent to access. A repository only accesses projects declared in its `.esedre/esedre.json` (`allowedProjects`). Unauthorized cross-project access is strictly blocked.\n- **Secret Developer Hash Route (`#/planner`, `#/plan`, `#planner`, `#plan`)**:\n - In web applications embedding Esedre (such as Professor Arwam), `#/planner` and `#/plan` serve as direct secret developer entry routes.\n - **SEO Invariant**: These developer routes are private and MUST NEVER be exposed in `sitemap.xml`, `robots.txt`, `llms.txt`, or public navigation links.\n - The URL hash is preserved across hard page refreshes (F5) without falling back to `#/chat` or other views.\n';
|
|
2179
|
+
function plantWrappers(targetDir) {
|
|
2180
|
+
const esedreDir = path5.join(targetDir, ".esedre");
|
|
2181
|
+
fs5.mkdirSync(esedreDir, { recursive: true });
|
|
2182
|
+
const wrappers = [
|
|
2183
|
+
{ name: "esedre.cmd", content: WRAPPER_CMD, exec: false },
|
|
2184
|
+
{ name: "esedre.ps1", content: WRAPPER_PS1, exec: false },
|
|
2185
|
+
{ name: "esedre", content: WRAPPER_BASH, exec: true },
|
|
2186
|
+
{ name: "ese.cmd", content: ESE_WRAPPER_CMD, exec: false },
|
|
2187
|
+
{ name: "ese.ps1", content: ESE_WRAPPER_PS1, exec: false },
|
|
2188
|
+
{ name: "ese", content: ESE_WRAPPER_BASH, exec: true }
|
|
2189
|
+
];
|
|
2190
|
+
for (const w of wrappers) {
|
|
2191
|
+
const fullPath = path5.join(esedreDir, w.name);
|
|
2192
|
+
fs5.writeFileSync(fullPath, w.content, "utf-8");
|
|
2193
|
+
if (w.exec) {
|
|
2194
|
+
try {
|
|
2195
|
+
fs5.chmodSync(fullPath, 493);
|
|
2196
|
+
} catch {
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
function setupMcpConfig(targetDir) {
|
|
2202
|
+
const agentsDir = path5.join(targetDir, ".agents");
|
|
2203
|
+
fs5.mkdirSync(agentsDir, { recursive: true });
|
|
2204
|
+
const mcpConfigPath = path5.join(agentsDir, "mcp_config.json");
|
|
2205
|
+
let configObj = { mcpServers: {} };
|
|
2206
|
+
if (fs5.existsSync(mcpConfigPath)) {
|
|
2207
|
+
try {
|
|
2208
|
+
configObj = JSON.parse(fs5.readFileSync(mcpConfigPath, "utf-8"));
|
|
2209
|
+
if (!configObj.mcpServers) configObj.mcpServers = {};
|
|
2210
|
+
} catch {
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if (process.platform === "win32") {
|
|
2214
|
+
configObj.mcpServers.esedre = {
|
|
2215
|
+
command: "cmd.exe",
|
|
2216
|
+
args: ["/c", ".esedre\\esedre.cmd", "mcp"]
|
|
2217
|
+
};
|
|
2218
|
+
} else {
|
|
2219
|
+
configObj.mcpServers.esedre = {
|
|
2220
|
+
command: ".esedre/esedre",
|
|
2221
|
+
args: ["mcp"]
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
try {
|
|
2225
|
+
fs5.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
|
|
2226
|
+
return true;
|
|
2227
|
+
} catch {
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
function setupSkill(targetDir) {
|
|
2232
|
+
const skillDir = path5.join(targetDir, ".agents", "skills", "esedre");
|
|
2233
|
+
fs5.mkdirSync(skillDir, { recursive: true });
|
|
2234
|
+
const skillFile = path5.join(skillDir, "SKILL.md");
|
|
2235
|
+
if (!fs5.existsSync(skillFile)) {
|
|
2236
|
+
fs5.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
|
|
2237
|
+
return { status: "MISSING", updated: true };
|
|
2238
|
+
}
|
|
2239
|
+
const currentContent = fs5.readFileSync(skillFile, "utf-8");
|
|
2240
|
+
const status = classifyContent(currentContent, ESEDRE_SKILL_TEMPLATE, []);
|
|
2241
|
+
if (status === "LATEST") {
|
|
2242
|
+
return { status, updated: false };
|
|
2243
|
+
}
|
|
2244
|
+
if (status === "HISTORIC_DEFAULT" || status === "MISSING") {
|
|
2245
|
+
fs5.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
|
|
2246
|
+
return { status, updated: true };
|
|
2247
|
+
}
|
|
2248
|
+
return { status, updated: false };
|
|
2249
|
+
}
|
|
2250
|
+
var VITE_CONFIG_FILES = [
|
|
2251
|
+
"vite.config.ts",
|
|
2252
|
+
"vite.config.js",
|
|
2253
|
+
"vite.config.mjs",
|
|
2254
|
+
"vite.config.mts",
|
|
2255
|
+
"vite.config.cjs"
|
|
2256
|
+
];
|
|
2257
|
+
function findViteConfig(targetDir) {
|
|
2258
|
+
for (const name of VITE_CONFIG_FILES) {
|
|
2259
|
+
const fullPath = path5.join(targetDir, name);
|
|
2260
|
+
if (fs5.existsSync(fullPath)) {
|
|
2261
|
+
return fullPath;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
function getRecommendedViteProxySnippet(port = DEFAULT_ESEDRE_PORT) {
|
|
2267
|
+
return `proxy: {
|
|
2268
|
+
'/esedre': {
|
|
2269
|
+
target: 'http://127.0.0.1:${port}',
|
|
2270
|
+
changeOrigin: true,
|
|
2271
|
+
rewrite: (path) => path.replace(/^\\/esedre/, ''),
|
|
2272
|
+
},
|
|
2273
|
+
}`;
|
|
2274
|
+
}
|
|
2275
|
+
function detectLineEnding(content) {
|
|
2276
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
2277
|
+
}
|
|
2278
|
+
function injectViteProxy(content, port = DEFAULT_ESEDRE_PORT) {
|
|
2279
|
+
if (content.includes("'/esedre'") || content.includes('"/esedre"')) {
|
|
2280
|
+
return content;
|
|
2281
|
+
}
|
|
2282
|
+
const eol = detectLineEnding(content);
|
|
2283
|
+
const proxyMatch = content.match(/(proxy\s*:\s*\{)/);
|
|
2284
|
+
if (proxyMatch && proxyMatch.index !== void 0) {
|
|
2285
|
+
const insertIdx = proxyMatch.index + proxyMatch[0].length;
|
|
2286
|
+
const snippet = `${eol} '/esedre': {${eol} target: 'http://127.0.0.1:${port}',${eol} changeOrigin: true,${eol} rewrite: (path) => path.replace(/^\\/esedre/, ''),${eol} },`;
|
|
2287
|
+
return content.slice(0, insertIdx) + snippet + content.slice(insertIdx);
|
|
2288
|
+
}
|
|
2289
|
+
const serverMatch = content.match(/(server\s*:\s*\{)/);
|
|
2290
|
+
if (serverMatch && serverMatch.index !== void 0) {
|
|
2291
|
+
const insertIdx = serverMatch.index + serverMatch[0].length;
|
|
2292
|
+
const snippet = `${eol} proxy: {${eol} '/esedre': {${eol} target: 'http://127.0.0.1:${port}',${eol} changeOrigin: true,${eol} rewrite: (path) => path.replace(/^\\/esedre/, ''),${eol} },${eol} },`;
|
|
2293
|
+
return content.slice(0, insertIdx) + snippet + content.slice(insertIdx);
|
|
2294
|
+
}
|
|
2295
|
+
const returnMatch = content.match(/(return\s*\{)/);
|
|
2296
|
+
if (returnMatch && returnMatch.index !== void 0) {
|
|
2297
|
+
const insertIdx = returnMatch.index + returnMatch[0].length;
|
|
2298
|
+
const snippet = `${eol} server: {${eol} proxy: {${eol} '/esedre': {${eol} target: 'http://127.0.0.1:${port}',${eol} changeOrigin: true,${eol} rewrite: (path) => path.replace(/^\\/esedre/, ''),${eol} },${eol} },${eol} },`;
|
|
2299
|
+
return content.slice(0, insertIdx) + snippet + content.slice(insertIdx);
|
|
2300
|
+
}
|
|
2301
|
+
const defineMatch = content.match(/(defineConfig\s*\(\s*\{)/);
|
|
2302
|
+
if (defineMatch && defineMatch.index !== void 0) {
|
|
2303
|
+
const insertIdx = defineMatch.index + defineMatch[0].length;
|
|
2304
|
+
const snippet = `${eol} server: {${eol} proxy: {${eol} '/esedre': {${eol} target: 'http://127.0.0.1:${port}',${eol} changeOrigin: true,${eol} rewrite: (path) => path.replace(/^\\/esedre/, ''),${eol} },${eol} },${eol} },`;
|
|
2305
|
+
return content.slice(0, insertIdx) + snippet + content.slice(insertIdx);
|
|
2306
|
+
}
|
|
2307
|
+
const exportMatch = content.match(/(export\s+default\s*\{)/);
|
|
2308
|
+
if (exportMatch && exportMatch.index !== void 0) {
|
|
2309
|
+
const insertIdx = exportMatch.index + exportMatch[0].length;
|
|
2310
|
+
const snippet = `${eol} server: {${eol} proxy: {${eol} '/esedre': {${eol} target: 'http://127.0.0.1:${port}',${eol} changeOrigin: true,${eol} rewrite: (path) => path.replace(/^\\/esedre/, ''),${eol} },${eol} },${eol} },`;
|
|
2311
|
+
return content.slice(0, insertIdx) + snippet + content.slice(insertIdx);
|
|
2312
|
+
}
|
|
2313
|
+
return null;
|
|
2314
|
+
}
|
|
2315
|
+
function configureViteProxy(targetDir, port = DEFAULT_ESEDRE_PORT, setupProxy) {
|
|
2316
|
+
const configPath = findViteConfig(targetDir);
|
|
2317
|
+
const snippet = getRecommendedViteProxySnippet(port);
|
|
2318
|
+
if (!configPath) {
|
|
2319
|
+
return {
|
|
2320
|
+
status: "NOT_APPLICABLE",
|
|
2321
|
+
recommendedProxySnippet: snippet
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
const relativeName = path5.basename(configPath);
|
|
2325
|
+
let content = "";
|
|
2326
|
+
try {
|
|
2327
|
+
content = fs5.readFileSync(configPath, "utf-8");
|
|
2328
|
+
} catch {
|
|
2329
|
+
return {
|
|
2330
|
+
status: "MANUAL_REQUIRED",
|
|
2331
|
+
configFile: relativeName,
|
|
2332
|
+
recommendedProxySnippet: snippet
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
if (content.includes("'/esedre'") || content.includes('"/esedre"')) {
|
|
2336
|
+
return {
|
|
2337
|
+
status: "ALREADY_CONFIGURED",
|
|
2338
|
+
configFile: relativeName,
|
|
2339
|
+
recommendedProxySnippet: snippet
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
if (setupProxy !== true) {
|
|
2343
|
+
return {
|
|
2344
|
+
status: "SKIPPED",
|
|
2345
|
+
configFile: relativeName,
|
|
2346
|
+
recommendedProxySnippet: snippet
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
const injected = injectViteProxy(content, port);
|
|
2350
|
+
if (!injected) {
|
|
2351
|
+
return {
|
|
2352
|
+
status: "MANUAL_REQUIRED",
|
|
2353
|
+
configFile: relativeName,
|
|
2354
|
+
recommendedProxySnippet: snippet
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
try {
|
|
2358
|
+
fs5.writeFileSync(configPath, injected, "utf-8");
|
|
2359
|
+
return {
|
|
2360
|
+
status: "CONFIGURED",
|
|
2361
|
+
configFile: relativeName,
|
|
2362
|
+
recommendedProxySnippet: snippet
|
|
2363
|
+
};
|
|
2364
|
+
} catch {
|
|
2365
|
+
return {
|
|
2366
|
+
status: "MANUAL_REQUIRED",
|
|
2367
|
+
configFile: relativeName,
|
|
2368
|
+
recommendedProxySnippet: snippet
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
function configureWorkspace(targetDir, options = {}) {
|
|
2373
|
+
const esedreDir = path5.join(targetDir, ".esedre");
|
|
2374
|
+
if (!fs5.existsSync(esedreDir)) {
|
|
2375
|
+
fs5.mkdirSync(esedreDir, { recursive: true });
|
|
2376
|
+
}
|
|
2377
|
+
const esedreJsonPath = path5.join(esedreDir, "esedre.json");
|
|
2378
|
+
let existingRaw = null;
|
|
2379
|
+
if (fs5.existsSync(esedreJsonPath)) {
|
|
2380
|
+
try {
|
|
2381
|
+
existingRaw = JSON.parse(fs5.readFileSync(esedreJsonPath, "utf-8"));
|
|
2382
|
+
} catch {
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
let config;
|
|
2386
|
+
const projectCode = options.projectCode || existingRaw?.projectCode;
|
|
2387
|
+
const projectName = options.projectName || existingRaw?.projectName || (projectCode ? projectCode : void 0);
|
|
2388
|
+
if (existingRaw) {
|
|
2389
|
+
config = migrateEsedreConfig(existingRaw, CURRENT_ESEDRE_VERSION);
|
|
2390
|
+
if (projectCode) config.projectCode = projectCode;
|
|
2391
|
+
if (projectName) config.projectName = projectName;
|
|
2392
|
+
if (options.allowedProjects) {
|
|
2393
|
+
config.allowedProjects = options.allowedProjects;
|
|
2394
|
+
} else if (projectCode) {
|
|
2395
|
+
if (!config.allowedProjects) {
|
|
2396
|
+
config.allowedProjects = [projectCode];
|
|
2397
|
+
} else if (!config.allowedProjects.some((p) => p.toUpperCase() === projectCode.toUpperCase())) {
|
|
2398
|
+
config.allowedProjects.push(projectCode);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
if (options.port) config.port = options.port;
|
|
2402
|
+
} else {
|
|
2403
|
+
config = {
|
|
2404
|
+
version: CURRENT_ESEDRE_VERSION,
|
|
2405
|
+
projectCode: projectCode || void 0,
|
|
2406
|
+
projectName: projectName || void 0,
|
|
2407
|
+
allowedProjects: options.allowedProjects || (projectCode ? [projectCode] : void 0),
|
|
2408
|
+
port: options.port || DEFAULT_ESEDRE_PORT
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
fs5.writeFileSync(esedreJsonPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2412
|
+
let projectRegistered = false;
|
|
2413
|
+
if (projectCode) {
|
|
2414
|
+
const ticketsDir = path5.join(esedreDir, "tickets");
|
|
2415
|
+
if (!fs5.existsSync(ticketsDir)) {
|
|
2416
|
+
fs5.mkdirSync(ticketsDir, { recursive: true });
|
|
2417
|
+
}
|
|
2418
|
+
const inRepoPJson = path5.join(esedreDir, "project.json");
|
|
2419
|
+
if (!fs5.existsSync(inRepoPJson)) {
|
|
2420
|
+
const projDesc = {
|
|
2421
|
+
id: 1,
|
|
2422
|
+
code: projectCode.toUpperCase(),
|
|
2423
|
+
slug: projectCode.toLowerCase(),
|
|
2424
|
+
name: projectName || projectCode.toUpperCase(),
|
|
2425
|
+
description: `${projectName || projectCode.toUpperCase()} project`,
|
|
2426
|
+
colors: {
|
|
2427
|
+
badge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-300",
|
|
2428
|
+
dot: "bg-cyan-400",
|
|
2429
|
+
border: "border-cyan-500/40"
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
fs5.writeFileSync(inRepoPJson, JSON.stringify(projDesc, null, 2) + "\n", "utf-8");
|
|
2433
|
+
projectRegistered = true;
|
|
2434
|
+
} else if (projectName) {
|
|
2435
|
+
try {
|
|
2436
|
+
const existing = JSON.parse(fs5.readFileSync(inRepoPJson, "utf-8"));
|
|
2437
|
+
if (existing.name !== projectName) {
|
|
2438
|
+
existing.name = projectName;
|
|
2439
|
+
fs5.writeFileSync(inRepoPJson, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
2440
|
+
}
|
|
2441
|
+
} catch {
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
plantWrappers(targetDir);
|
|
2446
|
+
const gitignoreUpdated = appendSnapshotToGitIgnore(targetDir);
|
|
2447
|
+
let mcpConfigured = false;
|
|
2448
|
+
if (options.setupMcp !== false) {
|
|
2449
|
+
mcpConfigured = setupMcpConfig(targetDir);
|
|
2450
|
+
}
|
|
2451
|
+
const skillRes = setupSkill(targetDir);
|
|
2452
|
+
const proxyRes = configureViteProxy(targetDir, config.port || DEFAULT_ESEDRE_PORT, options.setupProxy);
|
|
2453
|
+
const globalStoreRes = ensureGlobalEsedreStore();
|
|
2454
|
+
if (!globalStoreRes.ok) {
|
|
2455
|
+
console.warn(`\x1B[33m\u26A0\uFE0F Warning: Could not initialize global store at ${globalStoreRes.path}: ${globalStoreRes.error}\x1B[0m`);
|
|
2456
|
+
}
|
|
2457
|
+
return {
|
|
2458
|
+
esedreJsonCreatedOrUpdated: true,
|
|
2459
|
+
projectRegistered,
|
|
2460
|
+
projectName: config.projectName || config.projectCode,
|
|
2461
|
+
wrappersPlanted: true,
|
|
2462
|
+
gitignoreUpdated,
|
|
2463
|
+
mcpConfigured,
|
|
2464
|
+
skillConfigured: skillRes.updated,
|
|
2465
|
+
skillStatus: skillRes.status,
|
|
2466
|
+
viteProxyStatus: proxyRes.status,
|
|
2467
|
+
viteConfigFile: proxyRes.configFile,
|
|
2468
|
+
recommendedProxySnippet: proxyRes.recommendedProxySnippet,
|
|
2469
|
+
globalStoreStatus: globalStoreRes.ok ? "ready" : "warning",
|
|
2470
|
+
globalStorePath: globalStoreRes.path,
|
|
2471
|
+
globalStoreError: globalStoreRes.error
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// src/server/gateway.ts
|
|
2476
|
+
import fs7 from "node:fs";
|
|
2477
|
+
import http4 from "node:http";
|
|
2478
|
+
import path7 from "node:path";
|
|
2479
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2480
|
+
|
|
2481
|
+
// src/server/uiServer.ts
|
|
2482
|
+
import http2 from "node:http";
|
|
2483
|
+
import fs6 from "node:fs";
|
|
2484
|
+
import path6 from "node:path";
|
|
2485
|
+
var MIME_TYPES = {
|
|
2486
|
+
".html": "text/html; charset=utf-8",
|
|
2487
|
+
".js": "application/javascript; charset=utf-8",
|
|
2488
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
2489
|
+
".css": "text/css; charset=utf-8",
|
|
2490
|
+
".json": "application/json; charset=utf-8",
|
|
2491
|
+
".svg": "image/svg+xml",
|
|
2492
|
+
".png": "image/png",
|
|
2493
|
+
".jpg": "image/jpeg",
|
|
2494
|
+
".ico": "image/x-icon"
|
|
2495
|
+
};
|
|
2496
|
+
function startUiServer(port, webDir) {
|
|
2497
|
+
const server = http2.createServer((req, res) => {
|
|
2498
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2499
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
|
2500
|
+
if (req.method === "OPTIONS") {
|
|
2501
|
+
res.writeHead(204);
|
|
2502
|
+
res.end();
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
let reqPath = (req.url || "/").split("?")[0];
|
|
2506
|
+
if (reqPath === "/app" || reqPath === "/app/") {
|
|
2507
|
+
reqPath = "/";
|
|
2508
|
+
} else if (reqPath.startsWith("/app/")) {
|
|
2509
|
+
reqPath = reqPath.slice("/app".length);
|
|
2510
|
+
}
|
|
2511
|
+
if (!reqPath || reqPath === "/") {
|
|
2512
|
+
reqPath = "/index.html";
|
|
2513
|
+
}
|
|
2514
|
+
const safePath = path6.normalize(reqPath).replace(/^(\.\.[/\\])+/, "");
|
|
2515
|
+
let filePath = path6.join(webDir, safePath);
|
|
2516
|
+
if (!fs6.existsSync(filePath) || fs6.statSync(filePath).isDirectory()) {
|
|
2517
|
+
filePath = path6.join(webDir, "index.html");
|
|
2518
|
+
}
|
|
2519
|
+
if (!fs6.existsSync(filePath)) {
|
|
2520
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
2521
|
+
res.end("Esedre UI: index.html not found. Run npm run build first.");
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
const ext = path6.extname(filePath).toLowerCase();
|
|
2525
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
2526
|
+
try {
|
|
2527
|
+
const content = fs6.readFileSync(filePath);
|
|
2528
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
2529
|
+
res.end(content);
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
2532
|
+
res.end(`Internal Server Error: ${err.message}`);
|
|
2533
|
+
}
|
|
2534
|
+
});
|
|
2535
|
+
server.on("error", (err) => {
|
|
2536
|
+
if (err.code === "EADDRINUSE") {
|
|
2537
|
+
console.error(`\x1B[31mError: Internal UI port ${port} is already in use.\x1B[0m`);
|
|
2538
|
+
} else {
|
|
2539
|
+
console.error(`\x1B[31mUI Server Error: ${err.message}\x1B[0m`);
|
|
2540
|
+
}
|
|
2541
|
+
});
|
|
2542
|
+
server.listen(port, "127.0.0.1", () => {
|
|
2543
|
+
});
|
|
2544
|
+
return server;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// src/server/apiServer.ts
|
|
2548
|
+
import http3 from "node:http";
|
|
2549
|
+
function readJsonBody(req) {
|
|
2550
|
+
return new Promise((resolve, reject) => {
|
|
2551
|
+
let body = "";
|
|
2552
|
+
req.on("data", (chunk) => {
|
|
2553
|
+
body += chunk;
|
|
2554
|
+
});
|
|
2555
|
+
req.on("end", () => {
|
|
2556
|
+
if (!body) return resolve({});
|
|
2557
|
+
try {
|
|
2558
|
+
resolve(JSON.parse(body));
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
reject(err);
|
|
2561
|
+
}
|
|
2562
|
+
});
|
|
2563
|
+
req.on("error", reject);
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
function sendJson(res, status, data) {
|
|
2567
|
+
res.writeHead(status, {
|
|
2568
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
2569
|
+
"Access-Control-Allow-Origin": "*",
|
|
2570
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
|
|
2571
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
2572
|
+
});
|
|
2573
|
+
res.end(JSON.stringify(data));
|
|
2574
|
+
}
|
|
2575
|
+
var cachedAllData = null;
|
|
2576
|
+
var cachedAllTimestamp = 0;
|
|
2577
|
+
var CACHE_TTL_MS = 5e3;
|
|
2578
|
+
function invalidateApiCache() {
|
|
2579
|
+
cachedAllData = null;
|
|
2580
|
+
cachedAllTimestamp = 0;
|
|
2581
|
+
}
|
|
2582
|
+
function createApiHandler(storage, workspaceRoot) {
|
|
2583
|
+
return async (req, res) => {
|
|
2584
|
+
const rawUrl = req.url || "/";
|
|
2585
|
+
const parsedPath = rawUrl.split("?")[0];
|
|
2586
|
+
if (req.method === "OPTIONS") {
|
|
2587
|
+
if (parsedPath.startsWith("/api") || parsedPath.startsWith("/esedre/api")) {
|
|
2588
|
+
res.writeHead(204, {
|
|
2589
|
+
"Access-Control-Allow-Origin": "*",
|
|
2590
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
|
|
2591
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
2592
|
+
});
|
|
2593
|
+
res.end();
|
|
2594
|
+
return true;
|
|
2595
|
+
}
|
|
2596
|
+
return false;
|
|
2597
|
+
}
|
|
2598
|
+
if (!parsedPath.startsWith("/api") && !parsedPath.startsWith("/esedre/api")) {
|
|
2599
|
+
return false;
|
|
2600
|
+
}
|
|
2601
|
+
const url = new URL(rawUrl, "http://127.0.0.1");
|
|
2602
|
+
let pathname = url.pathname;
|
|
2603
|
+
if (pathname.startsWith("/esedre/api")) {
|
|
2604
|
+
pathname = pathname.slice("/esedre".length);
|
|
2605
|
+
}
|
|
2606
|
+
try {
|
|
2607
|
+
if (req.method === "GET") {
|
|
2608
|
+
if (pathname === "/api/planning/projects") {
|
|
2609
|
+
const projects = await storage.getProjects();
|
|
2610
|
+
sendJson(res, 200, projects);
|
|
2611
|
+
return true;
|
|
2612
|
+
}
|
|
2613
|
+
if (pathname === "/api/planning/tickets") {
|
|
2614
|
+
const project = url.searchParams.get("project") || void 0;
|
|
2615
|
+
const status = url.searchParams.get("status") || void 0;
|
|
2616
|
+
const type = url.searchParams.get("type") || url.searchParams.get("category") || void 0;
|
|
2617
|
+
const category = type;
|
|
2618
|
+
const search = url.searchParams.get("search") || void 0;
|
|
2619
|
+
const tickets = await storage.listTickets({
|
|
2620
|
+
project: project === "all" ? void 0 : project,
|
|
2621
|
+
status,
|
|
2622
|
+
type,
|
|
2623
|
+
category,
|
|
2624
|
+
search
|
|
2625
|
+
});
|
|
2626
|
+
sendJson(res, 200, tickets);
|
|
2627
|
+
return true;
|
|
2628
|
+
}
|
|
2629
|
+
if (pathname.startsWith("/api/planning/ticket/")) {
|
|
2630
|
+
const idStr = pathname.slice("/api/planning/ticket/".length);
|
|
2631
|
+
if (!idStr || !/^([a-zA-Z0-9]{1,6}-)?\d+$/.test(idStr)) {
|
|
2632
|
+
sendJson(res, 400, { error: "Invalid ticket ID" });
|
|
2633
|
+
return true;
|
|
2634
|
+
}
|
|
2635
|
+
const ticket = await storage.getTicket(idStr);
|
|
2636
|
+
if (!ticket) {
|
|
2637
|
+
sendJson(res, 400, { error: "Ticket not found" });
|
|
2638
|
+
return true;
|
|
2639
|
+
}
|
|
2640
|
+
sendJson(res, 200, ticket);
|
|
2641
|
+
return true;
|
|
2642
|
+
}
|
|
2643
|
+
if (pathname === "/api/planning/all") {
|
|
2644
|
+
const now = Date.now();
|
|
2645
|
+
if (cachedAllData && now - cachedAllTimestamp < CACHE_TTL_MS) {
|
|
2646
|
+
sendJson(res, 200, cachedAllData);
|
|
2647
|
+
return true;
|
|
2648
|
+
}
|
|
2649
|
+
const tickets = await storage.listTickets({});
|
|
2650
|
+
const metasMap = {};
|
|
2651
|
+
const detailsMap = {};
|
|
2652
|
+
const plansMap = {};
|
|
2653
|
+
const commentsMap = {};
|
|
2654
|
+
for (const t of tickets) {
|
|
2655
|
+
const numKey = String(t.meta.id);
|
|
2656
|
+
const code = t.projectDescriptor?.code || t.meta.project || "Profe";
|
|
2657
|
+
const prefixedKey = `${code}-${t.meta.id}`;
|
|
2658
|
+
metasMap[prefixedKey] = t.meta;
|
|
2659
|
+
if (!metasMap[numKey] || code.toUpperCase() === "PROF") {
|
|
2660
|
+
metasMap[numKey] = t.meta;
|
|
2661
|
+
}
|
|
2662
|
+
commentsMap[prefixedKey] = t.comments || [];
|
|
2663
|
+
if (!commentsMap[numKey] || code.toUpperCase() === "PROF") {
|
|
2664
|
+
commentsMap[numKey] = t.comments || [];
|
|
2665
|
+
}
|
|
2666
|
+
if (t.detail?.raw) {
|
|
2667
|
+
detailsMap[prefixedKey] = t.detail.raw;
|
|
2668
|
+
if (!detailsMap[numKey] || code.toUpperCase() === "PROF") {
|
|
2669
|
+
detailsMap[numKey] = t.detail.raw;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
if (t.planMarkdown) {
|
|
2673
|
+
plansMap[prefixedKey] = t.planMarkdown;
|
|
2674
|
+
if (!plansMap[numKey] || code.toUpperCase() === "PROF") {
|
|
2675
|
+
plansMap[numKey] = t.planMarkdown;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
const projects = await storage.getProjects();
|
|
2680
|
+
const responsePayload = {
|
|
2681
|
+
success: true,
|
|
2682
|
+
projects,
|
|
2683
|
+
metas: metasMap,
|
|
2684
|
+
details: detailsMap,
|
|
2685
|
+
plans: plansMap,
|
|
2686
|
+
comments: commentsMap,
|
|
2687
|
+
answers: {},
|
|
2688
|
+
inlineComments: {},
|
|
2689
|
+
planHistory: {},
|
|
2690
|
+
ticketHistory: {}
|
|
2691
|
+
};
|
|
2692
|
+
cachedAllData = responsePayload;
|
|
2693
|
+
cachedAllTimestamp = now;
|
|
2694
|
+
sendJson(res, 200, responsePayload);
|
|
2695
|
+
return true;
|
|
2696
|
+
}
|
|
2697
|
+
if (pathname === "/api/planning/metas") {
|
|
2698
|
+
const tickets = await storage.listTickets({});
|
|
2699
|
+
const metasMap = {};
|
|
2700
|
+
for (const t of tickets) {
|
|
2701
|
+
metasMap[String(t.meta.id)] = t.meta;
|
|
2702
|
+
}
|
|
2703
|
+
sendJson(res, 200, metasMap);
|
|
2704
|
+
return true;
|
|
2705
|
+
}
|
|
2706
|
+
if (pathname === "/api/planning/details") {
|
|
2707
|
+
const tickets = await storage.listTickets({});
|
|
2708
|
+
const detailsMap = {};
|
|
2709
|
+
for (const t of tickets) {
|
|
2710
|
+
if (t.detail?.raw) {
|
|
2711
|
+
detailsMap[String(t.meta.id)] = t.detail.raw;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
sendJson(res, 200, detailsMap);
|
|
2715
|
+
return true;
|
|
2716
|
+
}
|
|
2717
|
+
if (pathname === "/api/planning/plans") {
|
|
2718
|
+
const tickets = await storage.listTickets({});
|
|
2719
|
+
const plansMap = {};
|
|
2720
|
+
for (const t of tickets) {
|
|
2721
|
+
if (t.planMarkdown) {
|
|
2722
|
+
plansMap[String(t.meta.id)] = t.planMarkdown;
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
sendJson(res, 200, plansMap);
|
|
2726
|
+
return true;
|
|
2727
|
+
}
|
|
2728
|
+
if (pathname === "/api/planning/comments") {
|
|
2729
|
+
const tickets = await storage.listTickets({});
|
|
2730
|
+
const commentsMap = {};
|
|
2731
|
+
for (const t of tickets) {
|
|
2732
|
+
commentsMap[String(t.meta.id)] = t.comments || [];
|
|
2733
|
+
}
|
|
2734
|
+
sendJson(res, 200, commentsMap);
|
|
2735
|
+
return true;
|
|
2736
|
+
}
|
|
2737
|
+
if (pathname === "/api/planning/history") {
|
|
2738
|
+
sendJson(res, 200, {});
|
|
2739
|
+
return true;
|
|
2740
|
+
}
|
|
2741
|
+
if (pathname === "/api/planning/inline-comments") {
|
|
2742
|
+
sendJson(res, 200, {});
|
|
2743
|
+
return true;
|
|
2744
|
+
}
|
|
2745
|
+
if (pathname === "/api/planning/answers") {
|
|
2746
|
+
sendJson(res, 200, {});
|
|
2747
|
+
return true;
|
|
2748
|
+
}
|
|
2749
|
+
if (pathname === "/api/planning/plan-history") {
|
|
2750
|
+
sendJson(res, 200, {});
|
|
2751
|
+
return true;
|
|
2752
|
+
}
|
|
2753
|
+
if (pathname === "/api/planning/snapshot") {
|
|
2754
|
+
const projectCode = url.searchParams.get("project");
|
|
2755
|
+
if (!projectCode) {
|
|
2756
|
+
sendJson(res, 400, { error: "Project query parameter is required for snapshot generation (no default fallback)." });
|
|
2757
|
+
return true;
|
|
2758
|
+
}
|
|
2759
|
+
const snapshot = await generateProjectSnapshot(storage, projectCode, workspaceRoot);
|
|
2760
|
+
sendJson(res, 200, snapshot);
|
|
2761
|
+
return true;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
if (req.method === "POST") {
|
|
2765
|
+
const body = await readJsonBody(req);
|
|
2766
|
+
if (pathname === "/api/planning/tickets") {
|
|
2767
|
+
invalidateApiCache();
|
|
2768
|
+
const created = await storage.createTicket(body);
|
|
2769
|
+
sendJson(res, 201, { ticketId: String(created.meta.id), meta: created.meta });
|
|
2770
|
+
return true;
|
|
2771
|
+
}
|
|
2772
|
+
if (pathname === "/api/planning/create-project") {
|
|
2773
|
+
const { code, name, description, colors: colors2 } = body;
|
|
2774
|
+
if (!code || !name) {
|
|
2775
|
+
sendJson(res, 400, { error: "Both code and name are required to register a project." });
|
|
2776
|
+
return true;
|
|
2777
|
+
}
|
|
2778
|
+
const codeVal = validateProjectCode(code);
|
|
2779
|
+
if (!codeVal.valid) {
|
|
2780
|
+
sendJson(res, 400, { error: codeVal.error });
|
|
2781
|
+
return true;
|
|
2782
|
+
}
|
|
2783
|
+
invalidateApiCache();
|
|
2784
|
+
const project = await storage.registerProject({
|
|
2785
|
+
code,
|
|
2786
|
+
name,
|
|
2787
|
+
description,
|
|
2788
|
+
colors: colors2
|
|
2789
|
+
});
|
|
2790
|
+
sendJson(res, 201, project);
|
|
2791
|
+
return true;
|
|
2792
|
+
}
|
|
2793
|
+
if (pathname === "/api/planning/plans") {
|
|
2794
|
+
const { ticketId, planMarkdown, lastHash } = body;
|
|
2795
|
+
invalidateApiCache();
|
|
2796
|
+
await storage.savePlan(ticketId, planMarkdown, lastHash);
|
|
2797
|
+
sendJson(res, 200, { success: true });
|
|
2798
|
+
return true;
|
|
2799
|
+
}
|
|
2800
|
+
if (pathname === "/api/planning/comments") {
|
|
2801
|
+
const { ticketId, text, author } = body;
|
|
2802
|
+
invalidateApiCache();
|
|
2803
|
+
await storage.addComment(ticketId, { author: author || "Developer", text });
|
|
2804
|
+
const ticket = await storage.getTicket(ticketId);
|
|
2805
|
+
sendJson(res, 200, { comments: ticket?.comments || [] });
|
|
2806
|
+
return true;
|
|
2807
|
+
}
|
|
2808
|
+
if (pathname === "/api/planning/update-meta") {
|
|
2809
|
+
const { ticketId, updates, lastHash } = body;
|
|
2810
|
+
const updated = await storage.updateTicket(ticketId, updates, lastHash);
|
|
2811
|
+
sendJson(res, 200, { meta: updated.meta });
|
|
2812
|
+
return true;
|
|
2813
|
+
}
|
|
2814
|
+
if (pathname === "/api/planning/toggle-flag") {
|
|
2815
|
+
const { ticketId, flagged } = body;
|
|
2816
|
+
const updated = await storage.updateTicket(ticketId, {
|
|
2817
|
+
featureFlag: flagged ? "chat_enhanced" : ""
|
|
2818
|
+
});
|
|
2819
|
+
sendJson(res, 200, { meta: updated.meta });
|
|
2820
|
+
return true;
|
|
2821
|
+
}
|
|
2822
|
+
if (pathname === "/api/planning/details") {
|
|
2823
|
+
const { ticketId, detailMarkdown, metaUpdates } = body;
|
|
2824
|
+
if (metaUpdates) {
|
|
2825
|
+
await storage.updateTicket(ticketId, metaUpdates);
|
|
2826
|
+
}
|
|
2827
|
+
sendJson(res, 200, { success: true, detail: detailMarkdown });
|
|
2828
|
+
return true;
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
sendJson(res, 404, { error: `Endpoint not found: ${pathname}` });
|
|
2832
|
+
return true;
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
console.error("[Esedre API Error]:", err);
|
|
2835
|
+
sendJson(res, 500, { error: err.message });
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
function startApiServer(port, storage, workspaceRoot) {
|
|
2841
|
+
const handler = createApiHandler(storage, workspaceRoot);
|
|
2842
|
+
const server = http3.createServer(async (req, res) => {
|
|
2843
|
+
const handled = await handler(req, res);
|
|
2844
|
+
if (!handled && !res.headersSent) {
|
|
2845
|
+
sendJson(res, 404, { error: `Endpoint not found: ${req.url}` });
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
server.listen(port, "127.0.0.1", () => {
|
|
2849
|
+
});
|
|
2850
|
+
server.on("error", (err) => {
|
|
2851
|
+
if (err.code === "EADDRINUSE") {
|
|
2852
|
+
console.error(`\x1B[31mError: Internal API port ${port} is already in use.\x1B[0m`);
|
|
2853
|
+
} else {
|
|
2854
|
+
console.error(`\x1B[31mAPI Server Error: ${err.message}\x1B[0m`);
|
|
2855
|
+
}
|
|
2856
|
+
});
|
|
2857
|
+
return server;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// src/server/gateway.ts
|
|
2861
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
2862
|
+
var __dirname2 = path7.dirname(__filename2);
|
|
2863
|
+
function resolveWebDir(explicitWebDir, workspaceRoot) {
|
|
2864
|
+
if (explicitWebDir && fs7.existsSync(path7.join(explicitWebDir, "index.html"))) {
|
|
2865
|
+
return explicitWebDir;
|
|
2866
|
+
}
|
|
2867
|
+
const candidates = [
|
|
2868
|
+
// 1. Packaged layout right next to executable (e.g. tools/web)
|
|
2869
|
+
path7.resolve(__dirname2, "web"),
|
|
2870
|
+
// 2. From workspace root: <workspaceRoot>/dist/web
|
|
2871
|
+
workspaceRoot ? path7.resolve(workspaceRoot, "dist", "web") : "",
|
|
2872
|
+
// 3. In-repo legacy subfolder: <workspaceRoot>/esedre/dist/web
|
|
2873
|
+
workspaceRoot ? path7.resolve(workspaceRoot, "esedre", "dist", "web") : "",
|
|
2874
|
+
// 4. Sibling standalone repository from workspace root: <workspaceRoot>/../esedre/dist/web
|
|
2875
|
+
workspaceRoot ? path7.resolve(workspaceRoot, "..", "esedre", "dist", "web") : "",
|
|
2876
|
+
// 5. Packaged layout inside dist/web
|
|
2877
|
+
path7.resolve(__dirname2, "dist", "web"),
|
|
2878
|
+
// 6. Sibling standalone repository from __dirname: <__dirname>/../../esedre/dist/web
|
|
2879
|
+
path7.resolve(__dirname2, "..", "..", "esedre", "dist", "web"),
|
|
2880
|
+
// 7. Source tree layout: esedre/src/server/ -> esedre/dist/web
|
|
2881
|
+
path7.resolve(__dirname2, "..", "..", "dist", "web"),
|
|
2882
|
+
// 8. Legacy layout from __dirname
|
|
2883
|
+
path7.resolve(__dirname2, "..", "esedre", "dist", "web")
|
|
2884
|
+
].filter(Boolean);
|
|
2885
|
+
for (const candidate of candidates) {
|
|
2886
|
+
if (fs7.existsSync(path7.join(candidate, "index.html"))) {
|
|
2887
|
+
return candidate;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
return candidates[0] || path7.resolve(__dirname2, "web");
|
|
2891
|
+
}
|
|
2892
|
+
function proxyRequest(req, res, targetPort, rewritePath) {
|
|
2893
|
+
const targetPath = rewritePath ? rewritePath(req.url || "/") : req.url || "/";
|
|
2894
|
+
const options = {
|
|
2895
|
+
hostname: "127.0.0.1",
|
|
2896
|
+
port: targetPort,
|
|
2897
|
+
path: targetPath,
|
|
2898
|
+
method: req.method,
|
|
2899
|
+
headers: {
|
|
2900
|
+
...req.headers,
|
|
2901
|
+
host: `127.0.0.1:${targetPort}`,
|
|
2902
|
+
"x-forwarded-for": req.socket.remoteAddress || "",
|
|
2903
|
+
"x-forwarded-proto": "http"
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
const proxyReq = http4.request(options, (proxyRes) => {
|
|
2907
|
+
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
2908
|
+
proxyRes.pipe(res, { end: true });
|
|
2909
|
+
});
|
|
2910
|
+
proxyReq.on("error", (err) => {
|
|
2911
|
+
console.error(`[Gateway Proxy Error -> ${targetPort}]:`, err.message);
|
|
2912
|
+
if (!res.headersSent) {
|
|
2913
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
2914
|
+
res.end(`Bad Gateway: Could not forward request to internal port ${targetPort}`);
|
|
2915
|
+
}
|
|
2916
|
+
});
|
|
2917
|
+
req.pipe(proxyReq, { end: true });
|
|
2918
|
+
}
|
|
2919
|
+
function startGatewayCluster(options) {
|
|
2920
|
+
const gatewayPort = options.gatewayPort !== void 0 ? options.gatewayPort : 5674;
|
|
2921
|
+
const uiPort = options.uiPort !== void 0 ? options.uiPort : gatewayPort ? gatewayPort + 1 : 0;
|
|
2922
|
+
const apiPort = options.apiPort !== void 0 ? options.apiPort : gatewayPort ? gatewayPort + 2 : 0;
|
|
2923
|
+
const webDir = resolveWebDir(options.webDir, options.workspaceRoot);
|
|
2924
|
+
const uiServer = startUiServer(uiPort, webDir);
|
|
2925
|
+
const apiServer = startApiServer(apiPort, options.storage, options.workspaceRoot);
|
|
2926
|
+
const getUiPort = () => uiServer.address()?.port || uiPort;
|
|
2927
|
+
const getApiPort = () => apiServer.address()?.port || apiPort;
|
|
2928
|
+
const gatewayServer = http4.createServer((req, res) => {
|
|
2929
|
+
const rawUrl = req.url || "/";
|
|
2930
|
+
const parsedPath = rawUrl.split("?")[0];
|
|
2931
|
+
if (parsedPath === "/" || parsedPath === "/index.html" || parsedPath === "/esedre" || parsedPath === "/esedre/") {
|
|
2932
|
+
const search = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
|
|
2933
|
+
proxyRequest(req, res, getUiPort(), () => `/app/${search}`);
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
if (parsedPath.startsWith("/api") || parsedPath.startsWith("/esedre/api")) {
|
|
2937
|
+
proxyRequest(req, res, getApiPort(), (p) => {
|
|
2938
|
+
return p.replace(/^\/esedre\/api/, "/api");
|
|
2939
|
+
});
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
if (parsedPath.startsWith("/app") || parsedPath.startsWith("/esedre/app") || parsedPath.startsWith("/assets/")) {
|
|
2943
|
+
proxyRequest(req, res, getUiPort(), (p) => {
|
|
2944
|
+
return p.replace(/^\/esedre\/app/, "/app");
|
|
2945
|
+
});
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
proxyRequest(req, res, getUiPort());
|
|
2949
|
+
});
|
|
2950
|
+
gatewayServer.on("error", (err) => {
|
|
2951
|
+
if (err.code === "EADDRINUSE") {
|
|
2952
|
+
console.error(`\x1B[31mError: Port ${gatewayPort} is already in use by another process.\x1B[0m`);
|
|
2953
|
+
} else {
|
|
2954
|
+
console.error(`\x1B[31mGateway Server Error: ${err.message}\x1B[0m`);
|
|
2955
|
+
}
|
|
2956
|
+
});
|
|
2957
|
+
gatewayServer.listen(gatewayPort, "0.0.0.0", () => {
|
|
2958
|
+
});
|
|
2959
|
+
return {
|
|
2960
|
+
gatewayServer,
|
|
2961
|
+
uiServer,
|
|
2962
|
+
apiServer,
|
|
2963
|
+
close: async () => {
|
|
2964
|
+
await Promise.all([
|
|
2965
|
+
new Promise((resolve) => gatewayServer.close(resolve)),
|
|
2966
|
+
new Promise((resolve) => uiServer.close(resolve)),
|
|
2967
|
+
new Promise((resolve) => apiServer.close(resolve))
|
|
2968
|
+
]);
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// bin/esedre.ts
|
|
2974
|
+
function printHelp() {
|
|
2975
|
+
console.log(`
|
|
2976
|
+
${colors.bold}${colors.cyan}Esedre CLI (/eh-seh-dreh/)${colors.reset}: Developer Roadmap & Companion LLM Coordination Engine
|
|
2977
|
+
|
|
2978
|
+
${colors.bold}USAGE:${colors.reset}
|
|
2979
|
+
.esedre/esedre <command> [options]
|
|
2980
|
+
.esedre/ese <command> [options]
|
|
2981
|
+
esedre <command> [options]
|
|
2982
|
+
ese <command> [options]
|
|
2983
|
+
node tools/esedre.mjs <command> [options]
|
|
2984
|
+
|
|
2985
|
+
${colors.bold}COMMANDS:${colors.reset}
|
|
2986
|
+
${colors.bold}list${colors.reset} [-p|--project <code|all>] [-s|--status <status>] [-t|--type <type>] [-q|--search <q>] [--json]
|
|
2987
|
+
List roadmap tickets with optional filters (defaults to active project).
|
|
2988
|
+
\u2022 Status values: 'Planned', 'In Development', 'Completed', 'Rejected'
|
|
2989
|
+
\u2022 Type values: 'Feature', 'Platform', 'Tools', 'Idea', 'Bug'
|
|
2990
|
+
|
|
2991
|
+
${colors.bold}get${colors.reset} <id> [--json]
|
|
2992
|
+
Inspect full ticket specification, feature breakdown, and comments.
|
|
2993
|
+
|
|
2994
|
+
${colors.bold}plan${colors.reset} <id> [--file <path> | --set "<markdown>"] [--last-hash <sha1>] [--json]
|
|
2995
|
+
View or update implementation plan with optimistic concurrency control.
|
|
2996
|
+
|
|
2997
|
+
${colors.bold}create${colors.reset} --title "..." [-p|--project <code>] [-t|--type <type>] [--complexity <c>] [--effort "<e>"] [--summary "<s>"] [--json]
|
|
2998
|
+
Mint a new roadmap ticket with sequential numeric ID.
|
|
2999
|
+
|
|
3000
|
+
${colors.bold}update${colors.reset} <id> [-s|--status <status>] [--title "..."] [--flag] [--no-flag] [--last-hash <sha1>] [--force] [--json]
|
|
3001
|
+
Mutate ticket status, title, active state, or feature flag with OCC protection.
|
|
3002
|
+
|
|
3003
|
+
${colors.bold}comment${colors.reset} <id> --text "..." [--author "..."] [--json]
|
|
3004
|
+
Append a research finding, test verification, or note to ticket history.
|
|
3005
|
+
|
|
3006
|
+
${colors.bold}snapshot${colors.reset} [--project <code>] [--json]
|
|
3007
|
+
Generate lean read-only projection snapshot (.esedre/snapshot.json) for zero-latency agent context.
|
|
3008
|
+
|
|
3009
|
+
${colors.bold}configure${colors.reset} [--project <code>] [-n|--name <name>] [--allow <c1,c2>] [--port <n>] [--no-mcp] [--proxy] [--no-proxy] [-y|--yes] [--json]
|
|
3010
|
+
Initialize or update .esedre footprint, register project code & display name in Esedre,
|
|
3011
|
+
plant wrappers (.esedre/esedre.cmd, .esedre/ese.cmd, etc.), configure .gitignore,
|
|
3012
|
+
MCP server config, agent skill templates, and display guided next steps.
|
|
3013
|
+
|
|
3014
|
+
${colors.bold}upgrade${colors.reset} [--json]
|
|
3015
|
+
Upgrade workspace configuration schema, wrappers, and agent skills. Refreshes snapshot.
|
|
3016
|
+
|
|
3017
|
+
${colors.bold}projects${colors.reset} [--json]
|
|
3018
|
+
List registered workspace projects, project codes (e.g. 'CORE', 'WEB', 'DOCS'), and descriptions.
|
|
3019
|
+
|
|
3020
|
+
${colors.bold}daemon${colors.reset} [start|stop|status|logs] [--port <n>] [--quiet] [--json]
|
|
3021
|
+
Manage background daemon process (start, stop, check status, or view logs).
|
|
3022
|
+
|
|
3023
|
+
${colors.bold}serve${colors.reset} [--port <n>]
|
|
3024
|
+
Run the unified gateway & web server in foreground (default 5674).
|
|
3025
|
+
|
|
3026
|
+
${colors.bold}start${colors.reset} [--port <n>] [--quiet] [--json]
|
|
3027
|
+
Alias for 'daemon start'. Start Esedre background daemon process.
|
|
3028
|
+
|
|
3029
|
+
${colors.bold}stop${colors.reset} [--port <n>] [--quiet] [--json]
|
|
3030
|
+
Alias for 'daemon stop'. Stop the running Esedre background daemon process.
|
|
3031
|
+
|
|
3032
|
+
${colors.bold}status${colors.reset} [--port <n>] [--json]
|
|
3033
|
+
Alias for 'daemon status'. Check health and diagnostics of the daemon process.
|
|
3034
|
+
|
|
3035
|
+
${colors.bold}logs${colors.reset} [--port <n>] [--lines <n>]
|
|
3036
|
+
Tail recent output logs from the Esedre daemon process.
|
|
3037
|
+
|
|
3038
|
+
${colors.bold}mcp${colors.reset} Start the Model Context Protocol (MCP) JSON-RPC 2.0 stdio server for autonomous LLM coding agents.
|
|
3039
|
+
|
|
3040
|
+
${colors.bold}OPTIONS:${colors.reset}
|
|
3041
|
+
-p, --project <code> Project code (e.g. CORE, ALCE, DOCS).
|
|
3042
|
+
-n, --name <name> Project display name (e.g. "Alce Web Reader").
|
|
3043
|
+
-t, --type <type> Ticket type ('Feature', 'Platform', 'Tools', 'Idea', 'Bug').
|
|
3044
|
+
-s, --status <stat> Ticket status ('Planned', 'In Development', 'Completed', 'Rejected').
|
|
3045
|
+
-q, --search <query> Case-insensitive substring search query.
|
|
3046
|
+
--json Output raw machine-readable JSON (strongly recommended for autonomous LLM coding agents).
|
|
3047
|
+
--last-hash <hash> Optimistic concurrency control: last known sha1 hash of the ticket from 'get'.
|
|
3048
|
+
--force Bypass optimistic concurrency last-hash conflict checks on writes.
|
|
3049
|
+
-h, --help Show this help reference.
|
|
3050
|
+
|
|
3051
|
+
${colors.bold}TYPICAL AGENT WORKFLOW:${colors.reset}
|
|
3052
|
+
1. Inspect active tickets: ese list --status "In Development" --json
|
|
3053
|
+
2. Inspect ticket spec: ese get 96 --json
|
|
3054
|
+
3. Update plan markdown: ese plan 96 --file plan.md --last-hash <sha1>
|
|
3055
|
+
4. Complete ticket: ese update 96 --status "Completed" --last-hash <sha1>
|
|
3056
|
+
5. Add verification note: ese comment 96 --text "Verified tests pass." --author "Agent"
|
|
3057
|
+
* Tip: Read .esedre/snapshot.json directly for zero-latency roadmap context without subprocess execution.
|
|
3058
|
+
`);
|
|
3059
|
+
}
|
|
3060
|
+
function parseArgs(rawArgs) {
|
|
3061
|
+
const positionals = [];
|
|
3062
|
+
const flags = {};
|
|
3063
|
+
let command = "";
|
|
3064
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
3065
|
+
const arg = rawArgs[i];
|
|
3066
|
+
if (arg.startsWith("--")) {
|
|
3067
|
+
const key = arg.slice(2);
|
|
3068
|
+
const next = rawArgs[i + 1];
|
|
3069
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
3070
|
+
flags[key] = next;
|
|
3071
|
+
i++;
|
|
3072
|
+
} else {
|
|
3073
|
+
flags[key] = true;
|
|
3074
|
+
}
|
|
3075
|
+
} else if (arg.startsWith("-")) {
|
|
3076
|
+
const key = arg.slice(1);
|
|
3077
|
+
if (key === "h") {
|
|
3078
|
+
flags["help"] = true;
|
|
3079
|
+
} else if (key === "y") {
|
|
3080
|
+
flags["yes"] = true;
|
|
3081
|
+
} else if (key === "t" || key === "p" || key === "s" || key === "q" || key === "n") {
|
|
3082
|
+
const next = rawArgs[i + 1];
|
|
3083
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
3084
|
+
flags[key] = next;
|
|
3085
|
+
i++;
|
|
3086
|
+
} else {
|
|
3087
|
+
flags[key] = true;
|
|
3088
|
+
}
|
|
3089
|
+
} else {
|
|
3090
|
+
flags[key] = true;
|
|
3091
|
+
}
|
|
3092
|
+
} else {
|
|
3093
|
+
if (!command) {
|
|
3094
|
+
command = arg;
|
|
3095
|
+
} else {
|
|
3096
|
+
positionals.push(arg);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
if (flags["t"] && !flags["type"]) flags["type"] = flags["t"];
|
|
3101
|
+
if (flags["p"] && !flags["project"]) flags["project"] = flags["p"];
|
|
3102
|
+
if (flags["n"] && !flags["name"]) flags["name"] = flags["n"];
|
|
3103
|
+
if (flags["s"] && !flags["status"]) flags["status"] = flags["s"];
|
|
3104
|
+
if (flags["q"] && !flags["search"]) flags["search"] = flags["q"];
|
|
3105
|
+
return { command, positionals, flags };
|
|
3106
|
+
}
|
|
3107
|
+
async function main() {
|
|
3108
|
+
const rawArgs = process.argv.slice(2);
|
|
3109
|
+
const { command, positionals, flags } = parseArgs(rawArgs);
|
|
3110
|
+
if (!command || flags["help"] || flags["h"]) {
|
|
3111
|
+
printHelp();
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
const isJson = Boolean(flags["json"]);
|
|
3115
|
+
const discovered = findEsedreConfig(process.cwd());
|
|
3116
|
+
const rawStorage = new FilesystemStorageAdapter(discovered.workspaceRoot, discovered.config);
|
|
3117
|
+
const storage = new SecurityFilter(rawStorage, discovered.config);
|
|
3118
|
+
try {
|
|
3119
|
+
switch (command) {
|
|
3120
|
+
case "configure": {
|
|
3121
|
+
let projectCode = flags["project"] || flags["p"] || discovered.config?.projectCode;
|
|
3122
|
+
let projectName = flags["name"] || flags["n"] || discovered.config?.projectName;
|
|
3123
|
+
const allowArg = flags["allow"];
|
|
3124
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3125
|
+
const setupMcp = flags["no-mcp"] ? false : true;
|
|
3126
|
+
const isYes = Boolean(flags["yes"] || flags["y"]);
|
|
3127
|
+
if (process.stdin.isTTY && (!projectCode || !projectName) && !isJson) {
|
|
3128
|
+
const readline2 = await import("node:readline/promises");
|
|
3129
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
|
|
3130
|
+
try {
|
|
3131
|
+
if (!projectCode) {
|
|
3132
|
+
const codeAnswer = await rl.question(
|
|
3133
|
+
`${colors.cyan}?${colors.reset} Project code (1-6 alphanumeric characters, e.g. ALCE, DOCS): `
|
|
3134
|
+
);
|
|
3135
|
+
projectCode = codeAnswer.trim();
|
|
3136
|
+
}
|
|
3137
|
+
if (!projectName && projectCode) {
|
|
3138
|
+
const nameAnswer = await rl.question(
|
|
3139
|
+
`${colors.cyan}?${colors.reset} Project display name (e.g. ${projectCode} App) [${projectCode}]: `
|
|
3140
|
+
);
|
|
3141
|
+
projectName = nameAnswer.trim() || projectCode;
|
|
3142
|
+
}
|
|
3143
|
+
} finally {
|
|
3144
|
+
rl.close();
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
if (!projectCode) {
|
|
3148
|
+
console.error(`${colors.red}Error: Project code is required (--project <code>, -p <code>, or configure projectCode in esedre.json).${colors.reset}`);
|
|
3149
|
+
process.exit(1);
|
|
3150
|
+
}
|
|
3151
|
+
const codeVal = validateProjectCode(projectCode);
|
|
3152
|
+
if (!codeVal.valid) {
|
|
3153
|
+
console.error(`${colors.red}Error: ${codeVal.error}${colors.reset}`);
|
|
3154
|
+
process.exit(1);
|
|
3155
|
+
}
|
|
3156
|
+
if (!projectName) {
|
|
3157
|
+
projectName = projectCode;
|
|
3158
|
+
}
|
|
3159
|
+
const existingProjects = await storage.getProjects();
|
|
3160
|
+
const existing = existingProjects.find(
|
|
3161
|
+
(p) => p.code.toLowerCase() === projectCode.toLowerCase()
|
|
3162
|
+
);
|
|
3163
|
+
let projectNewlyRegistered = false;
|
|
3164
|
+
let registeredProj;
|
|
3165
|
+
if (!existing) {
|
|
3166
|
+
registeredProj = await rawStorage.registerProject({
|
|
3167
|
+
code: projectCode,
|
|
3168
|
+
name: projectName
|
|
3169
|
+
});
|
|
3170
|
+
projectNewlyRegistered = true;
|
|
3171
|
+
} else {
|
|
3172
|
+
registeredProj = existing;
|
|
3173
|
+
if (flags["name"] || flags["n"]) {
|
|
3174
|
+
registeredProj = await rawStorage.registerProject({
|
|
3175
|
+
code: projectCode,
|
|
3176
|
+
name: projectName
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
const allowedProjects = allowArg ? allowArg.split(",").map((s) => s.trim()) : discovered.config?.allowedProjects || [projectCode];
|
|
3181
|
+
let setupProxy = void 0;
|
|
3182
|
+
if (flags["proxy"] !== void 0) {
|
|
3183
|
+
setupProxy = Boolean(flags["proxy"]);
|
|
3184
|
+
} else if (flags["no-proxy"] !== void 0) {
|
|
3185
|
+
setupProxy = false;
|
|
3186
|
+
}
|
|
3187
|
+
const viteConfigPath = findViteConfig(discovered.workspaceRoot);
|
|
3188
|
+
if (viteConfigPath && setupProxy === void 0 && !isJson) {
|
|
3189
|
+
try {
|
|
3190
|
+
const rawContent = fs8.readFileSync(viteConfigPath, "utf-8");
|
|
3191
|
+
const alreadyConfigured = rawContent.includes("'/esedre'") || rawContent.includes('"/esedre"');
|
|
3192
|
+
if (!alreadyConfigured) {
|
|
3193
|
+
if (isYes) {
|
|
3194
|
+
setupProxy = true;
|
|
3195
|
+
} else if (process.stdin.isTTY) {
|
|
3196
|
+
const readline2 = await import("node:readline/promises");
|
|
3197
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
|
|
3198
|
+
try {
|
|
3199
|
+
const answer = await rl.question(
|
|
3200
|
+
`${colors.cyan}?${colors.reset} Detected ${colors.bold}${path8.basename(viteConfigPath)}${colors.reset}. Would you like to configure a reverse proxy for ${colors.bold}/esedre${colors.reset} targeting http://127.0.0.1:${port}? [Y/n] `
|
|
3201
|
+
);
|
|
3202
|
+
const trimmed = answer.trim().toLowerCase();
|
|
3203
|
+
setupProxy = trimmed === "" || trimmed === "y" || trimmed === "yes";
|
|
3204
|
+
} finally {
|
|
3205
|
+
rl.close();
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
} catch {
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
const res = configureWorkspace(discovered.workspaceRoot, {
|
|
3213
|
+
projectCode,
|
|
3214
|
+
projectName: registeredProj.name,
|
|
3215
|
+
allowedProjects,
|
|
3216
|
+
port,
|
|
3217
|
+
setupMcp,
|
|
3218
|
+
setupProxy
|
|
3219
|
+
});
|
|
3220
|
+
let snapshotTickets = 0;
|
|
3221
|
+
try {
|
|
3222
|
+
const snapshot = await generateProjectSnapshot(storage, projectCode, discovered.workspaceRoot);
|
|
3223
|
+
snapshotTickets = snapshot.totalTickets;
|
|
3224
|
+
} catch {
|
|
3225
|
+
}
|
|
3226
|
+
if (isJson) {
|
|
3227
|
+
console.log(JSON.stringify({
|
|
3228
|
+
...res,
|
|
3229
|
+
project: registeredProj,
|
|
3230
|
+
projectNewlyRegistered,
|
|
3231
|
+
snapshotTickets,
|
|
3232
|
+
urls: {
|
|
3233
|
+
ui: `http://localhost:${port}/app?project=${projectCode}`,
|
|
3234
|
+
portfolio: `http://localhost:${port}/app?project=all`,
|
|
3235
|
+
api: `http://localhost:${port}/api`
|
|
3236
|
+
},
|
|
3237
|
+
commands: {
|
|
3238
|
+
start: "ese start",
|
|
3239
|
+
status: "ese status",
|
|
3240
|
+
stop: "ese stop",
|
|
3241
|
+
create: `ese create --title "..." --project ${projectCode}`,
|
|
3242
|
+
list: `ese list --project ${projectCode}`
|
|
3243
|
+
}
|
|
3244
|
+
}, null, 2));
|
|
3245
|
+
} else {
|
|
3246
|
+
console.log(`${colors.bold}${colors.green}\u2714 Esedre workspace configured successfully!${colors.reset}`);
|
|
3247
|
+
console.log(` \u2022 Config: .esedre/esedre.json (Project: ${projectCode} (${registeredProj.name}), Version: ${CURRENT_ESEDRE_VERSION})`);
|
|
3248
|
+
if (projectNewlyRegistered) {
|
|
3249
|
+
console.log(` \u2022 Project: ${colors.green}Registered new project '${projectCode}' (${registeredProj.name}) in Esedre${colors.reset}`);
|
|
3250
|
+
} else {
|
|
3251
|
+
console.log(` \u2022 Project: Verified project '${projectCode}' (${registeredProj.name}) in Esedre`);
|
|
3252
|
+
}
|
|
3253
|
+
console.log(` \u2022 In-repo wrappers: .esedre/esedre.cmd, .esedre/ese.cmd, .esedre/esedre, .esedre/ese`);
|
|
3254
|
+
console.log(` \u2022 .gitignore: ${res.gitignoreUpdated ? "Added .esedre/snapshot.json" : "Already configured"}`);
|
|
3255
|
+
console.log(` \u2022 Snapshot: Generated .esedre/snapshot.json (${snapshotTickets} tickets projected)`);
|
|
3256
|
+
console.log(` \u2022 MCP: ${res.mcpConfigured ? "Configured in .agents/mcp_config.json" : "Skipped"}`);
|
|
3257
|
+
console.log(` \u2022 Skill: ${res.skillConfigured ? "Updated .agents/skills/esedre/SKILL.md" : `Retained (${res.skillStatus})`}`);
|
|
3258
|
+
if (res.globalStoreStatus === "ready") {
|
|
3259
|
+
console.log(` \u2022 Global Store: ${res.globalStorePath} (verified write permissions)`);
|
|
3260
|
+
} else if (res.globalStoreStatus === "warning") {
|
|
3261
|
+
console.log(` \u2022 ${colors.yellow}Global Store: Fallback to ${res.globalStorePath} (${res.globalStoreError})${colors.reset}`);
|
|
3262
|
+
}
|
|
3263
|
+
if (res.viteProxyStatus === "CONFIGURED") {
|
|
3264
|
+
console.log(` \u2022 ${colors.green}Vite Proxy: Added /esedre proxy to ${res.viteConfigFile} targeting http://127.0.0.1:${port}${colors.reset}`);
|
|
3265
|
+
} else if (res.viteProxyStatus === "ALREADY_CONFIGURED") {
|
|
3266
|
+
console.log(` \u2022 Vite Proxy: /esedre is already configured in ${res.viteConfigFile}`);
|
|
3267
|
+
} else if (res.viteProxyStatus === "SKIPPED") {
|
|
3268
|
+
console.log(` \u2022 Vite Proxy: Skipped configuring ${res.viteConfigFile}`);
|
|
3269
|
+
console.log(`
|
|
3270
|
+
${colors.bold}Recommendation:${colors.reset} To embed the dev planner within your UI, add this proxy to ${res.viteConfigFile}:`);
|
|
3271
|
+
console.log(colors.dim + (res.recommendedProxySnippet?.split("\n").map((l) => " " + l).join("\n") || "") + colors.reset);
|
|
3272
|
+
} else if (res.viteProxyStatus === "NOT_APPLICABLE") {
|
|
3273
|
+
console.log(`
|
|
3274
|
+
${colors.bold}Recommendation:${colors.reset} If you wish to embed the dev planner within your project's UI, configure a reverse proxy for '/esedre' targeting http://127.0.0.1:${port}:`);
|
|
3275
|
+
console.log(colors.dim + (res.recommendedProxySnippet?.split("\n").map((l) => " " + l).join("\n") || "") + colors.reset);
|
|
3276
|
+
} else if (res.viteProxyStatus === "MANUAL_REQUIRED") {
|
|
3277
|
+
console.log(` \u2022 ${colors.yellow}Vite Proxy: Could not automatically inject proxy into ${res.viteConfigFile}.${colors.reset}`);
|
|
3278
|
+
console.log(`
|
|
3279
|
+
${colors.bold}Recommendation:${colors.reset} Please manually add this proxy to your dev server configuration:`);
|
|
3280
|
+
console.log(colors.dim + (res.recommendedProxySnippet?.split("\n").map((l) => " " + l).join("\n") || "") + colors.reset);
|
|
3281
|
+
}
|
|
3282
|
+
console.log(`
|
|
3283
|
+
${colors.bold}Next Steps:${colors.reset}`);
|
|
3284
|
+
console.log(` 1. ${colors.bold}Start the background daemon:${colors.reset}`);
|
|
3285
|
+
console.log(` ${colors.cyan}ese start${colors.reset} (or: ${colors.dim}.esedre/ese start${colors.reset})`);
|
|
3286
|
+
console.log(`
|
|
3287
|
+
2. ${colors.bold}Open the visual Web UI:${colors.reset}`);
|
|
3288
|
+
console.log(` ${colors.cyan}http://localhost:${port}/app?project=${projectCode}${colors.reset}`);
|
|
3289
|
+
if (res.viteProxyStatus === "CONFIGURED" || res.viteProxyStatus === "ALREADY_CONFIGURED") {
|
|
3290
|
+
console.log(` ${colors.dim}Embedded via host dev server:${colors.reset} http://localhost:5173/esedre/app?project=${projectCode}`);
|
|
3291
|
+
}
|
|
3292
|
+
console.log(`
|
|
3293
|
+
3. ${colors.bold}Create your first ticket:${colors.reset}`);
|
|
3294
|
+
console.log(` ${colors.cyan}ese create --title "First feature" --type Feature --project ${projectCode}${colors.reset}`);
|
|
3295
|
+
console.log(`
|
|
3296
|
+
4. ${colors.bold}Inspect roadmap & tickets:${colors.reset}`);
|
|
3297
|
+
console.log(` ${colors.cyan}ese list --project ${projectCode}${colors.reset}`);
|
|
3298
|
+
}
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
case "upgrade": {
|
|
3302
|
+
const res = configureWorkspace(discovered.workspaceRoot, {
|
|
3303
|
+
projectCode: discovered.config?.projectCode,
|
|
3304
|
+
allowedProjects: discovered.config?.allowedProjects,
|
|
3305
|
+
port: discovered.config?.port
|
|
3306
|
+
});
|
|
3307
|
+
const projectCode = discovered.config?.projectCode;
|
|
3308
|
+
if (!projectCode) {
|
|
3309
|
+
console.error(`${colors.red}Error: "projectCode" is required in esedre.json configuration (no default fallback).${colors.reset}`);
|
|
3310
|
+
process.exit(1);
|
|
3311
|
+
}
|
|
3312
|
+
const snapshot = await generateProjectSnapshot(storage, projectCode, discovered.workspaceRoot);
|
|
3313
|
+
if (isJson) {
|
|
3314
|
+
console.log(JSON.stringify({ upgrade: res, snapshot: { totalTickets: snapshot.totalTickets } }, null, 2));
|
|
3315
|
+
} else {
|
|
3316
|
+
console.log(`${colors.bold}${colors.green}\u2714 Esedre upgrade completed!${colors.reset}`);
|
|
3317
|
+
console.log(` \u2022 Esedre Engine Version: ${CURRENT_ESEDRE_VERSION}`);
|
|
3318
|
+
console.log(` \u2022 Wrappers updated in .esedre/`);
|
|
3319
|
+
console.log(` \u2022 Refreshed .esedre/snapshot.json (${snapshot.totalTickets} tickets projected)`);
|
|
3320
|
+
if (res.skillStatus === "CUSTOMIZED") {
|
|
3321
|
+
console.log(` \u2022 ${colors.yellow}Notice: .agents/skills/esedre/SKILL.md is customized; preserved user modifications.${colors.reset}`);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
case "snapshot": {
|
|
3327
|
+
const projectCode = flags["project"] || discovered.config?.projectCode;
|
|
3328
|
+
if (!projectCode) {
|
|
3329
|
+
console.error(`${colors.red}Error: Project code is required (--project <code> or configure projectCode in esedre.json).${colors.reset}`);
|
|
3330
|
+
process.exit(1);
|
|
3331
|
+
}
|
|
3332
|
+
const snapshot = await generateProjectSnapshot(storage, projectCode, discovered.workspaceRoot);
|
|
3333
|
+
if (isJson) {
|
|
3334
|
+
console.log(JSON.stringify(snapshot, null, 2));
|
|
3335
|
+
} else {
|
|
3336
|
+
console.log(`${colors.green}\u2714 Generated projection snapshot for project '${projectCode}'${colors.reset}`);
|
|
3337
|
+
console.log(` \u2022 Location: .esedre/snapshot.json`);
|
|
3338
|
+
console.log(` \u2022 Tickets: ${snapshot.totalTickets}`);
|
|
3339
|
+
console.log(` \u2022 Timestamp: ${snapshot.generatedAt}`);
|
|
3340
|
+
}
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
case "mcp": {
|
|
3344
|
+
const mcpServer = new EsedreMcpServer(storage);
|
|
3345
|
+
mcpServer.start();
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
case "start": {
|
|
3349
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3350
|
+
const quiet = Boolean(flags["quiet"] || isJson);
|
|
3351
|
+
const state = await startDaemon({ port, quiet, workspaceRoot: discovered.workspaceRoot });
|
|
3352
|
+
if (isJson) {
|
|
3353
|
+
console.log(JSON.stringify(state, null, 2));
|
|
3354
|
+
}
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
case "stop": {
|
|
3358
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3359
|
+
const quiet = Boolean(flags["quiet"] || isJson);
|
|
3360
|
+
const stopped = await stopDaemon({ port, quiet, workspaceRoot: discovered.workspaceRoot });
|
|
3361
|
+
if (isJson) {
|
|
3362
|
+
console.log(JSON.stringify({ port, stopped }, null, 2));
|
|
3363
|
+
}
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
case "status": {
|
|
3367
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3368
|
+
const status = await getDaemonStatus({ port, workspaceRoot: discovered.workspaceRoot });
|
|
3369
|
+
if (isJson) {
|
|
3370
|
+
console.log(JSON.stringify(status, null, 2));
|
|
3371
|
+
} else {
|
|
3372
|
+
if (status.running) {
|
|
3373
|
+
console.log(`${colors.bold}${colors.green}\u25CF Esedre daemon is RUNNING${colors.reset}`);
|
|
3374
|
+
console.log(` \u2022 Port: ${status.port}`);
|
|
3375
|
+
if (status.pid) console.log(` \u2022 PID: ${status.pid}`);
|
|
3376
|
+
if (status.uptimeSeconds !== void 0) console.log(` \u2022 Uptime: ${status.uptimeSeconds}s`);
|
|
3377
|
+
if (status.startedAt) console.log(` \u2022 Started: ${status.startedAt}`);
|
|
3378
|
+
if (status.projects?.length) console.log(` \u2022 Projects: ${status.projects.join(", ")}`);
|
|
3379
|
+
if (status.logFile) console.log(` \u2022 Logs: ${status.logFile}`);
|
|
3380
|
+
} else {
|
|
3381
|
+
console.log(`${colors.yellow}\u25CB Esedre daemon is STOPPED (port ${status.port})${colors.reset}`);
|
|
3382
|
+
if (status.logFile && fs8.existsSync(status.logFile)) {
|
|
3383
|
+
console.log(` \u2022 Recent Logs: ${status.logFile}`);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
case "logs": {
|
|
3390
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3391
|
+
const lines = flags["lines"] ? parseInt(String(flags["lines"]), 10) : 40;
|
|
3392
|
+
printDaemonLogs({ port, lines, workspaceRoot: discovered.workspaceRoot });
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
3395
|
+
case "serve": {
|
|
3396
|
+
const gatewayPort = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3397
|
+
const cluster = startGatewayCluster({
|
|
3398
|
+
gatewayPort,
|
|
3399
|
+
uiPort: gatewayPort + 1,
|
|
3400
|
+
apiPort: gatewayPort + 2,
|
|
3401
|
+
storage,
|
|
3402
|
+
workspaceRoot: discovered.workspaceRoot
|
|
3403
|
+
});
|
|
3404
|
+
console.log(`${colors.bold}${colors.green}\u2714 Esedre Server active on http://localhost:${gatewayPort}${colors.reset}`);
|
|
3405
|
+
console.log(` \u2022 Web UI: http://localhost:${gatewayPort}/app (internal: ${gatewayPort + 1})`);
|
|
3406
|
+
console.log(` \u2022 REST API: http://localhost:${gatewayPort}/api (internal: ${gatewayPort + 2})`);
|
|
3407
|
+
console.log(` \u2022 Rewrite /: http://localhost:${gatewayPort}/ -> http://localhost:${gatewayPort}/app/`);
|
|
3408
|
+
console.log(` \u2022 Portfolio: http://localhost:${gatewayPort}/app?project=all`);
|
|
3409
|
+
console.log(` \u2022 Press Ctrl+C to stop.`);
|
|
3410
|
+
process.on("SIGINT", async () => {
|
|
3411
|
+
console.log(`
|
|
3412
|
+
${colors.dim}Shutting down Esedre cluster...${colors.reset}`);
|
|
3413
|
+
await cluster.close();
|
|
3414
|
+
process.exit(0);
|
|
3415
|
+
});
|
|
3416
|
+
return;
|
|
3417
|
+
}
|
|
3418
|
+
case "daemon": {
|
|
3419
|
+
const sub = positionals[0] || "status";
|
|
3420
|
+
const port = flags["port"] ? parseInt(String(flags["port"]), 10) : discovered.config?.port || 5674;
|
|
3421
|
+
const quiet = Boolean(flags["quiet"] || isJson);
|
|
3422
|
+
if (sub === "start") {
|
|
3423
|
+
const state = await startDaemon({ port, quiet, workspaceRoot: discovered.workspaceRoot });
|
|
3424
|
+
if (isJson) console.log(JSON.stringify(state, null, 2));
|
|
3425
|
+
return;
|
|
3426
|
+
} else if (sub === "stop") {
|
|
3427
|
+
const stopped = await stopDaemon({ port, quiet, workspaceRoot: discovered.workspaceRoot });
|
|
3428
|
+
if (isJson) console.log(JSON.stringify({ port, stopped }, null, 2));
|
|
3429
|
+
return;
|
|
3430
|
+
} else if (sub === "status") {
|
|
3431
|
+
const status = await getDaemonStatus({ port, workspaceRoot: discovered.workspaceRoot });
|
|
3432
|
+
if (isJson) {
|
|
3433
|
+
console.log(JSON.stringify(status, null, 2));
|
|
3434
|
+
} else {
|
|
3435
|
+
if (status.running) {
|
|
3436
|
+
console.log(`${colors.bold}${colors.green}\u25CF Esedre daemon is RUNNING${colors.reset}`);
|
|
3437
|
+
console.log(` \u2022 Port: ${status.port}`);
|
|
3438
|
+
if (status.pid) console.log(` \u2022 PID: ${status.pid}`);
|
|
3439
|
+
if (status.uptimeSeconds !== void 0) console.log(` \u2022 Uptime: ${status.uptimeSeconds}s`);
|
|
3440
|
+
if (status.startedAt) console.log(` \u2022 Started: ${status.startedAt}`);
|
|
3441
|
+
if (status.projects?.length) console.log(` \u2022 Projects: ${status.projects.join(", ")}`);
|
|
3442
|
+
if (status.logFile) console.log(` \u2022 Logs: ${status.logFile}`);
|
|
3443
|
+
} else {
|
|
3444
|
+
console.log(`${colors.yellow}\u25CB Esedre daemon is STOPPED (port ${status.port})${colors.reset}`);
|
|
3445
|
+
if (status.logFile && fs8.existsSync(status.logFile)) {
|
|
3446
|
+
console.log(` \u2022 Recent Logs: ${status.logFile}`);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
return;
|
|
3451
|
+
} else if (sub === "logs") {
|
|
3452
|
+
const logFile = path8.resolve(discovered.workspaceRoot || process.cwd(), ".esedre", "daemon.log");
|
|
3453
|
+
if (fs8.existsSync(logFile)) {
|
|
3454
|
+
console.log(fs8.readFileSync(logFile, "utf-8"));
|
|
3455
|
+
} else {
|
|
3456
|
+
console.log(`${colors.dim}No daemon log file found at ${logFile}${colors.reset}`);
|
|
3457
|
+
}
|
|
3458
|
+
return;
|
|
3459
|
+
} else {
|
|
3460
|
+
console.error(`${colors.red}Unknown daemon subcommand: '${sub}'. Expected 'start', 'stop', 'status', or 'logs'.${colors.reset}`);
|
|
3461
|
+
process.exit(1);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
case "projects": {
|
|
3465
|
+
const projects = await storage.getProjects();
|
|
3466
|
+
if (isJson) {
|
|
3467
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
3468
|
+
} else {
|
|
3469
|
+
console.log(`${colors.bold}Registered Projects:${colors.reset}`);
|
|
3470
|
+
for (const p of projects) {
|
|
3471
|
+
console.log(` \u2022 ${colors.bold}${p.code}${colors.reset} (#${p.id}): ${p.name}: ${colors.dim}${p.description}${colors.reset}`);
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
case "list": {
|
|
3477
|
+
const rawProject = flags["project"];
|
|
3478
|
+
const project = rawProject === "all" ? void 0 : rawProject || discovered.config?.projectCode || void 0;
|
|
3479
|
+
const status = flags["status"] || void 0;
|
|
3480
|
+
const type = flags["type"] || flags["category"] || void 0;
|
|
3481
|
+
const category = type;
|
|
3482
|
+
const search = flags["search"] || void 0;
|
|
3483
|
+
const tickets = await storage.listTickets({ project, status, type, category, search });
|
|
3484
|
+
if (isJson) {
|
|
3485
|
+
console.log(JSON.stringify(tickets.map((t) => ({
|
|
3486
|
+
id: t.meta.id,
|
|
3487
|
+
title: t.meta.title,
|
|
3488
|
+
type: t.meta.type || t.meta.category,
|
|
3489
|
+
category: t.meta.type || t.meta.category,
|
|
3490
|
+
status: t.meta.status,
|
|
3491
|
+
complexity: t.meta.complexity,
|
|
3492
|
+
effort: t.meta.estimatedEffort,
|
|
3493
|
+
project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
|
|
3494
|
+
sha1: t.sha1 || t.meta.sha1
|
|
3495
|
+
})), null, 2));
|
|
3496
|
+
} else {
|
|
3497
|
+
console.log(formatTicketListTable(tickets));
|
|
3498
|
+
console.log(`
|
|
3499
|
+
${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
|
|
3500
|
+
}
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
case "get": {
|
|
3504
|
+
const idStr = positionals[0];
|
|
3505
|
+
if (!idStr) {
|
|
3506
|
+
console.error(`${colors.red}Error: Ticket ID is required (e.g. ese get 1 or ese get Profe-1)${colors.reset}`);
|
|
3507
|
+
process.exit(1);
|
|
3508
|
+
}
|
|
3509
|
+
const projectFlag = flags["project"];
|
|
3510
|
+
const lookupKey = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
|
|
3511
|
+
const ticket = await storage.getTicket(lookupKey);
|
|
3512
|
+
if (!ticket) {
|
|
3513
|
+
console.error(`${colors.red}Error: Ticket #${lookupKey} not found.${colors.reset}`);
|
|
3514
|
+
process.exit(1);
|
|
3515
|
+
}
|
|
3516
|
+
if (isJson) {
|
|
3517
|
+
console.log(JSON.stringify(ticket, null, 2));
|
|
3518
|
+
} else {
|
|
3519
|
+
console.log(formatTicketDetail(ticket));
|
|
3520
|
+
}
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
case "plan": {
|
|
3524
|
+
const idStr = positionals[0];
|
|
3525
|
+
if (!idStr) {
|
|
3526
|
+
console.error(`${colors.red}Error: Ticket ID is required (e.g. ese plan 1 or ese plan Profe-1)${colors.reset}`);
|
|
3527
|
+
process.exit(1);
|
|
3528
|
+
}
|
|
3529
|
+
const projectFlag = flags["project"];
|
|
3530
|
+
const id = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
|
|
3531
|
+
const setPlan = flags["set"];
|
|
3532
|
+
const filePath = flags["file"];
|
|
3533
|
+
const isForce = Boolean(flags["force"]);
|
|
3534
|
+
const lastHash = isForce ? void 0 : flags["last-hash"];
|
|
3535
|
+
if (setPlan || filePath) {
|
|
3536
|
+
let content = setPlan;
|
|
3537
|
+
if (filePath) {
|
|
3538
|
+
if (!fs8.existsSync(filePath)) {
|
|
3539
|
+
console.error(`${colors.red}Error: Plan file "${filePath}" not found.${colors.reset}`);
|
|
3540
|
+
process.exit(1);
|
|
3541
|
+
}
|
|
3542
|
+
content = fs8.readFileSync(filePath, "utf-8");
|
|
3543
|
+
}
|
|
3544
|
+
await storage.savePlan(id, content, lastHash);
|
|
3545
|
+
console.log(`${colors.green}\u2714 Implementation plan saved for Ticket #${id}${colors.reset}`);
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
const plan = await storage.getPlan(id);
|
|
3549
|
+
if (!plan) {
|
|
3550
|
+
console.log(`${colors.dim}No implementation plan found for Ticket #${id}.${colors.reset}`);
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
if (isJson) {
|
|
3554
|
+
console.log(JSON.stringify({ ticketId: id, planMarkdown: plan }, null, 2));
|
|
3555
|
+
} else {
|
|
3556
|
+
console.log(plan);
|
|
3557
|
+
}
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
case "create": {
|
|
3561
|
+
const title = flags["title"];
|
|
3562
|
+
if (!title) {
|
|
3563
|
+
console.error(`${colors.red}Error: --title is required${colors.reset}`);
|
|
3564
|
+
process.exit(1);
|
|
3565
|
+
}
|
|
3566
|
+
const type = flags["type"] || flags["category"] || "Feature";
|
|
3567
|
+
const category = type;
|
|
3568
|
+
const projectCode = flags["project"] || discovered.config?.projectCode;
|
|
3569
|
+
if (!projectCode) {
|
|
3570
|
+
console.error(`${colors.red}Error: Project is required to create a ticket (--project <code> or configure projectCode in esedre.json).${colors.reset}`);
|
|
3571
|
+
process.exit(1);
|
|
3572
|
+
}
|
|
3573
|
+
const complexity = flags["complexity"] || "Medium";
|
|
3574
|
+
const estimatedEffort = flags["effort"] || "2.0 \u2013 4.0 hours";
|
|
3575
|
+
const summary = flags["summary"] || void 0;
|
|
3576
|
+
const submittedBy = flags["author"] || "Developer";
|
|
3577
|
+
const val = validateProjectCode(projectCode);
|
|
3578
|
+
if (!val.valid) {
|
|
3579
|
+
console.error(`${colors.red}Error: ${val.error}${colors.reset}`);
|
|
3580
|
+
process.exit(1);
|
|
3581
|
+
}
|
|
3582
|
+
const created = await storage.createTicket({
|
|
3583
|
+
title,
|
|
3584
|
+
type,
|
|
3585
|
+
category,
|
|
3586
|
+
projectCode,
|
|
3587
|
+
complexity,
|
|
3588
|
+
estimatedEffort,
|
|
3589
|
+
summary,
|
|
3590
|
+
submittedBy
|
|
3591
|
+
});
|
|
3592
|
+
if (isJson) {
|
|
3593
|
+
console.log(JSON.stringify(created, null, 2));
|
|
3594
|
+
} else {
|
|
3595
|
+
console.log(`${colors.green}\u2714 Created Ticket #${created.meta.id}: ${created.meta.title}${colors.reset} [${created.projectDescriptor?.code || created.meta.project || "UNASSIGNED"}] (SHA-1: ${created.sha1?.slice(0, 8)})`);
|
|
3596
|
+
}
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
case "update": {
|
|
3600
|
+
const idStr = positionals[0];
|
|
3601
|
+
if (!idStr) {
|
|
3602
|
+
console.error(`${colors.red}Error: Ticket ID is required (e.g. ese update 96 --status "Completed")${colors.reset}`);
|
|
3603
|
+
process.exit(1);
|
|
3604
|
+
}
|
|
3605
|
+
const id = parseInt(idStr, 10);
|
|
3606
|
+
if (isNaN(id)) {
|
|
3607
|
+
console.error(`${colors.red}Error: Invalid Ticket ID "${idStr}"${colors.reset}`);
|
|
3608
|
+
process.exit(1);
|
|
3609
|
+
}
|
|
3610
|
+
const status = flags["status"];
|
|
3611
|
+
const title = flags["title"];
|
|
3612
|
+
const complexity = flags["complexity"];
|
|
3613
|
+
const effort = flags["effort"];
|
|
3614
|
+
const inDev = flags["in-dev"] !== void 0 ? Boolean(flags["in-dev"]) : void 0;
|
|
3615
|
+
const flag = flags["flag"];
|
|
3616
|
+
const isForce = Boolean(flags["force"]);
|
|
3617
|
+
const lastHash = isForce ? void 0 : flags["last-hash"];
|
|
3618
|
+
const updates = {};
|
|
3619
|
+
const type = flags["type"] || flags["category"] || void 0;
|
|
3620
|
+
if (type) {
|
|
3621
|
+
updates.type = type;
|
|
3622
|
+
updates.category = type;
|
|
3623
|
+
}
|
|
3624
|
+
if (status) updates.status = status;
|
|
3625
|
+
if (title) updates.title = title;
|
|
3626
|
+
if (complexity) updates.complexity = complexity;
|
|
3627
|
+
if (effort) updates.estimatedEffort = effort;
|
|
3628
|
+
if (inDev !== void 0) updates.isActivePlanning = inDev;
|
|
3629
|
+
if (flag) updates.featureFlag = flag;
|
|
3630
|
+
const updated = await storage.updateTicket(id, updates, lastHash);
|
|
3631
|
+
if (isJson) {
|
|
3632
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
3633
|
+
} else {
|
|
3634
|
+
console.log(`${colors.green}\u2714 Updated Ticket #${id}: ${updated.meta.title} (Status: ${updated.meta.status}, SHA-1: ${updated.sha1?.slice(0, 8)})${colors.reset}`);
|
|
3635
|
+
}
|
|
3636
|
+
return;
|
|
3637
|
+
}
|
|
3638
|
+
case "comment": {
|
|
3639
|
+
const idStr = positionals[0];
|
|
3640
|
+
if (!idStr) {
|
|
3641
|
+
console.error(`${colors.red}Error: Ticket ID is required (e.g. ese comment 96 --text "...")`);
|
|
3642
|
+
process.exit(1);
|
|
3643
|
+
}
|
|
3644
|
+
const id = parseInt(idStr, 10);
|
|
3645
|
+
if (isNaN(id)) {
|
|
3646
|
+
console.error(`${colors.red}Error: Invalid Ticket ID "${idStr}"`);
|
|
3647
|
+
process.exit(1);
|
|
3648
|
+
}
|
|
3649
|
+
const text = flags["text"];
|
|
3650
|
+
if (!text) {
|
|
3651
|
+
console.error(`${colors.red}Error: --text is required`);
|
|
3652
|
+
process.exit(1);
|
|
3653
|
+
}
|
|
3654
|
+
const author = flags["author"] || "User";
|
|
3655
|
+
const comment = await storage.addComment(id, { author, text });
|
|
3656
|
+
if (isJson) {
|
|
3657
|
+
console.log(JSON.stringify(comment, null, 2));
|
|
3658
|
+
} else {
|
|
3659
|
+
console.log(`${colors.green}\u2714 Added comment to Ticket #${id} by ${comment.author}${colors.reset}`);
|
|
3660
|
+
}
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
default: {
|
|
3664
|
+
console.error(`${colors.red}Error: Unknown command "${command}"${colors.reset}`);
|
|
3665
|
+
printHelp();
|
|
3666
|
+
process.exit(1);
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
} catch (err) {
|
|
3670
|
+
if (err instanceof EsedreConflictError || err.name === "EsedreConflictError") {
|
|
3671
|
+
console.error(`${colors.red}Conflict Error: ${err.message}${colors.reset}`);
|
|
3672
|
+
console.error(`${colors.dim}Use --force to override if you are certain.${colors.reset}`);
|
|
3673
|
+
process.exit(1);
|
|
3674
|
+
}
|
|
3675
|
+
if (err instanceof EsedreAuthorizationError || err.name === "EsedreAuthorizationError") {
|
|
3676
|
+
console.error(`${colors.red}Access Denied: ${err.message}${colors.reset}`);
|
|
3677
|
+
process.exit(1);
|
|
3678
|
+
}
|
|
3679
|
+
console.error(`${colors.red}Fatal Error: ${err.message}${colors.reset}`);
|
|
3680
|
+
process.exit(1);
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
main();
|