okengine 0.2.2 → 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 (66) 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 +204 -23
  7. package/src/cli/docker-cli.test.ts +10 -8
  8. package/src/cli/docker.ts +18 -7
  9. package/src/cli/doctor.ts +3 -18
  10. package/src/cli/hero-meta.test.ts +85 -0
  11. package/src/cli/hero-meta.ts +252 -0
  12. package/src/cli/index.ts +4 -1
  13. package/src/cli/json-out.test.ts +1 -1
  14. package/src/cli/load-config.images.test.ts +126 -0
  15. package/src/cli/load-config.ts +145 -2
  16. package/src/cli/ports.test.ts +51 -0
  17. package/src/cli/ports.ts +81 -0
  18. package/src/cli/registry.help.test.ts +18 -0
  19. package/src/cli/registry.ts +0 -4
  20. package/src/cli/safe-defaults.test.ts +3 -3
  21. package/src/config/define-config.test.ts +61 -0
  22. package/src/config/index.ts +89 -6
  23. package/src/config/resolve-driver.test.ts +30 -0
  24. package/src/console/server/app.ts +2 -1
  25. package/src/console/server/flows.ts +4 -1
  26. package/src/console/server/index.ts +5 -0
  27. package/src/console/server/operator-db.test.ts +213 -0
  28. package/src/console/server/operator-db.ts +470 -0
  29. package/src/console/server/plugin.ts +20 -0
  30. package/src/console/server/serve.ts +47 -1
  31. package/src/console/server/state.ts +25 -2
  32. package/src/console/ui/dist/assets/{index-B71Yl_SS.js → index-Dy4jht9P.js} +3 -3
  33. package/src/console/ui/dist/assets/{panel-overview-Bd48d9km.js → panel-overview-Dt_AeXgd.js} +1 -1
  34. package/src/console/ui/dist/assets/{panel-runs-BwsWqKeB.js → panel-runs-DGstFHeq.js} +1 -1
  35. package/src/console/ui/dist/assets/{panel-signals-9najbZY2.js → panel-signals-DzEa2Fnt.js} +1 -1
  36. package/src/console/ui/dist/assets/{panel-store-OHkP2pDp.js → panel-store-BJkbNgxx.js} +1 -1
  37. package/src/console/ui/dist/assets/{panel-traces-tn2JoY8U.js → panel-traces-BaRVO3gM.js} +1 -1
  38. package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
  39. package/src/console/ui/dist/favicon.svg +7 -0
  40. package/src/console/ui/dist/index.html +3 -2
  41. package/src/console/ui/shell/App.tsx +44 -4
  42. package/src/console/ui/shell/components/oke-logo.tsx +40 -0
  43. package/src/console/ui/shell/index.html +1 -0
  44. package/src/console/ui/shell/layout/Shell.tsx +2 -3
  45. package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
  46. package/src/console/ui/shell/public/favicon.svg +7 -0
  47. package/src/console/ui/shell/setup/Wizard.tsx +5 -2
  48. package/src/docker/compose.ts +45 -14
  49. package/src/docker/derive.ts +32 -10
  50. package/src/docker/docker.test.ts +28 -4
  51. package/src/docker/dockerfile.integration.test.ts +3 -1
  52. package/src/docker/index.ts +10 -0
  53. package/src/docker/stack-id.test.ts +86 -0
  54. package/src/docker/stack-id.ts +108 -0
  55. package/src/docker/stack.integration.test.ts +17 -8
  56. package/src/docker/types.ts +20 -1
  57. package/src/kernel/app.ts +39 -8
  58. package/src/kernel/boot-bind/store.test.ts +60 -0
  59. package/src/kernel/boot-bind/store.ts +142 -12
  60. package/src/kernel/boot.ts +28 -4
  61. package/src/mcp/server.ts +99 -57
  62. package/src/runtime/dev-request-log.test.ts +33 -0
  63. package/src/runtime/dev-request-log.ts +130 -0
  64. package/src/term.test.ts +98 -5
  65. package/src/term.ts +287 -6
  66. package/src/console/ui/dist/assets/style-Cnl7WLya.css +0 -3
@@ -76,8 +76,17 @@ export interface ElementRuntimes {
76
76
 
77
77
  /** Declarations + options consumed by {@link bootApplication}. */
78
78
  export interface BootOptions {
79
- /** Active environment (defaults to `dev`). */
79
+ /**
80
+ * Active environment (defaults to `dev`, or `stack` when
81
+ * {@link stack} / `OKE_STACK=1`).
82
+ */
80
83
  readonly env?: ConfigEnv;
84
+ /**
85
+ * Local-server mode (`oke dev -s` / `OKE_STACK=1`): force driver maps to the
86
+ * `stack` profile and prefer compose URLs (`.env.stack`).
87
+ * When unset, derived from `process.env.OKE_STACK`.
88
+ */
89
+ readonly stack?: boolean;
81
90
  /** Optional `oke.config.ts` document. */
82
91
  readonly config?: OkeConfig;
83
92
  /** Pre-built runtimes (skip construction when present). */
@@ -243,9 +252,24 @@ async function loadBind<T>(name: string): Promise<T> {
243
252
  * @param options - Declarations, config, pre-built runtimes
244
253
  */
245
254
  export async function bootApplication(
246
- options: BootOptions = {},
255
+ input: BootOptions = {},
247
256
  ): Promise<BootResult> {
248
- const env: ConfigEnv = options.env ?? "dev";
257
+ const stack =
258
+ input.stack === true ||
259
+ (input.stack !== false && process.env.OKE_STACK === "1");
260
+ // `-s` always selects the `stack` driver profile — not a mix of test/dev +
261
+ // prod store overrides (templates often pin `env: "test"` for harnesses).
262
+ const env: ConfigEnv = stack ? "stack" : (input.env ?? "dev");
263
+ let config = input.config;
264
+ if (config === undefined) {
265
+ try {
266
+ const { loadOkeConfig } = await import("../cli/load-config.ts");
267
+ config = (await loadOkeConfig(process.cwd())).config;
268
+ } catch {
269
+ config = undefined;
270
+ }
271
+ }
272
+ const options: BootOptions = { ...input, config, env, stack };
249
273
  const pre = options.elements ?? {};
250
274
  const now = options.now ?? (() => Date.now());
251
275
  const needs = resolveElementNeeds(options);
@@ -340,7 +364,7 @@ export async function bootApplication(
340
364
  let store = pre.store;
341
365
  if (needs.store) {
342
366
  if (!store) {
343
- store = storeBind!.bindStore(options, env, now);
367
+ store = storeBind!.bindStore(options, env, now, stack);
344
368
  } else {
345
369
  for (const decl of options.stores ?? []) {
346
370
  store.register?.(decl);
package/src/mcp/server.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  * adapters receive structured operator ids.
9
9
  */
10
10
 
11
+ import {
12
+ logDevRequest,
13
+ shouldLogDevRequests,
14
+ } from "../runtime/dev-request-log.ts";
11
15
  import {
12
16
  checkRequestSecurity,
13
17
  forbiddenResponse,
@@ -67,28 +71,48 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
67
71
  const transportSessions = new Set<string>();
68
72
 
69
73
  const fetch = async (request: Request): Promise<Response> => {
74
+ const started = performance.now();
75
+ let flowLabel: string | undefined;
76
+ const url = new URL(request.url);
77
+ const method = request.method.toUpperCase();
78
+
79
+ const respond = (response: Response): Response => {
80
+ if (shouldLogDevRequests()) {
81
+ logDevRequest({
82
+ surface: "MCP",
83
+ method,
84
+ path: url.pathname,
85
+ flow: flowLabel,
86
+ status: response.status,
87
+ ms: Math.round(performance.now() - started),
88
+ });
89
+ }
90
+ return response;
91
+ };
92
+
70
93
  const security = checkRequestSecurity(request, allowed);
71
- if (!security.ok) return forbiddenResponse(security.reason);
94
+ if (!security.ok) return respond(forbiddenResponse(security.reason));
72
95
 
73
- const url = new URL(request.url);
74
- if (request.method === "GET" && url.pathname === "/health") {
75
- return Response.json({ ok: true, surface: "mcp" });
96
+ if (method === "GET" && url.pathname === "/health") {
97
+ return respond(Response.json({ ok: true, surface: "mcp" }));
76
98
  }
77
99
 
78
- if (request.method !== "POST" || url.pathname !== "/mcp") {
79
- return new Response("Not Found", { status: 404 });
100
+ if (method !== "POST" || url.pathname !== "/mcp") {
101
+ return respond(new Response("Not Found", { status: 404 }));
80
102
  }
81
103
 
82
104
  const bearer = extractBearer(request.headers.get("authorization"));
83
105
  if (bearer === null) {
84
- return jsonRpcHttp(
85
- rpcError(
86
- null,
87
- RpcErrorCode.unauthorized,
88
- "Bearer token required",
89
- asData({ reason: "missing-token" }, "error"),
106
+ return respond(
107
+ jsonRpcHttp(
108
+ rpcError(
109
+ null,
110
+ RpcErrorCode.unauthorized,
111
+ "Bearer token required",
112
+ asData({ reason: "missing-token" }, "error"),
113
+ ),
114
+ 401,
90
115
  ),
91
- 401,
92
116
  );
93
117
  }
94
118
 
@@ -103,14 +127,16 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
103
127
  } catch (err) {
104
128
  const message =
105
129
  err instanceof SessionError ? err.message : "authentication failed";
106
- return jsonRpcHttp(
107
- rpcError(
108
- null,
109
- RpcErrorCode.unauthorized,
110
- message,
111
- asData({ reason: "auth-failed", message }, "error"),
130
+ return respond(
131
+ jsonRpcHttp(
132
+ rpcError(
133
+ null,
134
+ RpcErrorCode.unauthorized,
135
+ message,
136
+ asData({ reason: "auth-failed", message }, "error"),
137
+ ),
138
+ 401,
112
139
  ),
113
- 401,
114
140
  );
115
141
  }
116
142
 
@@ -122,22 +148,27 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
122
148
  try {
123
149
  body = await request.json();
124
150
  } catch {
125
- return jsonRpcHttp(
126
- rpcError(null, RpcErrorCode.parse, "invalid JSON body"),
127
- 400,
151
+ return respond(
152
+ jsonRpcHttp(
153
+ rpcError(null, RpcErrorCode.parse, "invalid JSON body"),
154
+ 400,
155
+ ),
128
156
  );
129
157
  }
130
158
 
131
159
  const parsed = parseJsonRpcRequest(body);
132
160
  if (!parsed.ok) {
133
- return jsonRpcHttp(
134
- rpcError(null, RpcErrorCode.invalidRequest, parsed.message),
135
- 400,
161
+ return respond(
162
+ jsonRpcHttp(
163
+ rpcError(null, RpcErrorCode.invalidRequest, parsed.message),
164
+ 400,
165
+ ),
136
166
  );
137
167
  }
138
168
 
139
169
  const { request: rpc } = parsed;
140
170
  const id: JsonRpcId = rpc.id;
171
+ flowLabel = rpc.method;
141
172
 
142
173
  switch (rpc.method) {
143
174
  case "initialize": {
@@ -149,10 +180,10 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
149
180
  serverInfo: { name: "okengine-mcp", version },
150
181
  sessionId,
151
182
  };
152
- return jsonRpcHttp(rpcSuccess(id, result));
183
+ return respond(jsonRpcHttp(rpcSuccess(id, result)));
153
184
  }
154
185
  case "ping":
155
- return jsonRpcHttp(rpcSuccess(id, { ok: true }));
186
+ return respond(jsonRpcHttp(rpcSuccess(id, { ok: true })));
156
187
  case "tools/list": {
157
188
  const listed = tools.listTools().map((t) => ({
158
189
  name: t.name,
@@ -163,22 +194,27 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
163
194
  destructiveHint: t.mutability === "write",
164
195
  },
165
196
  }));
166
- return jsonRpcHttp(
167
- rpcSuccess(id, {
168
- tools: listed,
169
- // Catalogue itself is data.
170
- _oke: asData({ count: listed.length }, "catalog"),
171
- }),
197
+ return respond(
198
+ jsonRpcHttp(
199
+ rpcSuccess(id, {
200
+ tools: listed,
201
+ // Catalogue itself is data.
202
+ _oke: asData({ count: listed.length }, "catalog"),
203
+ }),
204
+ ),
172
205
  );
173
206
  }
174
207
  case "tools/call": {
175
208
  const call = parseToolsCallParams(rpc.params);
176
209
  if (!call.ok) {
177
- return jsonRpcHttp(
178
- rpcError(id, RpcErrorCode.invalidParams, call.message),
179
- 400,
210
+ return respond(
211
+ jsonRpcHttp(
212
+ rpcError(id, RpcErrorCode.invalidParams, call.message),
213
+ 400,
214
+ ),
180
215
  );
181
216
  }
217
+ flowLabel = call.name;
182
218
  const result = await tools.callTool(
183
219
  requester,
184
220
  call.name,
@@ -193,34 +229,40 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
193
229
  : result.code === "not-found"
194
230
  ? RpcErrorCode.methodNotFound
195
231
  : RpcErrorCode.invalidParams;
196
- return jsonRpcHttp(
197
- rpcError(id, code, result.message, result.data),
198
- result.code === "unauthorized" ? 401 : 403,
232
+ return respond(
233
+ jsonRpcHttp(
234
+ rpcError(id, code, result.message, result.data),
235
+ result.code === "unauthorized" ? 401 : 403,
236
+ ),
199
237
  );
200
238
  }
201
239
  // MCP content blocks: text carries JSON of the inert envelope.
202
240
  // Agents must treat it as data (envelope.kind === "data").
203
- return jsonRpcHttp(
204
- rpcSuccess(id, {
205
- content: [
206
- {
207
- type: "text",
208
- text: JSON.stringify(result.data),
209
- },
210
- ],
211
- structuredContent: result.data,
212
- isError: false,
213
- }),
241
+ return respond(
242
+ jsonRpcHttp(
243
+ rpcSuccess(id, {
244
+ content: [
245
+ {
246
+ type: "text",
247
+ text: JSON.stringify(result.data),
248
+ },
249
+ ],
250
+ structuredContent: result.data,
251
+ isError: false,
252
+ }),
253
+ ),
214
254
  );
215
255
  }
216
256
  default:
217
- return jsonRpcHttp(
218
- rpcError(
219
- id,
220
- RpcErrorCode.methodNotFound,
221
- `method not found: ${rpc.method}`,
257
+ return respond(
258
+ jsonRpcHttp(
259
+ rpcError(
260
+ id,
261
+ RpcErrorCode.methodNotFound,
262
+ `method not found: ${rpc.method}`,
263
+ ),
264
+ 404,
222
265
  ),
223
- 404,
224
266
  );
225
267
  }
226
268
  };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Dev request log gating and silence rules.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ isSilentDevRequest,
8
+ shouldLogDevRequests,
9
+ } from "./dev-request-log.ts";
10
+
11
+ describe("dev-request-log", () => {
12
+ const prev = process.env.OKE_DEV_REQUEST_LOG;
13
+
14
+ afterEach(() => {
15
+ if (prev === undefined) delete process.env.OKE_DEV_REQUEST_LOG;
16
+ else process.env.OKE_DEV_REQUEST_LOG = prev;
17
+ });
18
+
19
+ test("shouldLogDevRequests follows OKE_DEV_REQUEST_LOG", () => {
20
+ process.env.OKE_DEV_REQUEST_LOG = "1";
21
+ expect(shouldLogDevRequests()).toBe(true);
22
+ process.env.OKE_DEV_REQUEST_LOG = "0";
23
+ expect(shouldLogDevRequests()).toBe(false);
24
+ });
25
+
26
+ test("isSilentDevRequest skips live, health, assets, client.json", () => {
27
+ expect(isSilentDevRequest("GET", "/console/live")).toBe(true);
28
+ expect(isSilentDevRequest("GET", "/health")).toBe(true);
29
+ expect(isSilentDevRequest("GET", "/assets/index.js")).toBe(true);
30
+ expect(isSilentDevRequest("GET", "/_oke/client.json")).toBe(true);
31
+ expect(isSilentDevRequest("POST", "/console/flows")).toBe(false);
32
+ });
33
+ });
@@ -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
  });