@rashidee/co2 1.3.17 → 1.3.19
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/index.js +75 -26
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/util-gencicdscript/SKILL.md +18 -23
- package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +176 -141
- package/plugin/skills/util-plancicd/SKILL.md +319 -312
- package/static/assets/{abnfDiagram-VRR7QNED-kjS3baPt.js → abnfDiagram-VRR7QNED-cQAiKjYG.js} +1 -1
- package/static/assets/{arc-CXd0xnDj.js → arc-C4ZlEQ3S.js} +1 -1
- package/static/assets/{architectureDiagram-ZJ3FMSHR-5QfnAz_Q.js → architectureDiagram-ZJ3FMSHR-C_31NGnA.js} +1 -1
- package/static/assets/{blockDiagram-677ZJIJ3-ByICFNDb.js → blockDiagram-677ZJIJ3-DDQQ2PAJ.js} +1 -1
- package/static/assets/{c4Diagram-LMCZKHZV-C5a0Ubby.js → c4Diagram-LMCZKHZV-BPbCZxQJ.js} +1 -1
- package/static/assets/channel-CufY_jLp.js +1 -0
- package/static/assets/{chunk-2Q5K7J3B-UucADWxr.js → chunk-2Q5K7J3B-B3LoONkc.js} +1 -1
- package/static/assets/{chunk-32BRIVSS-WAe17d3N.js → chunk-32BRIVSS-Buim75a9.js} +1 -1
- package/static/assets/{chunk-5VM5RSS4-BQBMOb0f.js → chunk-5VM5RSS4-BjHqxxbw.js} +1 -1
- package/static/assets/{chunk-EX3LRPZG-BzaET0n4.js → chunk-EX3LRPZG-Dn0_qUTW.js} +1 -1
- package/static/assets/{chunk-JWPE2WC7-D_tBvbZg.js → chunk-JWPE2WC7-DpYEF0BV.js} +1 -1
- package/static/assets/{chunk-MOJQB5TN-CsYb9JBc.js → chunk-MOJQB5TN-CbNDWijQ.js} +1 -1
- package/static/assets/{chunk-RYQCIY6F-D5xRlnc7.js → chunk-RYQCIY6F-DOhXYkGG.js} +1 -1
- package/static/assets/{chunk-V7JOEXUC-y0cEgq6c.js → chunk-V7JOEXUC-CfASUuSJ.js} +1 -1
- package/static/assets/{chunk-VR4S4FIN-CSXJHPXT.js → chunk-VR4S4FIN-vgVNnFtp.js} +1 -1
- package/static/assets/{chunk-XXDRQBXY-QFNFGLYZ.js → chunk-XXDRQBXY-CVjoYUTB.js} +1 -1
- package/static/assets/classDiagram-OUVF2IWQ-CVQD_QZa.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-CVQD_QZa.js +1 -0
- package/static/assets/{cose-bilkent-JH36ORCC-ValIntpZ.js → cose-bilkent-JH36ORCC-EqjuuBpY.js} +1 -1
- package/static/assets/{cynefin-VYW2F7L2-BgHVKyAR.js → cynefin-VYW2F7L2-DioyO_Cm.js} +1 -1
- package/static/assets/{cynefinDiagram-TSTJHNR4-LIZzpkLM.js → cynefinDiagram-TSTJHNR4-CuPa5A8w.js} +1 -1
- package/static/assets/{dagre-VKFMJZFB-DY9b36A5.js → dagre-VKFMJZFB-MIf1Dtbk.js} +1 -1
- package/static/assets/{diagram-FQU43EPY-aeuYUUxe.js → diagram-FQU43EPY-5OBJqg6R.js} +1 -1
- package/static/assets/{diagram-G47NLZAW-BWwcYYCF.js → diagram-G47NLZAW-B2ZGPKp3.js} +1 -1
- package/static/assets/{diagram-NH7WQ7WH-BObFlRPR.js → diagram-NH7WQ7WH-B5YEN-B-.js} +1 -1
- package/static/assets/{diagram-OA4YK3LP-BUnc8m1-.js → diagram-OA4YK3LP-BXg_CQ2m.js} +1 -1
- package/static/assets/{diagram-WEI45ONY-DsNxBIuJ.js → diagram-WEI45ONY-DVbqehF6.js} +1 -1
- package/static/assets/{ebnfDiagram-CCIWWBDH-DDL2Id1r.js → ebnfDiagram-CCIWWBDH-hmnXWJuZ.js} +1 -1
- package/static/assets/{erDiagram-Q63AITRT-DZZcG2QU.js → erDiagram-Q63AITRT-CLYATrvi.js} +1 -1
- package/static/assets/{flowDiagram-23GEKE2U-C6GvkBFb.js → flowDiagram-23GEKE2U-cs1YvuVH.js} +1 -1
- package/static/assets/{ganttDiagram-NO4QXBWP-C7ydCTnF.js → ganttDiagram-NO4QXBWP-CH5rfioY.js} +1 -1
- package/static/assets/{gitGraphDiagram-IHSO6WYX-C60P6bwf.js → gitGraphDiagram-IHSO6WYX-DjAOB5L7.js} +1 -1
- package/static/assets/{index-CfGMfAGO.js → index-B4Y8oBUt.js} +4 -4
- package/static/assets/{infoDiagram-FWYZ7A6U-CnH7Q2nX.js → infoDiagram-FWYZ7A6U-De2-PVxo.js} +1 -1
- package/static/assets/{ishikawaDiagram-FXEZZL3T-BNEvOQYX.js → ishikawaDiagram-FXEZZL3T-CDikxFR6.js} +1 -1
- package/static/assets/{journeyDiagram-5HDEW3XC-BOwGZTfX.js → journeyDiagram-5HDEW3XC-Bo6isp1w.js} +1 -1
- package/static/assets/{kanban-definition-HUTT4EX6-BEfHVl-l.js → kanban-definition-HUTT4EX6-jM1azdC7.js} +1 -1
- package/static/assets/{linear-BOHPNxBi.js → linear-CUk0_yzf.js} +1 -1
- package/static/assets/{mindmap-definition-LN4V7U3C-B84wfT_w.js → mindmap-definition-LN4V7U3C-l-cgg6PS.js} +1 -1
- package/static/assets/{pegDiagram-2B236MQR-BgEa7yRL.js → pegDiagram-2B236MQR-B-8nGU3G.js} +1 -1
- package/static/assets/{pieDiagram-ENE6RG2P-DgA9LQlc.js → pieDiagram-ENE6RG2P-Di1V86Ag.js} +1 -1
- package/static/assets/{quadrantDiagram-ABIIQ3AL-C5K_r0Mx.js → quadrantDiagram-ABIIQ3AL-BknVxapP.js} +1 -1
- package/static/assets/{railroadDiagram-RFXS5EU6-Ckh_mLXW.js → railroadDiagram-RFXS5EU6-D7Vohrlg.js} +1 -1
- package/static/assets/{requirementDiagram-TGXJPOKE-CtII7Obm.js → requirementDiagram-TGXJPOKE-BlFXj6Li.js} +1 -1
- package/static/assets/{sankeyDiagram-HTMAVEWB-DdkySwbL.js → sankeyDiagram-HTMAVEWB-Ck_A1wxA.js} +1 -1
- package/static/assets/{sequenceDiagram-DBY2YBRQ-DEIBg3kY.js → sequenceDiagram-DBY2YBRQ-D4mu07Ol.js} +1 -1
- package/static/assets/{sizeCapture-X5ZJPWSS-D2MM9f6M.js → sizeCapture-X5ZJPWSS-BwempBoZ.js} +1 -1
- package/static/assets/{stateDiagram-2N3HPSRC-DHmJsAte.js → stateDiagram-2N3HPSRC-DSh1YMwZ.js} +1 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-DrMkFhr2.js +1 -0
- package/static/assets/{swimlanes-5IMT3BWC-BBPvUQvZ.js → swimlanes-5IMT3BWC-4EE1KLA5.js} +2 -2
- package/static/assets/swimlanesDiagram-G3AALYLV-DoZQ3XaY.js +8 -0
- package/static/assets/{timeline-definition-FHXFAJF6-CG_Q0YTD.js → timeline-definition-FHXFAJF6-DVZepywq.js} +1 -1
- package/static/assets/{vennDiagram-L72KCM5P-DlFSe4is.js → vennDiagram-L72KCM5P-YqnYvNJT.js} +1 -1
- package/static/assets/{wardleyDiagram-EHGQE667-BTlvtp2f.js → wardleyDiagram-EHGQE667-Dc6PvPZE.js} +1 -1
- package/static/assets/{xychartDiagram-FW5EYKEG-Bm14zLxj.js → xychartDiagram-FW5EYKEG-DLbY5Kg1.js} +1 -1
- package/static/index.html +1 -1
- package/static/assets/channel-CrYjYK1f.js +0 -1
- package/static/assets/classDiagram-OUVF2IWQ-DoLCCqXD.js +0 -1
- package/static/assets/classDiagram-v2-EOCWNBFH-DoLCCqXD.js +0 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-B6sumoG6.js +0 -1
- package/static/assets/swimlanesDiagram-G3AALYLV-DH2ts1oT.js +0 -8
|
@@ -41,6 +41,11 @@ const PORT = Number(process.env.PORT || loadConfig().port || 5000)
|
|
|
41
41
|
const STUDIO = process.env.CO2_STUDIO_URL || 'http://127.0.0.1:3001'
|
|
42
42
|
const PROJECT_ID = process.env.CO2_PROJECT_ID || ''
|
|
43
43
|
const TOKEN = process.env.CO2_GUARD_TOKEN || ''
|
|
44
|
+
// Defect 1 — the build id stamped into cicd.config.json at generation. Pinned at boot; when a
|
|
45
|
+
// regeneration rewrites the config with a newer value while this process keeps running, /api/state
|
|
46
|
+
// reports the drift and the page shows a "running older code — restart" banner. Missing (older
|
|
47
|
+
// configs) → null → never a false positive.
|
|
48
|
+
const BOOT_GENERATED_AT = loadConfig().generatedAt || null
|
|
44
49
|
|
|
45
50
|
mkdirSync(LOG_DIR, { recursive: true })
|
|
46
51
|
const running = new Map() // app name -> ChildProcess (only processes THIS app spawned)
|
|
@@ -63,30 +68,60 @@ function currentBranch() {
|
|
|
63
68
|
|
|
64
69
|
// ---- Develop gate (Compound Context Studio) --------------------------------------------
|
|
65
70
|
// An application that never completed a conductor-develop run has nothing to build or run —
|
|
66
|
-
// its tab controls are disabled.
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// its tab controls are disabled. The studio build guard is authoritative; when it is
|
|
72
|
+
// unreachable we fall back to the on-disk develop tracking file, and ONLY a definitive
|
|
73
|
+
// "never developed" (false) disables controls — unknown fails OPEN (Defect 2c/4).
|
|
74
|
+
//
|
|
75
|
+
// Identity differs across the two sides: the studio keys by the CLAUDE.md application name
|
|
76
|
+
// (e.g. `skolafund-web`), while the plan/config carry a display name (`SkolaFund Web`) and a
|
|
77
|
+
// folder (`skolafund_web`). Match on a separator-insensitive key so all three collapse to the
|
|
78
|
+
// same value (Defect 2b) — exact-lowercase matching silently missed every app.
|
|
79
|
+
function norm(s) { return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '') }
|
|
80
|
+
let guardApps = null // Map norm(name) -> developedOnce (last good answer, kept across blips)
|
|
81
|
+
// ok | unauthorized | unreachable | http-<code> | not-configured | init — surfaced in the notice
|
|
82
|
+
// so a disabled/unknown control says WHAT actually happened instead of a hard-coded guess.
|
|
83
|
+
let guardStatus = PROJECT_ID ? 'init' : 'not-configured'
|
|
70
84
|
async function refreshGuard() {
|
|
71
|
-
if (!PROJECT_ID) return
|
|
85
|
+
if (!PROJECT_ID) { guardStatus = 'not-configured'; return }
|
|
72
86
|
try {
|
|
73
87
|
const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
|
|
74
|
-
if (
|
|
88
|
+
if (r.status === 401) { guardStatus = 'unauthorized'; return } // keep last good guardApps
|
|
89
|
+
if (!r.ok) { guardStatus = 'http-' + r.status; return }
|
|
75
90
|
const g = await r.json()
|
|
76
91
|
if (Array.isArray(g.applications)) {
|
|
77
|
-
|
|
78
|
-
for (const a of g.applications)
|
|
92
|
+
const next = new Map()
|
|
93
|
+
for (const a of g.applications) next.set(norm(a.name), !!a.developedOnce)
|
|
94
|
+
guardApps = next
|
|
95
|
+
guardStatus = 'ok'
|
|
79
96
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
} catch { guardStatus = 'unreachable' } // keep last good guardApps
|
|
98
|
+
}
|
|
99
|
+
// On-disk fallback (Defect 2 fix 3) — conductor-feature-develop writes this app's
|
|
100
|
+
// context/develop/IMPLEMENTATION_MASTER.md with `**Status**: COMPLETED` when development
|
|
101
|
+
// finishes. Read it (mtime-cached) so the gate is correct even with the studio down, restarted
|
|
102
|
+
// or renamed. Mirrors the studio's TRACKING_FILE_MAP.develop.
|
|
103
|
+
const developMarkerCache = new Map() // abs path -> { mtimeMs, done }
|
|
104
|
+
function localDeveloped(app) {
|
|
105
|
+
const p = join(PROJECT_DIR, app.folder || '', 'context', 'develop', 'IMPLEMENTATION_MASTER.md')
|
|
106
|
+
try {
|
|
107
|
+
const mtimeMs = statSync(p).mtimeMs
|
|
108
|
+
const hit = developMarkerCache.get(p)
|
|
109
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.done
|
|
110
|
+
const done = /^\s*\**\s*Status\s*\**\s*:\s*COMPLETED\b/im.test(readFileSync(p, 'utf8'))
|
|
111
|
+
developMarkerCache.set(p, { mtimeMs, done })
|
|
112
|
+
return done
|
|
113
|
+
} catch { return false }
|
|
83
114
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
// { val: true|false|null, src: 'studio'|'local'|'none' }. Studio wins; local marker fills the gap
|
|
116
|
+
// when the studio has no answer; null only when nobody knows (controls then stay ENABLED).
|
|
117
|
+
function developInfo(app) {
|
|
118
|
+
if (guardApps) {
|
|
119
|
+
let v = guardApps.get(norm(app.name))
|
|
120
|
+
if (v === undefined) v = guardApps.get(norm(app.folder))
|
|
121
|
+
if (v !== undefined) return { val: v, src: 'studio' }
|
|
122
|
+
}
|
|
123
|
+
if (localDeveloped(app)) return { val: true, src: 'local' }
|
|
124
|
+
return { val: null, src: 'none' }
|
|
90
125
|
}
|
|
91
126
|
refreshGuard()
|
|
92
127
|
setInterval(refreshGuard, 30_000).unref?.()
|
|
@@ -143,7 +178,11 @@ function runLogged(app, command, onExit) {
|
|
|
143
178
|
const log = createWriteStream(logPath(app.name), { flags: 'a' })
|
|
144
179
|
log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
|
|
145
180
|
broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
|
|
146
|
-
|
|
181
|
+
// detached on POSIX so the shell AND everything it forks (a compound `a && b` start command,
|
|
182
|
+
// pnpm → vite, etc.) share ONE process group we can signal as a unit on Stop. Without it, `sh`
|
|
183
|
+
// running `a && b` cannot exec into `b`, so killing the shell alone leaves the real server
|
|
184
|
+
// reparented to init and still holding the port (Defect 3). Windows kills the tree via taskkill.
|
|
185
|
+
const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env, detached: process.platform !== 'win32' })
|
|
147
186
|
const tap = (d) => { log.write(d); broadcast(app.name, d) }
|
|
148
187
|
child.stdout.on('data', tap)
|
|
149
188
|
child.stderr.on('data', tap)
|
|
@@ -229,13 +268,6 @@ function tcpProbe(host, port, timeout = 1000) {
|
|
|
229
268
|
})
|
|
230
269
|
}
|
|
231
270
|
|
|
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
271
|
// Managed Dockerfile from the plan's ## Dockerfiles — written before Build only when the
|
|
240
272
|
// application folder has none (an existing Dockerfile is never overwritten).
|
|
241
273
|
function ensureDockerfile(app) {
|
|
@@ -249,38 +281,37 @@ function ensureDockerfile(app) {
|
|
|
249
281
|
} catch (e) { logLine(app, `dockerfile: FAILED to write — ${e.message}`) }
|
|
250
282
|
}
|
|
251
283
|
|
|
252
|
-
function buildApp(app
|
|
253
|
-
if (activity.has(app.name))
|
|
284
|
+
function buildApp(app) {
|
|
285
|
+
if (activity.has(app.name)) return
|
|
254
286
|
activity.set(app.name, 'building'); lastError.delete(app.name)
|
|
255
287
|
ensureConfigs(app)
|
|
256
288
|
ensureDockerfile(app)
|
|
257
|
-
runLogged(app,
|
|
289
|
+
runLogged(app, app.build, (code) => {
|
|
258
290
|
activity.delete(app.name)
|
|
259
291
|
if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
|
|
260
292
|
else logLine(app, 'build OK')
|
|
261
|
-
onDone && onDone()
|
|
262
293
|
})
|
|
263
294
|
}
|
|
264
295
|
|
|
265
|
-
function deployApp(app
|
|
296
|
+
function deployApp(app) {
|
|
266
297
|
if (activity.has(app.name) || !app.deploy) return
|
|
267
298
|
activity.set(app.name, 'deploying'); lastError.delete(app.name)
|
|
268
|
-
runLogged(app,
|
|
299
|
+
runLogged(app, app.deploy, (code) => {
|
|
269
300
|
activity.delete(app.name)
|
|
270
301
|
if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
|
|
271
302
|
else logLine(app, `deploy OK (${app.deployMethod})`)
|
|
272
303
|
})
|
|
273
304
|
}
|
|
274
305
|
|
|
275
|
-
function startApp(app
|
|
306
|
+
function startApp(app) {
|
|
276
307
|
if (running.get(app.name) || activity.has(app.name)) return
|
|
277
308
|
failed.delete(app.name); lastError.delete(app.name)
|
|
278
309
|
ensureConfigs(app)
|
|
279
310
|
if (app.method === 'container') {
|
|
280
|
-
runLogged(app,
|
|
311
|
+
runLogged(app, app.start, () => {})
|
|
281
312
|
saveState(); return
|
|
282
313
|
}
|
|
283
|
-
const child = runLogged(app,
|
|
314
|
+
const child = runLogged(app, app.start, (code) => {
|
|
284
315
|
running.delete(app.name)
|
|
285
316
|
// A user-requested Stop exits nonzero (killed) — that is not a failure.
|
|
286
317
|
if (code !== 0 && !stopping.delete(app.name)) {
|
|
@@ -293,20 +324,27 @@ function startApp(app, version) {
|
|
|
293
324
|
saveState()
|
|
294
325
|
}
|
|
295
326
|
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
327
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
328
|
+
// A signal-0 probe: alive if it succeeds OR fails with EPERM (exists, not ours to signal).
|
|
329
|
+
function pidAlive(pid) { try { process.kill(Number(pid), 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
330
|
+
|
|
331
|
+
// shell:true (and a compound start command) spawns a tree whose grandchildren survive a bare
|
|
332
|
+
// child.kill(). Signal the whole PROCESS GROUP (detached → pgid === the shell's pid), escalating
|
|
333
|
+
// SIGTERM → SIGKILL. Windows: taskkill /T /F kills the tree. Async — the caller awaits it.
|
|
334
|
+
async function killTree(child) {
|
|
335
|
+
const pid = child && child.pid
|
|
336
|
+
if (!pid) return
|
|
337
|
+
if (process.platform === 'win32') {
|
|
338
|
+
try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
|
|
339
|
+
return
|
|
303
340
|
}
|
|
341
|
+
try { process.kill(-pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch {} }
|
|
342
|
+
for (let i = 0; i < 40 && pidAlive(pid); i++) await delay(100) // up to ~4s for a graceful exit
|
|
343
|
+
if (pidAlive(pid)) { try { process.kill(-pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch {} } }
|
|
304
344
|
}
|
|
305
345
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
// for any running app, not only ones this CI/CD app spawned. Returns true if a PID was killed.
|
|
309
|
-
function killByPort(port) {
|
|
346
|
+
// PIDs listening on a TCP port. POSIX: lsof, then fuser — both tolerated absent. Windows: netstat.
|
|
347
|
+
function portListeners(port) {
|
|
310
348
|
try {
|
|
311
349
|
if (process.platform === 'win32') {
|
|
312
350
|
const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
@@ -315,36 +353,60 @@ function killByPort(port) {
|
|
|
315
353
|
const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
|
|
316
354
|
if (m && Number(m[1]) === Number(port)) pids.add(m[2])
|
|
317
355
|
}
|
|
318
|
-
|
|
319
|
-
try { execFileSync('taskkill', ['/pid', pid, '/T', '/F'], { stdio: 'ignore' }) } catch {}
|
|
320
|
-
}
|
|
321
|
-
return pids.size > 0
|
|
356
|
+
return [...pids]
|
|
322
357
|
}
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
358
|
+
for (const cmd of [`lsof -ti tcp:${port}`, `fuser -n tcp ${port} 2>/dev/null`]) {
|
|
359
|
+
const out = execFileSync('sh', ['-c', `${cmd} || true`], { encoding: 'utf8' })
|
|
360
|
+
const pids = [...new Set(out.split(/\s+/).map((s) => s.trim()).filter((s) => /^\d+$/.test(s)))]
|
|
361
|
+
if (pids.length) return pids
|
|
362
|
+
}
|
|
363
|
+
} catch {}
|
|
364
|
+
return []
|
|
365
|
+
}
|
|
366
|
+
// Signal a pid's whole process group (falls back to the bare pid if the group is already gone).
|
|
367
|
+
function killGroup(pid, sig) {
|
|
368
|
+
let pgid = 0
|
|
369
|
+
if (process.platform !== 'win32') {
|
|
370
|
+
try { pgid = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim()) } catch {}
|
|
371
|
+
}
|
|
372
|
+
try { process.kill(pgid ? -pgid : Number(pid), sig) } catch { try { process.kill(Number(pid), sig) } catch {} }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Stop whatever is listening on a port even when we hold no child handle for it (started outside
|
|
376
|
+
// this app, or before this app last restarted) — group-kill each listener, escalating
|
|
377
|
+
// SIGTERM → SIGKILL. Returns true when the port ended up FREE.
|
|
378
|
+
async function killByPort(port) {
|
|
379
|
+
let pids = portListeners(port)
|
|
380
|
+
if (!pids.length) return true // nothing listening — already free
|
|
381
|
+
if (process.platform === 'win32') {
|
|
382
|
+
for (const pid of pids) { try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) } catch {} }
|
|
383
|
+
for (let i = 0; i < 20 && portListeners(port).length; i++) await delay(100)
|
|
384
|
+
return portListeners(port).length === 0
|
|
385
|
+
}
|
|
386
|
+
for (const pid of pids) killGroup(pid, 'SIGTERM')
|
|
387
|
+
for (let i = 0; i < 30 && portListeners(port).length; i++) await delay(100) // ~3s grace
|
|
388
|
+
for (const pid of portListeners(port)) killGroup(pid, 'SIGKILL')
|
|
389
|
+
await delay(200)
|
|
390
|
+
return portListeners(port).length === 0
|
|
328
391
|
}
|
|
329
392
|
|
|
330
|
-
function stopApp(app
|
|
393
|
+
async function stopApp(app) {
|
|
331
394
|
if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
|
|
332
|
-
const stopCmd =
|
|
395
|
+
const stopCmd = app.stop
|
|
333
396
|
try { execFileSync(stopCmd.split(' ')[0], stopCmd.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
}
|
|
397
|
+
running.delete(app.name)
|
|
398
|
+
saveState()
|
|
399
|
+
return
|
|
346
400
|
}
|
|
401
|
+
lastError.delete(app.name)
|
|
402
|
+
const child = running.get(app.name)
|
|
403
|
+
if (child) { stopping.add(app.name); await killTree(child) }
|
|
347
404
|
running.delete(app.name)
|
|
405
|
+
// ALWAYS sweep the port after killing our handle — the child dying is NOT proof the server did
|
|
406
|
+
// (its reparented grandchildren, or an app started outside this app, can still hold the port).
|
|
407
|
+
// Report an honest failure if it never frees, instead of the old silent "success" (Defect 3).
|
|
408
|
+
const free = await killByPort(app.port)
|
|
409
|
+
if (!free) lastError.set(app.name, `stop failed — port ${app.port} is still in use`)
|
|
348
410
|
saveState()
|
|
349
411
|
}
|
|
350
412
|
|
|
@@ -368,10 +430,10 @@ body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;
|
|
|
368
430
|
.badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
|
|
369
431
|
.url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
|
|
370
432
|
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
433
|
button:hover:enabled{background:#eef1f5}
|
|
373
434
|
button:disabled{opacity:.4;cursor:default}
|
|
374
435
|
.notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
|
|
436
|
+
.stale{padding:6px 12px;font-size:12px;color:#fff;background:#c23b3b;font-weight:600;display:none}
|
|
375
437
|
.err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
|
|
376
438
|
#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
439
|
.muted{color:#5b6572;font-size:12px}
|
|
@@ -389,11 +451,8 @@ button:disabled{opacity:.4;cursor:default}
|
|
|
389
451
|
.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
452
|
</style></head><body>
|
|
391
453
|
<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
454
|
<span style="flex:1"></span><a id="infralink" class="url" href="infra" target="_blank" rel="noreferrer" style="display:none">Infrastructure ↗</a></div>
|
|
455
|
+
<div class="stale" id="stale"></div>
|
|
397
456
|
<div class="tabs" id="tabs"></div>
|
|
398
457
|
<div class="bar" id="bar"></div>
|
|
399
458
|
<div class="notice" id="notice"></div>
|
|
@@ -419,11 +478,19 @@ function renderTabs(){
|
|
|
419
478
|
<span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
|
|
420
479
|
}
|
|
421
480
|
|
|
481
|
+
// A guard answer we could not get, said plainly — the notice names the cause it observed instead
|
|
482
|
+
// of always blaming an unreachable studio (Defect 2c/4).
|
|
483
|
+
function guardReason(s){
|
|
484
|
+
return ({unauthorized:'studio auth failed',unreachable:'studio unreachable','not-configured':'studio link not set',init:'checking…',ok:'not listed by the studio'})[s]
|
|
485
|
+
|| ('studio error '+String(s||'').replace(/^http-/,''))
|
|
486
|
+
}
|
|
422
487
|
function renderBar(){
|
|
423
488
|
const a = appOf(active)
|
|
424
489
|
if (!a) return
|
|
425
490
|
const url = 'http://'+location.hostname+':'+a.port+'/'
|
|
426
|
-
|
|
491
|
+
// Fail OPEN — only a definitive "never developed" (false) disables controls; unknown (null)
|
|
492
|
+
// leaves them enabled so an unverifiable studio never locks the user out (Defect 2c/4).
|
|
493
|
+
const gate = a.developedOnce !== false
|
|
427
494
|
const busy = a.phase==='building'||a.phase==='deploying'
|
|
428
495
|
document.getElementById('bar').innerHTML = \`
|
|
429
496
|
<span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
|
|
@@ -431,7 +498,7 @@ function renderBar(){
|
|
|
431
498
|
<a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
|
|
432
499
|
<span style="flex:1"></span>
|
|
433
500
|
<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+'
|
|
501
|
+
<button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''} title="\${a.image?'Builds the app AND the docker image '+a.image+':latest':'Runs the plan build command'}">Build</button>
|
|
435
502
|
<button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
|
|
436
503
|
<button onclick="act('stop')" \${a.phase!=='running'?'disabled':''}>Stop</button>
|
|
437
504
|
\${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
|
|
@@ -440,7 +507,10 @@ function renderBar(){
|
|
|
440
507
|
notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
|
|
441
508
|
notice.style.display = 'block'
|
|
442
509
|
} else if (a.developedOnce === null) {
|
|
443
|
-
notice.textContent = 'Develop status
|
|
510
|
+
notice.textContent = 'Develop status unverified (' + guardReason(state.guardStatus) + ') — controls left enabled.'
|
|
511
|
+
notice.style.display = 'block'
|
|
512
|
+
} else if (a.developSource === 'local') {
|
|
513
|
+
notice.textContent = 'Develop status read from this app’s local tracking file (studio guard unavailable).'
|
|
444
514
|
notice.style.display = 'block'
|
|
445
515
|
} else notice.style.display = 'none'
|
|
446
516
|
const err = document.getElementById('err')
|
|
@@ -461,7 +531,10 @@ function setActive(name){
|
|
|
461
531
|
function openWs(name){
|
|
462
532
|
// Path-relative + scheme-aware so the stream works direct (:5000/) AND behind the studio's
|
|
463
533
|
// /api/cicd/<id>/app-proxy/ prefix (which tunnels the upgrade), also under HTTPS.
|
|
464
|
-
|
|
534
|
+
// NO backslash/regex here on purpose: this string is inside server.js's PAGE template literal,
|
|
535
|
+
// where a client-side regex like /\/$/ cooks to //$/ (a line comment) and kills the script.
|
|
536
|
+
var base = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname
|
|
537
|
+
ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+base+'/ws?app='+encodeURIComponent(name))
|
|
465
538
|
ws.onmessage = e => appendLog(e.data)
|
|
466
539
|
ws.onclose = () => { ws = null; if (active === name) wsTimer = setTimeout(() => openWs(name), 2000) }
|
|
467
540
|
}
|
|
@@ -501,35 +574,21 @@ async function openConfig(){
|
|
|
501
574
|
function closeConfig(){ document.getElementById('cfgModal').classList.remove('open') }
|
|
502
575
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConfig() })
|
|
503
576
|
|
|
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
577
|
async function refresh(){
|
|
518
578
|
const s = await fetch('api/state').then(r => r.json()).catch(() => null)
|
|
519
579
|
if (!s) return
|
|
520
580
|
state = s
|
|
521
581
|
document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
|
|
522
582
|
document.getElementById('infralink').style.display = s.hasInfra ? 'inline' : 'none'
|
|
523
|
-
|
|
583
|
+
// Defect 1 — the app was regenerated in place while this process kept serving the old code.
|
|
584
|
+
const stale = document.getElementById('stale')
|
|
585
|
+
if (s.regenerated) { stale.textContent = 'This CI/CD app is running older code than was generated — restart it (Stop, then Start).'; stale.style.display = 'block' }
|
|
586
|
+
else stale.style.display = 'none'
|
|
524
587
|
if (!active && s.apps.length) setActive(s.apps[0].name)
|
|
525
588
|
renderTabs(); renderBar()
|
|
526
589
|
}
|
|
527
590
|
async function act(op){
|
|
528
|
-
await fetch('api/apps/'+encodeURIComponent(active)+'/'+op
|
|
529
|
-
refresh()
|
|
530
|
-
}
|
|
531
|
-
async function actAll(op){
|
|
532
|
-
await fetch('api/all/'+op+'?version='+encodeURIComponent(selectedVersion()), { method: 'POST' })
|
|
591
|
+
await fetch('api/apps/'+encodeURIComponent(active)+'/'+op, { method: 'POST' })
|
|
533
592
|
refresh()
|
|
534
593
|
}
|
|
535
594
|
// Rescan immediately when the user returns to this page (tab focus / regained visibility), so
|
|
@@ -602,19 +661,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
602
661
|
const apps = await Promise.all(loadConfig().apps.map(async (a) => {
|
|
603
662
|
const healthy = await probe(a)
|
|
604
663
|
const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
|
|
664
|
+
const dev = developInfo(a)
|
|
605
665
|
return {
|
|
606
666
|
name: a.name, method: a.method, port: a.port, phase,
|
|
607
667
|
deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
|
|
608
668
|
image: a.image || null,
|
|
609
669
|
external: healthy && !running.has(a.name),
|
|
610
|
-
developedOnce:
|
|
670
|
+
developedOnce: dev.val, developSource: dev.src,
|
|
611
671
|
error: lastError.get(a.name) || null,
|
|
612
672
|
}
|
|
613
673
|
}))
|
|
674
|
+
// Defect 1 — regenerated in place while still running the old code (config's generatedAt
|
|
675
|
+
// moved past the value pinned at boot).
|
|
676
|
+
const nowGen = loadConfig().generatedAt || null
|
|
614
677
|
return json(res, 200, {
|
|
615
678
|
currentBranch: currentBranch(),
|
|
616
679
|
hasInfra: (loadConfig().infra || []).length > 0,
|
|
617
|
-
|
|
680
|
+
guardStatus,
|
|
681
|
+
regenerated: !!(BOOT_GENERATED_AT && nowGen !== BOOT_GENERATED_AT),
|
|
618
682
|
apps,
|
|
619
683
|
})
|
|
620
684
|
}
|
|
@@ -624,54 +688,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
624
688
|
if (!app) return json(res, 404, { error: 'unknown app' })
|
|
625
689
|
return json(res, 200, { configs: (app.configs || []).map((c) => readConfig(app, c)) })
|
|
626
690
|
}
|
|
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
691
|
const path = (req.url || '').split('?')[0]
|
|
632
692
|
const m = path.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
|
|
633
693
|
if (m && req.method === 'POST') {
|
|
634
694
|
const app = loadConfig().apps.find((a) => a.name === decodeURIComponent(m[1]))
|
|
635
695
|
if (!app) return json(res, 404, { error: 'unknown app' })
|
|
636
|
-
|
|
696
|
+
// Only a DEFINITIVE "never developed" blocks — unknown (null) fails open (Defect 2c/4).
|
|
697
|
+
if (m[2] !== 'stop' && developInfo(app).val === false) {
|
|
637
698
|
return json(res, 409, { error: 'application has not completed a develop workflow run' })
|
|
638
699
|
}
|
|
639
|
-
if (m[2] === 'build') buildApp(app
|
|
640
|
-
else if (m[2] === 'start') startApp(app
|
|
641
|
-
else if (m[2] === 'deploy') deployApp(app
|
|
642
|
-
else stopApp(app
|
|
700
|
+
if (m[2] === 'build') buildApp(app)
|
|
701
|
+
else if (m[2] === 'start') startApp(app)
|
|
702
|
+
else if (m[2] === 'deploy') deployApp(app)
|
|
703
|
+
else await stopApp(app)
|
|
643
704
|
return json(res, 200, { ok: true })
|
|
644
705
|
}
|
|
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
706
|
json(res, 404, { error: 'not found' })
|
|
676
707
|
})
|
|
677
708
|
|
|
@@ -708,6 +739,10 @@ server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
|
|
|
708
739
|
|
|
709
740
|
`cicd/app/cicd.config.json` shape — one `apps` entry per application from the plan's Deployment
|
|
710
741
|
Decisions table, one `infra` entry per row of the plan's `## Infrastructure` table.
|
|
742
|
+
Top-level `"generatedAt"` is an ISO-8601 UTC timestamp **stamped fresh on every generation**
|
|
743
|
+
(Defect 1): the running server pins the value it booted with and flags a "restart required" banner
|
|
744
|
+
once a regeneration moves it forward, so a stale in-place-replaced process is self-diagnosing. It
|
|
745
|
+
MUST change on each run — use the actual generation time (e.g. `new Date().toISOString()`).
|
|
711
746
|
`deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is `null` when the method
|
|
712
747
|
is `none`). `infra[].url` is `null` for a headless service (Console URL cell `-`); emit
|
|
713
748
|
`"infra": []` when the plan declares no supporting services.
|
|
@@ -718,9 +753,8 @@ Docker fields (from the plan's `## Docker` / `## Dockerfiles` sections): top-lev
|
|
|
718
753
|
`"dockerfile"` is `{ "path": "Dockerfile", "content": "<managed body>" }` for a `managed`
|
|
719
754
|
Dockerfile (materialized before Build when missing — never overwriting), or
|
|
720
755
|
`{ "path": "Dockerfile", "content": null }` for an `existing` one (`null` entirely when the
|
|
721
|
-
app has no image). Commands
|
|
722
|
-
|
|
723
|
-
execution time.
|
|
756
|
+
app has no image). Commands are copied verbatim from the plan and run as-is — no `{VERSION}`
|
|
757
|
+
or any other token substitution.
|
|
724
758
|
|
|
725
759
|
Each app also carries a `configs` array — the runtime config files the application reads at start,
|
|
726
760
|
from the plan's `## Application Configs` section (one entry per file). Fields:
|
|
@@ -736,6 +770,7 @@ verbatim (this is a local dev CI/CD app).
|
|
|
736
770
|
```json
|
|
737
771
|
{
|
|
738
772
|
"port": 5000,
|
|
773
|
+
"generatedAt": "2026-07-24T09:15:00.000Z",
|
|
739
774
|
"docker": { "registry": "registry.example.com/team" },
|
|
740
775
|
"apps": [
|
|
741
776
|
{
|
|
@@ -743,7 +778,7 @@ verbatim (this is a local dev CI/CD app).
|
|
|
743
778
|
"folder": "my-app",
|
|
744
779
|
"method": "process",
|
|
745
780
|
"port": 3000,
|
|
746
|
-
"build": "pnpm install && pnpm build && docker build -t registry.example.com/team/my-app:
|
|
781
|
+
"build": "rm -rf dist && pnpm install && pnpm build && docker build --no-cache -t registry.example.com/team/my-app:latest .",
|
|
747
782
|
"start": "node dist/index.js",
|
|
748
783
|
"stop": "process-kill",
|
|
749
784
|
"deployMethod": "kubernetes",
|