okengine 0.6.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +90 -20
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Discover and tear down leftover `oke-dev-<id>` compose stacks.
3
+ *
4
+ * Identity matches {@link stackAppSlug}: compose project `oke-dev-<6hex>`.
5
+ * Cleanup uses `docker compose -p <project> down -v` (no compose files required).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { stackAppSlug } from "./stack-id.ts";
10
+
11
+ /** Compose project names created by `oke dev --docker`. */
12
+ export const OKE_DEV_PROJECT_RE = /^oke-dev-[0-9a-f]{6}$/;
13
+
14
+ /** Result of one `docker …` invocation. */
15
+ export interface DockerRunResult {
16
+ readonly stdout: string;
17
+ readonly stderr: string;
18
+ readonly exitCode: number;
19
+ }
20
+
21
+ /** Injectable docker CLI runner (tests). */
22
+ export type DockerRunner = (args: readonly string[]) => Promise<DockerRunResult>;
23
+
24
+ /** One container belonging to an OKE compose project. */
25
+ export interface OkeContainer {
26
+ readonly id: string;
27
+ readonly name: string;
28
+ readonly service: string;
29
+ readonly state: string;
30
+ readonly project: string;
31
+ }
32
+
33
+ /** One compose project with zero or more containers. */
34
+ export interface OkeStack {
35
+ readonly project: string;
36
+ readonly containers: readonly OkeContainer[];
37
+ }
38
+
39
+ /**
40
+ * Compose project name for a project root (`oke-dev-<id>`).
41
+ *
42
+ * @param cwd - Project root
43
+ */
44
+ export function composeProjectName(cwd: string): string {
45
+ return `oke-${stackAppSlug(cwd)}`;
46
+ }
47
+
48
+ /**
49
+ * Whether a compose project name is an OKE local stack.
50
+ *
51
+ * @param name - Compose project name
52
+ */
53
+ export function isOkeDevProject(name: string): boolean {
54
+ return OKE_DEV_PROJECT_RE.test(name);
55
+ }
56
+
57
+ /**
58
+ * Unique compose projects implied by a container selection.
59
+ *
60
+ * @param containers - Selected containers
61
+ */
62
+ export function projectsFromContainerSelection(
63
+ containers: readonly OkeContainer[],
64
+ ): readonly string[] {
65
+ return [...new Set(containers.map((c) => c.project))].sort();
66
+ }
67
+
68
+ /**
69
+ * True when cwd looks like an okengine app root.
70
+ *
71
+ * @param cwd - Directory to probe
72
+ */
73
+ export async function isOkeProjectRoot(cwd: string): Promise<boolean> {
74
+ for (const name of ["oke.config.ts", "oke.config.mts", "oke.config.js"] as const) {
75
+ if (await Bun.file(join(cwd, name)).exists()) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Default runner — `docker` on PATH.
82
+ *
83
+ * @param args - Args after `docker`
84
+ */
85
+ export async function defaultDockerRunner(args: readonly string[]): Promise<DockerRunResult> {
86
+ const proc = Bun.spawn(["docker", ...args], {
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ });
90
+ const [stdout, stderr, exitCode] = await Promise.all([
91
+ new Response(proc.stdout).text(),
92
+ new Response(proc.stderr).text(),
93
+ proc.exited,
94
+ ]);
95
+ return { stdout, stderr, exitCode };
96
+ }
97
+
98
+ /**
99
+ * Discover every `oke-dev-*` compose project on this machine.
100
+ *
101
+ * Sources: `compose ls`, container labels, volume/network name prefixes.
102
+ *
103
+ * @param run - Docker runner
104
+ */
105
+ export async function listOkeComposeProjects(
106
+ run: DockerRunner = defaultDockerRunner,
107
+ ): Promise<readonly OkeStack[]> {
108
+ const projectNames = new Set<string>();
109
+
110
+ for (const name of await listComposeProjectNames(run)) {
111
+ if (isOkeDevProject(name)) projectNames.add(name);
112
+ }
113
+ for (const c of await listAllOkeContainers(run)) {
114
+ projectNames.add(c.project);
115
+ }
116
+ for (const name of await listOkeResourceProjectNames(run, "volume")) {
117
+ projectNames.add(name);
118
+ }
119
+ for (const name of await listOkeResourceProjectNames(run, "network")) {
120
+ projectNames.add(name);
121
+ }
122
+
123
+ const stacks: OkeStack[] = [];
124
+ for (const project of [...projectNames].sort()) {
125
+ const containers = await listStackContainers(project, run);
126
+ stacks.push({ project, containers });
127
+ }
128
+ return stacks;
129
+ }
130
+
131
+ /**
132
+ * List containers for one compose project.
133
+ *
134
+ * @param project - Compose project name
135
+ * @param run - Docker runner
136
+ */
137
+ export async function listStackContainers(
138
+ project: string,
139
+ run: DockerRunner = defaultDockerRunner,
140
+ ): Promise<readonly OkeContainer[]> {
141
+ const result = await run([
142
+ "ps",
143
+ "-a",
144
+ "--filter",
145
+ `label=com.docker.compose.project=${project}`,
146
+ "--format",
147
+ "{{json .}}",
148
+ ]);
149
+ if (result.exitCode !== 0) {
150
+ throw new Error(
151
+ `oke docker clean: docker ps failed (${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
152
+ );
153
+ }
154
+ return parseContainerJsonLines(result.stdout, project);
155
+ }
156
+
157
+ /**
158
+ * Tear down one stack — containers, networks, and named volumes.
159
+ *
160
+ * @param project - Compose project name
161
+ * @param run - Docker runner
162
+ */
163
+ export async function downStack(
164
+ project: string,
165
+ run: DockerRunner = defaultDockerRunner,
166
+ ): Promise<void> {
167
+ if (!isOkeDevProject(project)) {
168
+ throw new Error(`oke docker clean: refusing non-OKE project ${project}`);
169
+ }
170
+ const result = await run(["compose", "-p", project, "down", "-v", "--remove-orphans"]);
171
+ if (result.exitCode !== 0) {
172
+ throw new Error(
173
+ `oke docker clean: docker compose down failed for ${project} (${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
174
+ );
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Stable selection key for a container (or empty stack sentinel).
180
+ *
181
+ * @param project - Compose project
182
+ * @param containerName - Container name, or empty for volume-only stacks
183
+ */
184
+ export function selectionKey(project: string, containerName = ""): string {
185
+ return containerName ? `${project}\0${containerName}` : `${project}\0`;
186
+ }
187
+
188
+ /**
189
+ * Parse selection keys back into projects (and optional container filter).
190
+ *
191
+ * @param keys - Values from multiselect
192
+ * @param stacks - Known stacks (for container lookup)
193
+ */
194
+ export function resolveSelection(
195
+ keys: readonly string[],
196
+ stacks: readonly OkeStack[],
197
+ ): {
198
+ readonly projects: readonly string[];
199
+ readonly containers: readonly OkeContainer[];
200
+ } {
201
+ const byProject = new Map(stacks.map((s) => [s.project, s] as const));
202
+ const projects = new Set<string>();
203
+ const containers: OkeContainer[] = [];
204
+
205
+ for (const key of keys) {
206
+ const sep = key.indexOf("\0");
207
+ const project = sep === -1 ? key : key.slice(0, sep);
208
+ const containerName = sep === -1 ? "" : key.slice(sep + 1);
209
+ if (!isOkeDevProject(project)) continue;
210
+ projects.add(project);
211
+ const stack = byProject.get(project);
212
+ if (!stack) continue;
213
+ if (!containerName) continue;
214
+ const found = stack.containers.find((c) => c.name === containerName);
215
+ if (found) containers.push(found);
216
+ }
217
+
218
+ return { projects: [...projects].sort(), containers };
219
+ }
220
+
221
+ async function listComposeProjectNames(run: DockerRunner): Promise<readonly string[]> {
222
+ const result = await run(["compose", "ls", "-a", "--format", "json"]);
223
+ if (result.exitCode !== 0) {
224
+ // Compose plugin missing or Docker down — other probes may still find leftovers.
225
+ return [];
226
+ }
227
+ const names: string[] = [];
228
+ for (const row of parseJsonRecords(result.stdout)) {
229
+ const name = stringField(row, "Name") ?? stringField(row, "name");
230
+ if (name) names.push(name);
231
+ }
232
+ return names;
233
+ }
234
+
235
+ async function listAllOkeContainers(run: DockerRunner): Promise<readonly OkeContainer[]> {
236
+ const result = await run([
237
+ "ps",
238
+ "-a",
239
+ "--filter",
240
+ "label=com.docker.compose.project",
241
+ "--format",
242
+ "{{json .}}",
243
+ ]);
244
+ if (result.exitCode !== 0) return [];
245
+ const out: OkeContainer[] = [];
246
+ for (const row of parseJsonRecords(result.stdout)) {
247
+ const project =
248
+ labelValue(row, "com.docker.compose.project") ??
249
+ stringField(row, "ComposeProject") ??
250
+ stringField(row, "Project");
251
+ if (!project || !isOkeDevProject(project)) continue;
252
+ out.push(containerFromRow(row, project));
253
+ }
254
+ return out;
255
+ }
256
+
257
+ async function listOkeResourceProjectNames(
258
+ run: DockerRunner,
259
+ kind: "volume" | "network",
260
+ ): Promise<readonly string[]> {
261
+ const result = await run([kind, "ls", "--format", "{{json .}}"]);
262
+ if (result.exitCode !== 0) return [];
263
+ const names = new Set<string>();
264
+ for (const row of parseJsonRecords(result.stdout)) {
265
+ const name = stringField(row, "Name") ?? stringField(row, "name");
266
+ if (!name) continue;
267
+ const project = projectFromResourceName(name);
268
+ if (project) names.add(project);
269
+ }
270
+ return [...names];
271
+ }
272
+
273
+ /**
274
+ * Extract `oke-dev-<id>` from a volume/network name like `oke-dev-abc123_oke`.
275
+ *
276
+ * @param name - Docker resource name
277
+ */
278
+ export function projectFromResourceName(name: string): string | null {
279
+ const m = /^(oke-dev-[0-9a-f]{6})(?:_|$)/.exec(name);
280
+ return m?.[1] ?? null;
281
+ }
282
+
283
+ function parseContainerJsonLines(stdout: string, project: string): readonly OkeContainer[] {
284
+ return parseJsonRecords(stdout).map((row) => containerFromRow(row, project));
285
+ }
286
+
287
+ function containerFromRow(row: Record<string, unknown>, project: string): OkeContainer {
288
+ const names = stringField(row, "Names") ?? stringField(row, "Name") ?? "";
289
+ const name = names.split(",")[0]?.replace(/^\//, "") || names;
290
+ const service =
291
+ labelValue(row, "com.docker.compose.service") ?? stringField(row, "Service") ?? name;
292
+ const id = stringField(row, "ID") ?? stringField(row, "Id") ?? name;
293
+ const state =
294
+ stringField(row, "State") ?? stringField(row, "Status") ?? stringField(row, "state") ?? "";
295
+ return { id, name, service, state, project };
296
+ }
297
+
298
+ function labelValue(row: Record<string, unknown>, key: string): string | null {
299
+ const labels = row.Labels ?? row.labels;
300
+ if (typeof labels === "string") {
301
+ for (const part of labels.split(",")) {
302
+ const eq = part.indexOf("=");
303
+ if (eq === -1) continue;
304
+ if (part.slice(0, eq) === key) return part.slice(eq + 1);
305
+ }
306
+ return null;
307
+ }
308
+ if (labels && typeof labels === "object") {
309
+ const v = (labels as Record<string, unknown>)[key];
310
+ return typeof v === "string" ? v : null;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ function stringField(row: Record<string, unknown>, key: string): string | null {
316
+ const v = row[key];
317
+ return typeof v === "string" && v.length > 0 ? v : null;
318
+ }
319
+
320
+ /**
321
+ * Parse Docker `--format json` / `{{json .}}` output (array or NDJSON).
322
+ *
323
+ * @param text - stdout
324
+ */
325
+ export function parseJsonRecords(text: string): readonly Record<string, unknown>[] {
326
+ const trimmed = text.trim();
327
+ if (!trimmed) return [];
328
+ try {
329
+ const parsed: unknown = JSON.parse(trimmed);
330
+ if (Array.isArray(parsed)) {
331
+ return parsed.filter(
332
+ (x): x is Record<string, unknown> => typeof x === "object" && x !== null,
333
+ );
334
+ }
335
+ if (typeof parsed === "object" && parsed !== null) {
336
+ return [parsed as Record<string, unknown>];
337
+ }
338
+ } catch {
339
+ // NDJSON — one object per line
340
+ }
341
+ const out: Record<string, unknown>[] = [];
342
+ for (const line of trimmed.split("\n")) {
343
+ const t = line.trim();
344
+ if (!t) continue;
345
+ try {
346
+ const row: unknown = JSON.parse(t);
347
+ if (typeof row === "object" && row !== null && !Array.isArray(row)) {
348
+ out.push(row as Record<string, unknown>);
349
+ }
350
+ } catch {
351
+ // skip malformed lines
352
+ }
353
+ }
354
+ return out;
355
+ }
@@ -81,6 +81,10 @@ describe("image recipes", () => {
81
81
  expect(applied.volumes).toContain("ai-data:/root/.ollama");
82
82
  expect(applied.healthcheck?.test.join(" ")).toContain("ollama list");
83
83
  expect(String(applied.command)).toContain("ollama pull");
84
+ // Compose must see `$$` so `$pid` / `$i` are not treated as project env.
85
+ expect(String(applied.command)).toContain("pid=$$!");
86
+ expect(String(applied.command)).toContain("wait $$pid");
87
+ expect(String(applied.command)).not.toMatch(/(?<!\$)\$pid\b/);
84
88
  const url = recipeFor(spec.image).url(spec, {
85
89
  host: "127.0.0.1",
86
90
  port: 11434,
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Integration: generated Dockerfile builds (and optionally runs).
3
+ *
4
+ * Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon. Never an empty pass.
3
5
  */
4
6
 
5
7
  import { describe, expect, test } from "bun:test";
@@ -8,47 +10,50 @@ import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
11
  import { deriveInfrastructure, writeDerivedFiles } from "./index.ts";
10
12
 
11
- async function dockerAvailable(): Promise<boolean> {
13
+ function dockerAvailable(): boolean {
12
14
  try {
13
- const proc = Bun.spawn(["docker", "info"], {
14
- stdout: "pipe",
15
- stderr: "pipe",
16
- });
17
- const code = await proc.exited;
18
- return code === 0;
15
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
19
16
  } catch {
20
17
  return false;
21
18
  }
22
19
  }
23
20
 
24
- describe("generated Dockerfile integration", () => {
25
- test("oke docker output builds and runs", async () => {
26
- if (!(await dockerAvailable())) {
27
- console.warn("skipping: docker daemon not available");
28
- return;
29
- }
21
+ const WANT = process.env.OKE_TEST_DOCKER === "1";
22
+ const DOCKER = WANT && dockerAvailable();
23
+ if (!DOCKER) {
24
+ console.log(
25
+ WANT
26
+ ? "skip: generated Dockerfile e2e (docker daemon not available)"
27
+ : "skip: generated Dockerfile e2e (OKE_TEST_DOCKER≠1)",
28
+ );
29
+ }
30
+ const live = DOCKER ? test : test.skip;
30
31
 
31
- const dir = await mkdtemp(join(tmpdir(), "oke-df-build-"));
32
- try {
33
- // Minimal app that `oke start` can import.
34
- await Bun.write(
35
- join(dir, "package.json"),
36
- JSON.stringify(
37
- {
38
- name: "oke-docker-fixture",
39
- private: true,
40
- type: "module",
41
- bin: { oke: "./oke.ts" },
42
- scripts: { start: "bun ./app.ts" },
43
- okengine: { entry: "./app.ts" },
44
- },
45
- null,
46
- 2,
47
- ),
48
- );
49
- await Bun.write(
50
- join(dir, "oke.ts"),
51
- `#!/usr/bin/env bun
32
+ describe("generated Dockerfile integration", () => {
33
+ live(
34
+ "oke docker output builds and runs",
35
+ async () => {
36
+ const dir = await mkdtemp(join(tmpdir(), "oke-df-build-"));
37
+ try {
38
+ // Minimal app that `oke start` can import.
39
+ await Bun.write(
40
+ join(dir, "package.json"),
41
+ JSON.stringify(
42
+ {
43
+ name: "oke-docker-fixture",
44
+ private: true,
45
+ type: "module",
46
+ bin: { oke: "./oke.ts" },
47
+ scripts: { start: "bun ./app.ts" },
48
+ okengine: { entry: "./app.ts" },
49
+ },
50
+ null,
51
+ 2,
52
+ ),
53
+ );
54
+ await Bun.write(
55
+ join(dir, "oke.ts"),
56
+ `#!/usr/bin/env bun
52
57
  const [cmd] = process.argv.slice(2);
53
58
  if (cmd === "start") {
54
59
  await import("./app.ts");
@@ -57,10 +62,10 @@ if (cmd === "start") {
57
62
  process.exit(1);
58
63
  }
59
64
  `,
60
- );
61
- await Bun.write(
62
- join(dir, "app.ts"),
63
- `const server = Bun.serve({
65
+ );
66
+ await Bun.write(
67
+ join(dir, "app.ts"),
68
+ `const server = Bun.serve({
64
69
  port: Number(process.env.PORT ?? 6530),
65
70
  hostname: "0.0.0.0",
66
71
  fetch() {
@@ -69,95 +74,97 @@ if (cmd === "start") {
69
74
  });
70
75
  console.log("listening", server.port);
71
76
  `,
72
- );
77
+ );
73
78
 
74
- // No lockfile — adjust Dockerfile install for the fixture.
75
- const derived = deriveInfrastructure({
76
- images: { "store.sql": "postgres:18-alpine" },
77
- credentials: {
78
- "store.sql": {
79
- user: "oke",
80
- password: "fixture-pass",
81
- database: "oke",
79
+ // No lockfile — adjust Dockerfile install for the fixture.
80
+ const derived = deriveInfrastructure({
81
+ images: { "store.sql": "postgres:18-alpine" },
82
+ credentials: {
83
+ "store.sql": {
84
+ user: "oke",
85
+ password: "fixture-pass",
86
+ database: "oke",
87
+ },
82
88
  },
83
- },
84
- app: "fixture",
85
- // Flat fixture root (build context `.`).
86
- composeDir: ".",
87
- });
88
- await writeDerivedFiles(derived, dir);
89
+ app: "fixture",
90
+ // Flat fixture root (build context `.`).
91
+ composeDir: ".",
92
+ });
93
+ await writeDerivedFiles(derived, dir);
89
94
 
90
- // Fixture has no bun.lock — rewrite install step.
91
- let df = await Bun.file(join(dir, "Dockerfile")).text();
92
- df = df.replace(
93
- "RUN bun install --frozen-lockfile --production",
94
- "RUN bun install --production",
95
- );
96
- await Bun.write(join(dir, "Dockerfile"), df);
95
+ // Fixture has no bun.lock — rewrite install step.
96
+ let df = await Bun.file(join(dir, "Dockerfile")).text();
97
+ df = df.replace(
98
+ "RUN bun install --frozen-lockfile --production",
99
+ "RUN bun install --production",
100
+ );
101
+ await Bun.write(join(dir, "Dockerfile"), df);
97
102
 
98
- const tag = `oke-docker-fixture:${Date.now()}`;
99
- const build = Bun.spawn(["docker", "build", "-t", tag, dir], {
100
- stdout: "pipe",
101
- stderr: "pipe",
102
- });
103
- const [buildOut, buildErr, buildCode] = await Promise.all([
104
- new Response(build.stdout).text(),
105
- new Response(build.stderr).text(),
106
- build.exited,
107
- ]);
108
- expect(buildCode).toBe(0);
109
- if (buildCode !== 0) {
110
- console.error(buildOut, buildErr);
111
- }
103
+ const tag = `oke-docker-fixture:${Date.now()}`;
104
+ const build = Bun.spawn(["docker", "build", "-t", tag, dir], {
105
+ stdout: "pipe",
106
+ stderr: "pipe",
107
+ });
108
+ const [buildOut, buildErr, buildCode] = await Promise.all([
109
+ new Response(build.stdout).text(),
110
+ new Response(build.stderr).text(),
111
+ build.exited,
112
+ ]);
113
+ expect(buildCode).toBe(0);
114
+ if (buildCode !== 0) {
115
+ console.error(buildOut, buildErr);
116
+ }
112
117
 
113
- const name = `oke-fixture-run-${Date.now()}`;
114
- const run = Bun.spawn(["docker", "run", "-d", "--name", name, "-p", "0:6530", tag], {
115
- stdout: "pipe",
116
- stderr: "pipe",
117
- });
118
- const [runOut, runErr, runCode] = await Promise.all([
119
- new Response(run.stdout).text(),
120
- new Response(run.stderr).text(),
121
- run.exited,
122
- ]);
123
- expect(runCode).toBe(0);
124
- if (runCode !== 0) console.error(runOut, runErr);
118
+ const name = `oke-fixture-run-${Date.now()}`;
119
+ const run = Bun.spawn(["docker", "run", "-d", "--name", name, "-p", "0:6530", tag], {
120
+ stdout: "pipe",
121
+ stderr: "pipe",
122
+ });
123
+ const [runOut, runErr, runCode] = await Promise.all([
124
+ new Response(run.stdout).text(),
125
+ new Response(run.stderr).text(),
126
+ run.exited,
127
+ ]);
128
+ expect(runCode).toBe(0);
129
+ if (runCode !== 0) console.error(runOut, runErr);
125
130
 
126
- // Resolve published port
127
- const portProc = Bun.spawn(["docker", "port", name, "6530"], {
128
- stdout: "pipe",
129
- stderr: "pipe",
130
- });
131
- const portOut = await new Response(portProc.stdout).text();
132
- await portProc.exited;
133
- const m = portOut.match(/:(\d+)/);
134
- expect(m).toBeTruthy();
135
- const hostPort = m![1]!;
131
+ // Resolve published port
132
+ const portProc = Bun.spawn(["docker", "port", name, "6530"], {
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+ const portOut = await new Response(portProc.stdout).text();
137
+ await portProc.exited;
138
+ const m = portOut.match(/:(\d+)/);
139
+ expect(m).toBeTruthy();
140
+ const hostPort = m![1]!;
136
141
 
137
- let body: { ok?: boolean } | null = null;
138
- for (let i = 0; i < 20; i++) {
139
- try {
140
- const res = await fetch(`http://127.0.0.1:${hostPort}/`);
141
- if (res.ok) {
142
- body = (await res.json()) as { ok?: boolean };
143
- break;
142
+ let body: { ok?: boolean } | null = null;
143
+ for (let i = 0; i < 20; i++) {
144
+ try {
145
+ const res = await fetch(`http://127.0.0.1:${hostPort}/`);
146
+ if (res.ok) {
147
+ body = (await res.json()) as { ok?: boolean };
148
+ break;
149
+ }
150
+ } catch {
151
+ await Bun.sleep(250);
144
152
  }
145
- } catch {
146
- await Bun.sleep(250);
147
153
  }
148
- }
149
- expect(body?.ok).toBe(true);
154
+ expect(body?.ok).toBe(true);
150
155
 
151
- await Bun.spawn(["docker", "rm", "-f", name], {
152
- stdout: "pipe",
153
- stderr: "pipe",
154
- }).exited;
155
- await Bun.spawn(["docker", "rmi", "-f", tag], {
156
- stdout: "pipe",
157
- stderr: "pipe",
158
- }).exited;
159
- } finally {
160
- await rm(dir, { recursive: true, force: true }).catch(() => {});
161
- }
162
- }, 180_000);
156
+ await Bun.spawn(["docker", "rm", "-f", name], {
157
+ stdout: "pipe",
158
+ stderr: "pipe",
159
+ }).exited;
160
+ await Bun.spawn(["docker", "rmi", "-f", tag], {
161
+ stdout: "pipe",
162
+ stderr: "pipe",
163
+ }).exited;
164
+ } finally {
165
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
166
+ }
167
+ },
168
+ 180_000,
169
+ );
163
170
  });
@@ -66,6 +66,25 @@ export {
66
66
  stackAppSlug,
67
67
  stackInstanceId,
68
68
  } from "./stack-id.ts";
69
+ export {
70
+ OKE_DEV_PROJECT_RE,
71
+ composeProjectName,
72
+ defaultDockerRunner,
73
+ downStack,
74
+ isOkeDevProject,
75
+ isOkeProjectRoot,
76
+ listOkeComposeProjects,
77
+ listStackContainers,
78
+ parseJsonRecords,
79
+ projectFromResourceName,
80
+ projectsFromContainerSelection,
81
+ resolveSelection,
82
+ selectionKey,
83
+ type DockerRunResult,
84
+ type DockerRunner,
85
+ type OkeContainer,
86
+ type OkeStack,
87
+ } from "./cleanup.ts";
69
88
  export {
70
89
  formatImagesLock,
71
90
  pinImages,