owflow 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -21
- package/dist/__tests__/verify_template.test.js +102 -0
- package/dist/agents/task-classifier.md +7 -19
- package/dist/commands/quick-dev.md +48 -6
- package/dist/commands/quick-plan.md +39 -4
- package/dist/commands/work.md +13 -17
- package/dist/index.js +4 -0
- package/dist/skills/development/SKILL.md +83 -115
- package/dist/skills/migration/SKILL.md +2 -33
- package/dist/skills/orchestrator-framework/references/orchestrator-creation-checklist.md +1 -0
- package/dist/skills/orchestrator-framework/references/orchestrator-patterns.md +4 -59
- package/dist/skills/performance/SKILL.md +2 -30
- package/dist/skills/quick-bugfix/SKILL.md +96 -26
- package/dist/skills/research/SKILL.md +3 -42
- package/dist/templates/orchestrator-state-base.yml +17 -0
- package/dist/templates/orchestrator-state-development.yml +94 -0
- package/dist/templates/orchestrator-state-migration.yml +55 -0
- package/dist/templates/orchestrator-state-performance.yml +53 -0
- package/dist/templates/orchestrator-state-research.yml +61 -0
- package/dist/templates/quick-bugfix-task.yml +10 -0
- package/dist/templates/quick-dev-task.yml +10 -0
- package/dist/templates/quick-plan-task.yml +11 -0
- package/dist/tools/verify_template.js +61 -0
- package/package.json +4 -3
- package/dist/agents/ui-mockup-generator.md +0 -369
- package/dist/commands/product-design.md +0 -37
- package/dist/skills/product-design/SKILL.md +0 -843
- package/dist/skills/product-design/references/characteristic-detection.md +0 -92
- package/dist/skills/product-design/references/interaction-patterns.md +0 -206
- package/dist/skills/product-design/references/visual-companion.md +0 -190
- package/dist/skills/product-design/server/index.js +0 -302
- package/dist/skills/product-design/server/index.ts +0 -359
- package/dist/skills/product-design/server/template.html +0 -256
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
// Parse --task-path from CLI args
|
|
7
|
-
const taskPathArg = process.argv.find((a) => a.startsWith("--task-path="));
|
|
8
|
-
const taskPath = taskPathArg ? taskPathArg.split("=")[1] : null;
|
|
9
|
-
if (!taskPath) {
|
|
10
|
-
console.warn("[visual-companion] No --task-path provided. Mockups will NOT be saved to disk.");
|
|
11
|
-
}
|
|
12
|
-
// In-memory state: array of all mockups (screens)
|
|
13
|
-
const mockups = [];
|
|
14
|
-
let latestId = null;
|
|
15
|
-
let version = 0;
|
|
16
|
-
const sseClients = [];
|
|
17
|
-
function slugify(title) {
|
|
18
|
-
return (title
|
|
19
|
-
.toLowerCase()
|
|
20
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
-
.replace(/^-|-$/g, "") || "untitled");
|
|
22
|
-
}
|
|
23
|
-
// Save a rendered standalone HTML file to disk. Returns true if saved, false if skipped.
|
|
24
|
-
function saveToDisk(mockup) {
|
|
25
|
-
if (!taskPath) {
|
|
26
|
-
console.warn(`[visual-companion] Skipping disk save for "${mockup.id}" — no task path configured.`);
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
const dir = path.join(taskPath, "analysis", "mockups");
|
|
30
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
-
const html = renderScreen(mockup);
|
|
32
|
-
const filePath = path.join(dir, `${mockup.id}.html`);
|
|
33
|
-
fs.writeFileSync(filePath, html, "utf-8");
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
// Render a single screen page with navigation
|
|
37
|
-
function renderScreen(mockup) {
|
|
38
|
-
const templatePath = path.join(__dirname, "template.html");
|
|
39
|
-
let html = fs.readFileSync(templatePath, "utf-8");
|
|
40
|
-
const title = mockup.title || "Untitled";
|
|
41
|
-
const content = `<style>${mockup.css || ""}</style>\n<div id="mockup-content">${mockup.html || ""}</div>`;
|
|
42
|
-
const annotations = JSON.stringify(mockup.annotations || []);
|
|
43
|
-
// Build screen nav
|
|
44
|
-
const navItems = mockups
|
|
45
|
-
.map((m) => `<a href="/screen/${m.id}" class="nav-screen${m.id === mockup.id ? " active" : ""}">${m.title}</a>`)
|
|
46
|
-
.join("");
|
|
47
|
-
const idx = mockups.indexOf(mockup);
|
|
48
|
-
const prev = idx > 0 ? mockups[idx - 1] : null;
|
|
49
|
-
const next = idx < mockups.length - 1 ? mockups[idx + 1] : null;
|
|
50
|
-
const prevLink = prev
|
|
51
|
-
? `<a href="/screen/${prev.id}" class="nav-arrow">← ${prev.title}</a>`
|
|
52
|
-
: "<span></span>";
|
|
53
|
-
const nextLink = next
|
|
54
|
-
? `<a href="/screen/${next.id}" class="nav-arrow">${next.title} →</a>`
|
|
55
|
-
: "<span></span>";
|
|
56
|
-
const nav = mockups.length > 1
|
|
57
|
-
? `<nav class="screen-nav"><div class="nav-screens">${navItems}</div><div class="nav-prevnext">${prevLink}${nextLink}</div></nav>`
|
|
58
|
-
: "";
|
|
59
|
-
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
60
|
-
html = html.replace("{{NAV}}", nav);
|
|
61
|
-
html = html.replace("{{CONTENT}}", content);
|
|
62
|
-
html = html.replace("{{ANNOTATIONS}}", annotations);
|
|
63
|
-
return html;
|
|
64
|
-
}
|
|
65
|
-
// Render the gallery index page
|
|
66
|
-
function renderGallery() {
|
|
67
|
-
const templatePath = path.join(__dirname, "template.html");
|
|
68
|
-
let html = fs.readFileSync(templatePath, "utf-8");
|
|
69
|
-
const title = `Design Gallery — ${mockups.length} screen${mockups.length !== 1 ? "s" : ""}`;
|
|
70
|
-
let content;
|
|
71
|
-
if (mockups.length === 0) {
|
|
72
|
-
content =
|
|
73
|
-
'<div class="placeholder">Waiting for design mockups...<br>The orchestrator will send screens here.</div>';
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
const cards = mockups
|
|
77
|
-
.map((m) => `
|
|
78
|
-
<a href="/screen/${m.id}" class="gallery-card">
|
|
79
|
-
<div class="gallery-card-title">${m.title}</div>
|
|
80
|
-
<div class="gallery-card-preview"><style scoped>${m.css || ""}</style>${m.html || ""}</div>
|
|
81
|
-
</a>
|
|
82
|
-
`)
|
|
83
|
-
.join("");
|
|
84
|
-
content = `<div class="gallery-grid">${cards}</div>`;
|
|
85
|
-
}
|
|
86
|
-
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
87
|
-
html = html.replace("{{NAV}}", "");
|
|
88
|
-
html = html.replace("{{CONTENT}}", content);
|
|
89
|
-
html = html.replace("{{ANNOTATIONS}}", "[]");
|
|
90
|
-
return html;
|
|
91
|
-
}
|
|
92
|
-
// Parse JSON body from request
|
|
93
|
-
function parseBody(req) {
|
|
94
|
-
return new Promise((resolve, reject) => {
|
|
95
|
-
let data = "";
|
|
96
|
-
req.on("data", (chunk) => {
|
|
97
|
-
data += chunk;
|
|
98
|
-
});
|
|
99
|
-
req.on("end", () => {
|
|
100
|
-
try {
|
|
101
|
-
resolve(data ? JSON.parse(data) : {});
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
104
|
-
reject(new Error("Invalid JSON body"));
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
req.on("error", reject);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
// Notify all SSE clients
|
|
111
|
-
function notifyClients() {
|
|
112
|
-
for (let i = sseClients.length - 1; i >= 0; i--) {
|
|
113
|
-
try {
|
|
114
|
-
sseClients[i].write("data: refresh\n\n");
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
sseClients.splice(i, 1);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
function jsonResponse(res, statusCode, body) {
|
|
122
|
-
const payload = JSON.stringify(body);
|
|
123
|
-
res.writeHead(statusCode, {
|
|
124
|
-
"Content-Type": "application/json",
|
|
125
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
126
|
-
});
|
|
127
|
-
res.end(payload);
|
|
128
|
-
}
|
|
129
|
-
function htmlResponse(res, html) {
|
|
130
|
-
res.writeHead(200, {
|
|
131
|
-
"Content-Type": "text/html",
|
|
132
|
-
"Content-Length": Buffer.byteLength(html),
|
|
133
|
-
});
|
|
134
|
-
res.end(html);
|
|
135
|
-
}
|
|
136
|
-
// Main request handler
|
|
137
|
-
async function handler(req, res) {
|
|
138
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
139
|
-
try {
|
|
140
|
-
// GET /status
|
|
141
|
-
if (req.method === "GET" && url.pathname === "/status") {
|
|
142
|
-
jsonResponse(res, 200, {
|
|
143
|
-
status: "ok",
|
|
144
|
-
version: "1.0.0",
|
|
145
|
-
port: activePort,
|
|
146
|
-
screens: mockups.length,
|
|
147
|
-
taskPath: taskPath || null,
|
|
148
|
-
persistence: !!taskPath,
|
|
149
|
-
});
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
// POST /shutdown
|
|
153
|
-
if (req.method === "POST" && url.pathname === "/shutdown") {
|
|
154
|
-
jsonResponse(res, 200, { status: "shutting_down" });
|
|
155
|
-
cleanupPidFile();
|
|
156
|
-
setTimeout(() => process.exit(0), 100);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// GET /events (SSE)
|
|
160
|
-
if (req.method === "GET" && url.pathname === "/events") {
|
|
161
|
-
res.writeHead(200, {
|
|
162
|
-
"Content-Type": "text/event-stream",
|
|
163
|
-
"Cache-Control": "no-cache",
|
|
164
|
-
Connection: "keep-alive",
|
|
165
|
-
});
|
|
166
|
-
res.write("data: connected\n\n");
|
|
167
|
-
sseClients.push(res);
|
|
168
|
-
req.on("close", () => {
|
|
169
|
-
const idx = sseClients.indexOf(res);
|
|
170
|
-
if (idx !== -1)
|
|
171
|
-
sseClients.splice(idx, 1);
|
|
172
|
-
});
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
// POST /update
|
|
176
|
-
if (req.method === "POST" && url.pathname === "/update") {
|
|
177
|
-
const body = await parseBody(req);
|
|
178
|
-
const id = slugify(body.title || "untitled");
|
|
179
|
-
const mockup = {
|
|
180
|
-
id,
|
|
181
|
-
type: body.type || "mockup",
|
|
182
|
-
title: body.title || "Untitled",
|
|
183
|
-
html: body.html || "",
|
|
184
|
-
css: body.css || "",
|
|
185
|
-
annotations: body.annotations || [],
|
|
186
|
-
};
|
|
187
|
-
// Update existing or add new
|
|
188
|
-
const existingIdx = mockups.findIndex((m) => m.id === id);
|
|
189
|
-
if (existingIdx !== -1) {
|
|
190
|
-
mockups[existingIdx] = mockup;
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
mockups.push(mockup);
|
|
194
|
-
}
|
|
195
|
-
latestId = id;
|
|
196
|
-
version++;
|
|
197
|
-
const saved = saveToDisk(mockup);
|
|
198
|
-
notifyClients();
|
|
199
|
-
jsonResponse(res, 200, {
|
|
200
|
-
status: "updated",
|
|
201
|
-
version,
|
|
202
|
-
id,
|
|
203
|
-
screens: mockups.length,
|
|
204
|
-
saved,
|
|
205
|
-
});
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
// GET /screen/:id
|
|
209
|
-
const screenMatch = url.pathname.match(/^\/screen\/([a-z0-9-]+)$/);
|
|
210
|
-
if (req.method === "GET" && screenMatch) {
|
|
211
|
-
const mockup = mockups.find((m) => m.id === screenMatch[1]);
|
|
212
|
-
if (!mockup) {
|
|
213
|
-
jsonResponse(res, 404, { error: "Screen not found" });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
htmlResponse(res, renderScreen(mockup));
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
// GET /latest
|
|
220
|
-
if (req.method === "GET" && url.pathname === "/latest") {
|
|
221
|
-
const mockup = mockups.find((m) => m.id === latestId);
|
|
222
|
-
if (!mockup) {
|
|
223
|
-
htmlResponse(res, renderGallery());
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
htmlResponse(res, renderScreen(mockup));
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
// GET / (gallery)
|
|
230
|
-
if (req.method === "GET" && url.pathname === "/") {
|
|
231
|
-
htmlResponse(res, renderGallery());
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
jsonResponse(res, 404, { error: "Not found" });
|
|
235
|
-
}
|
|
236
|
-
catch (err) {
|
|
237
|
-
console.error("Request error:", err.message);
|
|
238
|
-
jsonResponse(res, 500, { error: err.message });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
// PID file management
|
|
242
|
-
function pidFilePath() {
|
|
243
|
-
if (!taskPath)
|
|
244
|
-
return null;
|
|
245
|
-
return path.join(taskPath, "analysis", "mockups", ".visual-companion.pid");
|
|
246
|
-
}
|
|
247
|
-
function writePidFile() {
|
|
248
|
-
const p = pidFilePath();
|
|
249
|
-
if (!p)
|
|
250
|
-
return;
|
|
251
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
252
|
-
fs.writeFileSync(p, String(process.pid), "utf-8");
|
|
253
|
-
}
|
|
254
|
-
function cleanupPidFile() {
|
|
255
|
-
const p = pidFilePath();
|
|
256
|
-
if (p)
|
|
257
|
-
try {
|
|
258
|
-
fs.unlinkSync(p);
|
|
259
|
-
}
|
|
260
|
-
catch { }
|
|
261
|
-
}
|
|
262
|
-
process.on("SIGTERM", () => {
|
|
263
|
-
cleanupPidFile();
|
|
264
|
-
process.exit(0);
|
|
265
|
-
});
|
|
266
|
-
process.on("SIGINT", () => {
|
|
267
|
-
cleanupPidFile();
|
|
268
|
-
process.exit(0);
|
|
269
|
-
});
|
|
270
|
-
// Port fallback logic
|
|
271
|
-
let activePort = null;
|
|
272
|
-
function tryPort(port) {
|
|
273
|
-
return new Promise((resolve, reject) => {
|
|
274
|
-
const server = http.createServer(handler);
|
|
275
|
-
server.listen(port, () => resolve(server));
|
|
276
|
-
server.on("error", reject);
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
async function start() {
|
|
280
|
-
const ports = [3847, 3848, 3849, 3850];
|
|
281
|
-
for (const port of ports) {
|
|
282
|
-
try {
|
|
283
|
-
await tryPort(port);
|
|
284
|
-
activePort = port;
|
|
285
|
-
console.log(`Visual companion server running at http://localhost:${port}`);
|
|
286
|
-
if (taskPath)
|
|
287
|
-
console.log(`Saving mockups to: ${path.join(taskPath, "analysis", "mockups")}`);
|
|
288
|
-
writePidFile();
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
if (err.code === "EADDRINUSE") {
|
|
293
|
-
console.error(`Port ${port} in use, trying next...`);
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
throw err;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
console.error("All ports (3847-3850) in use. Cannot start server.");
|
|
300
|
-
process.exit(1);
|
|
301
|
-
}
|
|
302
|
-
start();
|
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
6
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
|
|
8
|
-
// Parse --task-path from CLI args
|
|
9
|
-
const taskPathArg = process.argv.find((a) => a.startsWith("--task-path="));
|
|
10
|
-
const taskPath = taskPathArg ? taskPathArg.split("=")[1] : null;
|
|
11
|
-
|
|
12
|
-
if (!taskPath) {
|
|
13
|
-
console.warn(
|
|
14
|
-
"[visual-companion] No --task-path provided. Mockups will NOT be saved to disk.",
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface Mockup {
|
|
19
|
-
id: string;
|
|
20
|
-
title?: string;
|
|
21
|
-
html?: string;
|
|
22
|
-
css?: string;
|
|
23
|
-
annotations?: any[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// In-memory state: array of all mockups (screens)
|
|
27
|
-
const mockups: Array<Mockup> = [];
|
|
28
|
-
let latestId: string | null = null;
|
|
29
|
-
let version = 0;
|
|
30
|
-
const sseClients: Array<any> = [];
|
|
31
|
-
|
|
32
|
-
function slugify(title: string): string {
|
|
33
|
-
return (
|
|
34
|
-
title
|
|
35
|
-
.toLowerCase()
|
|
36
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
37
|
-
.replace(/^-|-$/g, "") || "untitled"
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Save a rendered standalone HTML file to disk. Returns true if saved, false if skipped.
|
|
42
|
-
function saveToDisk(mockup: Mockup): boolean {
|
|
43
|
-
if (!taskPath) {
|
|
44
|
-
console.warn(
|
|
45
|
-
`[visual-companion] Skipping disk save for "${mockup.id}" — no task path configured.`,
|
|
46
|
-
);
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
const dir = path.join(taskPath, "analysis", "mockups");
|
|
50
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
-
|
|
52
|
-
const html = renderScreen(mockup);
|
|
53
|
-
const filePath = path.join(dir, `${mockup.id}.html`);
|
|
54
|
-
fs.writeFileSync(filePath, html, "utf-8");
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Render a single screen page with navigation
|
|
59
|
-
function renderScreen(mockup: Mockup) {
|
|
60
|
-
const templatePath = path.join(__dirname, "template.html");
|
|
61
|
-
let html = fs.readFileSync(templatePath, "utf-8");
|
|
62
|
-
|
|
63
|
-
const title = mockup.title || "Untitled";
|
|
64
|
-
const content = `<style>${mockup.css || ""}</style>\n<div id="mockup-content">${mockup.html || ""}</div>`;
|
|
65
|
-
const annotations = JSON.stringify(mockup.annotations || []);
|
|
66
|
-
|
|
67
|
-
// Build screen nav
|
|
68
|
-
const navItems = mockups
|
|
69
|
-
.map(
|
|
70
|
-
(m) =>
|
|
71
|
-
`<a href="/screen/${m.id}" class="nav-screen${m.id === mockup.id ? " active" : ""}">${m.title}</a>`,
|
|
72
|
-
)
|
|
73
|
-
.join("");
|
|
74
|
-
|
|
75
|
-
const idx = mockups.indexOf(mockup);
|
|
76
|
-
const prev = idx > 0 ? mockups[idx - 1] : null;
|
|
77
|
-
const next = idx < mockups.length - 1 ? mockups[idx + 1] : null;
|
|
78
|
-
const prevLink = prev
|
|
79
|
-
? `<a href="/screen/${prev.id}" class="nav-arrow">← ${prev.title}</a>`
|
|
80
|
-
: "<span></span>";
|
|
81
|
-
const nextLink = next
|
|
82
|
-
? `<a href="/screen/${next.id}" class="nav-arrow">${next.title} →</a>`
|
|
83
|
-
: "<span></span>";
|
|
84
|
-
|
|
85
|
-
const nav =
|
|
86
|
-
mockups.length > 1
|
|
87
|
-
? `<nav class="screen-nav"><div class="nav-screens">${navItems}</div><div class="nav-prevnext">${prevLink}${nextLink}</div></nav>`
|
|
88
|
-
: "";
|
|
89
|
-
|
|
90
|
-
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
91
|
-
html = html.replace("{{NAV}}", nav);
|
|
92
|
-
html = html.replace("{{CONTENT}}", content);
|
|
93
|
-
html = html.replace("{{ANNOTATIONS}}", annotations);
|
|
94
|
-
|
|
95
|
-
return html;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Render the gallery index page
|
|
99
|
-
function renderGallery() {
|
|
100
|
-
const templatePath = path.join(__dirname, "template.html");
|
|
101
|
-
let html = fs.readFileSync(templatePath, "utf-8");
|
|
102
|
-
|
|
103
|
-
const title = `Design Gallery — ${mockups.length} screen${mockups.length !== 1 ? "s" : ""}`;
|
|
104
|
-
|
|
105
|
-
let content;
|
|
106
|
-
if (mockups.length === 0) {
|
|
107
|
-
content =
|
|
108
|
-
'<div class="placeholder">Waiting for design mockups...<br>The orchestrator will send screens here.</div>';
|
|
109
|
-
} else {
|
|
110
|
-
const cards = mockups
|
|
111
|
-
.map(
|
|
112
|
-
(m) => `
|
|
113
|
-
<a href="/screen/${m.id}" class="gallery-card">
|
|
114
|
-
<div class="gallery-card-title">${m.title}</div>
|
|
115
|
-
<div class="gallery-card-preview"><style scoped>${m.css || ""}</style>${m.html || ""}</div>
|
|
116
|
-
</a>
|
|
117
|
-
`,
|
|
118
|
-
)
|
|
119
|
-
.join("");
|
|
120
|
-
content = `<div class="gallery-grid">${cards}</div>`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
124
|
-
html = html.replace("{{NAV}}", "");
|
|
125
|
-
html = html.replace("{{CONTENT}}", content);
|
|
126
|
-
html = html.replace("{{ANNOTATIONS}}", "[]");
|
|
127
|
-
|
|
128
|
-
return html;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Parse JSON body from request
|
|
132
|
-
function parseBody(req: http.IncomingMessage): Promise<any> {
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
let data = "";
|
|
135
|
-
req.on("data", (chunk) => {
|
|
136
|
-
data += chunk;
|
|
137
|
-
});
|
|
138
|
-
req.on("end", () => {
|
|
139
|
-
try {
|
|
140
|
-
resolve(data ? JSON.parse(data) : {});
|
|
141
|
-
} catch (err) {
|
|
142
|
-
reject(new Error("Invalid JSON body"));
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
req.on("error", reject);
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Notify all SSE clients
|
|
150
|
-
function notifyClients() {
|
|
151
|
-
for (let i = sseClients.length - 1; i >= 0; i--) {
|
|
152
|
-
try {
|
|
153
|
-
sseClients[i].write("data: refresh\n\n");
|
|
154
|
-
} catch {
|
|
155
|
-
sseClients.splice(i, 1);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function jsonResponse(res: http.ServerResponse, statusCode: number, body: any) {
|
|
161
|
-
const payload = JSON.stringify(body);
|
|
162
|
-
res.writeHead(statusCode, {
|
|
163
|
-
"Content-Type": "application/json",
|
|
164
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
165
|
-
});
|
|
166
|
-
res.end(payload);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function htmlResponse(res: http.ServerResponse, html: string) {
|
|
170
|
-
res.writeHead(200, {
|
|
171
|
-
"Content-Type": "text/html",
|
|
172
|
-
"Content-Length": Buffer.byteLength(html),
|
|
173
|
-
});
|
|
174
|
-
res.end(html);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Main request handler
|
|
178
|
-
async function handler(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
179
|
-
const url = new URL(req.url!, `http://${req.headers.host}`);
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
// GET /status
|
|
183
|
-
if (req.method === "GET" && url.pathname === "/status") {
|
|
184
|
-
jsonResponse(res, 200, {
|
|
185
|
-
status: "ok",
|
|
186
|
-
version: "1.0.0",
|
|
187
|
-
port: activePort,
|
|
188
|
-
screens: mockups.length,
|
|
189
|
-
taskPath: taskPath || null,
|
|
190
|
-
persistence: !!taskPath,
|
|
191
|
-
});
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// POST /shutdown
|
|
196
|
-
if (req.method === "POST" && url.pathname === "/shutdown") {
|
|
197
|
-
jsonResponse(res, 200, { status: "shutting_down" });
|
|
198
|
-
cleanupPidFile();
|
|
199
|
-
setTimeout(() => process.exit(0), 100);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// GET /events (SSE)
|
|
204
|
-
if (req.method === "GET" && url.pathname === "/events") {
|
|
205
|
-
res.writeHead(200, {
|
|
206
|
-
"Content-Type": "text/event-stream",
|
|
207
|
-
"Cache-Control": "no-cache",
|
|
208
|
-
Connection: "keep-alive",
|
|
209
|
-
});
|
|
210
|
-
res.write("data: connected\n\n");
|
|
211
|
-
sseClients.push(res);
|
|
212
|
-
req.on("close", () => {
|
|
213
|
-
const idx = sseClients.indexOf(res);
|
|
214
|
-
if (idx !== -1) sseClients.splice(idx, 1);
|
|
215
|
-
});
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// POST /update
|
|
220
|
-
if (req.method === "POST" && url.pathname === "/update") {
|
|
221
|
-
const body = await parseBody(req);
|
|
222
|
-
const id = slugify(body.title || "untitled");
|
|
223
|
-
|
|
224
|
-
const mockup = {
|
|
225
|
-
id,
|
|
226
|
-
type: body.type || "mockup",
|
|
227
|
-
title: body.title || "Untitled",
|
|
228
|
-
html: body.html || "",
|
|
229
|
-
css: body.css || "",
|
|
230
|
-
annotations: body.annotations || [],
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
// Update existing or add new
|
|
234
|
-
const existingIdx = mockups.findIndex((m) => m.id === id);
|
|
235
|
-
if (existingIdx !== -1) {
|
|
236
|
-
mockups[existingIdx] = mockup;
|
|
237
|
-
} else {
|
|
238
|
-
mockups.push(mockup);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
latestId = id;
|
|
242
|
-
version++;
|
|
243
|
-
const saved = saveToDisk(mockup);
|
|
244
|
-
notifyClients();
|
|
245
|
-
jsonResponse(res, 200, {
|
|
246
|
-
status: "updated",
|
|
247
|
-
version,
|
|
248
|
-
id,
|
|
249
|
-
screens: mockups.length,
|
|
250
|
-
saved,
|
|
251
|
-
});
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// GET /screen/:id
|
|
256
|
-
const screenMatch = url.pathname.match(/^\/screen\/([a-z0-9-]+)$/);
|
|
257
|
-
if (req.method === "GET" && screenMatch) {
|
|
258
|
-
const mockup = mockups.find((m) => m.id === screenMatch[1]);
|
|
259
|
-
if (!mockup) {
|
|
260
|
-
jsonResponse(res, 404, { error: "Screen not found" });
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
htmlResponse(res, renderScreen(mockup));
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// GET /latest
|
|
268
|
-
if (req.method === "GET" && url.pathname === "/latest") {
|
|
269
|
-
const mockup = mockups.find((m) => m.id === latestId);
|
|
270
|
-
if (!mockup) {
|
|
271
|
-
htmlResponse(res, renderGallery());
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
htmlResponse(res, renderScreen(mockup));
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// GET / (gallery)
|
|
279
|
-
if (req.method === "GET" && url.pathname === "/") {
|
|
280
|
-
htmlResponse(res, renderGallery());
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
jsonResponse(res, 404, { error: "Not found" });
|
|
285
|
-
} catch (err: any) {
|
|
286
|
-
console.error("Request error:", err.message);
|
|
287
|
-
jsonResponse(res, 500, { error: err.message });
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// PID file management
|
|
292
|
-
function pidFilePath() {
|
|
293
|
-
if (!taskPath) return null;
|
|
294
|
-
return path.join(taskPath, "analysis", "mockups", ".visual-companion.pid");
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function writePidFile() {
|
|
298
|
-
const p = pidFilePath();
|
|
299
|
-
if (!p) return;
|
|
300
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
301
|
-
fs.writeFileSync(p, String(process.pid), "utf-8");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function cleanupPidFile() {
|
|
305
|
-
const p = pidFilePath();
|
|
306
|
-
if (p)
|
|
307
|
-
try {
|
|
308
|
-
fs.unlinkSync(p);
|
|
309
|
-
} catch {}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
process.on("SIGTERM", () => {
|
|
313
|
-
cleanupPidFile();
|
|
314
|
-
process.exit(0);
|
|
315
|
-
});
|
|
316
|
-
process.on("SIGINT", () => {
|
|
317
|
-
cleanupPidFile();
|
|
318
|
-
process.exit(0);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// Port fallback logic
|
|
322
|
-
let activePort: number | null = null;
|
|
323
|
-
|
|
324
|
-
function tryPort(port: number) {
|
|
325
|
-
return new Promise<http.Server>((resolve, reject) => {
|
|
326
|
-
const server = http.createServer(handler);
|
|
327
|
-
server.listen(port, () => resolve(server));
|
|
328
|
-
server.on("error", reject);
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function start() {
|
|
333
|
-
const ports = [3847, 3848, 3849, 3850];
|
|
334
|
-
for (const port of ports) {
|
|
335
|
-
try {
|
|
336
|
-
await tryPort(port);
|
|
337
|
-
activePort = port;
|
|
338
|
-
console.log(
|
|
339
|
-
`Visual companion server running at http://localhost:${port}`,
|
|
340
|
-
);
|
|
341
|
-
if (taskPath)
|
|
342
|
-
console.log(
|
|
343
|
-
`Saving mockups to: ${path.join(taskPath, "analysis", "mockups")}`,
|
|
344
|
-
);
|
|
345
|
-
writePidFile();
|
|
346
|
-
return;
|
|
347
|
-
} catch (err: any) {
|
|
348
|
-
if (err.code === "EADDRINUSE") {
|
|
349
|
-
console.error(`Port ${port} in use, trying next...`);
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
throw err;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
console.error("All ports (3847-3850) in use. Cannot start server.");
|
|
356
|
-
process.exit(1);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
start();
|