okengine 0.9.1 → 0.10.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.
Files changed (166) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +97 -9
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/providers/azure-redis.mdx +85 -0
  14. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  15. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  16. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  17. package/site/content/docs/providers/elasticache.mdx +92 -0
  18. package/site/content/docs/providers/index.mdx +101 -0
  19. package/site/content/docs/providers/memorystore.mdx +89 -0
  20. package/site/content/docs/providers/meta.json +20 -0
  21. package/site/content/docs/providers/neon.mdx +115 -0
  22. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  23. package/site/content/docs/providers/supabase.mdx +117 -0
  24. package/site/content/docs/providers/upstash.mdx +91 -0
  25. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  26. package/site/content/docs/recipes/caddy.mdx +128 -0
  27. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  28. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  29. package/site/content/docs/recipes/index.mdx +146 -0
  30. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  31. package/site/content/docs/recipes/mailpit.mdx +136 -0
  32. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  33. package/site/content/docs/recipes/meta.json +31 -0
  34. package/site/content/docs/recipes/ollama.mdx +142 -0
  35. package/site/content/docs/recipes/openbao.mdx +141 -0
  36. package/site/content/docs/recipes/pgdog.mdx +137 -0
  37. package/site/content/docs/recipes/postgres.mdx +136 -0
  38. package/site/content/docs/recipes/redis.mdx +130 -0
  39. package/site/content/docs/recipes/rustfs.mdx +148 -0
  40. package/site/content/docs/recipes/sglang.mdx +105 -0
  41. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  42. package/site/content/docs/recipes/timescale.mdx +129 -0
  43. package/site/content/docs/recipes/traefik.mdx +136 -0
  44. package/site/content/docs/recipes/valkey.mdx +121 -0
  45. package/site/content/docs/recipes/vllm.mdx +106 -0
  46. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  47. package/site/content/docs/reference/cli.md +42 -6
  48. package/site/content/docs/reference/configuration.mdx +5 -3
  49. package/site/content/docs/reference/environment-variables.mdx +12 -12
  50. package/site/content/docs/reference/fx.mdx +38 -4
  51. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  52. package/src/cli/ai-setup/apply.ts +65 -23
  53. package/src/cli/ai-setup/catalog.ts +1316 -35
  54. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  55. package/src/cli/ai-setup/index.ts +44 -7
  56. package/src/cli/ai-setup/prompts.ts +430 -486
  57. package/src/cli/ai-setup/recommend.test.ts +150 -50
  58. package/src/cli/ai-setup/recommend.ts +118 -101
  59. package/src/cli/ask-seed.test.ts +68 -0
  60. package/src/cli/ask-seed.ts +79 -0
  61. package/src/cli/db-seed-redact.test.ts +25 -0
  62. package/src/cli/db-seed.ts +36 -7
  63. package/src/cli/db.test.ts +1 -1
  64. package/src/cli/db.ts +2 -2
  65. package/src/cli/dev-controls.test.ts +107 -0
  66. package/src/cli/dev-controls.ts +354 -0
  67. package/src/cli/dev-schema-sync.ts +12 -4
  68. package/src/cli/dev.test.ts +50 -30
  69. package/src/cli/dev.ts +607 -66
  70. package/src/cli/drizzle-env.test.ts +40 -6
  71. package/src/cli/drizzle-env.ts +22 -1
  72. package/src/cli/ensure-drizzle-config.ts +6 -4
  73. package/src/cli/hero-meta.test.ts +13 -1
  74. package/src/cli/hero-meta.ts +28 -6
  75. package/src/cli/index.ts +5 -0
  76. package/src/cli/registry.ts +24 -0
  77. package/src/cli/replay.test.ts +105 -0
  78. package/src/cli/replay.ts +206 -0
  79. package/src/compiler/extract.ts +3 -3
  80. package/src/config/index.ts +3 -3
  81. package/src/console/server/claim.ts +3 -1
  82. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  83. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  84. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  85. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  86. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  87. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  88. package/src/console/ui/dist/index.html +1 -1
  89. package/src/console/ui/overview/slo.ts +31 -3
  90. package/src/console/ui/overview/types.ts +6 -0
  91. package/src/console/ui/runs/errors.test.ts +53 -0
  92. package/src/console/ui/runs/errors.ts +88 -0
  93. package/src/console/ui/runs/index.ts +8 -0
  94. package/src/console/ui/runs/search.ts +16 -0
  95. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  96. package/src/docker/ai-model-status.test.ts +101 -0
  97. package/src/docker/ai-model-status.ts +276 -0
  98. package/src/docker/compose-health.test.ts +115 -0
  99. package/src/docker/compose-health.ts +234 -0
  100. package/src/docker/compose.ts +24 -8
  101. package/src/docker/derive.ts +35 -9
  102. package/src/docker/docker.test.ts +304 -12
  103. package/src/docker/helpers.ts +56 -0
  104. package/src/docker/index.ts +52 -0
  105. package/src/docker/ollama-pull.ts +232 -0
  106. package/src/docker/recipes/cockroach.ts +24 -0
  107. package/src/docker/recipes/index.ts +32 -0
  108. package/src/docker/recipes/llama-cpp.ts +273 -0
  109. package/src/docker/recipes/ollama.ts +19 -23
  110. package/src/docker/recipes/pgdog.ts +4 -1
  111. package/src/docker/recipes/postgres.ts +5 -18
  112. package/src/docker/recipes/sglang.ts +55 -0
  113. package/src/docker/recipes/supabase.ts +21 -0
  114. package/src/docker/recipes/timescale.ts +18 -0
  115. package/src/docker/recipes/vllm.ts +44 -0
  116. package/src/docker/recipes/yugabyte.ts +23 -0
  117. package/src/docker/stack-id.test.ts +22 -0
  118. package/src/docker/stack-id.ts +38 -4
  119. package/src/docker/types.ts +15 -0
  120. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  121. package/src/drivers/signal-engine.ts +10 -0
  122. package/src/drivers/signal-postgres.ts +24 -2
  123. package/src/drivers/signal-types.ts +10 -0
  124. package/src/elements/channel/fallback.test.ts +91 -0
  125. package/src/elements/channel/hard-bounce.test.ts +82 -0
  126. package/src/elements/channel/injection.test.ts +153 -0
  127. package/src/elements/channel/locale-ar.test.ts +102 -0
  128. package/src/elements/channel/otp-delivery.ts +5 -2
  129. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  130. package/src/elements/channel/runtime.ts +24 -1
  131. package/src/elements/channel/test-helpers.ts +60 -0
  132. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  133. package/src/elements/store/cache.ts +1 -1
  134. package/src/elements/store/emit-drizzle.ts +43 -9
  135. package/src/elements/store/runtime.ts +1 -1
  136. package/src/elements/store/schema-decl.test.ts +2 -0
  137. package/src/elements/store/schema-decl.ts +15 -0
  138. package/src/elements/store/seed.ts +1 -1
  139. package/src/index.ts +1 -0
  140. package/src/kernel/app.ts +117 -10
  141. package/src/kernel/boot-bind/signal.ts +6 -2
  142. package/src/kernel/boot-bind/store.ts +2 -1
  143. package/src/kernel/boot.test.ts +6 -5
  144. package/src/kernel/boot.ts +8 -3
  145. package/src/kernel/compensate.test.ts +131 -0
  146. package/src/kernel/correlation.test.ts +108 -0
  147. package/src/kernel/effect-timing.test.ts +82 -0
  148. package/src/kernel/flow.ts +38 -0
  149. package/src/kernel/fx-runs.test.ts +49 -0
  150. package/src/kernel/fx.test.ts +14 -0
  151. package/src/kernel/fx.ts +111 -1
  152. package/src/manifest/types.ts +8 -2
  153. package/src/runs/collect.ts +3 -0
  154. package/src/runs/export-otlp.ts +64 -0
  155. package/src/runs/index.ts +16 -0
  156. package/src/runs/parquet.ts +9 -0
  157. package/src/runs/types.ts +5 -0
  158. package/src/runs/window.test.ts +68 -0
  159. package/src/runs/window.ts +148 -0
  160. package/src/runtime/boot-warn.test.ts +45 -0
  161. package/src/runtime/boot-warn.ts +17 -0
  162. package/src/runtime/dev-request-log.ts +5 -5
  163. package/src/term.test.ts +95 -10
  164. package/src/term.ts +369 -14
  165. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  166. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -19,7 +19,7 @@ import {
19
19
  } from "../elements/store/load-plugin-tables.ts";
20
20
  import { createFx, type Fx } from "../kernel/fx.ts";
21
21
  import type { OkeApp } from "../kernel/app.ts";
22
- import { resolveDrizzleKitEnv } from "./drizzle-env.ts";
22
+ import { applyComposeEnvToProcess, resolveDrizzleKitEnv } from "./drizzle-env.ts";
23
23
  import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts";
24
24
  import { loadOkeConfig } from "./load-config.ts";
25
25
  import { resolveDevSqlEnv } from "./resolve-dev-sql-env.ts";
@@ -38,7 +38,7 @@ export interface SeedOptions {
38
38
  readonly env?: ConfigEnv;
39
39
  /** Skip interactive confirm (`--force`). */
40
40
  readonly force?: boolean;
41
- /** Override seed module path (default `src/seed/index.ts`). */
41
+ /** Override seed module path (default `src/db/seed/index.ts`). */
42
42
  readonly seedPath?: string;
43
43
  /** Override app entry for boot. */
44
44
  readonly entry?: string;
@@ -77,13 +77,17 @@ export function isSeedDef(value: unknown): value is SeedDef {
77
77
  /**
78
78
  * Redact password from a connection URL for display.
79
79
  *
80
+ * Builds the string by hand — assigning unicode bullets to `URL.password`
81
+ * then calling `toString()` percent-encodes them (`%E2%80%A2`).
82
+ *
80
83
  * @param raw - Connection URL or file path
81
84
  */
82
85
  export function redactConnectionTarget(raw: string): string {
83
86
  try {
84
87
  const u = new URL(raw);
85
- if (u.password) u.password = "••••";
86
- return u.toString();
88
+ if (!u.password) return u.toString();
89
+ const auth = u.username ? `${u.username}:••••@` : "••••@";
90
+ return `${u.protocol}//${auth}${u.host}${u.pathname}${u.search}${u.hash}`;
87
91
  } catch {
88
92
  return raw;
89
93
  }
@@ -158,14 +162,35 @@ export async function executeSeedDef(
158
162
  }
159
163
  }
160
164
 
165
+ /** Default seed module (create-oke `src/db` layout). */
166
+ export const DEFAULT_SEED_REL = "src/db/seed/index.ts";
167
+
168
+ /** Legacy seed module path. */
169
+ export const LEGACY_SEED_REL = "src/seed/index.ts";
170
+
161
171
  /**
162
- * Load `src/seed/index.ts` (or override) and return its SeedDef.
172
+ * Resolve the seed module path (explicit new default legacy).
173
+ *
174
+ * @param cwd - Project root
175
+ * @param seedPath - Optional override
176
+ */
177
+ export async function resolveSeedModulePath(cwd: string, seedPath?: string): Promise<string> {
178
+ if (seedPath) return resolve(cwd, seedPath);
179
+ for (const rel of [DEFAULT_SEED_REL, LEGACY_SEED_REL]) {
180
+ const abs = resolve(cwd, rel);
181
+ if (await Bun.file(abs).exists()) return abs;
182
+ }
183
+ return resolve(cwd, DEFAULT_SEED_REL);
184
+ }
185
+
186
+ /**
187
+ * Load `src/db/seed/index.ts` (or legacy / override) and return its SeedDef.
163
188
  *
164
189
  * @param cwd - Project root
165
190
  * @param seedPath - Relative or absolute path
166
191
  */
167
192
  export async function loadSeedDef(cwd: string, seedPath?: string): Promise<SeedDef> {
168
- const abs = resolve(cwd, seedPath ?? "src/seed/index.ts");
193
+ const abs = await resolveSeedModulePath(cwd, seedPath);
169
194
  if (!(await Bun.file(abs).exists())) {
170
195
  throw new Error(`oke db seed: no seed module at ${abs}`);
171
196
  }
@@ -193,6 +218,10 @@ export async function bootSeedFx(
193
218
  entry?: string,
194
219
  config?: OkeConfig | null,
195
220
  ): Promise<{ readonly fx: Fx; readonly stop: () => Promise<void> }> {
221
+ if (env === "docker") {
222
+ await applyComposeEnvToProcess(cwd);
223
+ if (process.env.OKE_DOCKER === undefined) process.env.OKE_DOCKER = "1";
224
+ }
196
225
  const entryAbs = await resolveAppEntryForPluginTables(cwd, entry ?? config?.db?.entry);
197
226
  if (!entryAbs) {
198
227
  throw new Error(
@@ -208,7 +237,7 @@ export async function bootSeedFx(
208
237
  const storeRuntime = app.elements?.store ?? app.bootResult?.store;
209
238
  if (!storeRuntime) {
210
239
  throw new Error(
211
- "oke db seed: app booted without a store runtime — ensure stores are registered on the app (e.g. Object.assign(app.$options, { stores: [db] }))",
240
+ "oke db seed: app booted without a store runtime — ensure stores are registered on the app (e.g. oke({ name, stores: [db] }))",
212
241
  );
213
242
  }
214
243
  const fx = createFx({
@@ -242,7 +242,7 @@ export const app = oke({ name: "plugin-only" }).plug(
242
242
  const emitted = await emitAbstractSchemaPrestep(dir, loaded.config, () => {}, "local");
243
243
  expect(emitted).toBe(true);
244
244
 
245
- const generated = await readFile(join(dir, "src", "schema.generated.ts"), "utf8");
245
+ const generated = await readFile(join(dir, "src", "db", "schema.generated.ts"), "utf8");
246
246
  expect(generated).toContain('sqliteTable("metrics_daily"');
247
247
  expect(generated).toContain('integer("hits")');
248
248
  });
package/src/cli/db.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * plus explicit seed runs.
4
4
  *
5
5
  * Distinct from `oke schema generate` (core/plugin stub tables).
6
- * When abstract decls exist (`src/schema.decl.ts`), emits Drizzle first —
6
+ * When abstract decls exist (`src/db/schema.decl.ts`), emits Drizzle first —
7
7
  * not a third schema CLI.
8
8
  */
9
9
 
@@ -448,7 +448,7 @@ export async function dbCli(args: readonly string[]): Promise<number> {
448
448
  console.log(`oke db push|generate|migrate|seed [--config|-c] [--env name] [--force]
449
449
 
450
450
  Domain schema sync via drizzle-kit, plus explicit seed.
451
- When src/schema.decl.ts and/or a plugged app entry exists, emits
451
+ When src/db/schema.decl.ts and/or a plugged app entry exists, emits
452
452
  schema.generated.ts from store.schema.table + live plugin .table()
453
453
  contributions — then runs drizzle-kit.
454
454
  Hand-written src/schema.ts remains supported (emit skipped if nothing to emit).
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ controlServicesFromStack,
4
+ createDevControlDispatcher,
5
+ formatDevControlsHelp,
6
+ formatDevControlsHint,
7
+ formatDevControlsServiceList,
8
+ parseDevControlKey,
9
+ } from "./dev-controls.ts";
10
+
11
+ describe("dev-controls", () => {
12
+ test("parseDevControlKey maps raw keys", () => {
13
+ expect(parseDevControlKey("q")).toBe("q");
14
+ expect(parseDevControlKey("\u0003")).toBe("q");
15
+ expect(parseDevControlKey("?")).toBe("?");
16
+ expect(parseDevControlKey("h")).toBe("?");
17
+ expect(parseDevControlKey("c")).toBe("c");
18
+ expect(parseDevControlKey("l")).toBe("l");
19
+ expect(parseDevControlKey("u")).toBe("u");
20
+ expect(parseDevControlKey("x")).toBe("x");
21
+ expect(parseDevControlKey("r")).toBe("r");
22
+ expect(parseDevControlKey("3")).toBe("3");
23
+ expect(parseDevControlKey("\u001b")).toBe("esc");
24
+ expect(parseDevControlKey("z")).toBeNull();
25
+ });
26
+
27
+ test("format hint and help are scannable", () => {
28
+ expect(formatDevControlsHint(false)).toContain("keys");
29
+ expect(formatDevControlsHint(false)).toContain("refresh");
30
+ expect(formatDevControlsHint(false)).toContain("quit");
31
+ expect(formatDevControlsHelp(false)).toContain("select service");
32
+ expect(formatDevControlsHelp(false)).toContain("refresh");
33
+ expect(formatDevControlsHelp(false)).not.toMatch(/\u001b\[/);
34
+ });
35
+
36
+ test("controlServicesFromStack dedupes by serviceName", () => {
37
+ const list = controlServicesFromStack([
38
+ { label: "ai", serviceName: "ai" },
39
+ { label: "mail", serviceName: "channel-email" },
40
+ { label: "mail-ui", serviceName: "channel-email" },
41
+ { label: "postgres", serviceName: "store-sql" },
42
+ ]);
43
+ expect(list.map((s) => s.serviceName)).toEqual(["ai", "channel-email", "store-sql"]);
44
+ });
45
+
46
+ test("formatDevControlsServiceList numbers rows", () => {
47
+ const out = formatDevControlsServiceList(
48
+ [
49
+ { label: "ai", serviceName: "ai" },
50
+ { label: "postgres", serviceName: "store-sql" },
51
+ ],
52
+ (name) => (name === "ai" ? "error" : "ready"),
53
+ false,
54
+ 1,
55
+ );
56
+ expect(out).toContain("1");
57
+ expect(out).toContain("ai");
58
+ expect(out).toContain("postgres");
59
+ expect(out).toContain(">");
60
+ });
61
+
62
+ test("dispatcher select → stop → up stack → quit", async () => {
63
+ const calls: string[] = [];
64
+ let quit = false;
65
+ let settled = 0;
66
+ let refreshed = 0;
67
+ let panels = 0;
68
+ const d = createDevControlDispatcher({
69
+ write: () => {},
70
+ onQuit: () => {
71
+ quit = true;
72
+ },
73
+ onRefresh: () => {
74
+ refreshed += 1;
75
+ },
76
+ onShowPanel: () => {
77
+ panels += 1;
78
+ },
79
+ onComposeSettled: () => {
80
+ settled += 1;
81
+ },
82
+ services: () => [
83
+ { label: "ai", serviceName: "ai" },
84
+ { label: "postgres", serviceName: "store-sql" },
85
+ ],
86
+ composeAction: async (action, names) => {
87
+ calls.push(`${action}:${names.join(",") || "*"}`);
88
+ },
89
+ });
90
+ d.handleKey("l");
91
+ await Bun.sleep(10);
92
+ d.handleKey("1");
93
+ d.handleKey("x");
94
+ await Bun.sleep(10);
95
+ d.handleKey("u");
96
+ await Bun.sleep(10);
97
+ d.handleKey("c");
98
+ await Bun.sleep(10);
99
+ d.handleKey("q");
100
+ expect(calls).toEqual(["stop:ai", "up:*"]);
101
+ expect(settled).toBe(2);
102
+ expect(refreshed).toBe(1);
103
+ expect(panels).toBe(1);
104
+ expect(quit).toBe(true);
105
+ d.stop();
106
+ });
107
+ });
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Keyboard controls for a live `oke dev` session (TTY raw mode).
3
+ *
4
+ * Keys: `?` help · `q` quit · `c` refresh · `l` list · `u` up all ·
5
+ * `x` stop all · `1`–`9` select service then `u`/`x`/`r`.
6
+ *
7
+ * Compose health / model phase update the status board above Logs — not
8
+ * the log stream. `c` clears the TTY and reprints the latest board.
9
+ */
10
+
11
+ import {
12
+ formatStatusDot,
13
+ formatStatusLine,
14
+ termColorEnabled,
15
+ termStyle,
16
+ type DevStatus,
17
+ } from "../term.ts";
18
+
19
+ /** One Docker service row addressable by a digit key. */
20
+ export type DevControlService = {
21
+ /** Human label (`ai`, `postgres`). */
22
+ readonly label: string;
23
+ /** Compose service name (`ai`, `store-sql`). */
24
+ readonly serviceName: string;
25
+ };
26
+
27
+ /** Compose action against zero or more service names. */
28
+ export type DevComposeControlAction = "up" | "stop" | "restart";
29
+
30
+ /** Options for {@link startDevControls}. */
31
+ export type StartDevControlsOptions = {
32
+ readonly write: (text: string) => void;
33
+ /** Called on `q` / Ctrl+C. */
34
+ readonly onQuit: () => void;
35
+ /**
36
+ * Clear the TTY and reprint chrome + latest status board (drops log noise).
37
+ */
38
+ readonly onRefresh?: () => void | Promise<void>;
39
+ /**
40
+ * Show help / services in a clean pane (typically refresh chrome first so
41
+ * the panel is not interleaved with request logs).
42
+ *
43
+ * @param body - Formatted panel text
44
+ */
45
+ readonly onShowPanel?: (body: string) => void | Promise<void>;
46
+ /**
47
+ * After a compose up/stop/restart — sync health into the board (no log lines).
48
+ */
49
+ readonly onComposeSettled?: () => void | Promise<void>;
50
+ /** Live service catalogue (unique compose services). */
51
+ readonly services: () => readonly DevControlService[];
52
+ /**
53
+ * Run a compose control action.
54
+ *
55
+ * @param action - up / stop / restart
56
+ * @param serviceNames - Empty → whole stack
57
+ */
58
+ readonly composeAction: (
59
+ action: DevComposeControlAction,
60
+ serviceNames: readonly string[],
61
+ ) => Promise<void>;
62
+ /** Optional live ● status for list view. */
63
+ readonly statusOf?: (serviceName: string) => DevStatus | undefined;
64
+ readonly stdin?: NodeJS.ReadStream;
65
+ readonly isTTY?: boolean;
66
+ readonly color?: boolean;
67
+ };
68
+
69
+ /** Handle returned by {@link startDevControls}. */
70
+ export type DevControlsHandle = {
71
+ readonly stop: () => void;
72
+ };
73
+
74
+ /**
75
+ * One-line keys hint under Logs.
76
+ *
77
+ * @param color - Color on/off
78
+ */
79
+ export function formatDevControlsHint(color: boolean = termColorEnabled()): string {
80
+ const s = termStyle(color);
81
+ return (
82
+ `${s.dim}│${s.reset} ${s.dim}keys${s.reset} ` +
83
+ `${s.cyan}?${s.reset} help · ` +
84
+ `${s.cyan}c${s.reset} refresh · ` +
85
+ `${s.cyan}q${s.reset} quit · ` +
86
+ `${s.cyan}l${s.reset} services · ` +
87
+ `${s.cyan}u${s.reset} up · ` +
88
+ `${s.cyan}x${s.reset} stop` +
89
+ `\n`
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Full help block for `?` / `h`.
95
+ *
96
+ * @param color - Color on/off
97
+ */
98
+ export function formatDevControlsHelp(color: boolean = termColorEnabled()): string {
99
+ const s = termStyle(color);
100
+ const bar = `${s.dim}│${s.reset}`;
101
+ const k = (key: string, meaning: string) =>
102
+ `${bar} ${s.cyan}${key.padEnd(6)}${s.reset} ${s.dim}${meaning}${s.reset}`;
103
+ return [
104
+ "",
105
+ `${s.green}◇${s.reset} ${s.bold}Keys${s.reset}`,
106
+ k("?", "help"),
107
+ k("c", "refresh — clear logs, show latest ●"),
108
+ k("q", "quit oke dev"),
109
+ k("l", "list docker services"),
110
+ k("u", "compose up -d (all)"),
111
+ k("x", "compose stop (all)"),
112
+ k("1-9", "select service"),
113
+ k("… u", "start selected"),
114
+ k("… x", "stop selected"),
115
+ k("… r", "restart selected"),
116
+ k("esc", "clear selection"),
117
+ bar,
118
+ "",
119
+ ].join("\n");
120
+ }
121
+
122
+ /**
123
+ * Format a numbered service list for `l`.
124
+ *
125
+ * @param services - Catalogue
126
+ * @param statusOf - Optional ● lookup
127
+ * @param color - Color on/off
128
+ * @param selected - Highlighted 1-based index
129
+ */
130
+ export function formatDevControlsServiceList(
131
+ services: readonly DevControlService[],
132
+ statusOf?: (serviceName: string) => DevStatus | undefined,
133
+ color: boolean = termColorEnabled(),
134
+ selected?: number,
135
+ ): string {
136
+ const s = termStyle(color);
137
+ const bar = `${s.dim}│${s.reset}`;
138
+ if (services.length === 0) {
139
+ return `${bar} ${s.dim}no docker services${s.reset}\n`;
140
+ }
141
+ const lines = [
142
+ "",
143
+ `${s.green}◇${s.reset} ${s.bold}Services${s.reset} ${s.dim}1-9 then u/x/r${s.reset}`,
144
+ ];
145
+ const max = Math.min(9, services.length);
146
+ for (let i = 0; i < max; i++) {
147
+ const svc = services[i]!;
148
+ const st = statusOf?.(svc.serviceName) ?? "pending";
149
+ const dot = formatStatusDot(st, color);
150
+ const num = `${i + 1}`;
151
+ const mark = selected === i + 1 ? `${s.cyan}>${s.reset}` : " ";
152
+ lines.push(
153
+ `${bar} ${mark}${s.cyan}${num}${s.reset} ${dot} ${svc.label.padEnd(12)} ${s.dim}${svc.serviceName}${s.reset}`,
154
+ );
155
+ }
156
+ if (services.length > 9) {
157
+ lines.push(`${bar} ${s.dim}… ${services.length - 9} more (first 9 only)${s.reset}`);
158
+ }
159
+ lines.push(bar);
160
+ lines.push("");
161
+ return `${lines.join("\n")}\n`;
162
+ }
163
+
164
+ /**
165
+ * Parse one raw stdin chunk into a control key (tests / non-TTY inject).
166
+ *
167
+ * @param chunk - Raw bytes/string
168
+ */
169
+ export function parseDevControlKey(chunk: string): string | null {
170
+ if (chunk.length === 0) return null;
171
+ if (chunk === "\u0003") return "q"; // Ctrl+C
172
+ if (chunk === "\u001b") return "esc";
173
+ const ch = chunk[0]!;
174
+ if (ch === "?" || ch === "h" || ch === "H") return "?";
175
+ if (ch === "c" || ch === "C") return "c";
176
+ if (ch === "q" || ch === "Q") return "q";
177
+ if (ch === "l" || ch === "L") return "l";
178
+ if (ch === "u" || ch === "U") return "u";
179
+ if (ch === "x" || ch === "X") return "x";
180
+ if (ch === "r" || ch === "R") return "r";
181
+ if (ch >= "1" && ch <= "9") return ch;
182
+ return null;
183
+ }
184
+
185
+ /** Key dispatcher for tests and {@link startDevControls}. */
186
+ export type DevControlDispatcher = {
187
+ readonly handleKey: (key: string) => void;
188
+ readonly stop: () => void;
189
+ };
190
+
191
+ /**
192
+ * Pure key dispatcher (no stdin) — used by tests and the TTY listener.
193
+ *
194
+ * @param opts - Quit / compose / services
195
+ */
196
+ export function createDevControlDispatcher(opts: StartDevControlsOptions): DevControlDispatcher {
197
+ const color = opts.color ?? termColorEnabled();
198
+ let selected: number | null = null;
199
+ let busy = false;
200
+ let stopped = false;
201
+
202
+ const runAction = async (
203
+ action: DevComposeControlAction,
204
+ serviceNames: readonly string[],
205
+ _label: string,
206
+ ): Promise<void> => {
207
+ if (busy) return;
208
+ busy = true;
209
+ try {
210
+ await opts.composeAction(action, serviceNames);
211
+ await opts.onComposeSettled?.();
212
+ } catch (err) {
213
+ const msg = err instanceof Error ? err.message : String(err);
214
+ opts.write(formatStatusLine(`${action} failed — ${msg}`, color, "error"));
215
+ } finally {
216
+ busy = false;
217
+ }
218
+ };
219
+
220
+ return {
221
+ handleKey(key: string): void {
222
+ if (stopped) return;
223
+ if (key === "q") {
224
+ opts.onQuit();
225
+ return;
226
+ }
227
+ if (key === "c") {
228
+ void Promise.resolve(opts.onRefresh?.()).catch((err: unknown) => {
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ opts.write(formatStatusLine(`refresh failed — ${msg}`, color, "error"));
231
+ });
232
+ return;
233
+ }
234
+ if (key === "?") {
235
+ const body = formatDevControlsHelp(color);
236
+ void Promise.resolve(opts.onShowPanel?.(body) ?? opts.write(body));
237
+ return;
238
+ }
239
+ if (key === "esc") {
240
+ selected = null;
241
+ return;
242
+ }
243
+ if (key === "l") {
244
+ const body = formatDevControlsServiceList(
245
+ opts.services(),
246
+ opts.statusOf,
247
+ color,
248
+ selected ?? undefined,
249
+ );
250
+ void Promise.resolve(opts.onShowPanel?.(body) ?? opts.write(body));
251
+ return;
252
+ }
253
+ if (key >= "1" && key <= "9") {
254
+ const n = Number(key);
255
+ const list = opts.services();
256
+ if (n < 1 || n > list.length || n > 9) {
257
+ opts.write(formatStatusLine(`no service ${n} — press l to list`, color, "pending"));
258
+ return;
259
+ }
260
+ selected = n;
261
+ const svc = list[n - 1]!;
262
+ opts.write(
263
+ formatStatusLine(
264
+ `selected ${n} ${svc.label} — u start · x stop · r restart · esc`,
265
+ color,
266
+ opts.statusOf?.(svc.serviceName) ?? "pending",
267
+ ),
268
+ );
269
+ return;
270
+ }
271
+ if (key === "u" || key === "x" || key === "r") {
272
+ const action: DevComposeControlAction =
273
+ key === "u" ? "up" : key === "x" ? "stop" : "restart";
274
+ if (selected !== null) {
275
+ const list = opts.services();
276
+ const svc = list[selected - 1];
277
+ if (!svc) {
278
+ selected = null;
279
+ opts.write(formatStatusLine("selection expired — press l", color, "pending"));
280
+ return;
281
+ }
282
+ selected = null;
283
+ void runAction(action, [svc.serviceName], svc.label);
284
+ return;
285
+ }
286
+ if (key === "r") {
287
+ opts.write(formatStatusLine("select a service first (l then 1-9)", color, "pending"));
288
+ return;
289
+ }
290
+ void runAction(action, [], "stack");
291
+ }
292
+ },
293
+ stop() {
294
+ stopped = true;
295
+ },
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Start raw-mode keyboard controls. No-op when stdin is not a TTY.
301
+ *
302
+ * @param opts - Quit / compose / services
303
+ */
304
+ export function startDevControls(opts: StartDevControlsOptions): DevControlsHandle {
305
+ const stdin = opts.stdin ?? process.stdin;
306
+ const isTTY = opts.isTTY ?? stdin.isTTY === true;
307
+ const dispatcher = createDevControlDispatcher(opts);
308
+
309
+ if (!isTTY || typeof stdin.setRawMode !== "function") {
310
+ return { stop: () => dispatcher.stop() };
311
+ }
312
+
313
+ const onData = (buf: string | Buffer): void => {
314
+ const chunk = typeof buf === "string" ? buf : buf.toString("utf8");
315
+ const key = parseDevControlKey(chunk);
316
+ if (key) dispatcher.handleKey(key);
317
+ };
318
+
319
+ stdin.setRawMode(true);
320
+ stdin.resume();
321
+ stdin.setEncoding("utf8");
322
+ stdin.on("data", onData);
323
+
324
+ return {
325
+ stop() {
326
+ dispatcher.stop();
327
+ stdin.off("data", onData);
328
+ try {
329
+ if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
330
+ } catch {
331
+ // already closed
332
+ }
333
+ },
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Dedupe stack summary rows into control services (first label wins).
339
+ *
340
+ * @param rows - Stack summary services with compose names
341
+ */
342
+ export function controlServicesFromStack(
343
+ rows: readonly { readonly label: string; readonly serviceName?: string }[],
344
+ ): DevControlService[] {
345
+ const seen = new Set<string>();
346
+ const out: DevControlService[] = [];
347
+ for (const row of rows) {
348
+ const name = row.serviceName?.trim();
349
+ if (!name || seen.has(name)) continue;
350
+ seen.add(name);
351
+ out.push({ label: row.label, serviceName: name });
352
+ }
353
+ return out;
354
+ }
@@ -30,6 +30,11 @@ export interface DevSchemaSyncOptions {
30
30
  readonly pushFn?: (cwd: string, env: ConfigEnv) => Promise<number>;
31
31
  /** Derive docker infra without running compose (tests). */
32
32
  readonly dryRun?: boolean;
33
+ /**
34
+ * Skip the “compose env ready” line — `oke dev -d` already brought the
35
+ * stack up and prints {@link import("../term.ts").formatStackSummary}.
36
+ */
37
+ readonly quietComposeReady?: boolean;
33
38
  }
34
39
 
35
40
  /** Outcome of {@link syncDevSchema}. */
@@ -71,6 +76,7 @@ async function ensureDockerStack(
71
76
  cwd: string,
72
77
  config: OkeConfig,
73
78
  write: (text: string) => void,
79
+ quietComposeReady = false,
74
80
  ): Promise<void> {
75
81
  const images = resolveImages(config);
76
82
  if (Object.keys(images).length === 0) {
@@ -95,9 +101,11 @@ async function ensureDockerStack(
95
101
  host: "127.0.0.1",
96
102
  });
97
103
  await writeDerivedFiles(derived, resolve(cwd, DEFAULT_DOCKER_DIR), { writeStackEnv: true });
98
- write(
99
- "oke: docker compose env ready under docker/.env.docker (start containers with `oke dev -d` or docker compose up)\n",
100
- );
104
+ if (!quietComposeReady) {
105
+ write(
106
+ "oke: docker compose env ready under docker/.env.docker (start containers with `oke dev -d` or docker compose up)\n",
107
+ );
108
+ }
101
109
  }
102
110
 
103
111
  /**
@@ -126,7 +134,7 @@ export async function syncDevSchema(
126
134
  if (!config) {
127
135
  throw new Error("docker mode: oke.config.ts not found");
128
136
  }
129
- await ensureDockerStack(cwd, config, write);
137
+ await ensureDockerStack(cwd, config, write, options.quietComposeReady === true);
130
138
  }
131
139
 
132
140
  const push =