@rashidee/co2 1.3.10 → 1.3.12

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.
Files changed (74) hide show
  1. package/dist/.co2-dat/app.db +0 -0
  2. package/dist/.co2-dat/app.db-shm +0 -0
  3. package/dist/.co2-dat/app.db-wal +0 -0
  4. package/dist/index.js +408 -73
  5. package/package.json +41 -41
  6. package/plugin/skills/mockgen-shadcn/SKILL.md +11 -7
  7. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +14 -3
  8. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +98 -40
  9. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +98 -40
  10. package/plugin/skills/util-gencicdscript/SKILL.md +34 -8
  11. package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +798 -689
  12. package/plugin/skills/util-plancicd/SKILL.md +63 -2
  13. package/static/assets/{abnfDiagram-VRR7QNED-ChoWkBm-.js → abnfDiagram-VRR7QNED-Qm0Wk_H_.js} +1 -1
  14. package/static/assets/{arc-BOiutWhq.js → arc-Cfk6Kt0D.js} +1 -1
  15. package/static/assets/{architectureDiagram-ZJ3FMSHR-Dn4YKYfM.js → architectureDiagram-ZJ3FMSHR-DQTlZaUK.js} +1 -1
  16. package/static/assets/{blockDiagram-677ZJIJ3-BrXEfs29.js → blockDiagram-677ZJIJ3-D-4ibdoX.js} +1 -1
  17. package/static/assets/{c4Diagram-LMCZKHZV-ChaIzrlx.js → c4Diagram-LMCZKHZV-CHuqcdJm.js} +1 -1
  18. package/static/assets/channel-H9DRrduO.js +1 -0
  19. package/static/assets/{chunk-2Q5K7J3B-BOlnmyI2.js → chunk-2Q5K7J3B-MB9pWQe1.js} +1 -1
  20. package/static/assets/{chunk-32BRIVSS-BYtO1gsq.js → chunk-32BRIVSS-BttYlwU1.js} +1 -1
  21. package/static/assets/{chunk-5VM5RSS4-Cmx4Morj.js → chunk-5VM5RSS4-oyt0Tagd.js} +1 -1
  22. package/static/assets/{chunk-EX3LRPZG-BcgUofmA.js → chunk-EX3LRPZG-DaTxdTWl.js} +1 -1
  23. package/static/assets/{chunk-JWPE2WC7-CnNRXdvN.js → chunk-JWPE2WC7-CNgDxPAD.js} +1 -1
  24. package/static/assets/{chunk-MOJQB5TN-DGG6Q3Wn.js → chunk-MOJQB5TN-CoAudO3B.js} +1 -1
  25. package/static/assets/{chunk-RYQCIY6F-C8erKHls.js → chunk-RYQCIY6F-BrkDEEha.js} +1 -1
  26. package/static/assets/{chunk-V7JOEXUC-BNkIJqzu.js → chunk-V7JOEXUC-D14rGvBO.js} +1 -1
  27. package/static/assets/{chunk-VR4S4FIN-CGq-RseS.js → chunk-VR4S4FIN-CAtas2vF.js} +1 -1
  28. package/static/assets/{chunk-XXDRQBXY-DOVfEC7j.js → chunk-XXDRQBXY-DcVAnNUQ.js} +1 -1
  29. package/static/assets/classDiagram-OUVF2IWQ-DRNPM383.js +1 -0
  30. package/static/assets/classDiagram-v2-EOCWNBFH-DRNPM383.js +1 -0
  31. package/static/assets/{cose-bilkent-JH36ORCC-BrK3gCzK.js → cose-bilkent-JH36ORCC-B7y4MI6Z.js} +1 -1
  32. package/static/assets/{cynefin-VYW2F7L2-BTMYzKLg.js → cynefin-VYW2F7L2-D2mnk-iJ.js} +1 -1
  33. package/static/assets/{cynefinDiagram-TSTJHNR4-Df4k0_Hb.js → cynefinDiagram-TSTJHNR4-DNzQJhG7.js} +1 -1
  34. package/static/assets/{dagre-VKFMJZFB-0LnYnEqq.js → dagre-VKFMJZFB-NMSyTo8N.js} +1 -1
  35. package/static/assets/{diagram-FQU43EPY-B5nIVD5g.js → diagram-FQU43EPY-CdqS5Ike.js} +1 -1
  36. package/static/assets/{diagram-G47NLZAW-B2YLBzdb.js → diagram-G47NLZAW-Bsb4T5JI.js} +1 -1
  37. package/static/assets/{diagram-NH7WQ7WH-DPVumXIF.js → diagram-NH7WQ7WH-Z-PyzBSC.js} +1 -1
  38. package/static/assets/{diagram-OA4YK3LP-Da20vcLJ.js → diagram-OA4YK3LP-BZBTYbxg.js} +1 -1
  39. package/static/assets/{diagram-WEI45ONY-DQbTpuRZ.js → diagram-WEI45ONY-BL2LA5H_.js} +1 -1
  40. package/static/assets/{ebnfDiagram-CCIWWBDH-BEn1vEyb.js → ebnfDiagram-CCIWWBDH-BGtukMeo.js} +1 -1
  41. package/static/assets/{erDiagram-Q63AITRT-Ccg0DVBG.js → erDiagram-Q63AITRT-B6OJ4YCl.js} +1 -1
  42. package/static/assets/{flowDiagram-23GEKE2U-B2dMjEpa.js → flowDiagram-23GEKE2U-BBkMgJ1i.js} +1 -1
  43. package/static/assets/{ganttDiagram-NO4QXBWP-Kp1UsG59.js → ganttDiagram-NO4QXBWP-Cv8KHFBD.js} +1 -1
  44. package/static/assets/{gitGraphDiagram-IHSO6WYX-D6RB8sPf.js → gitGraphDiagram-IHSO6WYX-hYxDNjWx.js} +1 -1
  45. package/static/assets/{index-CM4GfOLV.css → index-DKI3BgkF.css} +1 -1
  46. package/static/assets/{index-GEUFVo96.js → index-DnYGyYET.js} +118 -120
  47. package/static/assets/{infoDiagram-FWYZ7A6U-C4ILACOF.js → infoDiagram-FWYZ7A6U-WuQIiedv.js} +1 -1
  48. package/static/assets/{ishikawaDiagram-FXEZZL3T-Q2Ef9fW7.js → ishikawaDiagram-FXEZZL3T-BJGOD-IY.js} +1 -1
  49. package/static/assets/{journeyDiagram-5HDEW3XC-B7ZV1i0L.js → journeyDiagram-5HDEW3XC-Ba_NXseS.js} +1 -1
  50. package/static/assets/{kanban-definition-HUTT4EX6-CjaPV_vw.js → kanban-definition-HUTT4EX6-C5RMm9uF.js} +1 -1
  51. package/static/assets/{linear-BELpVz92.js → linear-CLKppNoj.js} +1 -1
  52. package/static/assets/{mindmap-definition-LN4V7U3C-COhl-GrE.js → mindmap-definition-LN4V7U3C-ClSG_qmQ.js} +1 -1
  53. package/static/assets/{pegDiagram-2B236MQR-3FlTtWva.js → pegDiagram-2B236MQR-C_JEZqk3.js} +1 -1
  54. package/static/assets/{pieDiagram-ENE6RG2P-CLeOh_sh.js → pieDiagram-ENE6RG2P-D0lx7wDi.js} +1 -1
  55. package/static/assets/{quadrantDiagram-ABIIQ3AL-D2MnobFS.js → quadrantDiagram-ABIIQ3AL-BBKy0PVs.js} +1 -1
  56. package/static/assets/{railroadDiagram-RFXS5EU6-FjtL4fEj.js → railroadDiagram-RFXS5EU6-C1Badu5q.js} +1 -1
  57. package/static/assets/{requirementDiagram-TGXJPOKE-C5Y2uWRV.js → requirementDiagram-TGXJPOKE-1j59jBQH.js} +1 -1
  58. package/static/assets/{sankeyDiagram-HTMAVEWB-ITj9bISn.js → sankeyDiagram-HTMAVEWB-BLR5S8aZ.js} +1 -1
  59. package/static/assets/{sequenceDiagram-DBY2YBRQ-Ct2xTSbI.js → sequenceDiagram-DBY2YBRQ-BlqdK4qm.js} +1 -1
  60. package/static/assets/{sizeCapture-X5ZJPWSS-w9hGRpov.js → sizeCapture-X5ZJPWSS-C27ndGIT.js} +1 -1
  61. package/static/assets/{stateDiagram-2N3HPSRC-BlxDF1qr.js → stateDiagram-2N3HPSRC-DOu4536_.js} +1 -1
  62. package/static/assets/stateDiagram-v2-6OUMAXLB-DvP1ZCp8.js +1 -0
  63. package/static/assets/{swimlanes-5IMT3BWC-DFYm_SCW.js → swimlanes-5IMT3BWC-BpGitR9A.js} +2 -2
  64. package/static/assets/swimlanesDiagram-G3AALYLV-B5cf18Ey.js +8 -0
  65. package/static/assets/{timeline-definition-FHXFAJF6-johIMUxt.js → timeline-definition-FHXFAJF6-JIvdjH7z.js} +1 -1
  66. package/static/assets/{vennDiagram-L72KCM5P-BFk2-H5y.js → vennDiagram-L72KCM5P-DulnqLWm.js} +1 -1
  67. package/static/assets/{wardleyDiagram-EHGQE667-T7509MWq.js → wardleyDiagram-EHGQE667--MhZNL8M.js} +1 -1
  68. package/static/assets/{xychartDiagram-FW5EYKEG-DskgQ7uq.js → xychartDiagram-FW5EYKEG-CjnNgnMQ.js} +1 -1
  69. package/static/index.html +2 -2
  70. package/static/assets/channel-CKovdojM.js +0 -1
  71. package/static/assets/classDiagram-OUVF2IWQ-BRxqfb0_.js +0 -1
  72. package/static/assets/classDiagram-v2-EOCWNBFH-BRxqfb0_.js +0 -1
  73. package/static/assets/stateDiagram-v2-6OUMAXLB-CQVgnOlz.js +0 -1
  74. package/static/assets/swimlanesDiagram-G3AALYLV-DubMPSZe.js +0 -8
@@ -1,689 +1,798 @@
1
- # CI/CD App Template
2
-
3
- `cicd/app/server.js` — generic; reads `cicd.config.json`. Copy verbatim.
4
-
5
- ```js
6
- // Generated by util-gencicdscript — zero-dependency CI/CD build/run/deploy app.
7
- // Env (set by Compound Context Studio at spawn): PORT, CO2_STUDIO_URL, CO2_PROJECT_ID,
8
- // CO2_GUARD_TOKEN. Bare page — designed to be embedded in the studio as an iframe.
9
- // One nav tab per application; the tab body streams that application's log live over a
10
- // hand-rolled WebSocket (server→client push only). Memory safety: logs live on disk, the
11
- // server retains nothing per subscriber beyond the socket itself (bounded initial tail,
12
- // slow clients are dropped on backpressure), and the client trims its DOM log buffer.
13
- const http = require('node:http')
14
- const net = require('node:net')
15
- const { spawn, execFileSync } = require('node:child_process')
16
- const { createHash } = require('node:crypto')
17
- const { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, openSync, readSync, closeSync, createWriteStream } = require('node:fs')
18
- const { join, resolve } = require('node:path')
19
-
20
- const APP_DIR = __dirname
21
- const PROJECT_DIR = resolve(APP_DIR, '..', '..')
22
- const LOG_DIR = join(PROJECT_DIR, 'cicd', 'logs')
23
- const STATE_FILE = join(PROJECT_DIR, 'cicd', 'state.json')
24
- const CONFIG_PATH = join(APP_DIR, 'cicd.config.json')
25
- // Config is re-read on demand (mtime-cached), so a regenerated plan — corrected build/start
26
- // commands, renamed artifacts, added apps or changed infra — is picked up WITHOUT restarting
27
- // this app. Only the listen PORT is bound once at boot (a port change still needs a respawn).
28
- let configCache = { mtimeMs: -1, data: { port: 4100, apps: [], infra: [] } }
29
- function loadConfig() {
30
- try {
31
- const mtimeMs = statSync(CONFIG_PATH).mtimeMs
32
- if (mtimeMs !== configCache.mtimeMs) {
33
- configCache = { mtimeMs, data: JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) }
34
- }
35
- } catch {
36
- /* unreadable or mid-write — keep the last good config */
37
- }
38
- return configCache.data
39
- }
40
- const PORT = Number(process.env.PORT || loadConfig().port || 4100)
41
- const STUDIO = process.env.CO2_STUDIO_URL || 'http://127.0.0.1:3001'
42
- const PROJECT_ID = process.env.CO2_PROJECT_ID || ''
43
- const TOKEN = process.env.CO2_GUARD_TOKEN || ''
44
-
45
- mkdirSync(LOG_DIR, { recursive: true })
46
- const running = new Map() // app name -> ChildProcess (only processes THIS app spawned)
47
- const activity = new Map() // app name -> 'building' | 'deploying' (transient, exclusive)
48
- const failed = new Map() // app name -> true after a nonzero run exit
49
- const stopping = new Set() // app names whose exit was requested — not a failure
50
- const lastError = new Map() // app name -> string
51
-
52
- function saveState() {
53
- const s = {}
54
- for (const a of loadConfig().apps) s[a.name] = running.has(a.name) ? 'running' : 'stopped'
55
- try { writeFileSync(STATE_FILE, JSON.stringify(s)) } catch {}
56
- }
57
-
58
- function currentBranch() {
59
- try {
60
- return execFileSync('git', ['branch', '--show-current'], { cwd: PROJECT_DIR }).toString().trim()
61
- } catch { return null }
62
- }
63
-
64
- // ---- Develop gate (Compound Context Studio) --------------------------------------------
65
- // An application that never completed a conductor-develop run has nothing to build or run —
66
- // its tab controls are disabled. Refreshed every 30 s; the last known answer is kept when the
67
- // studio is briefly unreachable, and `null` (never fetched) disables controls with a hint.
68
- let guardApps = null // Map lower(name|folder) -> developedOnce
69
- async function refreshGuard() {
70
- if (!PROJECT_ID) return
71
- try {
72
- const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
73
- if (!r.ok) return
74
- const g = await r.json()
75
- if (Array.isArray(g.applications)) {
76
- guardApps = new Map()
77
- for (const a of g.applications) guardApps.set(String(a.name).toLowerCase(), !!a.developedOnce)
78
- }
79
- } catch {}
80
- }
81
- function developedOnce(app) {
82
- if (!guardApps) return null // unknown — controls stay disabled until the studio answers
83
- const byName = guardApps.get(app.name.toLowerCase())
84
- if (byName !== undefined) return byName
85
- const byFolder = guardApps.get(String(app.folder || '').toLowerCase())
86
- return byFolder === undefined ? null : byFolder
87
- }
88
- refreshGuard()
89
- setInterval(refreshGuard, 30_000).unref?.()
90
-
91
- // ---- Log streaming ---------------------------------------------------------------------
92
- // One log file per application (build + run + deploy share it — the tab is one stream).
93
- // Subscribers get a bounded tail on connect, then live appends; nothing is buffered
94
- // server-side, and a subscriber that cannot keep up is dropped instead of queuing forever.
95
- const subscribers = new Map() // app name -> Set<net.Socket>
96
- const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
97
-
98
- function wsFrame(data) {
99
- const payload = Buffer.from(data)
100
- const len = payload.length
101
- let header
102
- if (len < 126) header = Buffer.from([0x81, len])
103
- else if (len < 65_536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2) }
104
- else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
105
- return Buffer.concat([header, payload])
106
- }
107
-
108
- function broadcast(name, chunk) {
109
- const subs = subscribers.get(name)
110
- if (!subs || subs.size === 0) return
111
- const frame = wsFrame(chunk)
112
- for (const s of subs) {
113
- if (s.writableLength > 1_000_000) { subs.delete(s); try { s.destroy() } catch {} ; continue }
114
- s.write(frame)
115
- }
116
- }
117
-
118
- function logPath(name) { return join(LOG_DIR, `${name}.log`) }
119
-
120
- function logTail(name, bytes = 16_384) {
121
- try {
122
- const p = logPath(name)
123
- const size = statSync(p).size
124
- const start = Math.max(0, size - bytes)
125
- const buf = Buffer.alloc(size - start)
126
- const fd = openSync(p, 'r')
127
- readSync(fd, buf, 0, buf.length, start)
128
- closeSync(fd)
129
- return buf.toString('utf8')
130
- } catch { return '' }
131
- }
132
-
133
- function logLine(app, text) {
134
- const line = `\n--- ${new Date().toISOString()} ${text}\n`
135
- try { createWriteStream(logPath(app.name), { flags: 'a' }).end(line) } catch {}
136
- broadcast(app.name, line)
137
- }
138
-
139
- function runLogged(app, command, onExit) {
140
- const log = createWriteStream(logPath(app.name), { flags: 'a' })
141
- log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
142
- broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
143
- const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env })
144
- const tap = (d) => { log.write(d); broadcast(app.name, d) }
145
- child.stdout.on('data', tap)
146
- child.stderr.on('data', tap)
147
- child.on('exit', (code) => { log.end(); onExit(code) })
148
- return child
149
- }
150
-
151
- // ---- Application config (runtime .env etc.) --------------------------------------------
152
- // Each app declares the config files its start command reads (inferred by util-plancicd from
153
- // ENVIRONMENT.md or an existing .env/.env-* file). A `managed` file carries the plan's resolved
154
- // content and is materialized into the application folder before Build/Start when it is missing —
155
- // so an application always starts with COMPLETE configuration. The Config modal shows every file's
156
- // live on-disk content for troubleshooting. Paths are relative to the application folder.
157
- const MAX_CONFIG_BYTES = 200_000
158
- function configAbsPath(app, cfg) { return join(PROJECT_DIR, app.folder, cfg.path) }
159
- function ensureConfigs(app) {
160
- for (const cfg of app.configs || []) {
161
- if (!cfg.managed || cfg.content == null) continue // env-file configs are used as-is on disk
162
- const abs = configAbsPath(app, cfg)
163
- if (existsSync(abs)) continue // never overwrite a config already on disk
164
- try {
165
- mkdirSync(join(abs, '..'), { recursive: true })
166
- writeFileSync(abs, cfg.content)
167
- logLine(app, `config: wrote ${cfg.path} from the plan (was missing)`)
168
- } catch (e) {
169
- logLine(app, `config: FAILED to write ${cfg.path} — ${e.message}`)
170
- }
171
- }
172
- }
173
- function readConfig(app, cfg) {
174
- const abs = configAbsPath(app, cfg)
175
- let exists = false, content = ''
176
- try {
177
- if (existsSync(abs)) {
178
- exists = true
179
- const buf = readFileSync(abs)
180
- content = buf.length > MAX_CONFIG_BYTES
181
- ? buf.slice(0, MAX_CONFIG_BYTES).toString('utf8') + '\n… (truncated)'
182
- : buf.toString('utf8')
183
- } else if (cfg.managed && cfg.content != null) {
184
- content = cfg.content // planned content — not yet written to disk
185
- }
186
- } catch (e) { content = `(unreadable: ${e.message})` }
187
- return {
188
- path: cfg.path, label: cfg.label || cfg.path, source: cfg.source || 'env-file',
189
- managed: !!cfg.managed, keys: cfg.keys || [], note: cfg.note || '', exists, content,
190
- }
191
- }
192
-
193
- // ---- App lifecycle ---------------------------------------------------------------------
194
- // Candidate loopback hosts to check for liveness: IPv4 (127.0.0.1) AND IPv6 (::1) loopback
195
- // always, plus whatever host the plan's health URL names. Frameworks bind different families —
196
- // Spring Boot listens on 127.0.0.1, but Vite/Next preview dev servers bind IPv6 ::1 only — so
197
- // probing a single family misses half of them. `localhost` in a health URL is included as-is
198
- // and resolved by the OS.
199
- function probeHosts(app) {
200
- const hosts = new Set(['127.0.0.1', '::1'])
201
- try { if (app.health) hosts.add(new URL(app.health).hostname) } catch {}
202
- return [...hosts]
203
- }
204
- // Liveness = a raw TCP connect to the app's listen port on ANY candidate host, NOT an HTTP
205
- // fetch of a health URL. A listening port means the app is up regardless of auth (Spring
206
- // Security 401s), path (actuator disabled), or protocol — and it is reliable where Node's fetch
207
- // is not (undici can stall on localhost keep-alive and time out against a server curl reaches
208
- // in 20 ms). This also detects apps started outside this CI/CD app, so their state shows right.
209
- async function probe(app) {
210
- const results = await Promise.all(probeHosts(app).map((h) => tcpProbe(h, app.port, 800)))
211
- return results.some(Boolean)
212
- }
213
-
214
- // Infrastructure reachability — a raw TCP connect, so it works for non-HTTP services
215
- // (PostgreSQL, Redis) as well as web consoles. Status-only: infra is never controlled here.
216
- function tcpProbe(host, port, timeout = 1000) {
217
- return new Promise((resolve) => {
218
- const socket = new net.Socket()
219
- let done = false
220
- const finish = (ok) => { if (done) return; done = true; try { socket.destroy() } catch {} ; resolve(ok) }
221
- socket.setTimeout(timeout)
222
- socket.once('connect', () => finish(true))
223
- socket.once('timeout', () => finish(false))
224
- socket.once('error', () => finish(false))
225
- try { socket.connect(Number(port), host || '127.0.0.1') } catch { finish(false) }
226
- })
227
- }
228
-
229
- function buildApp(app) {
230
- if (activity.has(app.name)) return
231
- activity.set(app.name, 'building'); lastError.delete(app.name)
232
- ensureConfigs(app)
233
- runLogged(app, app.build, (code) => {
234
- activity.delete(app.name)
235
- if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
236
- else logLine(app, 'build OK')
237
- })
238
- }
239
-
240
- function deployApp(app) {
241
- if (activity.has(app.name) || !app.deploy) return
242
- activity.set(app.name, 'deploying'); lastError.delete(app.name)
243
- runLogged(app, app.deploy, (code) => {
244
- activity.delete(app.name)
245
- if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
246
- else logLine(app, `deploy OK (${app.deployMethod})`)
247
- })
248
- }
249
-
250
- function startApp(app) {
251
- if (running.get(app.name) || activity.has(app.name)) return
252
- failed.delete(app.name); lastError.delete(app.name)
253
- ensureConfigs(app)
254
- if (app.method === 'container') {
255
- runLogged(app, app.start, () => {})
256
- saveState(); return
257
- }
258
- const child = runLogged(app, app.start, (code) => {
259
- running.delete(app.name)
260
- // A user-requested Stop exits nonzero (killed) — that is not a failure.
261
- if (code !== 0 && !stopping.delete(app.name)) {
262
- failed.set(app.name, true)
263
- lastError.set(app.name, `process exited ${code} — see log`)
264
- } else stopping.delete(app.name)
265
- saveState()
266
- })
267
- running.set(app.name, child)
268
- saveState()
269
- }
270
-
271
- // shell:true spawns a shell whose grandchildren survive child.kill() on Windows — kill the
272
- // whole tree there (taskkill /T); plain kill elsewhere.
273
- function killTree(child) {
274
- if (process.platform === 'win32' && child.pid) {
275
- try { execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
276
- } else {
277
- try { child.kill() } catch {}
278
- }
279
- }
280
-
281
- // Stop an app we DON'T hold a child handle for (started outside this app, or before this app
282
- // last restarted) by killing whatever process tree is listening on its port — so Stop works
283
- // for any running app, not only ones this CI/CD app spawned. Returns true if a PID was killed.
284
- function killByPort(port) {
285
- try {
286
- if (process.platform === 'win32') {
287
- const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
288
- const pids = new Set()
289
- for (const line of out.split(/\r?\n/)) {
290
- const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
291
- if (m && Number(m[1]) === Number(port)) pids.add(m[2])
292
- }
293
- for (const pid of pids) {
294
- try { execFileSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'ignore' }) } catch {}
295
- }
296
- return pids.size > 0
297
- }
298
- const out = execFileSync('sh', ['-c', `lsof -ti tcp:${port} || true`], { encoding: 'utf8' })
299
- const pids = out.split(/\s+/).filter(Boolean)
300
- for (const pid of pids) { try { process.kill(Number(pid)) } catch {} }
301
- return pids.length > 0
302
- } catch { return false }
303
- }
304
-
305
- function stopApp(app) {
306
- if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
307
- try { execFileSync(app.stop.split(' ')[0], app.stop.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
308
- } else {
309
- const child = running.get(app.name)
310
- if (child) {
311
- stopping.add(app.name)
312
- killTree(child)
313
- } else {
314
- // No child handle — kill by port so an externally/previously started app still stops.
315
- lastError.delete(app.name)
316
- if (!killByPort(app.port)) {
317
- lastError.set(app.name, `could not stop — nothing found listening on port ${app.port}`)
318
- }
319
- }
320
- }
321
- running.delete(app.name)
322
- saveState()
323
- }
324
-
325
- function json(res, code, body) {
326
- res.writeHead(code, { 'content-type': 'application/json' })
327
- res.end(JSON.stringify(body))
328
- }
329
-
330
- // ---- Page ------------------------------------------------------------------------------
331
- const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>CI/CD</title><style>
332
- body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;display:flex;flex-direction:column;height:100vh}
333
- .top{display:flex;gap:8px;align-items:center;padding:10px 12px 6px;font-size:13px}
334
- .meta{font-size:11px;color:#5b6572}
335
- .tabs{display:flex;gap:2px;padding:0 12px;border-bottom:1px solid #d9dee5;flex-wrap:wrap}
336
- .tab{border:1px solid #d9dee5;border-bottom:none;border-radius:8px 8px 0 0;background:#eef1f5;padding:6px 14px;font-size:13px;cursor:pointer;display:flex;gap:6px;align-items:center}
337
- .tab.active{background:#fff;font-weight:600}
338
- .tab .dot{width:8px;height:8px;border-radius:99px;background:#8a94a0}
339
- .dot.running{background:#1c8a4a}.dot.failed{background:#c23b3b}.dot.building,.dot.deploying{background:#c9922a}
340
- .bar{display:flex;gap:8px;align-items:center;padding:10px 12px;background:#fff;border-bottom:1px solid #e1e5ea;flex-wrap:wrap}
341
- .badge{font-size:10px;text-transform:uppercase;border-radius:99px;padding:2px 8px;color:#fff;background:#8a94a0}
342
- .badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
343
- .url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
344
- button{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer}
345
- button:hover:enabled{background:#eef1f5}
346
- button:disabled{opacity:.4;cursor:default}
347
- .notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
348
- .err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
349
- #log{flex:1;margin:0;padding:10px 12px;background:#0f1216;color:#d7dde4;font-size:11px;line-height:1.5;overflow:auto;white-space:pre-wrap}
350
- .muted{color:#5b6572;font-size:12px}
351
- .modal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;padding:24px;z-index:20}
352
- .modal.open{display:flex}
353
- .modal-panel{background:#fff;border-radius:10px;max-width:920px;width:100%;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,.3)}
354
- .modal-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid #e1e5ea;font-size:13px}
355
- .modal-body{padding:12px 14px;overflow:auto}
356
- .cfg{margin-bottom:16px}
357
- .cfg h3{font-size:13px;margin:0 0 4px;display:flex;gap:8px;align-items:center}
358
- .cfg .src{font-size:10px;text-transform:uppercase;border-radius:99px;padding:2px 8px;background:#eef1f5;color:#5b6572}
359
- .cfg .miss{color:#c23b3b;font-weight:600}
360
- .cfg .ok{color:#1c8a4a;font-weight:600}
361
- .cfg .keys{font-size:11px;color:#5b6572;margin:2px 0}
362
- .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
- </style></head><body>
364
- <div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span><span style="flex:1"></span><a id="infralink" class="url" href="/infra" target="_blank" rel="noreferrer" style="display:none">Infrastructure ↗</a></div>
365
- <div class="tabs" id="tabs"></div>
366
- <div class="bar" id="bar"></div>
367
- <div class="notice" id="notice"></div>
368
- <div class="err" id="err"></div>
369
- <pre id="log"></pre>
370
- <div class="modal" id="cfgModal" onclick="if(event.target===this)closeConfig()">
371
- <div class="modal-panel">
372
- <div class="modal-head"><strong id="cfgTitle">Configuration</strong><span style="flex:1"></span><button onclick="closeConfig()">Close</button></div>
373
- <div class="modal-body" id="cfgBody"></div>
374
- </div>
375
- </div>
376
- <script>
377
- function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) }
378
- let state = { apps: [] }
379
- let active = null
380
- let ws = null, wsTimer = null
381
-
382
- function appOf(name){ return state.apps.find(a => a.name === name) }
383
-
384
- function renderTabs(){
385
- document.getElementById('tabs').innerHTML = state.apps.map(a => \`
386
- <div class="tab \${a.name===active?'active':''}" onclick="setActive('\${a.name}')">
387
- <span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
388
- }
389
-
390
- function renderBar(){
391
- const a = appOf(active)
392
- if (!a) return
393
- const url = 'http://'+location.hostname+':'+a.port+'/'
394
- const gate = a.developedOnce === true
395
- const busy = a.phase==='building'||a.phase==='deploying'
396
- document.getElementById('bar').innerHTML = \`
397
- <span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
398
- <span class="meta">\${a.method} · port \${a.port}</span>
399
- <a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
400
- <span style="flex:1"></span>
401
- <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>
403
- <button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
404
- <button onclick="act('stop')" \${a.phase!=='running'?'disabled':''}>Stop</button>
405
- \${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
406
- const notice = document.getElementById('notice')
407
- if (a.developedOnce === false) {
408
- notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
409
- notice.style.display = 'block'
410
- } else if (a.developedOnce === null) {
411
- notice.textContent = 'Develop status not verified yet (studio unreachable) — controls stay disabled.'
412
- notice.style.display = 'block'
413
- } else notice.style.display = 'none'
414
- const err = document.getElementById('err')
415
- if (a.error) { err.textContent = a.error; err.style.display = 'block' } else err.style.display = 'none'
416
- }
417
-
418
- function setActive(name){
419
- if (active === name) return
420
- active = name
421
- closeWs()
422
- document.getElementById('log').textContent = ''
423
- openWs(name)
424
- renderTabs(); renderBar()
425
- }
426
-
427
- // Live log stream. One socket at a time (closed on tab switch); auto-reconnect while the tab
428
- // stays active; the DOM buffer is trimmed so long sessions never grow the page unbounded.
429
- function openWs(name){
430
- ws = new WebSocket('ws://'+location.host+'/ws?app='+encodeURIComponent(name))
431
- ws.onmessage = e => appendLog(e.data)
432
- ws.onclose = () => { ws = null; if (active === name) wsTimer = setTimeout(() => openWs(name), 2000) }
433
- }
434
- function closeWs(){
435
- if (wsTimer) { clearTimeout(wsTimer); wsTimer = null }
436
- if (ws) { ws.onclose = null; try { ws.close() } catch {} ws = null }
437
- }
438
- function appendLog(text){
439
- const el = document.getElementById('log')
440
- const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
441
- el.textContent += text
442
- if (el.textContent.length > 400000) el.textContent = el.textContent.slice(-250000)
443
- if (atBottom) el.scrollTop = el.scrollHeight
444
- }
445
-
446
- // Config modal — the runtime config files this application needs to start (from the plan). Shows
447
- // each file's live on-disk content (or the planned content when a managed file is not yet written),
448
- // its source (environment / env-file / derived), required keys and present/MISSING status — so the
449
- // user can troubleshoot a start-up that is failing for want of complete configuration.
450
- async function openConfig(){
451
- if (!active) return
452
- const modal = document.getElementById('cfgModal')
453
- document.getElementById('cfgTitle').textContent = 'Configuration — '+active
454
- document.getElementById('cfgBody').innerHTML = '<div class="muted">Loading…</div>'
455
- modal.classList.add('open')
456
- const s = await fetch('/api/apps/'+encodeURIComponent(active)+'/config').then(r=>r.json()).catch(()=>null)
457
- const configs = (s && s.configs) || []
458
- document.getElementById('cfgBody').innerHTML = configs.length ? configs.map(c => \`
459
- <div class="cfg">
460
- <h3>\${esc(c.label)} <span class="src">\${esc(c.source)}</span>
461
- \${c.exists ? '<span class="ok">present</span>' : '<span class="miss">MISSING'+(c.managed&&c.content?' — created on Build/Start':'')+'</span>'}</h3>
462
- \${c.keys&&c.keys.length ? '<div class="keys">Required keys: '+c.keys.map(esc).join(', ')+'</div>' : ''}
463
- \${c.note ? '<div class="keys">'+esc(c.note)+'</div>' : ''}
464
- <pre>\${esc(c.content) || '(empty)'}</pre>
465
- </div>\`).join('') : '<div class="muted">No runtime configuration declared for this application.</div>'
466
- }
467
- function closeConfig(){ document.getElementById('cfgModal').classList.remove('open') }
468
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConfig() })
469
-
470
- async function refresh(){
471
- const s = await fetch('/api/state').then(r => r.json()).catch(() => null)
472
- if (!s) return
473
- state = s
474
- document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
475
- document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
476
- if (!active && s.apps.length) setActive(s.apps[0].name)
477
- renderTabs(); renderBar()
478
- }
479
- async function act(op){
480
- await fetch('/api/apps/'+encodeURIComponent(active)+'/'+op, { method: 'POST' })
481
- refresh()
482
- }
483
- // Rescan immediately when the user returns to this page (tab focus / regained visibility), so
484
- // coming back from another screen reflects the true app + infra state without waiting the poll.
485
- document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh() })
486
- window.addEventListener('focus', refresh)
487
- refresh(); setInterval(refresh, 3000)
488
- </script></body></html>`
489
-
490
- // ---- Infrastructure page ---------------------------------------------------------------
491
- // Standalone status-only page (opened in a new tab from the main page's Infrastructure link).
492
- // Lists every supporting 3rd-party service with a live TCP-reachability dot and a console link
493
- // when the service exposes a web UI. No lifecycle controls — infra is externally managed.
494
- const INFRA_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Infrastructure</title><style>
495
- body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;padding:16px}
496
- h1{font-size:16px;margin:0 0 12px}
497
- table{border-collapse:collapse;width:100%;max-width:820px;font-size:13px}
498
- th,td{text-align:left;padding:8px 10px;border-bottom:1px solid #e1e5ea}
499
- th{font-size:11px;text-transform:uppercase;color:#5b6572}
500
- .dot{display:inline-block;width:9px;height:9px;border-radius:99px;background:#8a94a0;margin-right:6px;vertical-align:middle}
501
- .dot.up{background:#1c8a4a}.dot.down{background:#c23b3b}
502
- .url{color:#1a66d0;text-decoration:underline}
503
- .muted{color:#5b6572;font-size:12px}
504
- .legend{margin-top:12px;font-size:11px;color:#5b6572}
505
- </style></head><body>
506
- <h1>Infrastructure</h1>
507
- <table><thead><tr><th>Status</th><th>Service</th><th>Endpoint</th><th>Console</th></tr></thead>
508
- <tbody id="rows"><tr><td colspan="4" class="muted">Loading…</td></tr></tbody></table>
509
- <div class="legend">● up down TCP reachability, polled every 3s</div>
510
- <script>
511
- function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) }
512
- async function refresh(){
513
- const s = await fetch('/api/infra').then(r=>r.json()).catch(()=>null)
514
- if(!s) return
515
- const rows = (s.infra||[]).map(i => \`
516
- <tr>
517
- <td><span class="dot \${i.up?'up':'down'}"></span>\${i.up?'up':'down'}</td>
518
- <td>\${esc(i.name)}\${i.description?' <span class="muted">— '+esc(i.description)+'</span>':''}</td>
519
- <td class="muted">\${esc(i.host)}:\${esc(i.port)}</td>
520
- <td>\${i.url?'<a class="url" href="'+esc(i.url)+'" target="_blank" rel="noreferrer">open ↗</a>':'—'}</td>
521
- </tr>\`).join('')
522
- document.getElementById('rows').innerHTML = rows || '<tr><td colspan="4" class="muted">No infrastructure declared.</td></tr>'
523
- }
524
- // Rescan immediately when the user returns to this page (tab focus / regained visibility), so
525
- // coming back from another screen reflects the true app + infra state without waiting the poll.
526
- document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh() })
527
- window.addEventListener('focus', refresh)
528
- refresh(); setInterval(refresh, 3000)
529
- </script></body></html>`
530
-
531
- // ---- HTTP ------------------------------------------------------------------------------
532
- const server = http.createServer(async (req, res) => {
533
- if (req.method === 'GET' && req.url === '/') {
534
- res.writeHead(200, { 'content-type': 'text/html' })
535
- return res.end(PAGE)
536
- }
537
- if (req.method === 'GET' && req.url === '/infra') {
538
- res.writeHead(200, { 'content-type': 'text/html' })
539
- return res.end(INFRA_PAGE)
540
- }
541
- if (req.method === 'GET' && req.url === '/api/infra') {
542
- const infra = await Promise.all((loadConfig().infra || []).map(async (i) => ({
543
- id: i.id, name: i.name, host: i.host || '127.0.0.1', port: i.port,
544
- url: i.url || null, description: i.description || '',
545
- up: await tcpProbe(i.host || '127.0.0.1', i.port),
546
- })))
547
- return json(res, 200, { infra })
548
- }
549
- if (req.method === 'GET' && req.url === '/api/state') {
550
- const apps = await Promise.all(loadConfig().apps.map(async (a) => {
551
- const healthy = await probe(a)
552
- const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
553
- return {
554
- name: a.name, method: a.method, port: a.port, phase,
555
- deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
556
- external: healthy && !running.has(a.name),
557
- developedOnce: developedOnce(a),
558
- error: lastError.get(a.name) || null,
559
- }
560
- }))
561
- return json(res, 200, { currentBranch: currentBranch(), hasInfra: (loadConfig().infra || []).length > 0, apps })
562
- }
563
- const cfgm = req.url && req.url.match(/^\/api\/apps\/([^/]+)\/config$/)
564
- if (cfgm && req.method === 'GET') {
565
- const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(cfgm[1]))
566
- if (!app) return json(res, 404, { error: 'unknown app' })
567
- return json(res, 200, { configs: (app.configs || []).map((c) => readConfig(app, c)) })
568
- }
569
- const m = req.url && req.url.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
570
- if (m && req.method === 'POST') {
571
- const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
572
- if (!app) return json(res, 404, { error: 'unknown app' })
573
- if (m[2] !== 'stop' && developedOnce(app) === false) {
574
- return json(res, 409, { error: 'application has not completed a develop workflow run' })
575
- }
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)
580
- return json(res, 200, { ok: true })
581
- }
582
- json(res, 404, { error: 'not found' })
583
- })
584
-
585
- // WebSocket endpoint /ws?app=<name> push-only log stream (see "Log streaming" above).
586
- server.on('upgrade', (req, socket) => {
587
- let name = ''
588
- let isWsPath = false
589
- try {
590
- const url = new URL(req.url, 'http://x')
591
- isWsPath = url.pathname === '/ws'
592
- name = url.searchParams.get('app') || ''
593
- } catch {}
594
- const key = req.headers['sec-websocket-key']
595
- const app = loadConfig().apps.find((a) => a.name === name)
596
- if (!app || !key || !isWsPath) { socket.destroy(); return }
597
- const accept = createHash('sha1').update(key + WS_GUID).digest('base64')
598
- socket.write(
599
- 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
600
- `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
601
- )
602
- let subs = subscribers.get(name)
603
- if (!subs) { subs = new Set(); subscribers.set(name, subs) }
604
- subs.add(socket)
605
- const cleanup = () => { subs.delete(socket); try { socket.destroy() } catch {} }
606
- socket.on('close', cleanup)
607
- socket.on('error', cleanup)
608
- // Client frames are only ever close (0x8) — anything else is ignored, never buffered.
609
- socket.on('data', (buf) => { if ((buf[0] & 0x0f) === 0x8) cleanup() })
610
- socket.write(wsFrame(logTail(name)))
611
- })
612
-
613
- server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
614
- ```
615
-
616
- `cicd/app/cicd.config.json` shape one `apps` entry per application from the plan's Deployment
617
- Decisions table, one `infra` entry per row of the plan's `## Infrastructure` table.
618
- `deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is `null` when the method
619
- is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
620
- `"infra": []` when the plan declares no supporting services.
621
-
622
- Each app also carries a `configs` array — the runtime config files the application reads at start,
623
- from the plan's `## Application Configs` section (one entry per file). Fields:
624
- `path` (relative to the application folder), `label`, `source` (`environment` | `env-file` |
625
- `derived`), `managed` (when `true` the server writes `content` to `path` before Build/Start if the
626
- file is missing, so start-up always has complete config), `keys` (the required keys the app needs
627
- to boot), `note`, and `content` (the plan's resolved file body when `managed`; `null`/omitted for an
628
- `env-file` used as-is on disk). Emit `"configs": []` when an application needs no runtime config
629
- file. NEVER omit a `managed.content` that the plan resolved that body is what guarantees the app
630
- starts configured. Per the dev-environment plaintext rule, `content` may include credentials
631
- verbatim (this is a local dev CI/CD app).
632
-
633
- ```json
634
- {
635
- "port": 4100,
636
- "apps": [
637
- {
638
- "name": "my-app",
639
- "folder": "my-app",
640
- "method": "process",
641
- "port": 3000,
642
- "build": "pnpm install && pnpm build",
643
- "start": "node dist/index.js",
644
- "stop": "process-kill",
645
- "deployMethod": "kubernetes",
646
- "deploy": "kubectl apply -f my-app/k8s/",
647
- "health": "http://127.0.0.1:3000/",
648
- "configs": [
649
- {
650
- "path": ".env",
651
- "label": ".env (runtime)",
652
- "source": "environment",
653
- "managed": true,
654
- "keys": ["DATABASE_URL", "REDIS_HOST", "PORT"],
655
- "note": "Resolved from ENVIRONMENT.md my-app section",
656
- "content": "DATABASE_URL=postgres://app:app@127.0.0.1:5432/appdb\nREDIS_HOST=127.0.0.1\nPORT=3000\n"
657
- },
658
- {
659
- "path": ".env-uat",
660
- "label": ".env-uat (existing)",
661
- "source": "env-file",
662
- "managed": false,
663
- "keys": ["DATABASE_URL", "REDIS_HOST"],
664
- "note": "Existing env file in the app folder — used as-is",
665
- "content": null
666
- }
667
- ]
668
- }
669
- ],
670
- "infra": [
671
- {
672
- "id": "postgres-main",
673
- "name": "PostgreSQL (app db)",
674
- "host": "127.0.0.1",
675
- "port": 5432,
676
- "url": null,
677
- "description": "Shared application database"
678
- },
679
- {
680
- "id": "keycloak",
681
- "name": "Keycloak",
682
- "host": "127.0.0.1",
683
- "port": 8180,
684
- "url": "http://127.0.0.1:8180",
685
- "description": "Single sign-on"
686
- }
687
- ]
688
- }
689
- ```
1
+ # CI/CD App Template
2
+
3
+ `cicd/app/server.js` — generic; reads `cicd.config.json`. Copy verbatim.
4
+
5
+ ```js
6
+ // Generated by util-gencicdscript — zero-dependency CI/CD build/run/deploy app.
7
+ // Env (set by Compound Context Studio at spawn): PORT, CO2_STUDIO_URL, CO2_PROJECT_ID,
8
+ // CO2_GUARD_TOKEN. Bare page — designed to be embedded in the studio as an iframe.
9
+ // One nav tab per application; the tab body streams that application's log live over a
10
+ // hand-rolled WebSocket (server→client push only). Memory safety: logs live on disk, the
11
+ // server retains nothing per subscriber beyond the socket itself (bounded initial tail,
12
+ // slow clients are dropped on backpressure), and the client trims its DOM log buffer.
13
+ const http = require('node:http')
14
+ const net = require('node:net')
15
+ const { spawn, execFileSync } = require('node:child_process')
16
+ const { createHash } = require('node:crypto')
17
+ const { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, openSync, readSync, closeSync, createWriteStream } = require('node:fs')
18
+ const { join, resolve } = require('node:path')
19
+
20
+ const APP_DIR = __dirname
21
+ const PROJECT_DIR = resolve(APP_DIR, '..', '..')
22
+ const LOG_DIR = join(PROJECT_DIR, 'cicd', 'logs')
23
+ const STATE_FILE = join(PROJECT_DIR, 'cicd', 'state.json')
24
+ const CONFIG_PATH = join(APP_DIR, 'cicd.config.json')
25
+ // Config is re-read on demand (mtime-cached), so a regenerated plan — corrected build/start
26
+ // commands, renamed artifacts, added apps or changed infra — is picked up WITHOUT restarting
27
+ // this app. Only the listen PORT is bound once at boot (a port change still needs a respawn).
28
+ let configCache = { mtimeMs: -1, data: { port: 5000, apps: [], infra: [] } }
29
+ function loadConfig() {
30
+ try {
31
+ const mtimeMs = statSync(CONFIG_PATH).mtimeMs
32
+ if (mtimeMs !== configCache.mtimeMs) {
33
+ configCache = { mtimeMs, data: JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) }
34
+ }
35
+ } catch {
36
+ /* unreadable or mid-write — keep the last good config */
37
+ }
38
+ return configCache.data
39
+ }
40
+ const PORT = Number(process.env.PORT || loadConfig().port || 5000)
41
+ const STUDIO = process.env.CO2_STUDIO_URL || 'http://127.0.0.1:3001'
42
+ const PROJECT_ID = process.env.CO2_PROJECT_ID || ''
43
+ const TOKEN = process.env.CO2_GUARD_TOKEN || ''
44
+
45
+ mkdirSync(LOG_DIR, { recursive: true })
46
+ const running = new Map() // app name -> ChildProcess (only processes THIS app spawned)
47
+ const activity = new Map() // app name -> 'building' | 'deploying' (transient, exclusive)
48
+ const failed = new Map() // app name -> true after a nonzero run exit
49
+ const stopping = new Set() // app names whose exit was requested — not a failure
50
+ const lastError = new Map() // app name -> string
51
+
52
+ function saveState() {
53
+ const s = {}
54
+ for (const a of loadConfig().apps) s[a.name] = running.has(a.name) ? 'running' : 'stopped'
55
+ try { writeFileSync(STATE_FILE, JSON.stringify(s)) } catch {}
56
+ }
57
+
58
+ function currentBranch() {
59
+ try {
60
+ return execFileSync('git', ['branch', '--show-current'], { cwd: PROJECT_DIR }).toString().trim()
61
+ } catch { return null }
62
+ }
63
+
64
+ // ---- Develop gate (Compound Context Studio) --------------------------------------------
65
+ // An application that never completed a conductor-develop run has nothing to build or run —
66
+ // its tab controls are disabled. Refreshed every 30 s; the last known answer is kept when the
67
+ // studio is briefly unreachable, and `null` (never fetched) disables controls with a hint.
68
+ let guardApps = null // Map lower(name|folder) -> developedOnce
69
+ let guardVersions = [] // project versions (semver strings, studio order — latest first)
70
+ async function refreshGuard() {
71
+ if (!PROJECT_ID) return
72
+ try {
73
+ const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
74
+ if (!r.ok) return
75
+ const g = await r.json()
76
+ if (Array.isArray(g.applications)) {
77
+ guardApps = new Map()
78
+ for (const a of g.applications) guardApps.set(String(a.name).toLowerCase(), !!a.developedOnce)
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)
82
+ } catch {}
83
+ }
84
+ function developedOnce(app) {
85
+ if (!guardApps) return null // unknown — controls stay disabled until the studio answers
86
+ const byName = guardApps.get(app.name.toLowerCase())
87
+ if (byName !== undefined) return byName
88
+ const byFolder = guardApps.get(String(app.folder || '').toLowerCase())
89
+ return byFolder === undefined ? null : byFolder
90
+ }
91
+ refreshGuard()
92
+ setInterval(refreshGuard, 30_000).unref?.()
93
+
94
+ // ---- Log streaming ---------------------------------------------------------------------
95
+ // One log file per application (build + run + deploy share it — the tab is one stream).
96
+ // Subscribers get a bounded tail on connect, then live appends; nothing is buffered
97
+ // server-side, and a subscriber that cannot keep up is dropped instead of queuing forever.
98
+ const subscribers = new Map() // app name -> Set<net.Socket>
99
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
100
+
101
+ function wsFrame(data) {
102
+ const payload = Buffer.from(data)
103
+ const len = payload.length
104
+ let header
105
+ if (len < 126) header = Buffer.from([0x81, len])
106
+ else if (len < 65_536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2) }
107
+ else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
108
+ return Buffer.concat([header, payload])
109
+ }
110
+
111
+ function broadcast(name, chunk) {
112
+ const subs = subscribers.get(name)
113
+ if (!subs || subs.size === 0) return
114
+ const frame = wsFrame(chunk)
115
+ for (const s of subs) {
116
+ if (s.writableLength > 1_000_000) { subs.delete(s); try { s.destroy() } catch {} ; continue }
117
+ s.write(frame)
118
+ }
119
+ }
120
+
121
+ function logPath(name) { return join(LOG_DIR, `${name}.log`) }
122
+
123
+ function logTail(name, bytes = 16_384) {
124
+ try {
125
+ const p = logPath(name)
126
+ const size = statSync(p).size
127
+ const start = Math.max(0, size - bytes)
128
+ const buf = Buffer.alloc(size - start)
129
+ const fd = openSync(p, 'r')
130
+ readSync(fd, buf, 0, buf.length, start)
131
+ closeSync(fd)
132
+ return buf.toString('utf8')
133
+ } catch { return '' }
134
+ }
135
+
136
+ function logLine(app, text) {
137
+ const line = `\n--- ${new Date().toISOString()} ${text}\n`
138
+ try { createWriteStream(logPath(app.name), { flags: 'a' }).end(line) } catch {}
139
+ broadcast(app.name, line)
140
+ }
141
+
142
+ function runLogged(app, command, onExit) {
143
+ const log = createWriteStream(logPath(app.name), { flags: 'a' })
144
+ log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
145
+ broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
146
+ const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env })
147
+ const tap = (d) => { log.write(d); broadcast(app.name, d) }
148
+ child.stdout.on('data', tap)
149
+ child.stderr.on('data', tap)
150
+ child.on('exit', (code) => { log.end(); onExit(code) })
151
+ return child
152
+ }
153
+
154
+ // ---- Application config (runtime .env etc.) --------------------------------------------
155
+ // Each app declares the config files its start command reads (inferred by util-plancicd from
156
+ // ENVIRONMENT.md or an existing .env/.env-* file). A `managed` file carries the plan's resolved
157
+ // content and is materialized into the application folder before Build/Start when it is missing —
158
+ // so an application always starts with COMPLETE configuration. The Config modal shows every file's
159
+ // live on-disk content for troubleshooting. Paths are relative to the application folder.
160
+ const MAX_CONFIG_BYTES = 200_000
161
+ function configAbsPath(app, cfg) { return join(PROJECT_DIR, app.folder, cfg.path) }
162
+ function ensureConfigs(app) {
163
+ for (const cfg of app.configs || []) {
164
+ if (!cfg.managed || cfg.content == null) continue // env-file configs are used as-is on disk
165
+ const abs = configAbsPath(app, cfg)
166
+ if (existsSync(abs)) continue // never overwrite a config already on disk
167
+ try {
168
+ mkdirSync(join(abs, '..'), { recursive: true })
169
+ writeFileSync(abs, cfg.content)
170
+ logLine(app, `config: wrote ${cfg.path} from the plan (was missing)`)
171
+ } catch (e) {
172
+ logLine(app, `config: FAILED to write ${cfg.path} — ${e.message}`)
173
+ }
174
+ }
175
+ }
176
+ function readConfig(app, cfg) {
177
+ const abs = configAbsPath(app, cfg)
178
+ let exists = false, content = ''
179
+ try {
180
+ if (existsSync(abs)) {
181
+ exists = true
182
+ const buf = readFileSync(abs)
183
+ content = buf.length > MAX_CONFIG_BYTES
184
+ ? buf.slice(0, MAX_CONFIG_BYTES).toString('utf8') + '\n… (truncated)'
185
+ : buf.toString('utf8')
186
+ } else if (cfg.managed && cfg.content != null) {
187
+ content = cfg.content // planned content — not yet written to disk
188
+ }
189
+ } catch (e) { content = `(unreadable: ${e.message})` }
190
+ return {
191
+ path: cfg.path, label: cfg.label || cfg.path, source: cfg.source || 'env-file',
192
+ managed: !!cfg.managed, keys: cfg.keys || [], note: cfg.note || '', exists, content,
193
+ }
194
+ }
195
+
196
+ // ---- App lifecycle ---------------------------------------------------------------------
197
+ // Candidate loopback hosts to check for liveness: IPv4 (127.0.0.1) AND IPv6 (::1) loopback
198
+ // always, plus whatever host the plan's health URL names. Frameworks bind different families —
199
+ // Spring Boot listens on 127.0.0.1, but Vite/Next preview dev servers bind IPv6 ::1 only — so
200
+ // probing a single family misses half of them. `localhost` in a health URL is included as-is
201
+ // and resolved by the OS.
202
+ function probeHosts(app) {
203
+ const hosts = new Set(['127.0.0.1', '::1'])
204
+ try { if (app.health) hosts.add(new URL(app.health).hostname) } catch {}
205
+ return [...hosts]
206
+ }
207
+ // Liveness = a raw TCP connect to the app's listen port on ANY candidate host, NOT an HTTP
208
+ // fetch of a health URL. A listening port means the app is up regardless of auth (Spring
209
+ // Security 401s), path (actuator disabled), or protocol — and it is reliable where Node's fetch
210
+ // is not (undici can stall on localhost keep-alive and time out against a server curl reaches
211
+ // in 20 ms). This also detects apps started outside this CI/CD app, so their state shows right.
212
+ async function probe(app) {
213
+ const results = await Promise.all(probeHosts(app).map((h) => tcpProbe(h, app.port, 800)))
214
+ return results.some(Boolean)
215
+ }
216
+
217
+ // Infrastructure reachability a raw TCP connect, so it works for non-HTTP services
218
+ // (PostgreSQL, Redis) as well as web consoles. Status-only: infra is never controlled here.
219
+ function tcpProbe(host, port, timeout = 1000) {
220
+ return new Promise((resolve) => {
221
+ const socket = new net.Socket()
222
+ let done = false
223
+ const finish = (ok) => { if (done) return; done = true; try { socket.destroy() } catch {} ; resolve(ok) }
224
+ socket.setTimeout(timeout)
225
+ socket.once('connect', () => finish(true))
226
+ socket.once('timeout', () => finish(false))
227
+ socket.once('error', () => finish(false))
228
+ try { socket.connect(Number(port), host || '127.0.0.1') } catch { finish(false) }
229
+ })
230
+ }
231
+
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 }
254
+ activity.set(app.name, 'building'); lastError.delete(app.name)
255
+ ensureConfigs(app)
256
+ ensureDockerfile(app)
257
+ runLogged(app, subst(app.build, version), (code) => {
258
+ activity.delete(app.name)
259
+ if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
260
+ else logLine(app, 'build OK')
261
+ onDone && onDone()
262
+ })
263
+ }
264
+
265
+ function deployApp(app, version) {
266
+ if (activity.has(app.name) || !app.deploy) return
267
+ activity.set(app.name, 'deploying'); lastError.delete(app.name)
268
+ runLogged(app, subst(app.deploy, version), (code) => {
269
+ activity.delete(app.name)
270
+ if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
271
+ else logLine(app, `deploy OK (${app.deployMethod})`)
272
+ })
273
+ }
274
+
275
+ function startApp(app, version) {
276
+ if (running.get(app.name) || activity.has(app.name)) return
277
+ failed.delete(app.name); lastError.delete(app.name)
278
+ ensureConfigs(app)
279
+ if (app.method === 'container') {
280
+ runLogged(app, subst(app.start, version), () => {})
281
+ saveState(); return
282
+ }
283
+ const child = runLogged(app, subst(app.start, version), (code) => {
284
+ running.delete(app.name)
285
+ // A user-requested Stop exits nonzero (killed) — that is not a failure.
286
+ if (code !== 0 && !stopping.delete(app.name)) {
287
+ failed.set(app.name, true)
288
+ lastError.set(app.name, `process exited ${code} — see log`)
289
+ } else stopping.delete(app.name)
290
+ saveState()
291
+ })
292
+ running.set(app.name, child)
293
+ saveState()
294
+ }
295
+
296
+ // shell:true spawns a shell whose grandchildren survive child.kill() on Windows — kill the
297
+ // whole tree there (taskkill /T); plain kill elsewhere.
298
+ function killTree(child) {
299
+ if (process.platform === 'win32' && child.pid) {
300
+ try { execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
301
+ } else {
302
+ try { child.kill() } catch {}
303
+ }
304
+ }
305
+
306
+ // Stop an app we DON'T hold a child handle for (started outside this app, or before this app
307
+ // last restarted) by killing whatever process tree is listening on its port — so Stop works
308
+ // for any running app, not only ones this CI/CD app spawned. Returns true if a PID was killed.
309
+ function killByPort(port) {
310
+ try {
311
+ if (process.platform === 'win32') {
312
+ const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
313
+ const pids = new Set()
314
+ for (const line of out.split(/\r?\n/)) {
315
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
316
+ if (m && Number(m[1]) === Number(port)) pids.add(m[2])
317
+ }
318
+ for (const pid of pids) {
319
+ try { execFileSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'ignore' }) } catch {}
320
+ }
321
+ return pids.size > 0
322
+ }
323
+ const out = execFileSync('sh', ['-c', `lsof -ti tcp:${port} || true`], { encoding: 'utf8' })
324
+ const pids = out.split(/\s+/).filter(Boolean)
325
+ for (const pid of pids) { try { process.kill(Number(pid)) } catch {} }
326
+ return pids.length > 0
327
+ } catch { return false }
328
+ }
329
+
330
+ function stopApp(app, version) {
331
+ if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
332
+ const stopCmd = subst(app.stop, version)
333
+ try { execFileSync(stopCmd.split(' ')[0], stopCmd.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
334
+ } else {
335
+ const child = running.get(app.name)
336
+ if (child) {
337
+ stopping.add(app.name)
338
+ killTree(child)
339
+ } else {
340
+ // No child handle — kill by port so an externally/previously started app still stops.
341
+ lastError.delete(app.name)
342
+ if (!killByPort(app.port)) {
343
+ lastError.set(app.name, `could not stop — nothing found listening on port ${app.port}`)
344
+ }
345
+ }
346
+ }
347
+ running.delete(app.name)
348
+ saveState()
349
+ }
350
+
351
+ function json(res, code, body) {
352
+ res.writeHead(code, { 'content-type': 'application/json' })
353
+ res.end(JSON.stringify(body))
354
+ }
355
+
356
+ // ---- Page ------------------------------------------------------------------------------
357
+ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>CI/CD</title><style>
358
+ body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;display:flex;flex-direction:column;height:100vh}
359
+ .top{display:flex;gap:8px;align-items:center;padding:10px 12px 6px;font-size:13px}
360
+ .meta{font-size:11px;color:#5b6572}
361
+ .tabs{display:flex;gap:2px;padding:0 12px;border-bottom:1px solid #d9dee5;flex-wrap:wrap}
362
+ .tab{border:1px solid #d9dee5;border-bottom:none;border-radius:8px 8px 0 0;background:#eef1f5;padding:6px 14px;font-size:13px;cursor:pointer;display:flex;gap:6px;align-items:center}
363
+ .tab.active{background:#fff;font-weight:600}
364
+ .tab .dot{width:8px;height:8px;border-radius:99px;background:#8a94a0}
365
+ .dot.running{background:#1c8a4a}.dot.failed{background:#c23b3b}.dot.building,.dot.deploying{background:#c9922a}
366
+ .bar{display:flex;gap:8px;align-items:center;padding:10px 12px;background:#fff;border-bottom:1px solid #e1e5ea;flex-wrap:wrap}
367
+ .badge{font-size:10px;text-transform:uppercase;border-radius:99px;padding:2px 8px;color:#fff;background:#8a94a0}
368
+ .badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
369
+ .url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
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}
372
+ button:hover:enabled{background:#eef1f5}
373
+ button:disabled{opacity:.4;cursor:default}
374
+ .notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
375
+ .err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
376
+ #log{flex:1;margin:0;padding:10px 12px;background:#0f1216;color:#d7dde4;font-size:11px;line-height:1.5;overflow:auto;white-space:pre-wrap}
377
+ .muted{color:#5b6572;font-size:12px}
378
+ .modal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;padding:24px;z-index:20}
379
+ .modal.open{display:flex}
380
+ .modal-panel{background:#fff;border-radius:10px;max-width:920px;width:100%;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,.3)}
381
+ .modal-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid #e1e5ea;font-size:13px}
382
+ .modal-body{padding:12px 14px;overflow:auto}
383
+ .cfg{margin-bottom:16px}
384
+ .cfg h3{font-size:13px;margin:0 0 4px;display:flex;gap:8px;align-items:center}
385
+ .cfg .src{font-size:10px;text-transform:uppercase;border-radius:99px;padding:2px 8px;background:#eef1f5;color:#5b6572}
386
+ .cfg .miss{color:#c23b3b;font-weight:600}
387
+ .cfg .ok{color:#1c8a4a;font-weight:600}
388
+ .cfg .keys{font-size:11px;color:#5b6572;margin:2px 0}
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}
390
+ </style></head><body>
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>
397
+ <div class="tabs" id="tabs"></div>
398
+ <div class="bar" id="bar"></div>
399
+ <div class="notice" id="notice"></div>
400
+ <div class="err" id="err"></div>
401
+ <pre id="log"></pre>
402
+ <div class="modal" id="cfgModal" onclick="if(event.target===this)closeConfig()">
403
+ <div class="modal-panel">
404
+ <div class="modal-head"><strong id="cfgTitle">Configuration</strong><span style="flex:1"></span><button onclick="closeConfig()">Close</button></div>
405
+ <div class="modal-body" id="cfgBody"></div>
406
+ </div>
407
+ </div>
408
+ <script>
409
+ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) }
410
+ let state = { apps: [] }
411
+ let active = null
412
+ let ws = null, wsTimer = null
413
+
414
+ function appOf(name){ return state.apps.find(a => a.name === name) }
415
+
416
+ function renderTabs(){
417
+ document.getElementById('tabs').innerHTML = state.apps.map(a => \`
418
+ <div class="tab \${a.name===active?'active':''}" onclick="setActive('\${a.name}')">
419
+ <span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
420
+ }
421
+
422
+ function renderBar(){
423
+ const a = appOf(active)
424
+ if (!a) return
425
+ const url = 'http://'+location.hostname+':'+a.port+'/'
426
+ const gate = a.developedOnce === true
427
+ const busy = a.phase==='building'||a.phase==='deploying'
428
+ document.getElementById('bar').innerHTML = \`
429
+ <span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
430
+ <span class="meta">\${a.method} · port \${a.port}</span>
431
+ <a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
432
+ <span style="flex:1"></span>
433
+ <button onclick="openConfig()" title="Show this application's runtime configuration files">Config</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>
435
+ <button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
436
+ <button onclick="act('stop')" \${a.phase!=='running'?'disabled':''}>Stop</button>
437
+ \${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
438
+ const notice = document.getElementById('notice')
439
+ if (a.developedOnce === false) {
440
+ notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
441
+ notice.style.display = 'block'
442
+ } else if (a.developedOnce === null) {
443
+ notice.textContent = 'Develop status not verified yet (studio unreachable) controls stay disabled.'
444
+ notice.style.display = 'block'
445
+ } else notice.style.display = 'none'
446
+ const err = document.getElementById('err')
447
+ if (a.error) { err.textContent = a.error; err.style.display = 'block' } else err.style.display = 'none'
448
+ }
449
+
450
+ function setActive(name){
451
+ if (active === name) return
452
+ active = name
453
+ closeWs()
454
+ document.getElementById('log').textContent = ''
455
+ openWs(name)
456
+ renderTabs(); renderBar()
457
+ }
458
+
459
+ // Live log stream. One socket at a time (closed on tab switch); auto-reconnect while the tab
460
+ // stays active; the DOM buffer is trimmed so long sessions never grow the page unbounded.
461
+ function openWs(name){
462
+ // Path-relative + scheme-aware so the stream works direct (:5000/) AND behind the studio's
463
+ // /api/cicd/<id>/app-proxy/ prefix (which tunnels the upgrade), also under HTTPS.
464
+ ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+location.pathname.replace(/\/$/,'')+'/ws?app='+encodeURIComponent(name))
465
+ ws.onmessage = e => appendLog(e.data)
466
+ ws.onclose = () => { ws = null; if (active === name) wsTimer = setTimeout(() => openWs(name), 2000) }
467
+ }
468
+ function closeWs(){
469
+ if (wsTimer) { clearTimeout(wsTimer); wsTimer = null }
470
+ if (ws) { ws.onclose = null; try { ws.close() } catch {} ws = null }
471
+ }
472
+ function appendLog(text){
473
+ const el = document.getElementById('log')
474
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
475
+ el.textContent += text
476
+ if (el.textContent.length > 400000) el.textContent = el.textContent.slice(-250000)
477
+ if (atBottom) el.scrollTop = el.scrollHeight
478
+ }
479
+
480
+ // Config modal the runtime config files this application needs to start (from the plan). Shows
481
+ // each file's live on-disk content (or the planned content when a managed file is not yet written),
482
+ // its source (environment / env-file / derived), required keys and present/MISSING status — so the
483
+ // user can troubleshoot a start-up that is failing for want of complete configuration.
484
+ async function openConfig(){
485
+ if (!active) return
486
+ const modal = document.getElementById('cfgModal')
487
+ document.getElementById('cfgTitle').textContent = 'Configuration — '+active
488
+ document.getElementById('cfgBody').innerHTML = '<div class="muted">Loading…</div>'
489
+ modal.classList.add('open')
490
+ const s = await fetch('api/apps/'+encodeURIComponent(active)+'/config').then(r=>r.json()).catch(()=>null)
491
+ const configs = (s && s.configs) || []
492
+ document.getElementById('cfgBody').innerHTML = configs.length ? configs.map(c => \`
493
+ <div class="cfg">
494
+ <h3>\${esc(c.label)} <span class="src">\${esc(c.source)}</span>
495
+ \${c.exists ? '<span class="ok">present</span>' : '<span class="miss">MISSING'+(c.managed&&c.content?' — created on Build/Start':'')+'</span>'}</h3>
496
+ \${c.keys&&c.keys.length ? '<div class="keys">Required keys: '+c.keys.map(esc).join(', ')+'</div>' : ''}
497
+ \${c.note ? '<div class="keys">'+esc(c.note)+'</div>' : ''}
498
+ <pre>\${esc(c.content) || '(empty)'}</pre>
499
+ </div>\`).join('') : '<div class="muted">No runtime configuration declared for this application.</div>'
500
+ }
501
+ function closeConfig(){ document.getElementById('cfgModal').classList.remove('open') }
502
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConfig() })
503
+
504
+ // Topbar version selector — the studio guard's project versions (latest first). Repopulated
505
+ // only when the list changes so the user's selection survives the 3 s poll.
506
+ let verChoices = ''
507
+ function renderVersions(versions){
508
+ const sel = document.getElementById('ver')
509
+ const list = (versions && versions.length ? versions : []).join('|')
510
+ if (list === verChoices) return
511
+ verChoices = list
512
+ sel.innerHTML = (versions && versions.length ? versions : ['latest'])
513
+ .map((v, i) => '<option value="'+esc(v)+'"'+(i===0?' selected':'')+'>'+esc(v)+'</option>').join('')
514
+ }
515
+ function selectedVersion(){ return document.getElementById('ver').value || '' }
516
+
517
+ async function refresh(){
518
+ const s = await fetch('api/state').then(r => r.json()).catch(() => null)
519
+ if (!s) return
520
+ state = s
521
+ document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
522
+ document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
523
+ renderVersions(s.versions)
524
+ if (!active && s.apps.length) setActive(s.apps[0].name)
525
+ renderTabs(); renderBar()
526
+ }
527
+ async function act(op){
528
+ await fetch('api/apps/'+encodeURIComponent(active)+'/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
529
+ refresh()
530
+ }
531
+ async function actAll(op){
532
+ await fetch('api/all/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
533
+ refresh()
534
+ }
535
+ // Rescan immediately when the user returns to this page (tab focus / regained visibility), so
536
+ // coming back from another screen reflects the true app + infra state without waiting the poll.
537
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh() })
538
+ window.addEventListener('focus', refresh)
539
+ refresh(); setInterval(refresh, 3000)
540
+ </script></body></html>`
541
+
542
+ // ---- Infrastructure page ---------------------------------------------------------------
543
+ // Standalone status-only page (opened in a new tab from the main page's Infrastructure link).
544
+ // Lists every supporting 3rd-party service with a live TCP-reachability dot and a console link
545
+ // when the service exposes a web UI. No lifecycle controls — infra is externally managed.
546
+ const INFRA_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Infrastructure</title><style>
547
+ body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;padding:16px}
548
+ h1{font-size:16px;margin:0 0 12px}
549
+ table{border-collapse:collapse;width:100%;max-width:820px;font-size:13px}
550
+ th,td{text-align:left;padding:8px 10px;border-bottom:1px solid #e1e5ea}
551
+ th{font-size:11px;text-transform:uppercase;color:#5b6572}
552
+ .dot{display:inline-block;width:9px;height:9px;border-radius:99px;background:#8a94a0;margin-right:6px;vertical-align:middle}
553
+ .dot.up{background:#1c8a4a}.dot.down{background:#c23b3b}
554
+ .url{color:#1a66d0;text-decoration:underline}
555
+ .muted{color:#5b6572;font-size:12px}
556
+ .legend{margin-top:12px;font-size:11px;color:#5b6572}
557
+ </style></head><body>
558
+ <h1>Infrastructure</h1>
559
+ <table><thead><tr><th>Status</th><th>Service</th><th>Endpoint</th><th>Console</th></tr></thead>
560
+ <tbody id="rows"><tr><td colspan="4" class="muted">Loading…</td></tr></tbody></table>
561
+ <div class="legend">● up down TCP reachability, polled every 3s</div>
562
+ <script>
563
+ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) }
564
+ async function refresh(){
565
+ const s = await fetch('api/infra').then(r=>r.json()).catch(()=>null)
566
+ if(!s) return
567
+ const rows = (s.infra||[]).map(i => \`
568
+ <tr>
569
+ <td><span class="dot \${i.up?'up':'down'}"></span>\${i.up?'up':'down'}</td>
570
+ <td>\${esc(i.name)}\${i.description?' <span class="muted">— '+esc(i.description)+'</span>':''}</td>
571
+ <td class="muted">\${esc(i.host)}:\${esc(i.port)}</td>
572
+ <td>\${i.url?'<a class="url" href="'+esc(i.url)+'" target="_blank" rel="noreferrer">open ↗</a>':''}</td>
573
+ </tr>\`).join('')
574
+ document.getElementById('rows').innerHTML = rows || '<tr><td colspan="4" class="muted">No infrastructure declared.</td></tr>'
575
+ }
576
+ // Rescan immediately when the user returns to this page (tab focus / regained visibility), so
577
+ // coming back from another screen reflects the true app + infra state without waiting the poll.
578
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh() })
579
+ window.addEventListener('focus', refresh)
580
+ refresh(); setInterval(refresh, 3000)
581
+ </script></body></html>`
582
+
583
+ // ---- HTTP ------------------------------------------------------------------------------
584
+ const server = http.createServer(async (req, res) => {
585
+ if (req.method === 'GET' && req.url === '/') {
586
+ res.writeHead(200, { 'content-type': 'text/html' })
587
+ return res.end(PAGE)
588
+ }
589
+ if (req.method === 'GET' && req.url === '/infra') {
590
+ res.writeHead(200, { 'content-type': 'text/html' })
591
+ return res.end(INFRA_PAGE)
592
+ }
593
+ if (req.method === 'GET' && req.url === '/api/infra') {
594
+ const infra = await Promise.all((loadConfig().infra || []).map(async (i) => ({
595
+ id: i.id, name: i.name, host: i.host || '127.0.0.1', port: i.port,
596
+ url: i.url || null, description: i.description || '',
597
+ up: await tcpProbe(i.host || '127.0.0.1', i.port),
598
+ })))
599
+ return json(res, 200, { infra })
600
+ }
601
+ if (req.method === 'GET' && req.url === '/api/state') {
602
+ const apps = await Promise.all(loadConfig().apps.map(async (a) => {
603
+ const healthy = await probe(a)
604
+ const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
605
+ return {
606
+ name: a.name, method: a.method, port: a.port, phase,
607
+ deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
608
+ image: a.image || null,
609
+ external: healthy && !running.has(a.name),
610
+ developedOnce: developedOnce(a),
611
+ error: lastError.get(a.name) || null,
612
+ }
613
+ }))
614
+ return json(res, 200, {
615
+ currentBranch: currentBranch(),
616
+ hasInfra: (loadConfig().infra || []).length > 0,
617
+ versions: guardVersions,
618
+ apps,
619
+ })
620
+ }
621
+ const cfgm = req.url && req.url.match(/^\/api\/apps\/([^/]+)\/config$/)
622
+ if (cfgm && req.method === 'GET') {
623
+ const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(cfgm[1]))
624
+ if (!app) return json(res, 404, { error: 'unknown app' })
625
+ return json(res, 200, { configs: (app.configs || []).map((c) => readConfig(app, c)) })
626
+ }
627
+ // ?version=<semver> on every action the topbar selection, substituted into {VERSION}.
628
+ const reqVersion = (() => {
629
+ try { return new URL(req.url, 'http://x').searchParams.get('version') || '' } catch { return '' }
630
+ })()
631
+ const path = (req.url || '').split('?')[0]
632
+ const m = path.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
633
+ if (m && req.method === 'POST') {
634
+ const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
635
+ if (!app) return json(res, 404, { error: 'unknown app' })
636
+ if (m[2] !== 'stop' && developedOnce(app) === false) {
637
+ return json(res, 409, { error: 'application has not completed a develop workflow run' })
638
+ }
639
+ if (m[2] === 'build') buildApp(app, reqVersion)
640
+ else if (m[2] === 'start') startApp(app, reqVersion)
641
+ else if (m[2] === 'deploy') deployApp(app, reqVersion)
642
+ else stopApp(app, reqVersion)
643
+ return json(res, 200, { ok: true })
644
+ }
645
+ // All-apps controls. Eligible = develop gate not failed and not mid-build/deploy; build and
646
+ // start additionally skip apps already running. Builds run SEQUENTIALLY (honest logs, no
647
+ // CPU storm); start/stop fire together. Ineligible apps are skipped silently.
648
+ const all = path.match(/^\/api\/all\/(build|start|stop)$/)
649
+ if (all && req.method === 'POST') {
650
+ const op = all[1]
651
+ const apps = loadConfig().apps
652
+ if (op === 'stop') {
653
+ for (const a of apps) {
654
+ if (running.has(a.name)) stopApp(a, reqVersion)
655
+ else probe(a).then((up) => { if (up) stopApp(a, reqVersion) })
656
+ }
657
+ return json(res, 200, { ok: true })
658
+ }
659
+ const eligible = []
660
+ for (const a of apps) {
661
+ if (developedOnce(a) === false || activity.has(a.name)) continue
662
+ if (await probe(a)) continue // already running — skip for build AND start
663
+ eligible.push(a)
664
+ }
665
+ if (op === 'start') {
666
+ for (const a of eligible) startApp(a, reqVersion)
667
+ return json(res, 200, { ok: true, count: eligible.length })
668
+ }
669
+ // op === 'build' — sequential chain: each build starts when the previous one exits.
670
+ const queue = [...eligible]
671
+ const next = () => { const a = queue.shift(); if (a) buildApp(a, reqVersion, next) }
672
+ next()
673
+ return json(res, 200, { ok: true, count: eligible.length })
674
+ }
675
+ json(res, 404, { error: 'not found' })
676
+ })
677
+
678
+ // WebSocket endpoint /ws?app=<name> — push-only log stream (see "Log streaming" above).
679
+ server.on('upgrade', (req, socket) => {
680
+ let name = ''
681
+ let isWsPath = false
682
+ try {
683
+ const url = new URL(req.url, 'http://x')
684
+ isWsPath = url.pathname === '/ws'
685
+ name = url.searchParams.get('app') || ''
686
+ } catch {}
687
+ const key = req.headers['sec-websocket-key']
688
+ const app = loadConfig().apps.find((a) => a.name === name)
689
+ if (!app || !key || !isWsPath) { socket.destroy(); return }
690
+ const accept = createHash('sha1').update(key + WS_GUID).digest('base64')
691
+ socket.write(
692
+ 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
693
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
694
+ )
695
+ let subs = subscribers.get(name)
696
+ if (!subs) { subs = new Set(); subscribers.set(name, subs) }
697
+ subs.add(socket)
698
+ const cleanup = () => { subs.delete(socket); try { socket.destroy() } catch {} }
699
+ socket.on('close', cleanup)
700
+ socket.on('error', cleanup)
701
+ // Client frames are only ever close (0x8) — anything else is ignored, never buffered.
702
+ socket.on('data', (buf) => { if ((buf[0] & 0x0f) === 0x8) cleanup() })
703
+ socket.write(wsFrame(logTail(name)))
704
+ })
705
+
706
+ server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
707
+ ```
708
+
709
+ `cicd/app/cicd.config.json` shape — one `apps` entry per application from the plan's Deployment
710
+ Decisions table, one `infra` entry per row of the plan's `## Infrastructure` table.
711
+ `deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is `null` when the method
712
+ is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
713
+ `"infra": []` when the plan declares no supporting services.
714
+
715
+ Docker fields (from the plan's `## Docker` / `## Dockerfiles` sections): top-level
716
+ `"docker": { "registry": "<repo>" }` or `null` when the plan is not dockerized. Per app,
717
+ `"image"` is the Docker table's Image value (`null` for a non-dockerized app) and
718
+ `"dockerfile"` is `{ "path": "Dockerfile", "content": "<managed body>" }` for a `managed`
719
+ Dockerfile (materialized before Build when missing — never overwriting), or
720
+ `{ "path": "Dockerfile", "content": null }` for an `existing` one (`null` entirely when the
721
+ app has no image). Commands may carry the literal `{VERSION}` token — copied verbatim; the
722
+ server substitutes the topbar-selected version (semver sans `v`, fallback `latest`) at
723
+ execution time.
724
+
725
+ Each app also carries a `configs` array — the runtime config files the application reads at start,
726
+ from the plan's `## Application Configs` section (one entry per file). Fields:
727
+ `path` (relative to the application folder), `label`, `source` (`environment` | `env-file` |
728
+ `derived`), `managed` (when `true` the server writes `content` to `path` before Build/Start if the
729
+ file is missing, so start-up always has complete config), `keys` (the required keys the app needs
730
+ to boot), `note`, and `content` (the plan's resolved file body when `managed`; `null`/omitted for an
731
+ `env-file` used as-is on disk). Emit `"configs": []` when an application needs no runtime config
732
+ file. NEVER omit a `managed.content` that the plan resolved — that body is what guarantees the app
733
+ starts configured. Per the dev-environment plaintext rule, `content` may include credentials
734
+ verbatim (this is a local dev CI/CD app).
735
+
736
+ ```json
737
+ {
738
+ "port": 5000,
739
+ "docker": { "registry": "registry.example.com/team" },
740
+ "apps": [
741
+ {
742
+ "name": "my-app",
743
+ "folder": "my-app",
744
+ "method": "process",
745
+ "port": 3000,
746
+ "build": "pnpm install && pnpm build && docker build -t registry.example.com/team/my-app:{VERSION} .",
747
+ "start": "node dist/index.js",
748
+ "stop": "process-kill",
749
+ "deployMethod": "kubernetes",
750
+ "deploy": "kubectl apply -f my-app/k8s/",
751
+ "health": "http://127.0.0.1:3000/",
752
+ "image": "registry.example.com/team/my-app",
753
+ "dockerfile": {
754
+ "path": "Dockerfile",
755
+ "content": "FROM node:22-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY . .\nEXPOSE 3000\nCMD [\"node\", \"dist/index.js\"]\n"
756
+ },
757
+ "configs": [
758
+ {
759
+ "path": ".env",
760
+ "label": ".env (runtime)",
761
+ "source": "environment",
762
+ "managed": true,
763
+ "keys": ["DATABASE_URL", "REDIS_HOST", "PORT"],
764
+ "note": "Resolved from ENVIRONMENT.md → my-app section",
765
+ "content": "DATABASE_URL=postgres://app:app@127.0.0.1:5432/appdb\nREDIS_HOST=127.0.0.1\nPORT=3000\n"
766
+ },
767
+ {
768
+ "path": ".env-uat",
769
+ "label": ".env-uat (existing)",
770
+ "source": "env-file",
771
+ "managed": false,
772
+ "keys": ["DATABASE_URL", "REDIS_HOST"],
773
+ "note": "Existing env file in the app folder — used as-is",
774
+ "content": null
775
+ }
776
+ ]
777
+ }
778
+ ],
779
+ "infra": [
780
+ {
781
+ "id": "postgres-main",
782
+ "name": "PostgreSQL (app db)",
783
+ "host": "127.0.0.1",
784
+ "port": 5432,
785
+ "url": null,
786
+ "description": "Shared application database"
787
+ },
788
+ {
789
+ "id": "keycloak",
790
+ "name": "Keycloak",
791
+ "host": "127.0.0.1",
792
+ "port": 8180,
793
+ "url": "http://127.0.0.1:8180",
794
+ "description": "Single sign-on"
795
+ }
796
+ ]
797
+ }
798
+ ```