okengine 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +30 -4
  2. package/docs/spec/example.md +12 -3
  3. package/docs/spec/four-applications.md +10 -3
  4. package/package.json +9 -40
  5. package/src/cli/dev-app-runner.ts +45 -6
  6. package/src/cli/dev.ts +136 -17
  7. package/src/cli/docker-cli.test.ts +10 -8
  8. package/src/cli/docker.ts +18 -7
  9. package/src/cli/hero-meta.test.ts +85 -0
  10. package/src/cli/hero-meta.ts +252 -0
  11. package/src/cli/load-config.images.test.ts +51 -0
  12. package/src/cli/load-config.ts +84 -6
  13. package/src/config/define-config.test.ts +61 -0
  14. package/src/config/index.ts +89 -6
  15. package/src/config/resolve-driver.test.ts +30 -0
  16. package/src/console/server/flows.ts +3 -1
  17. package/src/console/server/operator-db.test.ts +140 -2
  18. package/src/console/server/operator-db.ts +147 -1
  19. package/src/console/server/plugin.ts +20 -0
  20. package/src/console/server/serve.ts +8 -1
  21. package/src/console/server/state.ts +12 -1
  22. package/src/console/ui/dist/assets/{index-Bnf_3Hei.js → index-Dy4jht9P.js} +1 -1
  23. package/src/console/ui/dist/index.html +1 -1
  24. package/src/console/ui/shell/App.tsx +10 -2
  25. package/src/docker/compose.ts +45 -14
  26. package/src/docker/derive.ts +29 -9
  27. package/src/docker/docker.test.ts +28 -4
  28. package/src/docker/dockerfile.integration.test.ts +2 -0
  29. package/src/docker/index.ts +10 -0
  30. package/src/docker/stack-id.test.ts +86 -0
  31. package/src/docker/stack-id.ts +108 -0
  32. package/src/docker/stack.integration.test.ts +16 -7
  33. package/src/docker/types.ts +20 -1
  34. package/src/kernel/app.ts +39 -8
  35. package/src/kernel/boot-bind/store.test.ts +60 -0
  36. package/src/kernel/boot-bind/store.ts +142 -12
  37. package/src/kernel/boot.ts +28 -4
  38. package/src/mcp/server.ts +99 -57
  39. package/src/runtime/dev-request-log.test.ts +33 -0
  40. package/src/runtime/dev-request-log.ts +130 -0
  41. package/src/term.test.ts +98 -5
  42. package/src/term.ts +287 -6
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Dev-only request lines for `oke dev` (App / Console / MCP).
3
+ *
4
+ * Gated by `OKE_DEV_REQUEST_LOG=1`. Surfaces share one TTY; label + color
5
+ * keep streams readable without a multiplexer.
6
+ */
7
+
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import { formatRequestLine, type DevLogSurface } from "../term.ts";
10
+
11
+ const surfaceAls = new AsyncLocalStorage<DevLogSurface>();
12
+
13
+ /**
14
+ * Whether colored request lines should print.
15
+ */
16
+ export function shouldLogDevRequests(): boolean {
17
+ return process.env.OKE_DEV_REQUEST_LOG === "1";
18
+ }
19
+
20
+ /**
21
+ * Active surface for this async context, or `OKE_DEV_SURFACE`, or `App`.
22
+ */
23
+ export function currentDevSurface(): DevLogSurface {
24
+ const fromAls = surfaceAls.getStore();
25
+ if (fromAls) return fromAls;
26
+ const fromEnv = process.env.OKE_DEV_SURFACE;
27
+ if (fromEnv === "App" || fromEnv === "Console" || fromEnv === "MCP") {
28
+ return fromEnv;
29
+ }
30
+ return "App";
31
+ }
32
+
33
+ /**
34
+ * Run a handler tagged with a surface (Console wrap around `app.fetch`).
35
+ *
36
+ * @param surface - App · Console · MCP
37
+ * @param fn - Async work
38
+ */
39
+ export function runWithDevSurface<T>(
40
+ surface: DevLogSurface,
41
+ fn: () => T | Promise<T>,
42
+ ): T | Promise<T> {
43
+ return surfaceAls.run(surface, fn);
44
+ }
45
+
46
+ /** Fields for one request line. */
47
+ export type DevRequestLogInput = {
48
+ readonly surface?: DevLogSurface;
49
+ readonly method: string;
50
+ readonly path: string;
51
+ /** Flow name, RPC method, or tool id. */
52
+ readonly flow?: string;
53
+ readonly status: number;
54
+ readonly ms: number;
55
+ };
56
+
57
+ /**
58
+ * Paths that would drown the TTY (live WS, health probes, static assets).
59
+ *
60
+ * @param method - HTTP method
61
+ * @param path - URL pathname
62
+ */
63
+ export function isSilentDevRequest(method: string, path: string): boolean {
64
+ if (path === "/console/live") return true;
65
+ // Client-types regen — lands between hero and Logs and breaks the separator.
66
+ if (path === "/_oke/client.json") return true;
67
+ if (method === "GET" && (path === "/health" || path.endsWith("/health"))) {
68
+ return true;
69
+ }
70
+ return /\.(?:js|css|map|svg|png|ico|woff2?|ttf|webp)$/i.test(path);
71
+ }
72
+
73
+ /**
74
+ * Print one request line when {@link shouldLogDevRequests} is on.
75
+ *
76
+ * @param input - Request summary
77
+ */
78
+ export function logDevRequest(input: DevRequestLogInput): void {
79
+ if (!shouldLogDevRequests()) return;
80
+ if (isSilentDevRequest(input.method.toUpperCase(), input.path)) return;
81
+ process.stdout.write(
82
+ formatRequestLine({
83
+ surface: input.surface ?? currentDevSurface(),
84
+ method: input.method,
85
+ path: input.path,
86
+ flow: input.flow,
87
+ status: input.status,
88
+ ms: input.ms,
89
+ }),
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Time an async fetch handler and log the outcome.
95
+ *
96
+ * @param surface - Fixed surface (MCP) or omit to use ALS/env
97
+ * @param request - Incoming request
98
+ * @param handle - Inner fetch
99
+ * @param resolveFlow - Optional label after the response (e.g. RPC method)
100
+ */
101
+ export async function timedDevFetch(
102
+ request: Request,
103
+ handle: (request: Request) => Response | Promise<Response>,
104
+ options: {
105
+ readonly surface?: DevLogSurface;
106
+ readonly resolveFlow?: (
107
+ request: Request,
108
+ response: Response,
109
+ ) => string | undefined;
110
+ /** Skip logging for this path/method. */
111
+ readonly silent?: (request: Request) => boolean;
112
+ } = {},
113
+ ): Promise<Response> {
114
+ if (!shouldLogDevRequests() || options.silent?.(request)) {
115
+ return handle(request);
116
+ }
117
+ const started = performance.now();
118
+ const url = new URL(request.url);
119
+ const method = request.method.toUpperCase();
120
+ const response = await handle(request);
121
+ logDevRequest({
122
+ surface: options.surface ?? currentDevSurface(),
123
+ method,
124
+ path: url.pathname,
125
+ flow: options.resolveFlow?.(request, response),
126
+ status: response.status,
127
+ ms: Math.round(performance.now() - started),
128
+ });
129
+ return response;
130
+ }
package/src/term.test.ts CHANGED
@@ -7,7 +7,12 @@ import {
7
7
  formatAppReadyLine,
8
8
  formatClaimNote,
9
9
  formatDevBanner,
10
+ formatDevHero,
11
+ formatDevLogSeparator,
12
+ formatOkeWordmark,
10
13
  formatServiceLine,
14
+ formatRequestLine,
15
+ formatStackSummary,
11
16
  formatStatusLine,
12
17
  termStyle,
13
18
  } from "./term.ts";
@@ -19,11 +24,37 @@ describe("term", () => {
19
24
  expect(s.bold).toBe("");
20
25
  });
21
26
 
22
- test("formatDevBanner is readable without color", () => {
23
- const out = formatDevBanner({ color: false });
24
- expect(out).toContain("oke dev");
25
- expect(out).toContain("Starting");
26
- expect(out).toContain("watching");
27
+ test("formatOkeWordmark is block letters", () => {
28
+ const out = formatOkeWordmark(false);
29
+ expect(out).toContain("██");
30
+ expect(out).toContain("");
31
+ });
32
+
33
+ test("formatDevBanner shows profile env system elements", () => {
34
+ const out = formatDevBanner({
35
+ color: false,
36
+ version: "0.2.4",
37
+ profile: "local-server",
38
+ runtimeEnv: "local",
39
+ system: "darwin 25.4.0 · bun 1.3.14",
40
+ elements: [
41
+ { element: "flow", detail: "●" },
42
+ { element: "store", detail: "sql postgres · kv redis" },
43
+ { element: "signal", detail: "memory" },
44
+ ],
45
+ });
46
+ expect(out).toContain("oke dev v0.2.4");
47
+ expect(out).toContain("profile");
48
+ expect(out).toContain("local-server");
49
+ expect(out).toContain("env");
50
+ expect(out).toContain("local");
51
+ expect(out).toContain("system");
52
+ expect(out).toContain("bun 1.3.14");
53
+ expect(out).toContain("elements");
54
+ expect(out).toContain("store");
55
+ expect(out).toContain("postgres");
56
+ expect(out).not.toContain("on(Trigger)");
57
+ expect(out).not.toContain("O·K·E");
27
58
  expect(out).not.toMatch(/\u001b\[/);
28
59
  });
29
60
 
@@ -36,6 +67,34 @@ describe("term", () => {
36
67
  );
37
68
  });
38
69
 
70
+ test("formatDevHero keeps App Console MCP URLs", () => {
71
+ const out = formatDevHero({
72
+ appUrl: "http://127.0.0.1:6530",
73
+ consoleUrl: "http://127.0.0.1:6533",
74
+ mcpUrl: "http://127.0.0.1:6535",
75
+ profile: "local",
76
+ runtimeEnv: "local",
77
+ system: "darwin 25.4.0 · bun 1.3.14",
78
+ elements: [{ element: "flow", detail: "●" }],
79
+ color: false,
80
+ });
81
+ expect(out).toContain("oke dev");
82
+ expect(out).toContain("██");
83
+ expect(out).toContain("App");
84
+ expect(out).toContain("Console");
85
+ expect(out).toContain("MCP");
86
+ expect(out).toContain("http://127.0.0.1:6530");
87
+ expect(out).toContain("└");
88
+ expect(out).toContain("Logs");
89
+ });
90
+
91
+ test("formatDevLogSeparator closes hero and titles Logs", () => {
92
+ const out = formatDevLogSeparator(false);
93
+ expect(out).toContain("│");
94
+ expect(out).toContain("└");
95
+ expect(out).toContain("Logs");
96
+ });
97
+
39
98
  test("formatClaimNote embeds code and ownership line", () => {
40
99
  const code = "aabbccddeeff00112233445566778899";
41
100
  const out = formatClaimNote(code, false);
@@ -48,4 +107,38 @@ describe("term", () => {
48
107
  test("formatStatusLine keeps message", () => {
49
108
  expect(formatStatusLine("stack up (sql)", false)).toContain("stack up");
50
109
  });
110
+
111
+ test("formatRequestLine shows date, time, surface, flow, ms, status", () => {
112
+ const at = new Date(2026, 6, 26, 3, 11, 42);
113
+ const out = formatRequestLine({
114
+ surface: "App",
115
+ method: "GET",
116
+ path: "/health",
117
+ flow: "main.health",
118
+ status: 200,
119
+ ms: 12,
120
+ at,
121
+ color: false,
122
+ });
123
+ expect(out).toContain("2026-07-26");
124
+ expect(out).toContain("03:11:42");
125
+ expect(out).toContain("App");
126
+ expect(out).toContain("200");
127
+ expect(out).not.toMatch(/\u001b\[/);
128
+ });
129
+
130
+ test("formatStackSummary is scannable", () => {
131
+ const out = formatStackSummary({
132
+ project: "oke-dev-a3f791",
133
+ services: [
134
+ { label: "postgres", hostPort: 15975 },
135
+ { label: "redis", hostPort: 16975 },
136
+ ],
137
+ appDrivers: ["postgres", "redis"],
138
+ color: false,
139
+ });
140
+ expect(out).toContain("Stack");
141
+ expect(out).toContain(":15975");
142
+ expect(out).not.toMatch(/\u001b\[/);
143
+ });
51
144
  });
package/src/term.ts CHANGED
@@ -27,6 +27,7 @@ export type TermStyle = {
27
27
  readonly magenta: string;
28
28
  readonly yellow: string;
29
29
  readonly white: string;
30
+ readonly red: string;
30
31
  };
31
32
 
32
33
  /**
@@ -45,6 +46,7 @@ export function termStyle(color: boolean = termColorEnabled()): TermStyle {
45
46
  magenta: "",
46
47
  yellow: "",
47
48
  white: "",
49
+ red: "",
48
50
  };
49
51
  }
50
52
  return {
@@ -56,22 +58,108 @@ export function termStyle(color: boolean = termColorEnabled()): TermStyle {
56
58
  magenta: `${ESC}35m`,
57
59
  yellow: `${ESC}33m`,
58
60
  white: `${ESC}37m`,
61
+ red: `${ESC}31m`,
59
62
  };
60
63
  }
61
64
 
65
+ /** One eight-element row in the hero. */
66
+ export type DevHeroElement = {
67
+ readonly element: string;
68
+ readonly detail: string;
69
+ };
70
+
71
+ /** Shared options for the `oke dev` hero / banner. */
72
+ export type DevHeroMeta = {
73
+ /** `local` · `local-server` · `test` · `production` */
74
+ readonly profile?: string;
75
+ /** Data plane: `local` · `production` */
76
+ readonly runtimeEnv?: string;
77
+ /** Host OS + Bun, e.g. `darwin 25.4.0 · bun 1.3.14`. */
78
+ readonly system?: string;
79
+ /** Active drivers for the eight elements. */
80
+ readonly elements?: readonly DevHeroElement[];
81
+ readonly version?: string;
82
+ readonly color?: boolean;
83
+ readonly watching?: boolean;
84
+ };
85
+
86
+ /**
87
+ * Compact OKE wordmark — CRT / hacker block letters.
88
+ *
89
+ * @param color - Color on/off
90
+ */
91
+ export function formatOkeWordmark(
92
+ color: boolean = termColorEnabled(),
93
+ ): string {
94
+ const s = termStyle(color);
95
+ const ink = `${s.green}${s.bold}`;
96
+ const r = s.reset;
97
+ return [
98
+ `${ink} ██████╗ ██╗ ██╗███████╗${r}`,
99
+ `${ink} ██╔═══██╗██║ ██╔╝██╔════╝${r}`,
100
+ `${ink} ██║ ██║█████╔╝ █████╗ ${r}`,
101
+ `${ink} ██║ ██║██╔═██╗ ██╔══╝ ${r}`,
102
+ `${ink} ╚██████╔╝██║ ██╗███████╗${r}`,
103
+ `${ink} ╚═════╝ ╚═╝ ╚═╝╚══════╝${r}`,
104
+ ].join("\n") + "\n";
105
+ }
106
+
62
107
  /**
63
- * `oke dev` intro clack chrome; service URLs print as each surface binds.
108
+ * Detail rowsprofile, env, system, eight elements + drivers.
64
109
  *
65
- * @param options - Color / watch hint
110
+ * @param options - Snapshot fields from {@link import("./cli/hero-meta.ts").buildDevHeroSnapshot}
66
111
  */
67
- export function formatDevBanner(
68
- options: { readonly color?: boolean; readonly watching?: boolean } = {},
112
+ export function formatDevHeroDetails(
113
+ options: DevHeroMeta = {},
69
114
  ): string {
115
+ const s = termStyle(options.color ?? termColorEnabled());
116
+ const bar = `${s.dim}│${s.reset}`;
117
+ const label = (name: string) => `${s.dim}${name.padEnd(9)}${s.reset}`;
118
+ const lines: string[] = [];
119
+ if (options.profile) {
120
+ lines.push(
121
+ `${bar} ${label("profile")} ${s.cyan}${options.profile}${s.reset}`,
122
+ );
123
+ }
124
+ if (options.runtimeEnv) {
125
+ lines.push(
126
+ `${bar} ${label("env")} ${s.cyan}${options.runtimeEnv}${s.reset}`,
127
+ );
128
+ }
129
+ if (options.system) {
130
+ lines.push(
131
+ `${bar} ${label("system")} ${s.dim}${options.system}${s.reset}`,
132
+ );
133
+ }
134
+ const elements = options.elements ?? [];
135
+ if (elements.length > 0) {
136
+ lines.push(`${bar} ${s.dim}elements${s.reset}`);
137
+ for (const row of elements) {
138
+ const idle = row.detail === "—";
139
+ const detail = idle
140
+ ? `${s.dim}—${s.reset}`
141
+ : `${s.cyan}${row.detail}${s.reset}`;
142
+ lines.push(
143
+ `${bar} ${s.dim}${row.element.padEnd(9)}${s.reset} ${detail}`,
144
+ );
145
+ }
146
+ }
147
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
148
+ }
149
+
150
+ /**
151
+ * `oke dev` intro — wordmark + details; service URLs print as each surface binds.
152
+ *
153
+ * @param options - Color / watch / ports / entry
154
+ */
155
+ export function formatDevBanner(options: DevHeroMeta = {}): string {
70
156
  const s = termStyle(options.color ?? termColorEnabled());
71
157
  const bar = `${s.dim}│${s.reset}`;
72
158
  const lines = [
73
159
  "",
74
- `${s.cyan}${s.bold}┌${s.reset} ${s.bold}oke dev${s.reset}`,
160
+ formatOkeWordmark(options.color ?? termColorEnabled()).trimEnd(),
161
+ `${s.cyan}${s.bold}┌${s.reset} ${s.bold}oke dev${s.reset}` +
162
+ (options.version ? ` ${s.dim}v${options.version}${s.reset}` : ""),
75
163
  bar,
76
164
  `${s.green}◇${s.reset} Starting`,
77
165
  ];
@@ -80,6 +168,7 @@ export function formatDevBanner(
80
168
  `${bar} ${s.dim}watching — client types regenerate on save${s.reset}`,
81
169
  );
82
170
  }
171
+ lines.push(formatDevHeroDetails(options).trimEnd());
83
172
  lines.push(bar);
84
173
  return `${lines.join("\n")}\n`;
85
174
  }
@@ -116,7 +205,60 @@ export function formatAppReadyLine(
116
205
  }
117
206
 
118
207
  /**
119
- * Quiet status line (regen, stack up, ).
208
+ * ANSI clear screen + home (drops request logs; hero is reprinted after).
209
+ */
210
+ export function clearTerminalScreen(): string {
211
+ return "\u001b[2J\u001b[3J\u001b[H";
212
+ }
213
+
214
+ /**
215
+ * Compact hero reprinted on soft reload — URLs stay, request logs do not.
216
+ *
217
+ * @param options - Surface base URLs + meta
218
+ */
219
+ export function formatDevHero(
220
+ options: DevHeroMeta & {
221
+ readonly appUrl: string;
222
+ readonly consoleUrl: string;
223
+ readonly mcpUrl: string;
224
+ },
225
+ ): string {
226
+ const color = options.color ?? termColorEnabled();
227
+ const s = termStyle(color);
228
+ const bar = `${s.dim}│${s.reset}`;
229
+ return (
230
+ `\n${formatOkeWordmark(color)}` +
231
+ `${s.cyan}${s.bold}┌${s.reset} ${s.bold}oke dev${s.reset}` +
232
+ (options.version ? ` ${s.dim}v${options.version}${s.reset}` : "") +
233
+ `\n${bar}\n` +
234
+ formatDevHeroDetails({ ...options, color }) +
235
+ `${bar}\n` +
236
+ formatAppReadyLine(options.appUrl, color) +
237
+ formatServiceLine("Console", options.consoleUrl, color) +
238
+ formatServiceLine("MCP", options.mcpUrl, color) +
239
+ formatDevLogSeparator(color)
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Closes the hero block and opens the Logs section.
245
+ *
246
+ * @param color - Color on/off
247
+ */
248
+ export function formatDevLogSeparator(
249
+ color: boolean = termColorEnabled(),
250
+ ): string {
251
+ const s = termStyle(color);
252
+ return (
253
+ `${s.dim}│${s.reset}\n` +
254
+ `${s.dim}└${s.reset}\n` +
255
+ `\n${s.green}◇${s.reset} ${s.bold}Logs${s.reset}\n` +
256
+ `${s.dim}│${s.reset}\n\n`
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Quiet status line (regen, …).
120
262
  *
121
263
  * @param message - Status text
122
264
  * @param color - Color on/off
@@ -129,6 +271,145 @@ export function formatStatusLine(
129
271
  return `${s.dim}│${s.reset} ${s.dim}${message}${s.reset}\n`;
130
272
  }
131
273
 
274
+ /** One infra service row for {@link formatStackSummary}. */
275
+ export type StackSummaryService = {
276
+ /** Human label (`postgres`, `redis`). */
277
+ readonly label: string;
278
+ /** Published host port. */
279
+ readonly hostPort: number;
280
+ };
281
+
282
+ /** Surfaces that emit request lines during `oke dev`. */
283
+ export type DevLogSurface = "App" | "Console" | "MCP";
284
+
285
+ /**
286
+ * Local calendar date `YYYY-MM-DD` (no time).
287
+ *
288
+ * @param at - Instant (default now)
289
+ */
290
+ export function formatDevLogDate(at: Date = new Date()): string {
291
+ const y = at.getFullYear();
292
+ const m = String(at.getMonth() + 1).padStart(2, "0");
293
+ const d = String(at.getDate()).padStart(2, "0");
294
+ return `${y}-${m}-${d}`;
295
+ }
296
+
297
+ /**
298
+ * Local clock time `HH:MM:SS` (no date).
299
+ *
300
+ * @param at - Instant (default now)
301
+ */
302
+ export function formatDevLogTime(at: Date = new Date()): string {
303
+ const h = String(at.getHours()).padStart(2, "0");
304
+ const min = String(at.getMinutes()).padStart(2, "0");
305
+ const sec = String(at.getSeconds()).padStart(2, "0");
306
+ return `${h}:${min}:${sec}`;
307
+ }
308
+
309
+ /**
310
+ * One HTTP/RPC request line for the `oke dev` TTY.
311
+ *
312
+ * @example
313
+ * `● App GET /health main.health 12ms 200 2026-07-26 03:11:42`
314
+ *
315
+ * @param options - Surface, method, path, flow, timing, status
316
+ */
317
+ export function formatRequestLine(
318
+ options: {
319
+ readonly surface: DevLogSurface;
320
+ readonly method: string;
321
+ readonly path: string;
322
+ readonly flow?: string;
323
+ readonly status: number;
324
+ readonly ms: number;
325
+ /** Instant for date/time columns (default now). */
326
+ readonly at?: Date;
327
+ readonly color?: boolean;
328
+ },
329
+ ): string {
330
+ const s = termStyle(options.color ?? termColorEnabled());
331
+ const at = options.at ?? new Date();
332
+ const date = formatDevLogDate(at);
333
+ const time = formatDevLogTime(at);
334
+ const surfaceColor =
335
+ options.surface === "App"
336
+ ? s.green
337
+ : options.surface === "Console"
338
+ ? s.magenta
339
+ : s.cyan;
340
+ const statusColor =
341
+ options.status >= 500
342
+ ? s.red
343
+ : options.status >= 400
344
+ ? s.yellow
345
+ : s.green;
346
+ const methodRaw = options.method.toUpperCase();
347
+ const methodColor =
348
+ methodRaw === "GET"
349
+ ? s.green
350
+ : methodRaw === "POST"
351
+ ? s.yellow
352
+ : methodRaw === "PUT" || methodRaw === "PATCH"
353
+ ? s.magenta
354
+ : methodRaw === "DELETE"
355
+ ? s.red
356
+ : s.cyan;
357
+ const method = methodRaw.padEnd(4);
358
+ const path = options.path.length > 28
359
+ ? `${options.path.slice(0, 27)}…`
360
+ : options.path.padEnd(28);
361
+ const flow = (options.flow ?? "—").padEnd(22);
362
+ const ms = `${options.ms}ms`.padStart(6);
363
+ return (
364
+ `${surfaceColor}●${s.reset} ` +
365
+ `${surfaceColor}${options.surface.padEnd(7)}${s.reset} ` +
366
+ `${methodColor}${method}${s.reset} ` +
367
+ `${s.cyan}${path}${s.reset} ` +
368
+ `${s.dim}${flow}${s.reset} ` +
369
+ `${s.dim}${ms}${s.reset} ` +
370
+ `${statusColor}${options.status}${s.reset} ` +
371
+ `${s.dim}${date}${s.reset} ` +
372
+ `${s.dim}${time}${s.reset}\n`
373
+ );
374
+ }
375
+
376
+ /**
377
+ * Compact `oke dev -s` summary — project, ports, app driver mode.
378
+ *
379
+ * @param options - Project name, services, driver labels
380
+ */
381
+ export function formatStackSummary(
382
+ options: {
383
+ readonly project: string;
384
+ readonly services: readonly StackSummaryService[];
385
+ /** Drivers the host app will use (e.g. `postgres`, `redis`). */
386
+ readonly appDrivers?: readonly string[];
387
+ readonly color?: boolean;
388
+ },
389
+ ): string {
390
+ const s = termStyle(options.color ?? termColorEnabled());
391
+ const bar = `${s.dim}│${s.reset}`;
392
+ const pad = (label: string) => label.padEnd(8);
393
+ const lines: string[] = [
394
+ `${s.green}◇${s.reset} ${s.bold}Stack${s.reset} ${s.cyan}${options.project}${s.reset}`,
395
+ ];
396
+ for (const svc of options.services) {
397
+ lines.push(
398
+ `${bar} ${s.dim}${pad(svc.label)}${s.reset} ${s.cyan}:${svc.hostPort}${s.reset}`,
399
+ );
400
+ }
401
+ const drivers = options.appDrivers ?? [];
402
+ const appDetail =
403
+ drivers.length > 0
404
+ ? `host Bun · ${drivers.join(" + ")}`
405
+ : "host Bun";
406
+ lines.push(
407
+ `${bar} ${s.dim}${pad("app")}${s.reset} ${s.dim}${appDetail}${s.reset}`,
408
+ );
409
+ lines.push(bar);
410
+ return `${lines.join("\n")}\n`;
411
+ }
412
+
132
413
  /**
133
414
  * First-admin claim code note (clack `note`-style box).
134
415
  *