@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.
- package/dist/.co2-dat/app.db +0 -0
- package/dist/.co2-dat/app.db-shm +0 -0
- package/dist/.co2-dat/app.db-wal +0 -0
- package/dist/index.js +408 -73
- package/package.json +41 -41
- 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 +798 -689
- package/plugin/skills/util-plancicd/SKILL.md +63 -2
- package/static/assets/{abnfDiagram-VRR7QNED-ChoWkBm-.js → abnfDiagram-VRR7QNED-Qm0Wk_H_.js} +1 -1
- package/static/assets/{arc-BOiutWhq.js → arc-Cfk6Kt0D.js} +1 -1
- package/static/assets/{architectureDiagram-ZJ3FMSHR-Dn4YKYfM.js → architectureDiagram-ZJ3FMSHR-DQTlZaUK.js} +1 -1
- package/static/assets/{blockDiagram-677ZJIJ3-BrXEfs29.js → blockDiagram-677ZJIJ3-D-4ibdoX.js} +1 -1
- package/static/assets/{c4Diagram-LMCZKHZV-ChaIzrlx.js → c4Diagram-LMCZKHZV-CHuqcdJm.js} +1 -1
- package/static/assets/channel-H9DRrduO.js +1 -0
- package/static/assets/{chunk-2Q5K7J3B-BOlnmyI2.js → chunk-2Q5K7J3B-MB9pWQe1.js} +1 -1
- package/static/assets/{chunk-32BRIVSS-BYtO1gsq.js → chunk-32BRIVSS-BttYlwU1.js} +1 -1
- package/static/assets/{chunk-5VM5RSS4-Cmx4Morj.js → chunk-5VM5RSS4-oyt0Tagd.js} +1 -1
- package/static/assets/{chunk-EX3LRPZG-BcgUofmA.js → chunk-EX3LRPZG-DaTxdTWl.js} +1 -1
- package/static/assets/{chunk-JWPE2WC7-CnNRXdvN.js → chunk-JWPE2WC7-CNgDxPAD.js} +1 -1
- package/static/assets/{chunk-MOJQB5TN-DGG6Q3Wn.js → chunk-MOJQB5TN-CoAudO3B.js} +1 -1
- package/static/assets/{chunk-RYQCIY6F-C8erKHls.js → chunk-RYQCIY6F-BrkDEEha.js} +1 -1
- package/static/assets/{chunk-V7JOEXUC-BNkIJqzu.js → chunk-V7JOEXUC-D14rGvBO.js} +1 -1
- package/static/assets/{chunk-VR4S4FIN-CGq-RseS.js → chunk-VR4S4FIN-CAtas2vF.js} +1 -1
- package/static/assets/{chunk-XXDRQBXY-DOVfEC7j.js → chunk-XXDRQBXY-DcVAnNUQ.js} +1 -1
- package/static/assets/classDiagram-OUVF2IWQ-DRNPM383.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DRNPM383.js +1 -0
- package/static/assets/{cose-bilkent-JH36ORCC-BrK3gCzK.js → cose-bilkent-JH36ORCC-B7y4MI6Z.js} +1 -1
- package/static/assets/{cynefin-VYW2F7L2-BTMYzKLg.js → cynefin-VYW2F7L2-D2mnk-iJ.js} +1 -1
- package/static/assets/{cynefinDiagram-TSTJHNR4-Df4k0_Hb.js → cynefinDiagram-TSTJHNR4-DNzQJhG7.js} +1 -1
- package/static/assets/{dagre-VKFMJZFB-0LnYnEqq.js → dagre-VKFMJZFB-NMSyTo8N.js} +1 -1
- package/static/assets/{diagram-FQU43EPY-B5nIVD5g.js → diagram-FQU43EPY-CdqS5Ike.js} +1 -1
- package/static/assets/{diagram-G47NLZAW-B2YLBzdb.js → diagram-G47NLZAW-Bsb4T5JI.js} +1 -1
- package/static/assets/{diagram-NH7WQ7WH-DPVumXIF.js → diagram-NH7WQ7WH-Z-PyzBSC.js} +1 -1
- package/static/assets/{diagram-OA4YK3LP-Da20vcLJ.js → diagram-OA4YK3LP-BZBTYbxg.js} +1 -1
- package/static/assets/{diagram-WEI45ONY-DQbTpuRZ.js → diagram-WEI45ONY-BL2LA5H_.js} +1 -1
- package/static/assets/{ebnfDiagram-CCIWWBDH-BEn1vEyb.js → ebnfDiagram-CCIWWBDH-BGtukMeo.js} +1 -1
- package/static/assets/{erDiagram-Q63AITRT-Ccg0DVBG.js → erDiagram-Q63AITRT-B6OJ4YCl.js} +1 -1
- package/static/assets/{flowDiagram-23GEKE2U-B2dMjEpa.js → flowDiagram-23GEKE2U-BBkMgJ1i.js} +1 -1
- package/static/assets/{ganttDiagram-NO4QXBWP-Kp1UsG59.js → ganttDiagram-NO4QXBWP-Cv8KHFBD.js} +1 -1
- package/static/assets/{gitGraphDiagram-IHSO6WYX-D6RB8sPf.js → gitGraphDiagram-IHSO6WYX-hYxDNjWx.js} +1 -1
- package/static/assets/{index-CM4GfOLV.css → index-DKI3BgkF.css} +1 -1
- package/static/assets/{index-GEUFVo96.js → index-DnYGyYET.js} +118 -120
- package/static/assets/{infoDiagram-FWYZ7A6U-C4ILACOF.js → infoDiagram-FWYZ7A6U-WuQIiedv.js} +1 -1
- package/static/assets/{ishikawaDiagram-FXEZZL3T-Q2Ef9fW7.js → ishikawaDiagram-FXEZZL3T-BJGOD-IY.js} +1 -1
- package/static/assets/{journeyDiagram-5HDEW3XC-B7ZV1i0L.js → journeyDiagram-5HDEW3XC-Ba_NXseS.js} +1 -1
- package/static/assets/{kanban-definition-HUTT4EX6-CjaPV_vw.js → kanban-definition-HUTT4EX6-C5RMm9uF.js} +1 -1
- package/static/assets/{linear-BELpVz92.js → linear-CLKppNoj.js} +1 -1
- package/static/assets/{mindmap-definition-LN4V7U3C-COhl-GrE.js → mindmap-definition-LN4V7U3C-ClSG_qmQ.js} +1 -1
- package/static/assets/{pegDiagram-2B236MQR-3FlTtWva.js → pegDiagram-2B236MQR-C_JEZqk3.js} +1 -1
- package/static/assets/{pieDiagram-ENE6RG2P-CLeOh_sh.js → pieDiagram-ENE6RG2P-D0lx7wDi.js} +1 -1
- package/static/assets/{quadrantDiagram-ABIIQ3AL-D2MnobFS.js → quadrantDiagram-ABIIQ3AL-BBKy0PVs.js} +1 -1
- package/static/assets/{railroadDiagram-RFXS5EU6-FjtL4fEj.js → railroadDiagram-RFXS5EU6-C1Badu5q.js} +1 -1
- package/static/assets/{requirementDiagram-TGXJPOKE-C5Y2uWRV.js → requirementDiagram-TGXJPOKE-1j59jBQH.js} +1 -1
- package/static/assets/{sankeyDiagram-HTMAVEWB-ITj9bISn.js → sankeyDiagram-HTMAVEWB-BLR5S8aZ.js} +1 -1
- package/static/assets/{sequenceDiagram-DBY2YBRQ-Ct2xTSbI.js → sequenceDiagram-DBY2YBRQ-BlqdK4qm.js} +1 -1
- package/static/assets/{sizeCapture-X5ZJPWSS-w9hGRpov.js → sizeCapture-X5ZJPWSS-C27ndGIT.js} +1 -1
- package/static/assets/{stateDiagram-2N3HPSRC-BlxDF1qr.js → stateDiagram-2N3HPSRC-DOu4536_.js} +1 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-DvP1ZCp8.js +1 -0
- package/static/assets/{swimlanes-5IMT3BWC-DFYm_SCW.js → swimlanes-5IMT3BWC-BpGitR9A.js} +2 -2
- package/static/assets/swimlanesDiagram-G3AALYLV-B5cf18Ey.js +8 -0
- package/static/assets/{timeline-definition-FHXFAJF6-johIMUxt.js → timeline-definition-FHXFAJF6-JIvdjH7z.js} +1 -1
- package/static/assets/{vennDiagram-L72KCM5P-BFk2-H5y.js → vennDiagram-L72KCM5P-DulnqLWm.js} +1 -1
- package/static/assets/{wardleyDiagram-EHGQE667-T7509MWq.js → wardleyDiagram-EHGQE667--MhZNL8M.js} +1 -1
- package/static/assets/{xychartDiagram-FW5EYKEG-DskgQ7uq.js → xychartDiagram-FW5EYKEG-CjnNgnMQ.js} +1 -1
- package/static/index.html +2 -2
- package/static/assets/channel-CKovdojM.js +0 -1
- package/static/assets/classDiagram-OUVF2IWQ-BRxqfb0_.js +0 -1
- package/static/assets/classDiagram-v2-EOCWNBFH-BRxqfb0_.js +0 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-CQVgnOlz.js +0 -1
- 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:
|
|
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 ||
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
child
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
socket.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
socket.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
.
|
|
342
|
-
.
|
|
343
|
-
.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
.
|
|
353
|
-
.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
.
|
|
360
|
-
.
|
|
361
|
-
.
|
|
362
|
-
.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
<button onclick="
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
document.getElementById('
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
.
|
|
502
|
-
.
|
|
503
|
-
|
|
504
|
-
.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
document.getElementById('
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
{
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
+
```
|