cdk-local 0.78.1 → 0.79.0

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.
@@ -0,0 +1,1004 @@
1
+ import { c as getEmbedConfig, s as getLogger, u as setEmbedConfig } from "./docker-cmd-Dqx2YENO.js";
2
+ import { Hn as listTargets, Ln as resolveApp, cr as withErrorHandling, fr as appOptions, gr as regionOption, hr as parseContextOptions, lr as applyRoleArnIfSet, mr as contextOptions, pr as commonOptions, zn as Synthesizer } from "./local-list-DBlBRfA-.js";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { Command, Option } from "commander";
7
+ import { spawn } from "node:child_process";
8
+ import { createServer } from "node:http";
9
+ import { EventEmitter } from "node:events";
10
+
11
+ //#region src/local/studio-events.ts
12
+ /**
13
+ * In-process event bus that every studio observation flows through. The
14
+ * studio HTTP server subscribes and forwards events to the browser over
15
+ * SSE; the dispatch / log-streaming layers emit onto it.
16
+ *
17
+ * A thin typed wrapper over {@link EventEmitter} so producers and the
18
+ * server agree on the event shapes without `any`. Re-exported from
19
+ * `cdk-local/internal` so a host CLI embedding studio can subscribe.
20
+ */
21
+ var StudioEventBus = class {
22
+ emitter = new EventEmitter();
23
+ constructor() {
24
+ this.emitter.setMaxListeners(0);
25
+ }
26
+ emit(event, ...args) {
27
+ this.emitter.emit(event, ...args);
28
+ }
29
+ on(event, listener) {
30
+ this.emitter.on(event, listener);
31
+ return this;
32
+ }
33
+ off(event, listener) {
34
+ this.emitter.off(event, listener);
35
+ return this;
36
+ }
37
+ /**
38
+ * Number of listeners currently subscribed to `event`. Exposed so the
39
+ * SSE server's subscribe / unsubscribe symmetry can be asserted (a
40
+ * dropped client must not leak a listener).
41
+ */
42
+ listenerCount(event) {
43
+ return this.emitter.listenerCount(event);
44
+ }
45
+ };
46
+
47
+ //#endregion
48
+ //#region src/local/studio-ui.ts
49
+ /**
50
+ * The studio web UI, embedded as a string so it ships inside the
51
+ * `cdk-local` npm package (decision D9) with no asset-copy build step —
52
+ * `tsdown` bundles this module like any other source file. Served by
53
+ * the studio HTTP server (`startStudioServer`) at `GET /`.
54
+ *
55
+ * 3-pane shell (decision D6), framework-free vanilla JS (decision D7):
56
+ * - left = target list (from `GET /api/targets`); each runnable
57
+ * Lambda has an [Invoke] button and a selected-highlight.
58
+ * - center = the WORKSPACE for the selected target: an event composer
59
+ * (textarea + Invoke button) with the latest run's Request /
60
+ * Response / Logs shown BELOW it, so you can edit and re-invoke
61
+ * repeatedly without losing the composer.
62
+ * - right = the timeline (history) of every invocation; clicking a
63
+ * row loads it back into the workspace.
64
+ *
65
+ * The center workspace is deliberately adjacent to the left target list
66
+ * (short eye-travel: pick a target -> compose right next to it), and is
67
+ * the primary surface — the timeline is secondary history.
68
+ */
69
+ const STUDIO_CSS = `
70
+ * { box-sizing: border-box; }
71
+ body {
72
+ margin: 0; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
73
+ color: #e6e6e6; background: #1a1a1a; height: 100vh; overflow: hidden;
74
+ }
75
+ header {
76
+ padding: 8px 14px; background: #111; border-bottom: 1px solid #333;
77
+ display: flex; align-items: center; gap: 10px;
78
+ }
79
+ header .brand { font-weight: 700; color: #fff; }
80
+ header .meta { color: #888; font-size: 12px; }
81
+ main {
82
+ display: grid; grid-template-columns: 280px 5px 1fr 5px 320px;
83
+ height: calc(100vh - 38px);
84
+ }
85
+ .pane { overflow: auto; }
86
+ .splitter { background: #2a2a2a; cursor: col-resize; }
87
+ .splitter:hover, .splitter.dragging { background: #4ec97a; }
88
+ .pane h2 {
89
+ margin: 0; padding: 8px 12px; font-size: 11px; text-transform: uppercase;
90
+ letter-spacing: 0.5px; color: #888; background: #151515;
91
+ position: sticky; top: 0; border-bottom: 1px solid #2a2a2a; z-index: 1;
92
+ }
93
+ .group-title { padding: 8px 12px 2px; color: #6aa9ff; font-size: 11px; }
94
+ .target {
95
+ padding: 6px 12px; border-bottom: 1px solid #222; display: flex;
96
+ align-items: center; gap: 8px;
97
+ }
98
+ .target.runnable { cursor: pointer; }
99
+ .target.runnable:hover { background: #202020; }
100
+ .target.sel { background: #2a3550; }
101
+ .target .name { color: #ddd; flex: 1; overflow: hidden; text-overflow: ellipsis; }
102
+ .target .kind { color: #777; font-size: 11px; }
103
+ .target .invoke-btn {
104
+ padding: 2px 10px; font: 11px ui-monospace, Menlo, monospace; font-weight: 700;
105
+ color: #0d1f12; background: #4ec97a; border: 0; border-radius: 3px; cursor: pointer;
106
+ }
107
+ .target .invoke-btn:hover { background: #6fe097; }
108
+ .target.sel .invoke-btn { background: #6fe097; }
109
+ .empty { padding: 16px 12px; color: #666; }
110
+ .row {
111
+ padding: 5px 12px; border-bottom: 1px solid #222; cursor: pointer;
112
+ display: flex; gap: 8px; white-space: nowrap;
113
+ }
114
+ .row:hover { background: #222; }
115
+ .row.sel { background: #2a3550; }
116
+ .row .ts { color: #777; }
117
+ .row .label { color: #ddd; flex: 1; overflow: hidden; text-overflow: ellipsis; }
118
+ .row .status { color: #7bd88f; }
119
+ .row .status.err { color: #e0707a; }
120
+ #workspace { padding: 0 0 24px; }
121
+ .composer { padding: 10px 12px; border-bottom: 1px solid #2a2a2a; }
122
+ .composer .target-name { color: #fff; font-weight: 700; margin-bottom: 6px; }
123
+ .composer textarea {
124
+ width: 100%; min-height: 130px; resize: vertical; background: #111; color: #ddd;
125
+ border: 1px solid #333; border-radius: 3px; padding: 6px;
126
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
127
+ }
128
+ .composer button {
129
+ margin-top: 8px; padding: 6px 18px; background: #2a7d46; color: #fff;
130
+ border: 0; border-radius: 3px; cursor: pointer; font: inherit; font-weight: 700;
131
+ }
132
+ .composer button:hover { background: #339152; }
133
+ .composer button:disabled { background: #333; color: #888; cursor: default; }
134
+ .composer .err { color: #e0707a; margin-top: 6px; min-height: 18px; }
135
+ .section { padding: 8px 12px; border-bottom: 1px solid #222; }
136
+ .section h3 { margin: 0 0 6px; font-size: 11px; color: #888; text-transform: uppercase; }
137
+ .section h3 .ok { color: #7bd88f; }
138
+ .section h3 .bad { color: #e0707a; }
139
+ .section pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cfcfcf; }
140
+ #conn { font-size: 11px; }
141
+ #conn.up { color: #7bd88f; }
142
+ #conn.down { color: #e0707a; }
143
+ `;
144
+ const STUDIO_SCRIPT = `
145
+ const KIND_LABEL = { lambda: 'Lambda', api: 'API', alb: 'ALB', ecs: 'ECS', agentcore: 'AgentCore' };
146
+ const rowsById = new Map(); // invocationId -> timeline row element
147
+ const invById = new Map(); // invocationId -> latest invocation event
148
+ const logsById = new Map(); // invocationId -> [log lines]
149
+ const targetEls = new Map(); // targetId -> left-pane element
150
+ let active = null; // { id, kind, ta, btn, msg, result }
151
+ let shownInvId = null; // invocation whose result is in the workspace
152
+
153
+ function el(tag, cls, text) {
154
+ const e = document.createElement(tag);
155
+ if (cls) e.className = cls;
156
+ if (text != null) e.textContent = text;
157
+ return e;
158
+ }
159
+
160
+ async function loadTargets() {
161
+ const pane = document.getElementById('targets');
162
+ try {
163
+ const res = await fetch('/api/targets');
164
+ const data = await res.json();
165
+ pane.querySelectorAll('.group-title,.target,.empty').forEach((n) => n.remove());
166
+ let total = 0;
167
+ for (const group of data.groups) {
168
+ if (!group.entries.length) continue;
169
+ pane.appendChild(el('div', 'group-title', group.title));
170
+ for (const entry of group.entries) {
171
+ total += 1;
172
+ // Slice B: only Lambda targets are runnable from the UI (single-shot
173
+ // invoke). Other kinds list but are not yet selectable.
174
+ const runnable = group.kind === 'lambda';
175
+ const t = el('div', runnable ? 'target runnable' : 'target');
176
+ const name = el('span', 'name', entry.id);
177
+ name.title = entry.id; // full path on hover even when truncated
178
+ t.appendChild(name);
179
+ t.appendChild(el('span', 'kind', '(' + (KIND_LABEL[group.kind] || group.kind) + ')'));
180
+ if (runnable) {
181
+ const btn = el('button', 'invoke-btn', 'Invoke');
182
+ btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, group.kind); };
183
+ t.appendChild(btn);
184
+ t.onclick = () => selectTarget(entry.id, group.kind);
185
+ targetEls.set(entry.id, t);
186
+ }
187
+ pane.appendChild(t);
188
+ }
189
+ }
190
+ if (!total) pane.appendChild(el('div', 'empty', 'No runnable targets found.'));
191
+ } catch (err) {
192
+ pane.appendChild(el('div', 'empty', 'Failed to load targets: ' + err));
193
+ }
194
+ }
195
+
196
+ function highlightTarget(id) {
197
+ document.querySelectorAll('.target.sel').forEach((n) => n.classList.remove('sel'));
198
+ const t = targetEls.get(id);
199
+ if (t) t.classList.add('sel');
200
+ }
201
+
202
+ function selectTarget(id, kind) {
203
+ highlightTarget(id);
204
+ renderComposer(id, kind, '{}');
205
+ }
206
+
207
+ function renderComposer(id, kind, eventText) {
208
+ const ws = document.getElementById('workspace');
209
+ ws.innerHTML = '';
210
+
211
+ const composer = el('div', 'composer');
212
+ composer.appendChild(el('div', 'target-name', 'Invoke ' + id));
213
+ const ta = el('textarea');
214
+ ta.value = eventText;
215
+ ta.spellcheck = false;
216
+ composer.appendChild(ta);
217
+ composer.appendChild(document.createElement('br'));
218
+ const btn = el('button', null, 'Invoke');
219
+ const msg = el('div', 'err');
220
+ composer.appendChild(btn);
221
+ composer.appendChild(msg);
222
+
223
+ const result = el('div', 'result');
224
+
225
+ ws.appendChild(composer);
226
+ ws.appendChild(result);
227
+
228
+ active = { id, kind, ta, btn, msg, result };
229
+ btn.onclick = () => runInvoke();
230
+ shownInvId = null;
231
+ ta.focus();
232
+ }
233
+
234
+ async function runInvoke() {
235
+ if (!active) return;
236
+ const { id, kind, ta, btn, msg, result } = active;
237
+ let event;
238
+ try {
239
+ event = ta.value.trim() === '' ? {} : JSON.parse(ta.value);
240
+ } catch (err) {
241
+ msg.textContent = 'Invalid JSON: ' + err.message;
242
+ return;
243
+ }
244
+ msg.textContent = '';
245
+ btn.disabled = true;
246
+ btn.textContent = 'Invoking...';
247
+ result.innerHTML = '';
248
+ try {
249
+ const res = await fetch('/api/run', {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ targetId: id, kind, event }),
253
+ });
254
+ const data = await res.json();
255
+ if (data.invocationId) {
256
+ shownInvId = data.invocationId;
257
+ renderResult(shownInvId);
258
+ }
259
+ if (!res.ok || data.ok === false) {
260
+ msg.textContent = 'Invoke failed: ' + (data.error || ('HTTP ' + res.status));
261
+ }
262
+ } catch (err) {
263
+ msg.textContent = 'Request failed: ' + err;
264
+ } finally {
265
+ btn.disabled = false;
266
+ btn.textContent = 'Invoke';
267
+ }
268
+ }
269
+
270
+ function fmt(body) {
271
+ return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
272
+ }
273
+
274
+ function renderResult(invId) {
275
+ if (!active) return;
276
+ const result = active.result;
277
+ result.innerHTML = '';
278
+ const ev = invById.get(invId);
279
+ if (!ev) return;
280
+
281
+ const reqSec = el('div', 'section');
282
+ reqSec.appendChild(el('h3', null, 'Request'));
283
+ reqSec.appendChild(el('pre', null, ev.request != null ? fmt(ev.request) : '(none)'));
284
+ result.appendChild(reqSec);
285
+
286
+ const respSec = el('div', 'section');
287
+ const h = el('h3', null, 'Response');
288
+ if (ev.status != null) {
289
+ const cls = ev.status >= 200 && ev.status < 300 ? 'ok' : 'bad';
290
+ const meta = el('span', cls, ' ' + ev.status + (ev.durationMs != null ? ' · ' + ev.durationMs + 'ms' : ''));
291
+ h.appendChild(meta);
292
+ }
293
+ respSec.appendChild(h);
294
+ respSec.appendChild(el('pre', null, ev.response != null ? fmt(ev.response) : '(pending…)'));
295
+ result.appendChild(respSec);
296
+
297
+ const logs = logsById.get(invId) || [];
298
+ const logSec = el('div', 'section');
299
+ logSec.appendChild(el('h3', null, 'Logs'));
300
+ logSec.appendChild(el('pre', null, logs.length ? logs.join('\\n') : '(none)'));
301
+ result.appendChild(logSec);
302
+ }
303
+
304
+ function addInvocation(ev) {
305
+ invById.set(ev.id, Object.assign(invById.get(ev.id) || {}, ev));
306
+ const timeline = document.getElementById('timeline');
307
+ const placeholder = timeline.querySelector('.empty');
308
+ if (placeholder) placeholder.remove();
309
+
310
+ let row = rowsById.get(ev.id);
311
+ if (!row) {
312
+ row = el('div', 'row');
313
+ row.appendChild(el('span', 'ts'));
314
+ row.appendChild(el('span', 'label'));
315
+ row.appendChild(el('span', 'status'));
316
+ row.onclick = () => loadInvocation(ev.id);
317
+ rowsById.set(ev.id, row);
318
+ timeline.insertBefore(row, timeline.querySelector('.row')); // newest on top
319
+ }
320
+ const merged = invById.get(ev.id);
321
+ const d = new Date(merged.ts);
322
+ row.querySelector('.ts').textContent = d.toLocaleTimeString();
323
+ row.querySelector('.label').textContent = (merged.target || '') + ' ' + (merged.label || '');
324
+ const statusEl = row.querySelector('.status');
325
+ statusEl.textContent =
326
+ merged.status != null
327
+ ? merged.status + (merged.durationMs != null ? ' ' + merged.durationMs + 'ms' : '')
328
+ : '…';
329
+ statusEl.className = 'status' + (merged.status != null && (merged.status < 200 || merged.status >= 300) ? ' err' : '');
330
+
331
+ // Live-refresh the workspace result if it is showing this invocation.
332
+ if (shownInvId === ev.id) renderResult(ev.id);
333
+ }
334
+
335
+ function loadInvocation(id) {
336
+ const ev = invById.get(id);
337
+ if (!ev) return;
338
+ document.querySelectorAll('.row.sel').forEach((n) => n.classList.remove('sel'));
339
+ const row = rowsById.get(id);
340
+ if (row) row.classList.add('sel');
341
+ highlightTarget(ev.target);
342
+ renderComposer(ev.target, ev.kind, ev.request != null ? fmt(ev.request) : '{}');
343
+ shownInvId = id;
344
+ renderResult(id);
345
+ }
346
+
347
+ function connect() {
348
+ const conn = document.getElementById('conn');
349
+ const es = new EventSource('/api/events');
350
+ es.addEventListener('open', () => { conn.textContent = '● live'; conn.className = 'up'; });
351
+ es.addEventListener('error', () => { conn.textContent = '● disconnected'; conn.className = 'down'; });
352
+ es.addEventListener('invocation', (e) => addInvocation(JSON.parse(e.data)));
353
+ es.addEventListener('log', (e) => {
354
+ const ev = JSON.parse(e.data);
355
+ const arr = logsById.get(ev.containerId) || [];
356
+ arr.push(ev.line);
357
+ logsById.set(ev.containerId, arr);
358
+ if (shownInvId === ev.containerId) renderResult(ev.containerId);
359
+ });
360
+ }
361
+
362
+ function initSplitters() {
363
+ const main = document.querySelector('main');
364
+ let left = 280, right = 320;
365
+ const apply = () => {
366
+ main.style.gridTemplateColumns = left + 'px 5px 1fr 5px ' + right + 'px';
367
+ };
368
+ const wire = (splitterId, onMove) => {
369
+ const s = document.getElementById(splitterId);
370
+ s.addEventListener('mousedown', (e) => {
371
+ e.preventDefault();
372
+ const startX = e.clientX;
373
+ const l0 = left, r0 = right;
374
+ s.classList.add('dragging');
375
+ document.body.style.userSelect = 'none';
376
+ const move = (ev) => { onMove(ev.clientX - startX, l0, r0); apply(); };
377
+ const up = () => {
378
+ s.classList.remove('dragging');
379
+ document.body.style.userSelect = '';
380
+ document.removeEventListener('mousemove', move);
381
+ document.removeEventListener('mouseup', up);
382
+ };
383
+ document.addEventListener('mousemove', move);
384
+ document.addEventListener('mouseup', up);
385
+ });
386
+ };
387
+ const clamp = (v) => Math.max(160, Math.min(760, v));
388
+ wire('split-left', (dx, l0) => { left = clamp(l0 + dx); });
389
+ wire('split-right', (dx, l0, r0) => { right = clamp(r0 - dx); });
390
+ }
391
+
392
+ loadTargets();
393
+ connect();
394
+ initSplitters();
395
+ `;
396
+ /**
397
+ * Render the full studio HTML document. `appLabel` is shown in the
398
+ * header (the CDK app / stack context); `cliName` brands the title for
399
+ * host CLIs that rebrand `cdkl`.
400
+ */
401
+ function renderStudioHtml(appLabel, cliName) {
402
+ const safeApp = escapeHtml(appLabel);
403
+ const safeCli = escapeHtml(cliName);
404
+ return `<!doctype html>
405
+ <html lang="en">
406
+ <head>
407
+ <meta charset="utf-8" />
408
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
409
+ <title>${safeCli} studio</title>
410
+ <style>${STUDIO_CSS}</style>
411
+ </head>
412
+ <body>
413
+ <header>
414
+ <span class="brand">${safeCli} studio</span>
415
+ <span class="meta">${safeApp}</span>
416
+ <span id="conn" class="down">● connecting</span>
417
+ </header>
418
+ <main>
419
+ <section class="pane" id="targets"><h2>Targets</h2></section>
420
+ <div class="splitter" id="split-left"></div>
421
+ <section class="pane" id="workspace"><div class="empty">Pick a Lambda on the left to invoke it.</div></section>
422
+ <div class="splitter" id="split-right"></div>
423
+ <section class="pane" id="timeline"><h2>Timeline</h2><div class="empty">No requests yet.</div></section>
424
+ </main>
425
+ <script>${STUDIO_SCRIPT}<\/script>
426
+ </body>
427
+ </html>`;
428
+ }
429
+ /** Minimal HTML-escape for the few interpolated text values. */
430
+ function escapeHtml(s) {
431
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
432
+ }
433
+
434
+ //#endregion
435
+ //#region src/local/studio-server.ts
436
+ /**
437
+ * Project a {@link TargetListing} (the same enumeration `cdkl list`
438
+ * prints) into the grouped shape the studio UI renders. ECS services and
439
+ * task definitions are folded into one `ecs` group; everything else maps
440
+ * one category to one group. Exported so a unit test can assert the
441
+ * projection without booting the server.
442
+ */
443
+ function toStudioTargetGroups(listing) {
444
+ const map = (entries) => entries.map((e) => {
445
+ const t = {
446
+ id: e.displayPath ?? e.qualifiedId,
447
+ qualifiedId: e.qualifiedId
448
+ };
449
+ if (e.kind) t.surface = e.kind;
450
+ return t;
451
+ });
452
+ return [
453
+ {
454
+ kind: "lambda",
455
+ title: "Lambda Functions",
456
+ entries: map(listing.lambdas)
457
+ },
458
+ {
459
+ kind: "api",
460
+ title: "APIs",
461
+ entries: map(listing.apis)
462
+ },
463
+ {
464
+ kind: "ecs",
465
+ title: "ECS Services / Tasks",
466
+ entries: [...map(listing.ecsServices), ...map(listing.ecsTaskDefinitions)]
467
+ },
468
+ {
469
+ kind: "agentcore",
470
+ title: "AgentCore Runtimes",
471
+ entries: map(listing.agentCoreRuntimes)
472
+ },
473
+ {
474
+ kind: "alb",
475
+ title: "Load Balancers",
476
+ entries: map(listing.loadBalancers)
477
+ }
478
+ ];
479
+ }
480
+ const SSE_HEARTBEAT_MS = 15e3;
481
+ /**
482
+ * Boot the studio HTTP server: serves the embedded UI at `/`, the target
483
+ * list at `/api/targets`, and a Server-Sent-Events stream of the bus's
484
+ * `invocation` / `log` events at `/api/events`. Localhost-only by
485
+ * default. Resolves once the socket is listening.
486
+ */
487
+ async function startStudioServer(options) {
488
+ const host = options.host ?? "127.0.0.1";
489
+ const maxBump = options.maxPortBump ?? 20;
490
+ const html = renderStudioHtml(options.appLabel, options.cliName);
491
+ const targetsJson = JSON.stringify({ groups: options.targetGroups });
492
+ const server = createServer((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options.onRun));
493
+ const boundPort = await listenWithBump(server, host, options.port, maxBump);
494
+ return {
495
+ url: `http://${host}:${boundPort}`,
496
+ port: boundPort,
497
+ close: () => new Promise((resolveClose, reject) => {
498
+ server.close((err) => err ? reject(err) : resolveClose());
499
+ server.closeAllConnections?.();
500
+ })
501
+ };
502
+ }
503
+ function handleRequest(req, res, bus, html, targetsJson, onRun) {
504
+ const path = (req.url ?? "/").split("?")[0];
505
+ if (req.method === "GET" && (path === "/" || path === "/index.html")) {
506
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
507
+ res.end(html);
508
+ return;
509
+ }
510
+ if (req.method === "GET" && path === "/api/targets") {
511
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
512
+ res.end(targetsJson);
513
+ return;
514
+ }
515
+ if (req.method === "GET" && path === "/api/events") {
516
+ serveSse(req, res, bus);
517
+ return;
518
+ }
519
+ if (req.method === "POST" && path === "/api/run") {
520
+ handleRun(req, res, onRun);
521
+ return;
522
+ }
523
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
524
+ res.end("Not found");
525
+ }
526
+ const MAX_RUN_BODY_BYTES = 5 * 1024 * 1024;
527
+ /** Reply to `POST /api/run`: parse the JSON body, dispatch via `onRun`. */
528
+ async function handleRun(req, res, onRun) {
529
+ const sendJson = (statusCode, payload) => {
530
+ res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
531
+ res.end(JSON.stringify(payload));
532
+ };
533
+ if (!onRun) {
534
+ sendJson(501, { error: "Running targets is not supported by this studio server." });
535
+ return;
536
+ }
537
+ let body;
538
+ try {
539
+ body = await readJsonBody(req);
540
+ } catch (err) {
541
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
542
+ return;
543
+ }
544
+ try {
545
+ sendJson(200, await onRun(body));
546
+ } catch (err) {
547
+ sendJson(500, { error: err instanceof Error ? err.message : String(err) });
548
+ }
549
+ }
550
+ /** Read + JSON-parse a request body, bounded to {@link MAX_RUN_BODY_BYTES}. */
551
+ function readJsonBody(req) {
552
+ return new Promise((resolveBody, reject) => {
553
+ let raw = "";
554
+ let bytes = 0;
555
+ let done = false;
556
+ const settle = (fn) => {
557
+ if (done) return;
558
+ done = true;
559
+ fn();
560
+ };
561
+ req.setEncoding("utf8");
562
+ req.on("data", (chunk) => {
563
+ if (done) return;
564
+ bytes += Buffer.byteLength(chunk);
565
+ if (bytes > MAX_RUN_BODY_BYTES) {
566
+ settle(() => reject(/* @__PURE__ */ new Error("Request body too large.")));
567
+ req.destroy();
568
+ return;
569
+ }
570
+ raw += chunk;
571
+ });
572
+ req.on("end", () => {
573
+ settle(() => {
574
+ if (raw.trim() === "") {
575
+ resolveBody(void 0);
576
+ return;
577
+ }
578
+ try {
579
+ resolveBody(JSON.parse(raw));
580
+ } catch {
581
+ reject(/* @__PURE__ */ new Error("Invalid JSON body."));
582
+ }
583
+ });
584
+ });
585
+ req.on("error", (err) => settle(() => reject(err)));
586
+ });
587
+ }
588
+ function serveSse(req, res, bus) {
589
+ res.writeHead(200, {
590
+ "content-type": "text/event-stream; charset=utf-8",
591
+ "cache-control": "no-cache, no-transform",
592
+ connection: "keep-alive"
593
+ });
594
+ let closed = false;
595
+ const heartbeat = setInterval(() => safeWrite(":hb\n\n"), SSE_HEARTBEAT_MS);
596
+ heartbeat.unref?.();
597
+ const onInvocation = (ev) => {
598
+ safeWrite(`event: invocation\ndata: ${JSON.stringify(ev)}\n\n`);
599
+ };
600
+ const onLog = (ev) => {
601
+ safeWrite(`event: log\ndata: ${JSON.stringify(ev)}\n\n`);
602
+ };
603
+ function cleanup() {
604
+ if (closed) return;
605
+ closed = true;
606
+ clearInterval(heartbeat);
607
+ bus.off("invocation", onInvocation);
608
+ bus.off("log", onLog);
609
+ }
610
+ function safeWrite(chunk) {
611
+ if (closed || res.writableEnded || res.destroyed) {
612
+ cleanup();
613
+ return;
614
+ }
615
+ res.write(chunk);
616
+ }
617
+ bus.on("invocation", onInvocation);
618
+ bus.on("log", onLog);
619
+ req.on("close", cleanup);
620
+ res.on("close", cleanup);
621
+ res.on("error", cleanup);
622
+ safeWrite(":ok\n\n");
623
+ }
624
+ /**
625
+ * Listen on `port`, retrying `port+1`, `port+2`, ... on `EADDRINUSE` up
626
+ * to `maxBump` extra attempts. Resolves with the bound port.
627
+ */
628
+ function listenWithBump(server, host, port, maxBump) {
629
+ return new Promise((resolveListen, reject) => {
630
+ let attempt = 0;
631
+ const tryListen = (p) => {
632
+ const onError = (err) => {
633
+ if (err.code === "EADDRINUSE" && attempt < maxBump) {
634
+ attempt += 1;
635
+ server.removeListener("error", onError);
636
+ tryListen(p + 1);
637
+ return;
638
+ }
639
+ reject(err);
640
+ };
641
+ server.once("error", onError);
642
+ server.listen(p, host, () => {
643
+ server.removeListener("error", onError);
644
+ resolveListen(server.address().port);
645
+ });
646
+ };
647
+ tryListen(port);
648
+ });
649
+ }
650
+
651
+ //#endregion
652
+ //#region src/local/studio-dispatch.ts
653
+ let idCounter = 0;
654
+ /**
655
+ * Build the studio run dispatcher. Slice B drives a single-shot Lambda
656
+ * invoke by spawning the SAME `cdkl invoke` the headless command runs —
657
+ * studio is a control plane over the CLI, so re-using the whole command
658
+ * (rather than re-wiring its internals) guarantees byte-for-byte parity
659
+ * and keeps all of `cdkl invoke`'s process-global behavior
660
+ * (`process.exit`, env mutation, stdin) isolated in a child process.
661
+ *
662
+ * The child's stdout is the Lambda response payload; its stderr (status
663
+ * + container logs) is streamed line-by-line onto the bus as `log`
664
+ * events. An `invocation` start event is emitted before spawn and an end
665
+ * event (with response + status + duration) after exit, both keyed by
666
+ * the same correlation id so the UI threads them into one timeline row.
667
+ */
668
+ function createStudioDispatcher(config) {
669
+ const spawnFn = config.spawnFn ?? spawn;
670
+ const nodeBin = config.nodeBin ?? process.execPath;
671
+ const clock = config.clock ?? Date.now;
672
+ const idFactory = config.idFactory ?? (() => {
673
+ idCounter += 1;
674
+ return `inv-${clock()}-${idCounter}`;
675
+ });
676
+ async function run(req) {
677
+ const invocationId = idFactory();
678
+ const startedAt = clock();
679
+ if (req.kind !== "lambda") {
680
+ const error = `Running '${req.kind}' targets from studio is not supported yet (Lambda only).`;
681
+ config.bus.emit("invocation", {
682
+ id: invocationId,
683
+ ts: startedAt,
684
+ target: req.targetId,
685
+ kind: req.kind,
686
+ label: "invoke",
687
+ request: req.event,
688
+ response: error,
689
+ status: 501,
690
+ durationMs: clock() - startedAt
691
+ });
692
+ return {
693
+ invocationId,
694
+ ok: false,
695
+ status: 501,
696
+ error,
697
+ durationMs: clock() - startedAt
698
+ };
699
+ }
700
+ config.bus.emit("invocation", {
701
+ id: invocationId,
702
+ ts: startedAt,
703
+ target: req.targetId,
704
+ kind: req.kind,
705
+ label: "invoke",
706
+ request: req.event
707
+ });
708
+ let dir;
709
+ try {
710
+ dir = mkdtempSync(join(tmpdir(), "cdkl-studio-run-"));
711
+ const eventFile = join(dir, "event.json");
712
+ writeFileSync(eventFile, JSON.stringify(req.event ?? {}));
713
+ const args = [
714
+ "invoke",
715
+ req.targetId,
716
+ "--event",
717
+ eventFile
718
+ ];
719
+ if (config.app) args.push("--app", config.app);
720
+ if (config.profile) args.push("--profile", config.profile);
721
+ if (config.region) args.push("--region", config.region);
722
+ for (const [k, v] of Object.entries(config.context ?? {})) args.push("-c", `${k}=${v}`);
723
+ const { code, stdout, stderr } = await runChild(spawnFn, nodeBin, [config.cliEntry, ...args], config.cwd ?? process.cwd(), invocationId, req.targetId, config.bus, clock);
724
+ const durationMs = clock() - startedAt;
725
+ const ok = code === 0;
726
+ const failure = stderr.trim() || `cdkl invoke exited ${code}`;
727
+ const stdoutLines = stdout.split("\n").map((l) => l.replace(/\r$/, "").trimEnd()).filter((l) => l.trim().length > 0);
728
+ let responseIdx = -1;
729
+ let response;
730
+ if (ok) {
731
+ for (let i = stdoutLines.length - 1; i >= 0; i -= 1) {
732
+ const parsed = tryParseJson(stdoutLines[i] ?? "");
733
+ if (parsed.ok) {
734
+ responseIdx = i;
735
+ response = parsed.value;
736
+ break;
737
+ }
738
+ }
739
+ if (responseIdx === -1 && stdoutLines.length > 0) {
740
+ responseIdx = stdoutLines.length - 1;
741
+ response = stdoutLines[responseIdx];
742
+ }
743
+ } else response = failure;
744
+ stdoutLines.forEach((line, i) => {
745
+ if (i === responseIdx) return;
746
+ config.bus.emit("log", {
747
+ ts: clock(),
748
+ containerId: invocationId,
749
+ target: req.targetId,
750
+ line,
751
+ stream: "stdout"
752
+ });
753
+ });
754
+ const raw = responseIdx >= 0 ? stdoutLines[responseIdx] ?? "" : "";
755
+ const status = ok ? 200 : 500;
756
+ config.bus.emit("invocation", {
757
+ id: invocationId,
758
+ ts: startedAt,
759
+ target: req.targetId,
760
+ kind: req.kind,
761
+ label: "invoke",
762
+ request: req.event,
763
+ response,
764
+ status,
765
+ durationMs
766
+ });
767
+ const result = {
768
+ invocationId,
769
+ ok,
770
+ status,
771
+ durationMs
772
+ };
773
+ if (raw) result.raw = raw;
774
+ if (response !== void 0) result.response = response;
775
+ if (!ok) result.error = failure;
776
+ return result;
777
+ } finally {
778
+ if (dir) rmSync(dir, {
779
+ recursive: true,
780
+ force: true
781
+ });
782
+ }
783
+ }
784
+ return { run };
785
+ }
786
+ /**
787
+ * Spawn the child invoke, accumulate stdout, and stream stderr to the
788
+ * bus line-by-line as `log` events. Resolves on process close.
789
+ */
790
+ function runChild(spawnFn, nodeBin, argv, cwd, invocationId, target, bus, clock) {
791
+ return new Promise((resolve, reject) => {
792
+ let child;
793
+ try {
794
+ child = spawnFn(nodeBin, argv, { cwd });
795
+ } catch (err) {
796
+ reject(err instanceof Error ? err : new Error(String(err)));
797
+ return;
798
+ }
799
+ let stdout = "";
800
+ let stderr = "";
801
+ let lineBuf = "";
802
+ child.stdout.setEncoding("utf8");
803
+ child.stderr.setEncoding("utf8");
804
+ child.stdout.on("data", (chunk) => {
805
+ stdout += chunk;
806
+ });
807
+ child.stderr.on("data", (chunk) => {
808
+ stderr += chunk;
809
+ lineBuf += chunk;
810
+ let nl = lineBuf.indexOf("\n");
811
+ while (nl !== -1) {
812
+ const line = lineBuf.slice(0, nl);
813
+ lineBuf = lineBuf.slice(nl + 1);
814
+ if (line.length > 0) bus.emit("log", {
815
+ ts: clock(),
816
+ containerId: invocationId,
817
+ target,
818
+ line,
819
+ stream: "stderr"
820
+ });
821
+ nl = lineBuf.indexOf("\n");
822
+ }
823
+ });
824
+ child.on("error", (err) => reject(err));
825
+ child.on("close", (code) => {
826
+ if (lineBuf.length > 0) bus.emit("log", {
827
+ ts: clock(),
828
+ containerId: invocationId,
829
+ target,
830
+ line: lineBuf,
831
+ stream: "stderr"
832
+ });
833
+ resolve({
834
+ code,
835
+ stdout,
836
+ stderr
837
+ });
838
+ });
839
+ });
840
+ }
841
+ /** Try to JSON-parse `raw`; `ok` distinguishes a parsed value from a failure. */
842
+ function tryParseJson(raw) {
843
+ if (raw === "") return {
844
+ ok: false,
845
+ value: void 0
846
+ };
847
+ try {
848
+ return {
849
+ ok: true,
850
+ value: JSON.parse(raw)
851
+ };
852
+ } catch {
853
+ return {
854
+ ok: false,
855
+ value: void 0
856
+ };
857
+ }
858
+ }
859
+
860
+ //#endregion
861
+ //#region src/cli/commands/local-studio.ts
862
+ const STUDIO_TARGET_KINDS = [
863
+ "lambda",
864
+ "api",
865
+ "alb",
866
+ "ecs",
867
+ "agentcore"
868
+ ];
869
+ /**
870
+ * Validate + narrow the untyped `POST /api/run` body into a
871
+ * {@link StudioRunRequest}. Throws (→ 400 from the server) on a malformed
872
+ * body so a bad UI / curl payload fails loudly rather than spawning an
873
+ * `invoke` for an empty target.
874
+ */
875
+ function coerceRunRequest(body) {
876
+ if (typeof body !== "object" || body === null) throw new Error("Request body must be a JSON object.");
877
+ const { targetId, kind, event } = body;
878
+ if (typeof targetId !== "string" || targetId.trim() === "") throw new Error("Request body must include a non-empty \"targetId\" string.");
879
+ if (typeof kind !== "string" || !STUDIO_TARGET_KINDS.includes(kind)) throw new Error(`Request body "kind" must be one of: ${STUDIO_TARGET_KINDS.join(", ")}.`);
880
+ return {
881
+ targetId,
882
+ kind,
883
+ event
884
+ };
885
+ }
886
+ const DEFAULT_STUDIO_PORT = 9999;
887
+ /**
888
+ * Parse + validate the `--studio-port` value. Accepts `0` (OS-assigned)
889
+ * through `65535`. Exported so a unit test can assert the bounds without
890
+ * driving the full command. Throws on anything out of range / non-numeric.
891
+ */
892
+ function parseStudioPort(raw) {
893
+ const port = raw.trim() === "" ? NaN : Number(raw);
894
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--studio-port must be 0..65535 (got ${raw}).`);
895
+ return port;
896
+ }
897
+ async function localStudioCommand(options) {
898
+ const logger = getLogger();
899
+ if (options.verbose) logger.setLevel("debug");
900
+ const port = parseStudioPort(options.studioPort);
901
+ await applyRoleArnIfSet({
902
+ roleArn: options.roleArn,
903
+ region: void 0,
904
+ profile: options.profile
905
+ });
906
+ const appCmd = resolveApp(options.app);
907
+ if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
908
+ logger.info("Synthesizing CDK app...");
909
+ const synthesizer = new Synthesizer();
910
+ const context = parseContextOptions(options.context);
911
+ const synthOpts = {
912
+ app: appCmd,
913
+ output: options.output,
914
+ ...options.profile && { profile: options.profile },
915
+ ...Object.keys(context).length > 0 && { context }
916
+ };
917
+ const { stacks } = await synthesizer.synthesize(synthOpts);
918
+ const targetGroups = toStudioTargetGroups(listTargets(stacks));
919
+ const appLabel = stacks.map((s) => s.stackName).join(", ") || appCmd;
920
+ const bus = new StudioEventBus();
921
+ const dispatcher = createStudioDispatcher({
922
+ cliEntry: process.argv[1] ?? "",
923
+ bus,
924
+ cwd: process.cwd(),
925
+ ...appCmd ? { app: appCmd } : {},
926
+ ...options.profile ? { profile: options.profile } : {},
927
+ ...options.region ? { region: options.region } : {},
928
+ ...Object.keys(context).length > 0 ? { context } : {}
929
+ });
930
+ const server = await startStudioServer({
931
+ port,
932
+ bus,
933
+ targetGroups,
934
+ appLabel,
935
+ cliName: getEmbedConfig().cliName,
936
+ onRun: (body) => dispatcher.run(coerceRunRequest(body))
937
+ });
938
+ const cliName = getEmbedConfig().cliName;
939
+ logger.info(`${cliName} studio is running at ${server.url}`);
940
+ logger.info("Press Ctrl-C to stop.");
941
+ if (options.open && process.stdout.isTTY) openBrowser(server.url);
942
+ await blockUntilShutdown(server, cliName);
943
+ }
944
+ /** Best-effort cross-platform browser open. Failures are non-fatal. */
945
+ function openBrowser(url) {
946
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
947
+ try {
948
+ const child = spawn(cmd, [url], {
949
+ stdio: "ignore",
950
+ detached: true,
951
+ shell: process.platform === "win32"
952
+ });
953
+ child.on("error", () => void 0);
954
+ child.unref();
955
+ } catch {}
956
+ }
957
+ /**
958
+ * Block until SIGINT / SIGTERM, then close the studio server and resolve.
959
+ * Mirrors the long-running serve commands' graceful-shutdown contract.
960
+ */
961
+ function blockUntilShutdown(server, cliName) {
962
+ return new Promise((resolveShutdown) => {
963
+ let shuttingDown = false;
964
+ const shutdown = (signal) => {
965
+ if (shuttingDown) return;
966
+ shuttingDown = true;
967
+ getLogger().info(`Received ${signal}; stopping ${cliName} studio...`);
968
+ server.close().catch((err) => getLogger().warn(`Error stopping studio server: ${String(err)}`)).finally(() => resolveShutdown());
969
+ };
970
+ process.on("SIGINT", () => shutdown("SIGINT"));
971
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
972
+ });
973
+ }
974
+ function createLocalStudioCommand(opts = {}) {
975
+ setEmbedConfig(opts.embedConfig);
976
+ const cmd = new Command("studio").description("Open the local studio: a web console that lists the synthesized CDK app's runnable targets and lets you invoke / serve them from the browser while watching all activity in one timeline. The interactive counterpart to the headless invoke / start-* commands.").action(withErrorHandling(async (options) => {
977
+ await localStudioCommand(options);
978
+ }));
979
+ addStudioSpecificOptions(cmd);
980
+ [
981
+ ...commonOptions(),
982
+ ...appOptions(),
983
+ ...contextOptions
984
+ ].forEach((opt) => cmd.addOption(opt));
985
+ cmd.addOption(regionOption);
986
+ return cmd;
987
+ }
988
+ /**
989
+ * Register the option block `cdkl studio` adds on top of the shared
990
+ * common / app / context option helpers. Kept in a named helper (not
991
+ * inline in {@link createLocalStudioCommand}) so a host CLI embedding
992
+ * this factory inherits new studio flags without a duplicate
993
+ * `.addOption(...)` block, matching every other `add<Cmd>SpecificOptions`
994
+ * extraction. Chainable: returns `cmd`.
995
+ */
996
+ function addStudioSpecificOptions(cmd) {
997
+ cmd.addOption(new Option("--studio-port <port>", "Preferred port for the studio web server (bumps to the next free port on collision)").default(String(DEFAULT_STUDIO_PORT)));
998
+ cmd.addOption(new Option("--no-open", "Do not auto-open the browser when studio starts (TTY only)"));
999
+ return cmd;
1000
+ }
1001
+
1002
+ //#endregion
1003
+ export { startStudioServer as a, StudioEventBus as c, createStudioDispatcher as i, coerceRunRequest as n, toStudioTargetGroups as o, createLocalStudioCommand as r, renderStudioHtml as s, addStudioSpecificOptions as t };
1004
+ //# sourceMappingURL=local-studio-CSW1raG0.js.map