@rashidee/co2 1.3.9 → 1.3.11
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/dist/.co2-dat/app.db-shm +0 -0
- package/dist/.co2-dat/app.db-wal +0 -0
- package/dist/index.js +267 -74
- package/package.json +1 -1
- package/plugin/skills/mockgen-shadcn/SKILL.md +11 -7
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +14 -3
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +98 -40
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +98 -40
- package/plugin/skills/util-gencicdscript/SKILL.md +34 -8
- package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +127 -20
- package/plugin/skills/util-plancicd/SKILL.md +62 -1
- package/static/assets/{abnfDiagram-VRR7QNED-C0CrqPXX.js → abnfDiagram-VRR7QNED-DBzgA_2G.js} +1 -1
- package/static/assets/{arc-BFSRYIcI.js → arc-Bc02_4l6.js} +1 -1
- package/static/assets/{architectureDiagram-ZJ3FMSHR-DuwTK2W9.js → architectureDiagram-ZJ3FMSHR-ixN9wSDW.js} +1 -1
- package/static/assets/{blockDiagram-677ZJIJ3-Bn9tAB0n.js → blockDiagram-677ZJIJ3-Ob4lZkCY.js} +1 -1
- package/static/assets/{c4Diagram-LMCZKHZV-CDiCEjY7.js → c4Diagram-LMCZKHZV-BeJY3Zks.js} +1 -1
- package/static/assets/channel-CJFxgMC1.js +1 -0
- package/static/assets/{chunk-2Q5K7J3B-BMwS7NgW.js → chunk-2Q5K7J3B-DTFIWNGb.js} +1 -1
- package/static/assets/{chunk-32BRIVSS-8l0XBCjq.js → chunk-32BRIVSS-DLTvoPpT.js} +1 -1
- package/static/assets/{chunk-5VM5RSS4-C6Cv-67E.js → chunk-5VM5RSS4-BKlvf6hI.js} +1 -1
- package/static/assets/{chunk-EX3LRPZG-DjWs4lB9.js → chunk-EX3LRPZG-B9ej8_ma.js} +1 -1
- package/static/assets/{chunk-JWPE2WC7-DuK_Mu9c.js → chunk-JWPE2WC7-DAYaiy17.js} +1 -1
- package/static/assets/{chunk-MOJQB5TN-K-bFFmpr.js → chunk-MOJQB5TN-D6rRKzWc.js} +1 -1
- package/static/assets/{chunk-RYQCIY6F-B-b4TyDb.js → chunk-RYQCIY6F-CE4CHN6p.js} +1 -1
- package/static/assets/{chunk-V7JOEXUC-BZRKjyVr.js → chunk-V7JOEXUC-BR-Kl6S2.js} +1 -1
- package/static/assets/{chunk-VR4S4FIN-BcvbM_n3.js → chunk-VR4S4FIN-Bo9J3WGe.js} +1 -1
- package/static/assets/{chunk-XXDRQBXY-Z2nhcpRg.js → chunk-XXDRQBXY-DjI5dJHR.js} +1 -1
- package/static/assets/classDiagram-OUVF2IWQ-B5OYUyBf.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-B5OYUyBf.js +1 -0
- package/static/assets/{cose-bilkent-JH36ORCC-CPBw8Z2X.js → cose-bilkent-JH36ORCC-DLciz-3w.js} +1 -1
- package/static/assets/{cynefin-VYW2F7L2-DeJ1X-1F.js → cynefin-VYW2F7L2-DqfVJKpm.js} +1 -1
- package/static/assets/{cynefinDiagram-TSTJHNR4-BPs3jIZR.js → cynefinDiagram-TSTJHNR4-D14oLdNM.js} +1 -1
- package/static/assets/{dagre-VKFMJZFB-Bn5Xp63O.js → dagre-VKFMJZFB-BduMbW80.js} +1 -1
- package/static/assets/{diagram-FQU43EPY-DWIlVMUR.js → diagram-FQU43EPY-CLQg7RWT.js} +1 -1
- package/static/assets/{diagram-G47NLZAW-BWxpunVI.js → diagram-G47NLZAW-Dq3Ymr1b.js} +1 -1
- package/static/assets/{diagram-NH7WQ7WH-CpoAYUis.js → diagram-NH7WQ7WH-BPjov8S5.js} +1 -1
- package/static/assets/{diagram-OA4YK3LP-Dv1XfQGK.js → diagram-OA4YK3LP-BgQgpiJD.js} +1 -1
- package/static/assets/{diagram-WEI45ONY-DSk6wZtO.js → diagram-WEI45ONY-BeOPSl27.js} +1 -1
- package/static/assets/{ebnfDiagram-CCIWWBDH-DIDhI2dD.js → ebnfDiagram-CCIWWBDH-DFsTw8OM.js} +1 -1
- package/static/assets/{erDiagram-Q63AITRT-CGO6ep6G.js → erDiagram-Q63AITRT-scBEBawL.js} +1 -1
- package/static/assets/{flowDiagram-23GEKE2U-D8nzLjfG.js → flowDiagram-23GEKE2U-BfX-2ZO-.js} +1 -1
- package/static/assets/{ganttDiagram-NO4QXBWP-Usz8f26d.js → ganttDiagram-NO4QXBWP-BR5IJ1mv.js} +1 -1
- package/static/assets/{gitGraphDiagram-IHSO6WYX-WEJUXa7P.js → gitGraphDiagram-IHSO6WYX-zPu_QDUM.js} +1 -1
- package/static/assets/{index-CM4GfOLV.css → index-DKI3BgkF.css} +1 -1
- package/static/assets/{index-j3pCmOym.js → index-Dnp-sAA_.js} +111 -115
- package/static/assets/{infoDiagram-FWYZ7A6U-BRPwqRvz.js → infoDiagram-FWYZ7A6U-CpyKTQb9.js} +1 -1
- package/static/assets/{ishikawaDiagram-FXEZZL3T-CrntEiqz.js → ishikawaDiagram-FXEZZL3T-C2SXt-2Y.js} +1 -1
- package/static/assets/{journeyDiagram-5HDEW3XC-BmhRsz4F.js → journeyDiagram-5HDEW3XC-DJavk0C4.js} +1 -1
- package/static/assets/{kanban-definition-HUTT4EX6-CWxUXdc3.js → kanban-definition-HUTT4EX6-xN4pVGL7.js} +1 -1
- package/static/assets/{linear-BGjzqzT6.js → linear-BMWKojRX.js} +1 -1
- package/static/assets/{mindmap-definition-LN4V7U3C-CoVNmO4b.js → mindmap-definition-LN4V7U3C-DOaDf7u0.js} +1 -1
- package/static/assets/{pegDiagram-2B236MQR-hBHOzL7x.js → pegDiagram-2B236MQR-B1px906A.js} +1 -1
- package/static/assets/{pieDiagram-ENE6RG2P-Cpv7VZPG.js → pieDiagram-ENE6RG2P-s0vEagFS.js} +1 -1
- package/static/assets/{quadrantDiagram-ABIIQ3AL-BIQAB21D.js → quadrantDiagram-ABIIQ3AL-BEhIl8IZ.js} +1 -1
- package/static/assets/{railroadDiagram-RFXS5EU6-DX6hST3S.js → railroadDiagram-RFXS5EU6-fFsin92S.js} +1 -1
- package/static/assets/{requirementDiagram-TGXJPOKE-Gas2cE_l.js → requirementDiagram-TGXJPOKE-Cf9J7tii.js} +1 -1
- package/static/assets/{sankeyDiagram-HTMAVEWB-BpwoyJDe.js → sankeyDiagram-HTMAVEWB-Cy5gaZqM.js} +1 -1
- package/static/assets/{sequenceDiagram-DBY2YBRQ-CzBxna5k.js → sequenceDiagram-DBY2YBRQ-DI61Vnk1.js} +1 -1
- package/static/assets/{sizeCapture-X5ZJPWSS-Cn-5NsLr.js → sizeCapture-X5ZJPWSS-CmUBUW3B.js} +1 -1
- package/static/assets/{stateDiagram-2N3HPSRC-BiW03iCW.js → stateDiagram-2N3HPSRC-CBMhhwuE.js} +1 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-BD1I889C.js +1 -0
- package/static/assets/{swimlanes-5IMT3BWC-3jPuufWt.js → swimlanes-5IMT3BWC-CtZlr9wD.js} +2 -2
- package/static/assets/swimlanesDiagram-G3AALYLV-BfEZlw_9.js +8 -0
- package/static/assets/{timeline-definition-FHXFAJF6-CzThWt1O.js → timeline-definition-FHXFAJF6-BC69PWR-.js} +1 -1
- package/static/assets/{vennDiagram-L72KCM5P-D1Axj0CM.js → vennDiagram-L72KCM5P-jEaqKPSP.js} +1 -1
- package/static/assets/{wardleyDiagram-EHGQE667-mKj7UT6Q.js → wardleyDiagram-EHGQE667-CKFWi3EK.js} +1 -1
- package/static/assets/{xychartDiagram-FW5EYKEG-CSAkvAer.js → xychartDiagram-FW5EYKEG-CjyCKfOu.js} +1 -1
- package/static/index.html +2 -2
- package/static/assets/channel-CZGeGYqv.js +0 -1
- package/static/assets/classDiagram-OUVF2IWQ-DV5beYlE.js +0 -1
- package/static/assets/classDiagram-v2-EOCWNBFH-DV5beYlE.js +0 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-CmQ_Kjkj.js +0 -1
- package/static/assets/swimlanesDiagram-G3AALYLV-OaMW2HRF.js +0 -8
|
@@ -19,6 +19,13 @@ they contain NO server of their own.
|
|
|
19
19
|
`mockup.config.json` and re-listens on the new port. Compound Context Studio's Mockup
|
|
20
20
|
page reads its own topbar port input and passes it via the `PORT` env when its
|
|
21
21
|
**Start Hub** button spawns this server.
|
|
22
|
+
- **Reverse-proxy aware** (v3): Compound Context Studio embeds the hub through its own
|
|
23
|
+
same-origin proxy (`/api/artifacts/mockup-proxy/<projectId>/`), which sends the mount
|
|
24
|
+
path in an `X-Forwarded-Prefix` header. The hub prefixes every redirect and rewrites
|
|
25
|
+
root-absolute URL attributes (`href`/`src`/`action`/`hx-*`) in the HTML it serves —
|
|
26
|
+
including per-app assets generated before v3 — so mockups work behind the studio (or
|
|
27
|
+
any prefix-forwarding reverse proxy) without exposing the hub's port separately.
|
|
28
|
+
Requests without the header behave exactly as before.
|
|
22
29
|
|
|
23
30
|
The hub has **zero npm dependencies** — `npm start` (or `node server.js`) works with no
|
|
24
31
|
`npm install`.
|
|
@@ -97,12 +104,15 @@ Example:
|
|
|
97
104
|
#!/usr/bin/env node
|
|
98
105
|
/*
|
|
99
106
|
* CO2 Mockup Hub — one server for ALL application mockups.
|
|
100
|
-
* HUB_VERSION:
|
|
107
|
+
* HUB_VERSION: 3 (marker used by mockgen-* skills to decide upgrades — do not remove)
|
|
101
108
|
*
|
|
102
109
|
* Discovers every root-level application folder containing context/PRD.md.
|
|
103
110
|
* Apps with a context/mockup/mockup-manifest.json are routable:
|
|
104
111
|
* - stack "tailwind": pages assembled from partials + content fragments
|
|
105
112
|
* - stack "shadcn": served from the built dist/ folder (SPA fallback)
|
|
113
|
+
* Reverse proxy: when a request carries X-Forwarded-Prefix (Compound Context Studio's
|
|
114
|
+
* same-origin proxy sends it), redirects are prefixed and root-absolute URL
|
|
115
|
+
* attributes in served HTML are rewritten under the prefix.
|
|
106
116
|
* Port: PORT env var > mockup.config.json "port" > first unused port from 4000.
|
|
107
117
|
* - A PORT env value is exact (Compound Context Studio's Start Hub passes it) — a busy
|
|
108
118
|
* port fails fast so the caller sees the error.
|
|
@@ -118,10 +128,11 @@ const net = require('net');
|
|
|
118
128
|
const fs = require('fs');
|
|
119
129
|
const path = require('path');
|
|
120
130
|
|
|
121
|
-
const HUB_VERSION =
|
|
131
|
+
const HUB_VERSION = 3;
|
|
122
132
|
const HUB_DIR = __dirname;
|
|
123
133
|
const ROOT_DIR = path.resolve(HUB_DIR, '..');
|
|
124
134
|
const SAFE_SEG = /^[A-Za-z0-9._-]+$/;
|
|
135
|
+
const SAFE_PREFIX = /^\/[A-Za-z0-9/_-]*$/; // root-relative, no dots/query/fragment
|
|
125
136
|
const PORT_SCAN_START = 4000; // default: first unused port starting here
|
|
126
137
|
const PORT_SCAN_LIMIT = 100;
|
|
127
138
|
|
|
@@ -229,11 +240,39 @@ function findApp(slug) {
|
|
|
229
240
|
|
|
230
241
|
// ── Response helpers ─────────────────────────────────────────────────────────
|
|
231
242
|
|
|
243
|
+
/** The sanitized X-Forwarded-Prefix ('' when absent/unsafe), without trailing slash. */
|
|
244
|
+
function requestPrefix(req) {
|
|
245
|
+
var raw = String(req.headers['x-forwarded-prefix'] || '');
|
|
246
|
+
if (raw.endsWith('/')) raw = raw.slice(0, -1);
|
|
247
|
+
return SAFE_PREFIX.test(raw) ? raw : '';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Rewrite root-absolute URL attributes so they stay under the proxy prefix. Covers hub
|
|
252
|
+
* pages AND per-app assets (tailwind partials/content with hx-* attributes, shadcn dist
|
|
253
|
+
* index.html, MOCKUP.html, reports) — including assets generated before hub v3.
|
|
254
|
+
*/
|
|
255
|
+
function applyPrefix(html, prefix) {
|
|
256
|
+
if (!prefix) return html;
|
|
257
|
+
return html.replace(
|
|
258
|
+
/(href|src|action|hx-get|hx-post|hx-put|hx-patch|hx-delete)="\//g,
|
|
259
|
+
'$1="' + prefix + '/'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
function send(res, code, body, type) {
|
|
233
264
|
res.writeHead(code, { 'Content-Type': type || 'text/html; charset=utf-8' });
|
|
234
265
|
res.end(body);
|
|
235
266
|
}
|
|
236
267
|
|
|
268
|
+
/** All HTML goes out through this so root-absolute links are prefix-rewritten. The
|
|
269
|
+
* X-Hub-Prefix header tells the studio proxy the hub already rewrote its HTML, so the
|
|
270
|
+
* proxy skips its own legacy-hub rewrite pass (no double-prefixing). */
|
|
271
|
+
function sendHtml(res, code, html, prefix) {
|
|
272
|
+
if (prefix) res.setHeader('X-Hub-Prefix', prefix);
|
|
273
|
+
send(res, code, applyPrefix(html, prefix), 'text/html; charset=utf-8');
|
|
274
|
+
}
|
|
275
|
+
|
|
237
276
|
function redirect(res, location) {
|
|
238
277
|
res.writeHead(302, { Location: location });
|
|
239
278
|
res.end();
|
|
@@ -252,12 +291,18 @@ function safeJoin(baseDir, segments) {
|
|
|
252
291
|
return target;
|
|
253
292
|
}
|
|
254
293
|
|
|
255
|
-
function serveFile(res, filePath) {
|
|
294
|
+
function serveFile(res, filePath, prefix) {
|
|
256
295
|
if (!filePath) return false;
|
|
257
296
|
let stat;
|
|
258
297
|
try { stat = fs.statSync(filePath); } catch (err) { return false; }
|
|
259
298
|
if (!stat.isFile()) return false;
|
|
260
|
-
const
|
|
299
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
300
|
+
const type = MIME[ext] || 'application/octet-stream';
|
|
301
|
+
// HTML files (MOCKUP.html, shadcn dist index.html, reports) get prefix-rewritten.
|
|
302
|
+
if (ext === '.html' && prefix) {
|
|
303
|
+
sendHtml(res, 200, fs.readFileSync(filePath, 'utf8'), prefix);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
261
306
|
send(res, 200, fs.readFileSync(filePath), type);
|
|
262
307
|
return true;
|
|
263
308
|
}
|
|
@@ -340,7 +385,9 @@ function renderAppCard(app) {
|
|
|
340
385
|
|
|
341
386
|
// Port input (default: the current port — auto-picked as the first unused port from 4000
|
|
342
387
|
// when nothing was configured). Apply persists to mockup.config.json via POST /api/port
|
|
343
|
-
// and the hub re-listens on the new port; the page then follows it.
|
|
388
|
+
// and the hub re-listens on the new port; the page then follows it. Behind a proxy
|
|
389
|
+
// (HUB_PREFIX set) the page reloads the prefix root instead — the studio proxy reads
|
|
390
|
+
// the new port from mockup.config.json, so the same URL keeps working.
|
|
344
391
|
const PORT_SCRIPT = `
|
|
345
392
|
function applyPort() {
|
|
346
393
|
var msg = document.getElementById('port-msg');
|
|
@@ -349,25 +396,28 @@ const PORT_SCRIPT = `
|
|
|
349
396
|
msg.style.color = '#dc2626'; msg.textContent = 'Port must be 1024-65535'; return;
|
|
350
397
|
}
|
|
351
398
|
msg.style.color = '#64748b'; msg.textContent = 'Applying\\u2026';
|
|
352
|
-
fetch('/api/port', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
399
|
+
fetch(HUB_PREFIX + '/api/port', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
353
400
|
body: JSON.stringify({ port: port }) })
|
|
354
401
|
.then(function (r) { return r.json(); })
|
|
355
402
|
.then(function (res) {
|
|
356
403
|
if (!res.ok) { msg.style.color = '#dc2626'; msg.textContent = res.error || 'Failed'; return; }
|
|
357
404
|
msg.textContent = 'Restarting on port ' + res.port + '\\u2026';
|
|
358
405
|
setTimeout(function () {
|
|
359
|
-
location.href =
|
|
406
|
+
location.href = HUB_PREFIX
|
|
407
|
+
? HUB_PREFIX + '/'
|
|
408
|
+
: location.protocol + '//' + location.hostname + ':' + res.port + '/';
|
|
360
409
|
}, 700);
|
|
361
410
|
})
|
|
362
411
|
.catch(function () { msg.style.color = '#dc2626'; msg.textContent = 'Request failed'; });
|
|
363
412
|
}
|
|
364
413
|
`;
|
|
365
414
|
|
|
366
|
-
function renderLanding(apps, currentPort) {
|
|
415
|
+
function renderLanding(apps, currentPort, prefix) {
|
|
367
416
|
return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n'
|
|
368
417
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
|
369
418
|
+ '<title>Mockup Hub</title>\n<style>' + LANDING_CSS + '</style>\n'
|
|
370
|
-
|
|
419
|
+
// prefix is SAFE_PREFIX-sanitized, so it embeds safely in the inline script.
|
|
420
|
+
+ '<script>var HUB_PREFIX = "' + (prefix || '') + '";' + PORT_SCRIPT + '</script>\n</head>\n<body>\n'
|
|
371
421
|
+ '<div class="wrap">\n<h1>Mockup Hub</h1>\n'
|
|
372
422
|
+ '<p class="sub">All application mockups, served from one place. '
|
|
373
423
|
+ apps.length + ' application(s) discovered. Click a role to open its dashboard.</p>\n'
|
|
@@ -378,8 +428,8 @@ function renderLanding(apps, currentPort) {
|
|
|
378
428
|
+ '<span id="port-msg"></span></span></p>\n</div>\n</body>\n</html>';
|
|
379
429
|
}
|
|
380
430
|
|
|
381
|
-
function notReadyPage(app) {
|
|
382
|
-
return renderLanding([app], CURRENT_PORT);
|
|
431
|
+
function notReadyPage(app, prefix) {
|
|
432
|
+
return renderLanding([app], CURRENT_PORT, prefix);
|
|
383
433
|
}
|
|
384
434
|
|
|
385
435
|
function notFoundPage(what) {
|
|
@@ -410,45 +460,46 @@ function assemblePage(app, role, page) {
|
|
|
410
460
|
|
|
411
461
|
// ── Routing ──────────────────────────────────────────────────────────────────
|
|
412
462
|
|
|
413
|
-
function routeTailwind(app, segs, res) {
|
|
463
|
+
function routeTailwind(app, segs, res, prefix) {
|
|
414
464
|
if (segs.length === 1) {
|
|
415
|
-
if (serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
|
|
465
|
+
if (serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'), prefix)) return;
|
|
416
466
|
const first = app.roles.find((r) => r.slug);
|
|
417
|
-
if (first) return redirect(res, '/' + app.slug + '/' + first.slug + '/home');
|
|
418
|
-
return
|
|
467
|
+
if (first) return redirect(res, prefix + '/' + app.slug + '/' + first.slug + '/home');
|
|
468
|
+
return sendHtml(res, 404, notFoundPage(app.slug), prefix);
|
|
419
469
|
}
|
|
420
470
|
if (segs[1] === 'static' || segs[1] === 'reports') {
|
|
421
471
|
const file = safeJoin(path.join(app.mockupDir, segs[1]), segs.slice(2));
|
|
422
|
-
if (serveFile(res, file)) return;
|
|
423
|
-
return
|
|
472
|
+
if (serveFile(res, file, prefix)) return;
|
|
473
|
+
return sendHtml(res, 404, notFoundPage(), prefix);
|
|
424
474
|
}
|
|
425
|
-
if (!SAFE_SEG.test(segs[1])) return
|
|
426
|
-
if (segs.length === 2) return redirect(res, '/' + app.slug + '/' + segs[1] + '/home');
|
|
475
|
+
if (!SAFE_SEG.test(segs[1])) return sendHtml(res, 400, notFoundPage(), prefix);
|
|
476
|
+
if (segs.length === 2) return redirect(res, prefix + '/' + app.slug + '/' + segs[1] + '/home');
|
|
427
477
|
if (segs.length === 3 && SAFE_SEG.test(segs[2])) {
|
|
428
478
|
const html = assemblePage(app, segs[1], segs[2]);
|
|
429
479
|
if (html === null) {
|
|
430
480
|
return send(res, 500, '<h1>Missing partials. Re-run the mockup generator.</h1>');
|
|
431
481
|
}
|
|
432
|
-
return
|
|
482
|
+
return sendHtml(res, 200, html, prefix);
|
|
433
483
|
}
|
|
434
|
-
return
|
|
484
|
+
return sendHtml(res, 404, notFoundPage(), prefix);
|
|
435
485
|
}
|
|
436
486
|
|
|
437
|
-
function routeShadcn(app, segs, res) {
|
|
438
|
-
if (segs.length === 1 && serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
|
|
487
|
+
function routeShadcn(app, segs, res, prefix) {
|
|
488
|
+
if (segs.length === 1 && serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'), prefix)) return;
|
|
439
489
|
if (segs[1] === 'reports') {
|
|
440
490
|
const file = safeJoin(path.join(app.mockupDir, 'public', 'reports'), segs.slice(2));
|
|
441
|
-
if (serveFile(res, file)) return;
|
|
442
|
-
return
|
|
491
|
+
if (serveFile(res, file, prefix)) return;
|
|
492
|
+
return sendHtml(res, 404, notFoundPage(), prefix);
|
|
443
493
|
}
|
|
444
494
|
const distDir = path.join(app.mockupDir, 'dist');
|
|
445
495
|
if (segs.length > 1) {
|
|
446
496
|
const file = safeJoin(distDir, segs.slice(1));
|
|
447
|
-
if (serveFile(res, file)) return;
|
|
497
|
+
if (serveFile(res, file, prefix)) return;
|
|
448
498
|
}
|
|
449
|
-
// SPA fallback — React Router
|
|
450
|
-
|
|
451
|
-
|
|
499
|
+
// SPA fallback — React Router handles the path client-side (the app derives its
|
|
500
|
+
// basename from the current URL, so it also works under a proxy prefix)
|
|
501
|
+
if (serveFile(res, path.join(distDir, 'index.html'), prefix)) return;
|
|
502
|
+
return sendHtml(res, 404, notFoundPage(app.slug), prefix);
|
|
452
503
|
}
|
|
453
504
|
|
|
454
505
|
// POST /api/port — persist a new port (from the landing page's port input) and re-listen.
|
|
@@ -473,8 +524,11 @@ function handlePortUpdate(req, res) {
|
|
|
473
524
|
function route(req, res) {
|
|
474
525
|
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
475
526
|
const segs = urlPath.split('/').filter(Boolean);
|
|
527
|
+
const prefix = requestPrefix(req); // '' unless behind a prefix-forwarding proxy
|
|
476
528
|
|
|
477
|
-
if (segs.length === 0)
|
|
529
|
+
if (segs.length === 0) {
|
|
530
|
+
return sendHtml(res, 200, renderLanding(discoverApps(), CURRENT_PORT, prefix), prefix);
|
|
531
|
+
}
|
|
478
532
|
if (urlPath === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
|
479
533
|
|
|
480
534
|
if (segs[0] === 'api' && segs[1] === 'port' && segs.length === 2 && req.method === 'POST') {
|
|
@@ -483,26 +537,28 @@ function route(req, res) {
|
|
|
483
537
|
|
|
484
538
|
// HTMX fragment endpoint (tailwind): /api/content/:app/:role/:page
|
|
485
539
|
if (segs[0] === 'api' && segs[1] === 'content') {
|
|
486
|
-
if (segs.length !== 5) return
|
|
540
|
+
if (segs.length !== 5) return sendHtml(res, 404, notFoundPage(), prefix);
|
|
487
541
|
const app = findApp(segs[2]);
|
|
488
542
|
if (!app || app.status !== 'ready' || app.stack !== 'tailwind') {
|
|
489
|
-
return
|
|
543
|
+
return sendHtml(res, 404, notFoundPage(segs[2]), prefix);
|
|
544
|
+
}
|
|
545
|
+
if (!SAFE_SEG.test(segs[3]) || !SAFE_SEG.test(segs[4])) {
|
|
546
|
+
return sendHtml(res, 400, notFoundPage(), prefix);
|
|
490
547
|
}
|
|
491
|
-
if (!SAFE_SEG.test(segs[3]) || !SAFE_SEG.test(segs[4])) return send(res, 400, notFoundPage());
|
|
492
548
|
const content = readText(path.join(app.mockupDir, segs[3], 'content', segs[4] + '.html'));
|
|
493
549
|
if (content === null) {
|
|
494
550
|
return send(res, 404, '<div class="p-6 text-red-500">Content not found.</div>');
|
|
495
551
|
}
|
|
496
|
-
return
|
|
552
|
+
return sendHtml(res, 200, content, prefix);
|
|
497
553
|
}
|
|
498
554
|
|
|
499
|
-
if (!SAFE_SEG.test(segs[0])) return
|
|
555
|
+
if (!SAFE_SEG.test(segs[0])) return sendHtml(res, 400, notFoundPage(), prefix);
|
|
500
556
|
const app = findApp(segs[0]);
|
|
501
|
-
if (!app) return
|
|
502
|
-
if (app.status !== 'ready') return
|
|
557
|
+
if (!app) return sendHtml(res, 404, notFoundPage(segs[0]), prefix);
|
|
558
|
+
if (app.status !== 'ready') return sendHtml(res, 200, notReadyPage(app, prefix), prefix);
|
|
503
559
|
|
|
504
|
-
if (app.stack === 'shadcn') return routeShadcn(app, segs, res);
|
|
505
|
-
return routeTailwind(app, segs, res);
|
|
560
|
+
if (app.stack === 'shadcn') return routeShadcn(app, segs, res, prefix);
|
|
561
|
+
return routeTailwind(app, segs, res, prefix);
|
|
506
562
|
}
|
|
507
563
|
|
|
508
564
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
@@ -627,5 +683,7 @@ Mockup Hub
|
|
|
627
683
|
3. Open http://localhost:4000 in your browser
|
|
628
684
|
(the hub picks the first unused port from 4000; the exact URL is printed on start.
|
|
629
685
|
Change port: the landing page's Port input, PORT=4100 npm start, or edit
|
|
630
|
-
mockup.config.json — or use Compound Context Studio's Mockup page Start/Stop Hub.
|
|
686
|
+
mockup.config.json — or use Compound Context Studio's Mockup page Start/Stop Hub.
|
|
687
|
+
Inside the studio the hub is embedded through its same-origin proxy — no need to
|
|
688
|
+
expose this port when the studio itself is reachable.)
|
|
631
689
|
```
|
|
@@ -21,8 +21,12 @@ description: >
|
|
|
21
21
|
copy, docker run or kubectl apply, or none). Controls are disabled for any application that
|
|
22
22
|
has never completed a conductor-develop workflow run (checked against the studio's
|
|
23
23
|
/api/cicd-guard endpoint using the spawn-env token). Health/port probing marks apps already
|
|
24
|
-
running — even ones started outside this app.
|
|
25
|
-
|
|
24
|
+
running — even ones started outside this app. The top bar carries a version selector (fed by
|
|
25
|
+
the studio guard's versions, latest preselected) whose choice replaces the {VERSION} token in
|
|
26
|
+
commands — docker images are tagged <repo>/<app>:<version> when the plan's Docker section is
|
|
27
|
+
active (managed Dockerfiles materialized before Build when missing; Build never pushes) — and
|
|
28
|
+
Build All / Start All / Stop All buttons operating every eligible application at once
|
|
29
|
+
(sequential builds). When the approved plan declares an `## Infrastructure` list, the
|
|
26
30
|
main page shows an "Infrastructure" link that opens a separate status-only page listing every
|
|
27
31
|
supporting 3rd-party service with a live TCP-reachability indicator and a console link. Writes cicd/CICD_GENERATION.md with `**Status**:` IN PROGRESS →
|
|
28
32
|
COMPLETED so the studio watcher detects completion. Idempotent — re-running regenerates
|
|
@@ -62,9 +66,17 @@ Write `cicd/CICD_GENERATION.md`:
|
|
|
62
66
|
Read `references/cicd-app-template.md` for the complete file templates. Produce:
|
|
63
67
|
|
|
64
68
|
1. `cicd/app/cicd.config.json` — from the plan's tables:
|
|
65
|
-
`{ "port": <CI/CD app port>, "
|
|
66
|
-
"
|
|
67
|
-
"
|
|
69
|
+
`{ "port": <CI/CD app port>, "docker": { "registry" } | null, "apps": [{ "name", "folder",
|
|
70
|
+
"method", "port", "build", "start", "stop", "deployMethod", "deploy", "health", "image",
|
|
71
|
+
"dockerfile", "configs" }], "infra": [{ "id", "name",
|
|
72
|
+
"host", "port", "url", "description" }] }` — `docker.registry` comes from the plan's
|
|
73
|
+
`## Docker` section (`null` when the plan says not dockerized); per-app `image` is that
|
|
74
|
+
section's Image column (`null` for non-dockerized apps) and `dockerfile` is
|
|
75
|
+
`{ "path": "Dockerfile", "content": "<body>" }` for a `managed` Dockerfile from the plan's
|
|
76
|
+
`## Dockerfiles` section, or `{ "path": "Dockerfile", "content": null }` for an `existing`
|
|
77
|
+
one (`null` entirely when the app has no image). Copy managed Dockerfile bodies verbatim.
|
|
78
|
+
Commands may carry the literal `{VERSION}` token — copy it verbatim; the generated app
|
|
79
|
+
substitutes it at execution. `deployMethod` is the plan's Deploy Method column
|
|
68
80
|
(`none|web-server|docker|kubernetes`) and `deploy` its command (`null` when the method is
|
|
69
81
|
`none`). The `infra` array comes from the plan's `## Infrastructure` table (one entry per
|
|
70
82
|
row); `url` is `null` when the Console URL cell is `-`. Emit `"infra": []` when the plan's
|
|
@@ -104,11 +116,25 @@ port, application count) and STOP — the studio starts/embeds the app.
|
|
|
104
116
|
3rd-party services, each with a live TCP-reachability dot (polled) and a console link when a
|
|
105
117
|
`url` is set. Infrastructure is never built/started/stopped/deployed (no lifecycle controls,
|
|
106
118
|
no develop gate); the link is hidden when `infra` is empty.
|
|
107
|
-
- Build/Start/Stop/Deploy run the plan's commands
|
|
119
|
+
- Build/Start/Stop/Deploy run the plan's commands, each with the APPLICATION FOLDER
|
|
108
120
|
(`<project>/<folder>`) as working directory — command paths are relative to the application
|
|
109
121
|
folder (e.g. `java -jar target\app.jar`), never to the project root. Deploy renders only
|
|
110
|
-
when the plan assigned a deploy method other than `none`.
|
|
111
|
-
|
|
122
|
+
when the plan assigned a deploy method other than `none`.
|
|
123
|
+
- **Version selector (topbar)**: the page's top bar carries a version dropdown fed by the
|
|
124
|
+
studio guard response's `versions` array (already polled every 30 s; latest first —
|
|
125
|
+
preselected). When the studio is unreachable the selector holds a single `latest` entry.
|
|
126
|
+
The selected semver, minus its `v` prefix, replaces every literal `{VERSION}` in
|
|
127
|
+
build/start/stop/deploy commands at execution time (fallback `latest`). Tag-only
|
|
128
|
+
semantics: the code built is always the working tree — the selector never checks out code.
|
|
129
|
+
- **Dockerfile materialization**: before Build, when an app's `dockerfile.content` is set and
|
|
130
|
+
no file exists at its path in the application folder, the server writes it (never
|
|
131
|
+
overwriting an existing Dockerfile — mirror of the managed-config rule). Build never
|
|
132
|
+
pushes an image; pushing belongs to a `docker` deploy command.
|
|
133
|
+
- **All-apps controls**: the top bar carries **Build All**, **Start All** and **Stop All**
|
|
134
|
+
buttons. Build All builds eligible apps SEQUENTIALLY (one at a time — honest logs, no CPU
|
|
135
|
+
storm); Start All starts every eligible stopped app; Stop All stops every running app.
|
|
136
|
+
Eligible = develop-gate not failed, not busy, and (for build/start) not already running;
|
|
137
|
+
ineligible apps are skipped silently — per-tab badges show progress.
|
|
112
138
|
- Develop gate: controls stay disabled for an application that never completed a
|
|
113
139
|
conductor-develop run — checked via `${CO2_STUDIO_URL}/api/cicd-guard/${CO2_PROJECT_ID}`
|
|
114
140
|
with the CO2_GUARD_TOKEN from the spawn env (refreshed every 30 s, last answer cached).
|
|
@@ -66,6 +66,7 @@ function currentBranch() {
|
|
|
66
66
|
// its tab controls are disabled. Refreshed every 30 s; the last known answer is kept when the
|
|
67
67
|
// studio is briefly unreachable, and `null` (never fetched) disables controls with a hint.
|
|
68
68
|
let guardApps = null // Map lower(name|folder) -> developedOnce
|
|
69
|
+
let guardVersions = [] // project versions (semver strings, studio order — latest first)
|
|
69
70
|
async function refreshGuard() {
|
|
70
71
|
if (!PROJECT_ID) return
|
|
71
72
|
try {
|
|
@@ -76,6 +77,8 @@ async function refreshGuard() {
|
|
|
76
77
|
guardApps = new Map()
|
|
77
78
|
for (const a of g.applications) guardApps.set(String(a.name).toLowerCase(), !!a.developedOnce)
|
|
78
79
|
}
|
|
80
|
+
// The topbar version selector's choices — the {VERSION} tag substituted into commands.
|
|
81
|
+
if (Array.isArray(g.versions)) guardVersions = g.versions.map((v) => String(v.semver)).filter(Boolean)
|
|
79
82
|
} catch {}
|
|
80
83
|
}
|
|
81
84
|
function developedOnce(app) {
|
|
@@ -226,36 +229,58 @@ function tcpProbe(host, port, timeout = 1000) {
|
|
|
226
229
|
})
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
// {VERSION} token substitution — the version comes from the page's topbar selector (the
|
|
233
|
+
// studio guard's versions, latest preselected). Tag-only: never checks out code. The `v`
|
|
234
|
+
// prefix is stripped so docker tags read `1.2.0`, not `v1.2.0`.
|
|
235
|
+
function subst(cmd, version) {
|
|
236
|
+
return String(cmd || '').replaceAll('{VERSION}', String(version || 'latest').replace(/^v/, ''))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Managed Dockerfile from the plan's ## Dockerfiles — written before Build only when the
|
|
240
|
+
// application folder has none (an existing Dockerfile is never overwritten).
|
|
241
|
+
function ensureDockerfile(app) {
|
|
242
|
+
const d = app.dockerfile
|
|
243
|
+
if (!d || d.content == null) return
|
|
244
|
+
const abs = join(PROJECT_DIR, app.folder, d.path || 'Dockerfile')
|
|
245
|
+
if (existsSync(abs)) return
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(abs, d.content)
|
|
248
|
+
logLine(app, `dockerfile: wrote ${d.path || 'Dockerfile'} from the plan (was missing)`)
|
|
249
|
+
} catch (e) { logLine(app, `dockerfile: FAILED to write — ${e.message}`) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildApp(app, version, onDone) {
|
|
253
|
+
if (activity.has(app.name)) { onDone && onDone(); return }
|
|
231
254
|
activity.set(app.name, 'building'); lastError.delete(app.name)
|
|
232
255
|
ensureConfigs(app)
|
|
233
|
-
|
|
256
|
+
ensureDockerfile(app)
|
|
257
|
+
runLogged(app, subst(app.build, version), (code) => {
|
|
234
258
|
activity.delete(app.name)
|
|
235
259
|
if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
|
|
236
260
|
else logLine(app, 'build OK')
|
|
261
|
+
onDone && onDone()
|
|
237
262
|
})
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
function deployApp(app) {
|
|
265
|
+
function deployApp(app, version) {
|
|
241
266
|
if (activity.has(app.name) || !app.deploy) return
|
|
242
267
|
activity.set(app.name, 'deploying'); lastError.delete(app.name)
|
|
243
|
-
runLogged(app, app.deploy, (code) => {
|
|
268
|
+
runLogged(app, subst(app.deploy, version), (code) => {
|
|
244
269
|
activity.delete(app.name)
|
|
245
270
|
if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
|
|
246
271
|
else logLine(app, `deploy OK (${app.deployMethod})`)
|
|
247
272
|
})
|
|
248
273
|
}
|
|
249
274
|
|
|
250
|
-
function startApp(app) {
|
|
275
|
+
function startApp(app, version) {
|
|
251
276
|
if (running.get(app.name) || activity.has(app.name)) return
|
|
252
277
|
failed.delete(app.name); lastError.delete(app.name)
|
|
253
278
|
ensureConfigs(app)
|
|
254
279
|
if (app.method === 'container') {
|
|
255
|
-
runLogged(app, app.start, () => {})
|
|
280
|
+
runLogged(app, subst(app.start, version), () => {})
|
|
256
281
|
saveState(); return
|
|
257
282
|
}
|
|
258
|
-
const child = runLogged(app, app.start, (code) => {
|
|
283
|
+
const child = runLogged(app, subst(app.start, version), (code) => {
|
|
259
284
|
running.delete(app.name)
|
|
260
285
|
// A user-requested Stop exits nonzero (killed) — that is not a failure.
|
|
261
286
|
if (code !== 0 && !stopping.delete(app.name)) {
|
|
@@ -302,9 +327,10 @@ function killByPort(port) {
|
|
|
302
327
|
} catch { return false }
|
|
303
328
|
}
|
|
304
329
|
|
|
305
|
-
function stopApp(app) {
|
|
330
|
+
function stopApp(app, version) {
|
|
306
331
|
if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
|
|
307
|
-
|
|
332
|
+
const stopCmd = subst(app.stop, version)
|
|
333
|
+
try { execFileSync(stopCmd.split(' ')[0], stopCmd.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
|
|
308
334
|
} else {
|
|
309
335
|
const child = running.get(app.name)
|
|
310
336
|
if (child) {
|
|
@@ -342,6 +368,7 @@ body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;
|
|
|
342
368
|
.badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
|
|
343
369
|
.url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
|
|
344
370
|
button{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer}
|
|
371
|
+
select{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:3px 6px;font-size:12px}
|
|
345
372
|
button:hover:enabled{background:#eef1f5}
|
|
346
373
|
button:disabled{opacity:.4;cursor:default}
|
|
347
374
|
.notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
|
|
@@ -361,7 +388,12 @@ button:disabled{opacity:.4;cursor:default}
|
|
|
361
388
|
.cfg .keys{font-size:11px;color:#5b6572;margin:2px 0}
|
|
362
389
|
.cfg pre{background:#0f1216;color:#d7dde4;font-size:11px;padding:8px 10px;border-radius:6px;overflow:auto;max-height:320px;white-space:pre-wrap;margin:4px 0 0}
|
|
363
390
|
</style></head><body>
|
|
364
|
-
<div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span
|
|
391
|
+
<div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span>
|
|
392
|
+
<span class="meta">version</span><select id="ver" title="Tag substituted into {VERSION} in commands (docker image tag)"><option value="">latest</option></select>
|
|
393
|
+
<button onclick="actAll('build')" title="Build every eligible application, one at a time">Build All</button>
|
|
394
|
+
<button onclick="actAll('start')" title="Start every eligible stopped application">Start All</button>
|
|
395
|
+
<button onclick="actAll('stop')" title="Stop every running application">Stop All</button>
|
|
396
|
+
<span style="flex:1"></span><a id="infralink" class="url" href="/infra" target="_blank" rel="noreferrer" style="display:none">Infrastructure ↗</a></div>
|
|
365
397
|
<div class="tabs" id="tabs"></div>
|
|
366
398
|
<div class="bar" id="bar"></div>
|
|
367
399
|
<div class="notice" id="notice"></div>
|
|
@@ -399,7 +431,7 @@ function renderBar(){
|
|
|
399
431
|
<a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
|
|
400
432
|
<span style="flex:1"></span>
|
|
401
433
|
<button onclick="openConfig()" title="Show this application's runtime configuration files">Config</button>
|
|
402
|
-
<button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''}>Build</button>
|
|
434
|
+
<button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''} title="\${a.image?'Builds the app AND the docker image '+a.image+':<version>':'Runs the plan build command'}">Build</button>
|
|
403
435
|
<button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
|
|
404
436
|
<button onclick="act('stop')" \${a.phase!=='running'?'disabled':''}>Stop</button>
|
|
405
437
|
\${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
|
|
@@ -467,17 +499,35 @@ async function openConfig(){
|
|
|
467
499
|
function closeConfig(){ document.getElementById('cfgModal').classList.remove('open') }
|
|
468
500
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConfig() })
|
|
469
501
|
|
|
502
|
+
// Topbar version selector — the studio guard's project versions (latest first). Repopulated
|
|
503
|
+
// only when the list changes so the user's selection survives the 3 s poll.
|
|
504
|
+
let verChoices = ''
|
|
505
|
+
function renderVersions(versions){
|
|
506
|
+
const sel = document.getElementById('ver')
|
|
507
|
+
const list = (versions && versions.length ? versions : []).join('|')
|
|
508
|
+
if (list === verChoices) return
|
|
509
|
+
verChoices = list
|
|
510
|
+
sel.innerHTML = (versions && versions.length ? versions : ['latest'])
|
|
511
|
+
.map((v, i) => '<option value="'+esc(v)+'"'+(i===0?' selected':'')+'>'+esc(v)+'</option>').join('')
|
|
512
|
+
}
|
|
513
|
+
function selectedVersion(){ return document.getElementById('ver').value || '' }
|
|
514
|
+
|
|
470
515
|
async function refresh(){
|
|
471
516
|
const s = await fetch('/api/state').then(r => r.json()).catch(() => null)
|
|
472
517
|
if (!s) return
|
|
473
518
|
state = s
|
|
474
519
|
document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
|
|
475
520
|
document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
|
|
521
|
+
renderVersions(s.versions)
|
|
476
522
|
if (!active && s.apps.length) setActive(s.apps[0].name)
|
|
477
523
|
renderTabs(); renderBar()
|
|
478
524
|
}
|
|
479
525
|
async function act(op){
|
|
480
|
-
await fetch('/api/apps/'+encodeURIComponent(active)+'/'+op, { method: 'POST' })
|
|
526
|
+
await fetch('/api/apps/'+encodeURIComponent(active)+'/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
|
|
527
|
+
refresh()
|
|
528
|
+
}
|
|
529
|
+
async function actAll(op){
|
|
530
|
+
await fetch('/api/all/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
|
|
481
531
|
refresh()
|
|
482
532
|
}
|
|
483
533
|
// Rescan immediately when the user returns to this page (tab focus / regained visibility), so
|
|
@@ -553,12 +603,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
553
603
|
return {
|
|
554
604
|
name: a.name, method: a.method, port: a.port, phase,
|
|
555
605
|
deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
|
|
606
|
+
image: a.image || null,
|
|
556
607
|
external: healthy && !running.has(a.name),
|
|
557
608
|
developedOnce: developedOnce(a),
|
|
558
609
|
error: lastError.get(a.name) || null,
|
|
559
610
|
}
|
|
560
611
|
}))
|
|
561
|
-
return json(res, 200, {
|
|
612
|
+
return json(res, 200, {
|
|
613
|
+
currentBranch: currentBranch(),
|
|
614
|
+
hasInfra: (loadConfig().infra || []).length > 0,
|
|
615
|
+
versions: guardVersions,
|
|
616
|
+
apps,
|
|
617
|
+
})
|
|
562
618
|
}
|
|
563
619
|
const cfgm = req.url && req.url.match(/^\/api\/apps\/([^/]+)\/config$/)
|
|
564
620
|
if (cfgm && req.method === 'GET') {
|
|
@@ -566,19 +622,54 @@ const server = http.createServer(async (req, res) => {
|
|
|
566
622
|
if (!app) return json(res, 404, { error: 'unknown app' })
|
|
567
623
|
return json(res, 200, { configs: (app.configs || []).map((c) => readConfig(app, c)) })
|
|
568
624
|
}
|
|
569
|
-
|
|
625
|
+
// ?version=<semver> on every action — the topbar selection, substituted into {VERSION}.
|
|
626
|
+
const reqVersion = (() => {
|
|
627
|
+
try { return new URL(req.url, 'http://x').searchParams.get('version') || '' } catch { return '' }
|
|
628
|
+
})()
|
|
629
|
+
const path = (req.url || '').split('?')[0]
|
|
630
|
+
const m = path.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
|
|
570
631
|
if (m && req.method === 'POST') {
|
|
571
632
|
const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
|
|
572
633
|
if (!app) return json(res, 404, { error: 'unknown app' })
|
|
573
634
|
if (m[2] !== 'stop' && developedOnce(app) === false) {
|
|
574
635
|
return json(res, 409, { error: 'application has not completed a develop workflow run' })
|
|
575
636
|
}
|
|
576
|
-
if (m[2] === 'build') buildApp(app)
|
|
577
|
-
else if (m[2] === 'start') startApp(app)
|
|
578
|
-
else if (m[2] === 'deploy') deployApp(app)
|
|
579
|
-
else stopApp(app)
|
|
637
|
+
if (m[2] === 'build') buildApp(app, reqVersion)
|
|
638
|
+
else if (m[2] === 'start') startApp(app, reqVersion)
|
|
639
|
+
else if (m[2] === 'deploy') deployApp(app, reqVersion)
|
|
640
|
+
else stopApp(app, reqVersion)
|
|
580
641
|
return json(res, 200, { ok: true })
|
|
581
642
|
}
|
|
643
|
+
// All-apps controls. Eligible = develop gate not failed and not mid-build/deploy; build and
|
|
644
|
+
// start additionally skip apps already running. Builds run SEQUENTIALLY (honest logs, no
|
|
645
|
+
// CPU storm); start/stop fire together. Ineligible apps are skipped silently.
|
|
646
|
+
const all = path.match(/^\/api\/all\/(build|start|stop)$/)
|
|
647
|
+
if (all && req.method === 'POST') {
|
|
648
|
+
const op = all[1]
|
|
649
|
+
const apps = loadConfig().apps
|
|
650
|
+
if (op === 'stop') {
|
|
651
|
+
for (const a of apps) {
|
|
652
|
+
if (running.has(a.name)) stopApp(a, reqVersion)
|
|
653
|
+
else probe(a).then((up) => { if (up) stopApp(a, reqVersion) })
|
|
654
|
+
}
|
|
655
|
+
return json(res, 200, { ok: true })
|
|
656
|
+
}
|
|
657
|
+
const eligible = []
|
|
658
|
+
for (const a of apps) {
|
|
659
|
+
if (developedOnce(a) === false || activity.has(a.name)) continue
|
|
660
|
+
if (await probe(a)) continue // already running — skip for build AND start
|
|
661
|
+
eligible.push(a)
|
|
662
|
+
}
|
|
663
|
+
if (op === 'start') {
|
|
664
|
+
for (const a of eligible) startApp(a, reqVersion)
|
|
665
|
+
return json(res, 200, { ok: true, count: eligible.length })
|
|
666
|
+
}
|
|
667
|
+
// op === 'build' — sequential chain: each build starts when the previous one exits.
|
|
668
|
+
const queue = [...eligible]
|
|
669
|
+
const next = () => { const a = queue.shift(); if (a) buildApp(a, reqVersion, next) }
|
|
670
|
+
next()
|
|
671
|
+
return json(res, 200, { ok: true, count: eligible.length })
|
|
672
|
+
}
|
|
582
673
|
json(res, 404, { error: 'not found' })
|
|
583
674
|
})
|
|
584
675
|
|
|
@@ -619,6 +710,16 @@ Decisions table, one `infra` entry per row of the plan's `## Infrastructure` tab
|
|
|
619
710
|
is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
|
|
620
711
|
`"infra": []` when the plan declares no supporting services.
|
|
621
712
|
|
|
713
|
+
Docker fields (from the plan's `## Docker` / `## Dockerfiles` sections): top-level
|
|
714
|
+
`"docker": { "registry": "<repo>" }` or `null` when the plan is not dockerized. Per app,
|
|
715
|
+
`"image"` is the Docker table's Image value (`null` for a non-dockerized app) and
|
|
716
|
+
`"dockerfile"` is `{ "path": "Dockerfile", "content": "<managed body>" }` for a `managed`
|
|
717
|
+
Dockerfile (materialized before Build when missing — never overwriting), or
|
|
718
|
+
`{ "path": "Dockerfile", "content": null }` for an `existing` one (`null` entirely when the
|
|
719
|
+
app has no image). Commands may carry the literal `{VERSION}` token — copied verbatim; the
|
|
720
|
+
server substitutes the topbar-selected version (semver sans `v`, fallback `latest`) at
|
|
721
|
+
execution time.
|
|
722
|
+
|
|
622
723
|
Each app also carries a `configs` array — the runtime config files the application reads at start,
|
|
623
724
|
from the plan's `## Application Configs` section (one entry per file). Fields:
|
|
624
725
|
`path` (relative to the application folder), `label`, `source` (`environment` | `env-file` |
|
|
@@ -633,18 +734,24 @@ verbatim (this is a local dev CI/CD app).
|
|
|
633
734
|
```json
|
|
634
735
|
{
|
|
635
736
|
"port": 4100,
|
|
737
|
+
"docker": { "registry": "registry.example.com/team" },
|
|
636
738
|
"apps": [
|
|
637
739
|
{
|
|
638
740
|
"name": "my-app",
|
|
639
741
|
"folder": "my-app",
|
|
640
742
|
"method": "process",
|
|
641
743
|
"port": 3000,
|
|
642
|
-
"build": "pnpm install && pnpm build",
|
|
744
|
+
"build": "pnpm install && pnpm build && docker build -t registry.example.com/team/my-app:{VERSION} .",
|
|
643
745
|
"start": "node dist/index.js",
|
|
644
746
|
"stop": "process-kill",
|
|
645
747
|
"deployMethod": "kubernetes",
|
|
646
748
|
"deploy": "kubectl apply -f my-app/k8s/",
|
|
647
749
|
"health": "http://127.0.0.1:3000/",
|
|
750
|
+
"image": "registry.example.com/team/my-app",
|
|
751
|
+
"dockerfile": {
|
|
752
|
+
"path": "Dockerfile",
|
|
753
|
+
"content": "FROM node:22-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY . .\nEXPOSE 3000\nCMD [\"node\", \"dist/index.js\"]\n"
|
|
754
|
+
},
|
|
648
755
|
"configs": [
|
|
649
756
|
{
|
|
650
757
|
"path": ".env",
|