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
@@ -27,25 +27,25 @@ const credentials = {
27
27
  } as const;
28
28
 
29
29
  describe("oke docker CLI", () => {
30
- test("writes Dockerfile and compose without credentials in YAML", async () => {
30
+ test("writes Dockerfile and compose under docker/ by default", async () => {
31
31
  const dir = await mkdtemp(join(tmpdir(), "oke-cli-docker-"));
32
32
  const logs: string[] = [];
33
33
  const { code, result } = await runDockerDerive({
34
34
  cwd: dir,
35
- outDir: dir,
36
35
  images,
37
36
  credentials,
38
37
  write: (t) => logs.push(t),
39
38
  });
40
39
  expect(code).toBe(0);
41
40
  expect(result).toBeDefined();
42
- expect(await Bun.file(join(dir, "Dockerfile")).exists()).toBe(true);
43
- expect(await Bun.file(join(dir, "compose.store.sql.yml")).exists()).toBe(
44
- true,
45
- );
46
- const yml = await Bun.file(join(dir, "compose.store.sql.yml")).text();
41
+ expect(await Bun.file(join(dir, "docker/Dockerfile")).exists()).toBe(true);
42
+ expect(
43
+ await Bun.file(join(dir, "docker/compose.store.sql.yml")).exists(),
44
+ ).toBe(true);
45
+ const yml = await Bun.file(join(dir, "docker/compose.store.sql.yml")).text();
47
46
  expect(yml).not.toContain("test-password-not-in-yaml");
48
- expect(logs.join("")).toContain("compose.override.yml");
47
+ expect(yml).toContain("../.env.stack");
48
+ expect(logs.join("")).toContain("docker/compose.override.yml");
49
49
  });
50
50
  });
51
51
 
@@ -164,8 +164,10 @@ describe("oke dev --stack", () => {
164
164
  expect(code).toBe(0);
165
165
  expect(plan?.stackRoles).toEqual(["store.sql"]);
166
166
  expect(plan?.composeFiles?.some((f) => f.includes("store.sql"))).toBe(true);
167
+ expect(plan?.composeFiles).toContain("compose.yml");
167
168
  expect(plan?.stackEnv?.DATABASE_URL).toContain("postgres://");
168
169
  expect(await Bun.file(join(dir, "compose.yml")).exists()).toBe(false);
170
+ expect(await Bun.file(join(dir, "docker/compose.yml")).exists()).toBe(false);
169
171
  });
170
172
  });
171
173
 
package/src/cli/docker.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { resolve } from "node:path";
6
6
  import {
7
+ DEFAULT_DOCKER_DIR,
7
8
  deriveInfrastructure,
8
9
  writeDerivedFiles,
9
10
  type DeriveOptions,
@@ -38,7 +39,15 @@ export async function runDockerDerive(
38
39
  ): Promise<{ readonly code: number; readonly result?: DeriveResult }> {
39
40
  const write = options.write ?? ((t) => process.stdout.write(t));
40
41
  const cwd = options.cwd ?? process.cwd();
41
- const outDir = resolve(cwd, options.outDir ?? ".");
42
+ const composeDirOpt = options.outDir ?? DEFAULT_DOCKER_DIR;
43
+ const outDir = resolve(cwd, composeDirOpt);
44
+ // Relative dir for env_file / build.context rewriting (`.` when out === cwd).
45
+ const composeDir =
46
+ outDir === cwd
47
+ ? "."
48
+ : outDir.startsWith(`${cwd}/`) || outDir.startsWith(`${cwd}\\`)
49
+ ? outDir.slice(cwd.length + 1)
50
+ : ".";
42
51
 
43
52
  let images = options.images;
44
53
  let app = "app";
@@ -70,6 +79,8 @@ export async function runDockerDerive(
70
79
  app,
71
80
  prod: options.prod ?? false,
72
81
  outDir,
82
+ composeDir,
83
+ includeApp: true,
73
84
  ...(options.credentials ? { credentials: options.credentials } : {}),
74
85
  });
75
86
 
@@ -77,13 +88,13 @@ export async function runDockerDerive(
77
88
  await writeDerivedFiles(result, outDir, { writeStackEnv: false });
78
89
  }
79
90
 
80
- write(`oke docker: wrote ${result.files.length} file(s)\n`);
81
- for (const f of result.files) write(` ${f.path}\n`);
91
+ write(`oke docker: wrote ${result.files.length} file(s) → ${composeDir}/\n`);
92
+ for (const f of result.files) write(` ${composeDir}/${f.path}\n`);
82
93
  write(
83
- `compose merge order:\n${result.composeFiles.map((f) => ` -f ${f}`).join("\n")}\n`,
94
+ `compose merge order (cwd ${composeDir}/):\n${result.composeFiles.map((f) => ` -f ${f}`).join("\n")}\n`,
84
95
  );
85
96
  write(
86
- "(compose.override.yml is layer 4 — user-owned, never written by oke)\n",
97
+ `(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n`,
87
98
  );
88
99
  return { code: 0, result };
89
100
  } catch (err) {
@@ -109,9 +120,9 @@ export async function dockerCli(args: readonly string[]): Promise<number> {
109
120
  else if (a === "--config" || a === "-c") configPath = args[++i];
110
121
  else if (a === "--manifest" || a === "-m") manifestPath = args[++i];
111
122
  else if (a === "--help" || a === "-h") {
112
- console.log(`oke docker [--prod|-p] [--out|-o .] [--config|-c oke.config.ts]
123
+ console.log(`oke docker [--prod|-p] [--out|-o docker] [--config|-c oke.config.ts]
113
124
 
114
- Derive Dockerfile + compose.<role>.yml files.
125
+ Derive Dockerfile + compose.<role>.yml under docker/ (default).
115
126
  --prod is opt-in (never the default). Credentials are never written into YAML.
116
127
  `);
117
128
  return 0;
package/src/cli/doctor.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * `oke doctor` — verify secrets, ports, drivers, tenancy, schema drift.
3
3
  */
4
4
 
5
- import { createServer } from "node:net";
6
5
  import { resolve } from "node:path";
7
6
  import type { Manifest } from "../manifest/types.ts";
8
7
  import { APP_PORT, CONSOLE_PORT, MCP_PORT } from "../runtime/types.ts";
@@ -10,8 +9,11 @@ import { hasFlag, wantsJson } from "./args.ts";
10
9
  import { checkManifestPiiAsks } from "./doctor-pii.ts";
11
10
  import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
12
11
  import { loadManifest } from "./load-config.ts";
12
+ import { isPortInUse } from "./ports.ts";
13
13
  import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
14
14
 
15
+ export { isPortInUse } from "./ports.ts";
16
+
15
17
  /** One doctor finding. */
16
18
  export interface DoctorFinding {
17
19
  readonly code:
@@ -204,20 +206,3 @@ Verify secrets, ports, schema drift, and PII→model egress before serving.
204
206
  return code;
205
207
  }
206
208
 
207
- /**
208
- * True when something is already listening on `port`.
209
- *
210
- * @param port - TCP port
211
- */
212
- export function isPortInUse(port: number): Promise<boolean> {
213
- return new Promise((resolvePromise) => {
214
- const server = createServer();
215
- server.once("error", (err: NodeJS.ErrnoException) => {
216
- resolvePromise(err.code === "EADDRINUSE");
217
- });
218
- server.once("listening", () => {
219
- server.close(() => resolvePromise(false));
220
- });
221
- server.listen(port, "127.0.0.1");
222
- });
223
- }
@@ -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
+ }
package/src/cli/index.ts CHANGED
@@ -90,7 +90,10 @@ if (cmd === "completion") {
90
90
  }
91
91
 
92
92
  if (cmd === undefined || cmd === "--help" || cmd === "-h" || cmd === "help") {
93
- console.log(`${formatOkeHelp()}${EXIT_CODE_HELP}`);
93
+ // Bare `oke` — commands only. Exit-code table only on explicit --help.
94
+ const help =
95
+ cmd === undefined ? formatOkeHelp() : `${formatOkeHelp()}${EXIT_CODE_HELP}`;
96
+ console.log(help);
94
97
  process.exit(cmd ? EXIT_OK : EXIT_USAGE);
95
98
  }
96
99
 
@@ -42,7 +42,7 @@ describe("oke --json stdout discipline", () => {
42
42
  test("stack --json is valid JSON only on stdout", async () => {
43
43
  let out = "";
44
44
  const code = await runStackPreview({
45
- images: { "store.sql": "postgres:16-alpine" },
45
+ images: { "store.sql": "postgres:18-alpine" },
46
46
  json: true,
47
47
  write: (t) => {
48
48
  out += t;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Default image pins derived from prod drivers.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ defaultImagesFromConfig,
8
+ resolveImages,
9
+ stackDevDriverMismatches,
10
+ } from "./load-config.ts";
11
+
12
+ describe("defaultImagesFromConfig", () => {
13
+ test("maps postgres + redis prod drivers", () => {
14
+ const images = defaultImagesFromConfig({
15
+ drivers: {
16
+ store: {
17
+ sql: { prod: "postgres" },
18
+ kv: { prod: "redis" },
19
+ },
20
+ },
21
+ });
22
+ expect(images).toEqual({
23
+ "store.sql": "postgres:18-alpine",
24
+ "store.kv": "redis:8-alpine",
25
+ });
26
+ });
27
+
28
+ test("prefers pgvector image when index uses pgvector", () => {
29
+ const images = defaultImagesFromConfig({
30
+ drivers: {
31
+ store: {
32
+ sql: { prod: "postgres" },
33
+ index: { prod: "pgvector" },
34
+ },
35
+ },
36
+ });
37
+ expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
38
+ });
39
+
40
+ test("returns empty when no container drivers", () => {
41
+ expect(defaultImagesFromConfig({})).toEqual({});
42
+ expect(
43
+ defaultImagesFromConfig({
44
+ drivers: { store: { sql: { prod: "sqlite" } } },
45
+ }),
46
+ ).toEqual({});
47
+ });
48
+ });
49
+
50
+ describe("resolveImages", () => {
51
+ test("explicit images win over defaults", () => {
52
+ const images = resolveImages({
53
+ drivers: {
54
+ store: {
55
+ sql: { prod: "postgres" },
56
+ kv: { prod: "redis" },
57
+ },
58
+ },
59
+ images: { "store.sql": "pgvector/pgvector:pg17" },
60
+ });
61
+ expect(images).toEqual({ "store.sql": "pgvector/pgvector:pg17" });
62
+ });
63
+
64
+ test("falls back to driver defaults when images omitted", () => {
65
+ const images = resolveImages({
66
+ drivers: {
67
+ store: {
68
+ sql: { prod: "postgres" },
69
+ kv: { prod: "redis" },
70
+ },
71
+ },
72
+ });
73
+ expect(images["store.sql"]).toBe("postgres:18-alpine");
74
+ expect(images["store.kv"]).toBe("redis:8-alpine");
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
+ });