okengine 0.2.3 → 0.2.5

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,85 @@
1
+ /**
2
+ * Hero snapshot — profile, elements, encode/decode.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { OkeConfig } from "../config/index.ts";
7
+ import {
8
+ buildDevHeroSnapshot,
9
+ decodeHeroSnapshot,
10
+ encodeHeroSnapshot,
11
+ resolveDevProfile,
12
+ resolveHeroElements,
13
+ } from "./hero-meta.ts";
14
+
15
+ const SAMPLE: OkeConfig = {
16
+ drivers: {
17
+ store: {
18
+ sql: { dev: "sqlite", stack: "postgres", prod: "postgres" },
19
+ kv: { dev: "memory", stack: "redis", prod: "redis" },
20
+ },
21
+ signal: { dev: "memory", stack: "postgres", prod: "postgres" },
22
+ clock: { dev: "memory", stack: "postgres", prod: "postgres" },
23
+ vault: { dev: "dotenv", stack: "dotenv", prod: "sops" },
24
+ channel: {
25
+ email: { dev: "console", stack: "smtp", prod: "smtp" },
26
+ },
27
+ ai: { dev: "mock", stack: "mock" },
28
+ },
29
+ };
30
+
31
+ describe("hero-meta", () => {
32
+ test("resolveDevProfile maps stack and NODE_ENV", () => {
33
+ expect(resolveDevProfile({ stack: false })).toBe("local");
34
+ expect(resolveDevProfile({ stack: true })).toBe("local-server");
35
+ expect(resolveDevProfile({ stack: false, nodeEnv: "test" })).toBe("test");
36
+ expect(resolveDevProfile({ stack: false, nodeEnv: "production" })).toBe(
37
+ "production",
38
+ );
39
+ });
40
+
41
+ test("resolveHeroElements lists eight elements with drivers", () => {
42
+ const rows = resolveHeroElements(SAMPLE, { stack: false });
43
+ expect(rows.map((r) => r.element)).toEqual([
44
+ "flow",
45
+ "signal",
46
+ "store",
47
+ "clock",
48
+ "gate",
49
+ "vault",
50
+ "channel",
51
+ "ai",
52
+ ]);
53
+ expect(rows.find((r) => r.element === "store")?.detail).toContain("sqlite");
54
+ expect(rows.find((r) => r.element === "flow")?.detail).toBe("●");
55
+ });
56
+
57
+ test("stack profile applies to every element", () => {
58
+ const rows = resolveHeroElements(SAMPLE, {
59
+ stack: true,
60
+ sqlDriver: "postgres",
61
+ kvDriver: "redis",
62
+ });
63
+ const store = rows.find((r) => r.element === "store")?.detail ?? "";
64
+ expect(store).toContain("postgres");
65
+ expect(store).toContain("redis");
66
+ expect(store).not.toContain("sqlite");
67
+ expect(rows.find((r) => r.element === "signal")?.detail).toBe("postgres");
68
+ expect(rows.find((r) => r.element === "clock")?.detail).toBe("postgres");
69
+ expect(rows.find((r) => r.element === "channel")?.detail).toContain("smtp");
70
+ });
71
+
72
+ test("encode/decode round-trips", () => {
73
+ const snap = buildDevHeroSnapshot({
74
+ config: SAMPLE,
75
+ stack: true,
76
+ sqlDriver: "postgres",
77
+ kvDriver: "redis",
78
+ version: "0.2.4",
79
+ nodeEnv: "development",
80
+ });
81
+ expect(snap.profile).toBe("local-server");
82
+ expect(snap.runtimeEnv).toBe("local");
83
+ expect(decodeHeroSnapshot(encodeHeroSnapshot(snap))).toEqual(snap);
84
+ });
85
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Resolve `oke dev` hero facts — eight elements, profile, env, system.
3
+ */
4
+
5
+ import { release } from "node:os";
6
+ import {
7
+ resolveDriverId,
8
+ type ConfigEnv,
9
+ type DriversConfig,
10
+ type OkeConfig,
11
+ } from "../config/index.ts";
12
+
13
+ /** How the process is running (local laptop → production). */
14
+ export type DevRuntimeProfile =
15
+ | "local"
16
+ | "local-server"
17
+ | "test"
18
+ | "production";
19
+
20
+ /** Data plane: still on a laptop vs real production. */
21
+ export type DevRuntimeEnv = "local" | "production";
22
+
23
+ /** One of the eight elements with its active driver summary. */
24
+ export type HeroElementRow = {
25
+ readonly element: string;
26
+ /** Driver summary, or `—` when unbound. */
27
+ readonly detail: string;
28
+ };
29
+
30
+ /** Serializable hero payload (TTY + soft-reload env). */
31
+ export type DevHeroSnapshot = {
32
+ readonly profile: DevRuntimeProfile;
33
+ readonly runtimeEnv: DevRuntimeEnv;
34
+ readonly system: string;
35
+ readonly elements: readonly HeroElementRow[];
36
+ readonly version?: string;
37
+ };
38
+
39
+ const EIGHT_ELEMENTS = [
40
+ "flow",
41
+ "signal",
42
+ "store",
43
+ "clock",
44
+ "gate",
45
+ "vault",
46
+ "channel",
47
+ "ai",
48
+ ] as const;
49
+
50
+ /**
51
+ * Host / runtime line for the hero (`darwin 25.4.0 · bun 1.3.14`).
52
+ */
53
+ export function formatHeroSystemLine(
54
+ bunVersion: string = Bun.version,
55
+ platform: string = process.platform,
56
+ osRelease: string = release(),
57
+ ): string {
58
+ return `${platform} ${osRelease} · bun ${bunVersion}`;
59
+ }
60
+
61
+ /**
62
+ * Profile for an `oke dev` session.
63
+ *
64
+ * @param options - Stack / NODE_ENV hints
65
+ */
66
+ export function resolveDevProfile(options: {
67
+ readonly stack: boolean;
68
+ /** Explicit role — do not infer from ambient `NODE_ENV` (tests set `test`). */
69
+ readonly nodeEnv?: string;
70
+ }): DevRuntimeProfile {
71
+ if (options.nodeEnv === "test") return "test";
72
+ if (options.nodeEnv === "production") return "production";
73
+ return options.stack ? "local-server" : "local";
74
+ }
75
+
76
+ /**
77
+ * Data-plane environment (local machine vs production deploy).
78
+ *
79
+ * @param profile - Runtime profile
80
+ */
81
+ export function resolveDevRuntimeEnv(
82
+ profile: DevRuntimeProfile,
83
+ ): DevRuntimeEnv {
84
+ return profile === "production" ? "production" : "local";
85
+ }
86
+
87
+ /**
88
+ * Active driver id for an env map, preferring {@link prefer} when set.
89
+ *
90
+ * @param map - Env driver map
91
+ * @param env - Config env key
92
+ * @param prefer - Override (stack sql/kv)
93
+ */
94
+ function driverOr(
95
+ map: Parameters<typeof resolveDriverId>[0],
96
+ env: ConfigEnv,
97
+ prefer?: string,
98
+ ): string | undefined {
99
+ if (prefer !== undefined && prefer.length > 0) return prefer;
100
+ return resolveDriverId(map, env);
101
+ }
102
+
103
+ /**
104
+ * Summarize channel mediums for the active config env.
105
+ *
106
+ * @param channel - Channel driver maps
107
+ * @param env - Config env
108
+ */
109
+ function channelDetail(
110
+ channel: DriversConfig["channel"],
111
+ env: ConfigEnv,
112
+ ): string {
113
+ if (!channel) return "—";
114
+ const parts: string[] = [];
115
+ for (const medium of ["email", "sms", "whatsapp", "push"] as const) {
116
+ const id = resolveDriverId(channel[medium], env);
117
+ if (id) parts.push(`${medium} ${id}`);
118
+ }
119
+ return parts.length > 0 ? parts.join(" · ") : "—";
120
+ }
121
+
122
+ /**
123
+ * Summarize store facets for the active (or stack-overridden) drivers.
124
+ *
125
+ * @param store - Store driver maps
126
+ * @param env - Config env for non-overridden facets
127
+ * @param overrides - Stack sql/kv ids
128
+ */
129
+ function storeDetail(
130
+ store: DriversConfig["store"],
131
+ env: ConfigEnv,
132
+ overrides: { readonly sql?: string; readonly kv?: string },
133
+ ): string {
134
+ if (!store && !overrides.sql && !overrides.kv) return "—";
135
+ const parts: string[] = [];
136
+ const sql = driverOr(store?.sql, env, overrides.sql);
137
+ const kv = driverOr(store?.kv, env, overrides.kv);
138
+ const files = resolveDriverId(store?.files, env);
139
+ const index = resolveDriverId(store?.index, env);
140
+ if (sql) parts.push(`sql ${sql}`);
141
+ if (kv) parts.push(`kv ${kv}`);
142
+ if (files) parts.push(`files ${files}`);
143
+ if (index) parts.push(`index ${index}`);
144
+ return parts.length > 0 ? parts.join(" · ") : "—";
145
+ }
146
+
147
+ /**
148
+ * Resolve eight-element rows from `oke.config` + stack overrides.
149
+ *
150
+ * @param config - Loaded config (or null)
151
+ * @param options - Stack driver overrides; config env for maps
152
+ */
153
+ export function resolveHeroElements(
154
+ config: OkeConfig | null | undefined,
155
+ options: {
156
+ /** Use prod store ids from stack (`oke dev -s`). */
157
+ readonly stack: boolean;
158
+ readonly sqlDriver?: string;
159
+ readonly kvDriver?: string;
160
+ /** Which driver map column to read (default `dev`). */
161
+ readonly configEnv?: ConfigEnv;
162
+ },
163
+ ): readonly HeroElementRow[] {
164
+ const drivers = config?.drivers;
165
+ // `-s` reads the `stack` profile for every element (not a store-only override).
166
+ const configEnv: ConfigEnv =
167
+ options.configEnv ?? (options.stack ? "stack" : "dev");
168
+
169
+ const byName: Record<string, string> = {
170
+ flow: "●",
171
+ signal: resolveDriverId(drivers?.signal, configEnv) ?? "—",
172
+ store: storeDetail(drivers?.store, configEnv, {
173
+ sql: options.sqlDriver,
174
+ kv: options.kvDriver,
175
+ }),
176
+ clock: resolveDriverId(drivers?.clock, configEnv) ?? "—",
177
+ gate: "—",
178
+ vault: resolveDriverId(drivers?.vault, configEnv) ?? "—",
179
+ channel: channelDetail(drivers?.channel, configEnv),
180
+ ai: resolveDriverId(drivers?.ai, configEnv) ?? "—",
181
+ };
182
+
183
+ return EIGHT_ELEMENTS.map((element) => ({
184
+ element,
185
+ detail: byName[element] ?? "—",
186
+ }));
187
+ }
188
+
189
+ /**
190
+ * Build the full hero snapshot for banner / soft reload.
191
+ *
192
+ * @param options - Config, stack, versions
193
+ */
194
+ export function buildDevHeroSnapshot(options: {
195
+ readonly config?: OkeConfig | null;
196
+ readonly stack: boolean;
197
+ readonly sqlDriver?: string;
198
+ readonly kvDriver?: string;
199
+ readonly version?: string;
200
+ readonly nodeEnv?: string;
201
+ }): DevHeroSnapshot {
202
+ const profile = resolveDevProfile({
203
+ stack: options.stack,
204
+ nodeEnv: options.nodeEnv,
205
+ });
206
+ return {
207
+ profile,
208
+ runtimeEnv: resolveDevRuntimeEnv(profile),
209
+ system: formatHeroSystemLine(),
210
+ elements: resolveHeroElements(options.config, {
211
+ stack: options.stack,
212
+ sqlDriver: options.sqlDriver,
213
+ kvDriver: options.kvDriver,
214
+ }),
215
+ ...(options.version !== undefined ? { version: options.version } : {}),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Encode snapshot for the app child soft-reload env.
221
+ *
222
+ * @param snapshot - Hero facts
223
+ */
224
+ export function encodeHeroSnapshot(snapshot: DevHeroSnapshot): string {
225
+ return JSON.stringify(snapshot);
226
+ }
227
+
228
+ /**
229
+ * Decode soft-reload hero env (invalid → null).
230
+ *
231
+ * @param raw - JSON from `OKE_DEV_HERO_META`
232
+ */
233
+ export function decodeHeroSnapshot(raw: string | undefined): DevHeroSnapshot | null {
234
+ if (raw === undefined || raw.length === 0) return null;
235
+ try {
236
+ const parsed: unknown = JSON.parse(raw);
237
+ if (
238
+ typeof parsed !== "object" ||
239
+ parsed === null ||
240
+ !("profile" in parsed) ||
241
+ !("runtimeEnv" in parsed) ||
242
+ !("system" in parsed) ||
243
+ !("elements" in parsed) ||
244
+ !Array.isArray((parsed as DevHeroSnapshot).elements)
245
+ ) {
246
+ return null;
247
+ }
248
+ return parsed as DevHeroSnapshot;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
@@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test";
6
6
  import {
7
7
  defaultImagesFromConfig,
8
8
  resolveImages,
9
+ stackDevDriverMismatches,
9
10
  } from "./load-config.ts";
10
11
 
11
12
  describe("defaultImagesFromConfig", () => {
@@ -73,3 +74,53 @@ describe("resolveImages", () => {
73
74
  expect(images["store.kv"]).toBe("redis:8-alpine");
74
75
  });
75
76
  });
77
+
78
+ describe("stackDevDriverMismatches", () => {
79
+ test("flags stack/prod sqlite/memory when containers are up", () => {
80
+ const mismatches = stackDevDriverMismatches(
81
+ {
82
+ drivers: {
83
+ store: {
84
+ sql: { dev: "sqlite", stack: "sqlite", prod: "sqlite" },
85
+ kv: { dev: "memory", stack: "memory", prod: "memory" },
86
+ },
87
+ },
88
+ },
89
+ ["store.sql", "store.kv"],
90
+ );
91
+ expect(mismatches).toEqual([
92
+ { label: "sql", using: "sqlite", container: "postgres" },
93
+ { label: "kv", using: "memory", container: "redis" },
94
+ ]);
95
+ });
96
+
97
+ test("silent when stack profile matches containers", () => {
98
+ const mismatches = stackDevDriverMismatches(
99
+ {
100
+ drivers: {
101
+ store: {
102
+ sql: { dev: "sqlite", stack: "postgres", prod: "postgres" },
103
+ kv: { dev: "memory", stack: "redis", prod: "redis" },
104
+ },
105
+ },
106
+ },
107
+ ["store.sql", "store.kv"],
108
+ );
109
+ expect(mismatches).toEqual([]);
110
+ });
111
+
112
+ test("silent when only prod is set (stack falls back to prod)", () => {
113
+ const mismatches = stackDevDriverMismatches(
114
+ {
115
+ drivers: {
116
+ store: {
117
+ sql: { dev: "sqlite", prod: "postgres" },
118
+ kv: { dev: "memory", prod: "redis" },
119
+ },
120
+ },
121
+ },
122
+ ["store.sql", "store.kv"],
123
+ );
124
+ expect(mismatches).toEqual([]);
125
+ });
126
+ });
@@ -22,7 +22,18 @@ function driverId(ref: DriverRef | undefined): string | undefined {
22
22
  }
23
23
 
24
24
  /**
25
- * Derive default image pins from prod driver protocols.
25
+ * Prefer `stack` then `prod` for a driver map (local server ≈ production).
26
+ *
27
+ * @param map - Env driver map
28
+ */
29
+ function stackOrProdId(
30
+ map: { readonly stack?: DriverRef; readonly prod?: DriverRef } | undefined,
31
+ ): string | undefined {
32
+ return driverId(map?.stack) ?? driverId(map?.prod);
33
+ }
34
+
35
+ /**
36
+ * Derive default image pins from stack/prod driver protocols.
26
37
  *
27
38
  * Used when `oke.config.ts` omits `images` but declares postgres/redis (etc.)
28
39
  * so `oke dev -s` / `oke stack` / `oke docker` have something to run.
@@ -33,11 +44,11 @@ export function defaultImagesFromConfig(
33
44
  config: OkeConfig,
34
45
  ): Readonly<Record<string, string>> {
35
46
  const out: Record<string, string> = {};
36
- const sql = driverId(config.drivers?.store?.sql?.prod);
37
- const index = driverId(config.drivers?.store?.index?.prod);
38
- const kv = driverId(config.drivers?.store?.kv?.prod);
39
- const signal = driverId(config.drivers?.signal?.prod);
40
- const clock = driverId(config.drivers?.clock?.prod);
47
+ const sql = stackOrProdId(config.drivers?.store?.sql);
48
+ const index = stackOrProdId(config.drivers?.store?.index);
49
+ const kv = stackOrProdId(config.drivers?.store?.kv);
50
+ const signal = stackOrProdId(config.drivers?.signal);
51
+ const clock = stackOrProdId(config.drivers?.clock);
41
52
 
42
53
  const needsSql =
43
54
  sql === "postgres" ||
@@ -134,3 +145,70 @@ export function resolveImages(
134
145
  if (config) return defaultImagesFromConfig(config);
135
146
  return {};
136
147
  }
148
+
149
+ /** Compact driver mismatch for {@link formatStackSummary}. */
150
+ export type StackDriverMismatch = {
151
+ /** Short label (`sql`, `kv`). */
152
+ readonly label: string;
153
+ /** Active `drivers.*.stack` (→ prod) id. */
154
+ readonly using: string;
155
+ /** Driver that would use the container. */
156
+ readonly container: string;
157
+ };
158
+
159
+ /**
160
+ * Detect when `oke dev -s` containers will not back the app because the
161
+ * `stack` driver profile still points at local/memory backends.
162
+ *
163
+ * @param config - Loaded config
164
+ * @param stackRoles - Roles being composed
165
+ */
166
+ export function stackDevDriverMismatches(
167
+ config: OkeConfig,
168
+ stackRoles: readonly string[],
169
+ ): readonly StackDriverMismatch[] {
170
+ const roles = new Set(stackRoles);
171
+ const out: StackDriverMismatch[] = [];
172
+ const sqlStack =
173
+ driverId(config.drivers?.store?.sql?.stack) ??
174
+ driverId(config.drivers?.store?.sql?.prod);
175
+ const kvStack =
176
+ driverId(config.drivers?.store?.kv?.stack) ??
177
+ driverId(config.drivers?.store?.kv?.prod);
178
+
179
+ if (
180
+ roles.has("store.sql") &&
181
+ (sqlStack === "sqlite" || sqlStack === "memory" || sqlStack === undefined)
182
+ ) {
183
+ out.push({
184
+ label: "sql",
185
+ using: sqlStack ?? "unset",
186
+ container: "postgres",
187
+ });
188
+ }
189
+ if (
190
+ roles.has("store.kv") &&
191
+ (kvStack === "memory" || kvStack === undefined)
192
+ ) {
193
+ out.push({
194
+ label: "kv",
195
+ using: kvStack ?? "memory",
196
+ container: "redis",
197
+ });
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * @deprecated Use {@link stackDevDriverMismatches} — kept for older call sites.
204
+ * @param config - Loaded config
205
+ * @param stackRoles - Roles being composed
206
+ */
207
+ export function stackDevDriverWarnings(
208
+ config: OkeConfig,
209
+ stackRoles: readonly string[],
210
+ ): readonly string[] {
211
+ return stackDevDriverMismatches(config, stackRoles).map(
212
+ (m) => `${m.label}: ${m.using}`,
213
+ );
214
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `defineConfig` fills missing `stack` pins from `prod`.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { defineConfig, fillStackFromProd } from "./index.ts";
7
+
8
+ describe("fillStackFromProd / defineConfig", () => {
9
+ test("copies prod onto stack when stack is omitted", () => {
10
+ const filled = fillStackFromProd({
11
+ dev: "sqlite",
12
+ prod: "postgres",
13
+ });
14
+ expect(filled?.stack).toBe("postgres");
15
+ expect(filled?.dev).toBe("sqlite");
16
+ });
17
+
18
+ test("does not overwrite an explicit stack pin", () => {
19
+ expect(
20
+ fillStackFromProd({
21
+ dev: "sqlite",
22
+ stack: "memory",
23
+ prod: "postgres",
24
+ })?.stack,
25
+ ).toBe("memory");
26
+ });
27
+
28
+ test("vault sops → stack dotenv", () => {
29
+ expect(
30
+ fillStackFromProd({ dev: "dotenv", prod: "sops" }, { vault: true })
31
+ ?.stack,
32
+ ).toBe("dotenv");
33
+ });
34
+
35
+ test("defineConfig fills every element from prod", () => {
36
+ const cfg = defineConfig({
37
+ drivers: {
38
+ store: {
39
+ sql: { dev: "sqlite", prod: "postgres" },
40
+ kv: { dev: "memory", prod: "redis" },
41
+ files: { dev: "fs", prod: "s3" },
42
+ },
43
+ signal: { dev: "memory", prod: "postgres" },
44
+ clock: { dev: "memory", prod: "postgres" },
45
+ vault: { dev: "dotenv", prod: "sops" },
46
+ channel: {
47
+ email: { dev: "console", prod: "smtp" },
48
+ },
49
+ ai: { dev: "mock", prod: "anthropic" },
50
+ },
51
+ });
52
+ expect(cfg.drivers?.store?.sql?.stack).toBe("postgres");
53
+ expect(cfg.drivers?.store?.kv?.stack).toBe("redis");
54
+ expect(cfg.drivers?.store?.files?.stack).toBe("s3");
55
+ expect(cfg.drivers?.signal?.stack).toBe("postgres");
56
+ expect(cfg.drivers?.clock?.stack).toBe("postgres");
57
+ expect(cfg.drivers?.vault?.stack).toBe("dotenv");
58
+ expect(cfg.drivers?.channel?.email?.stack).toBe("smtp");
59
+ expect(cfg.drivers?.ai?.stack).toBe("anthropic");
60
+ });
61
+ });
@@ -5,8 +5,15 @@
5
5
  * @module
6
6
  */
7
7
 
8
- /** Environment role keys used in driver maps. */
9
- export type ConfigEnv = "dev" | "test" | "prod";
8
+ /**
9
+ * Environment role keys used in driver maps.
10
+ *
11
+ * - `dev` — local laptop (`oke dev`)
12
+ * - `stack` — local server (`oke dev -s` / compose infra + host Bun)
13
+ * - `test` — automated tests
14
+ * - `prod` — production deploy
15
+ */
16
+ export type ConfigEnv = "dev" | "stack" | "test" | "prod";
10
17
 
11
18
  /** Pool options for SQL drivers. */
12
19
  export interface DriverPoolOptions {
@@ -143,17 +150,90 @@ export interface OkeConfig {
143
150
  }
144
151
 
145
152
  /**
146
- * Define an `oke.config.ts` document (identity function + type check).
153
+ * Fill missing `stack` pins from `prod` (local server production protocols).
154
+ *
155
+ * Vault is the exception: `prod: "sops"` becomes `stack: "dotenv"` so
156
+ * `oke dev -s` can read `.env.stack` without age keys.
157
+ *
158
+ * @param map - Env → driver map
159
+ * @param options - Element-specific defaults
160
+ */
161
+ export function fillStackFromProd(
162
+ map: EnvDriverMap | undefined,
163
+ options: { readonly vault?: boolean } = {},
164
+ ): EnvDriverMap | undefined {
165
+ if (!map) return undefined;
166
+ if (map.stack !== undefined || map.prod === undefined) return map;
167
+ if (options.vault) {
168
+ const prodId = typeof map.prod === "string" ? map.prod : map.prod.driver;
169
+ if (prodId === "sops") {
170
+ return { ...map, stack: "dotenv" };
171
+ }
172
+ }
173
+ return { ...map, stack: map.prod };
174
+ }
175
+
176
+ /**
177
+ * Copy production driver pins onto `stack` wherever `stack` is omitted.
178
+ *
179
+ * @param drivers - Drivers block
180
+ */
181
+ export function fillDriversStackFromProd(
182
+ drivers: DriversConfig | undefined,
183
+ ): DriversConfig | undefined {
184
+ if (!drivers) return undefined;
185
+ const store = drivers.store
186
+ ? {
187
+ ...drivers.store,
188
+ sql: fillStackFromProd(drivers.store.sql),
189
+ kv: fillStackFromProd(drivers.store.kv),
190
+ files: fillStackFromProd(drivers.store.files),
191
+ index: fillStackFromProd(drivers.store.index),
192
+ }
193
+ : undefined;
194
+ const channel = drivers.channel
195
+ ? {
196
+ ...drivers.channel,
197
+ email: fillStackFromProd(drivers.channel.email),
198
+ sms: fillStackFromProd(drivers.channel.sms),
199
+ whatsapp: fillStackFromProd(drivers.channel.whatsapp),
200
+ push: fillStackFromProd(drivers.channel.push),
201
+ }
202
+ : undefined;
203
+ return {
204
+ ...drivers,
205
+ ...(store !== undefined ? { store } : {}),
206
+ signal: fillStackFromProd(drivers.signal),
207
+ clock: fillStackFromProd(drivers.clock),
208
+ vault: fillStackFromProd(drivers.vault, { vault: true }),
209
+ ...(channel !== undefined ? { channel } : {}),
210
+ ai: fillStackFromProd(drivers.ai),
211
+ runs: fillStackFromProd(drivers.runs),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Define an `oke.config.ts` document.
217
+ *
218
+ * Missing `stack` driver pins are filled from `prod` so `oke dev -s` uses the
219
+ * same server protocols as production (vault → dotenv for `.env.stack`).
147
220
  *
148
221
  * @param config - Driver / tenancy / i18n / topology
149
222
  */
150
- export function defineConfig<C extends OkeConfig>(config: C): C {
151
- return config;
223
+ export function defineConfig(config: OkeConfig): OkeConfig {
224
+ if (!config.drivers) return config;
225
+ return {
226
+ ...config,
227
+ drivers: fillDriversStackFromProd(config.drivers),
228
+ };
152
229
  }
153
230
 
154
231
  /**
155
232
  * Resolve a driver id for an env from an {@link EnvDriverMap}.
156
233
  *
234
+ * `stack` falls back to `prod` then `dev` so existing configs keep working
235
+ * until an explicit `stack:` pin is added.
236
+ *
157
237
  * @param map - Env → driver map
158
238
  * @param env - Active environment
159
239
  */
@@ -162,7 +242,10 @@ export function resolveDriverId(
162
242
  env: ConfigEnv,
163
243
  ): string | undefined {
164
244
  if (!map) return undefined;
165
- const ref = map[env] ?? map.dev ?? map.test;
245
+ const ref =
246
+ env === "stack"
247
+ ? (map.stack ?? map.prod ?? map.dev ?? map.test)
248
+ : (map[env] ?? map.dev ?? map.test);
166
249
  if (ref === undefined) return undefined;
167
250
  return typeof ref === "string" ? ref : ref.driver;
168
251
  }