okengine 0.2.3 → 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 (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
@@ -17,7 +17,10 @@ export function App() {
17
17
  queryKey: ["console.setup.status"],
18
18
  queryFn: async () => {
19
19
  const res = await consoleCalls.setupStatus();
20
- if (res.error) throw new Error(res.error.code);
20
+ if (res.error) {
21
+ if (res.error.code === "Unauthorized") setAccessToken(null);
22
+ throw new Error(res.error.code);
23
+ }
21
24
  return res.data as { setupClosed: boolean; claimRequired: boolean };
22
25
  },
23
26
  retry: 2,
@@ -36,7 +39,12 @@ export function App() {
36
39
  retry: false,
37
40
  queryFn: async () => {
38
41
  const res = await consoleCalls.sessionMe();
39
- if (res.error) throw new Error(res.error.code);
42
+ if (res.error) {
43
+ if (res.error.code === "Unauthorized" || res.error.code === "AuthFailed") {
44
+ setAccessToken(null);
45
+ }
46
+ throw new Error(res.error.code);
47
+ }
40
48
  return res.data;
41
49
  },
42
50
  });
@@ -21,12 +21,32 @@ import type {
21
21
  ServiceCredentials,
22
22
  ServiceSpec,
23
23
  } from "./types.ts";
24
+ import { DEFAULT_DOCKER_DIR } from "./types.ts";
24
25
  import { generateCredentials } from "./credentials.ts";
26
+ import { hostPortForInstance } from "./stack-id.ts";
25
27
  import { APP_PORT } from "../runtime/types.ts";
26
28
 
27
29
  /** Canonical layer-4 filename — never written by derivation. */
28
30
  export const COMPOSE_OVERRIDE = "compose.override.yml";
29
31
 
32
+ /**
33
+ * Relative path refs for compose files living under {@link DeriveOptions.composeDir}.
34
+ *
35
+ * @param composeDir - Directory relative to project root (`docker` or `.`)
36
+ */
37
+ export function composePathRefs(composeDir: string = DEFAULT_DOCKER_DIR): {
38
+ readonly envFile: string;
39
+ readonly buildContext: string;
40
+ readonly dockerfile: string;
41
+ } {
42
+ const flat = composeDir === "." || composeDir === "";
43
+ return {
44
+ envFile: flat ? ".env.stack" : "../.env.stack",
45
+ buildContext: flat ? "." : "..",
46
+ dockerfile: "Dockerfile",
47
+ };
48
+ }
49
+
30
50
  /**
31
51
  * Build normalised {@link ServiceSpec} list from image pins.
32
52
  *
@@ -40,12 +60,15 @@ export function buildSpecs(options: DeriveOptions): ServiceSpec[] {
40
60
  const creds =
41
61
  options.credentials?.[role] ?? generateCredentials(role);
42
62
  const port = recipe.port;
63
+ const hostPort = options.instanceId
64
+ ? hostPortForInstance(role, port, options.instanceId)
65
+ : defaultHostPort(role, port);
43
66
  specs.push({
44
67
  role,
45
68
  serviceName: serviceNameFor(role),
46
69
  image,
47
70
  port,
48
- hostPort: defaultHostPort(role, port),
71
+ hostPort,
49
72
  credentials: creds,
50
73
  });
51
74
  }
@@ -65,24 +88,31 @@ export function emitComposeLayers(
65
88
  const recipes = options.recipes ?? [];
66
89
  const appPort = options.appPort ?? APP_PORT;
67
90
  const app = options.app ?? "app";
91
+ const includeApp = options.includeApp !== false;
92
+ const paths = composePathRefs(options.composeDir ?? DEFAULT_DOCKER_DIR);
68
93
  const files: GeneratedFile[] = [];
69
94
 
70
- // Layer 1 — app + network
71
- const base = {
95
+ // Layer 1 — project name + network (+ optional app for deploy / oke docker)
96
+ const base: Record<string, unknown> = {
72
97
  name: `oke-${app}`,
73
- services: {
98
+ networks: { oke: { driver: "bridge" } },
99
+ };
100
+ if (includeApp) {
101
+ base.services = {
74
102
  app: {
75
- build: { context: ".", dockerfile: "Dockerfile" },
103
+ build: {
104
+ context: paths.buildContext,
105
+ dockerfile: paths.dockerfile,
106
+ },
76
107
  ports: [`${appPort}:${appPort}`],
77
- env_file: [".env.stack"],
108
+ env_file: [paths.envFile],
78
109
  depends_on: Object.fromEntries(
79
110
  specs.map((s) => [s.serviceName, { condition: "service_healthy" }]),
80
111
  ),
81
112
  networks: ["oke"],
82
113
  },
83
- },
84
- networks: { oke: { driver: "bridge" } },
85
- };
114
+ };
115
+ }
86
116
  files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
87
117
 
88
118
  // Layer 2 — per-role
@@ -93,7 +123,7 @@ export function emitComposeLayers(
93
123
  image: spec.image,
94
124
  ports: [`${spec.hostPort}:${spec.port}`],
95
125
  networks: ["oke"],
96
- env_file: [".env.stack"],
126
+ env_file: [paths.envFile],
97
127
  };
98
128
  if (applied.environment) service.environment = applied.environment;
99
129
  if (applied.command) service.command = applied.command;
@@ -111,8 +141,9 @@ export function emitComposeLayers(
111
141
 
112
142
  // Layer 3 — prod overlay
113
143
  if (options.prod) {
114
- const prodServices: Record<string, unknown> = {
115
- app: {
144
+ const prodServices: Record<string, unknown> = {};
145
+ if (includeApp) {
146
+ prodServices.app = {
116
147
  deploy: {
117
148
  replicas: 1,
118
149
  resources: {
@@ -120,8 +151,8 @@ export function emitComposeLayers(
120
151
  },
121
152
  },
122
153
  secrets: specs.flatMap((s) => secretNames(s)),
123
- },
124
- };
154
+ };
155
+ }
125
156
  for (const spec of specs) {
126
157
  prodServices[spec.serviceName] = {
127
158
  deploy: {
@@ -2,6 +2,7 @@
2
2
  * Derive Dockerfile + compose files from config image pins.
3
3
  */
4
4
 
5
+ import { mkdirSync } from "node:fs";
5
6
  import {
6
7
  assertNoCredentialsInYaml,
7
8
  buildSpecs,
@@ -11,6 +12,7 @@ import {
11
12
  } from "./compose.ts";
12
13
  import { emitDockerfile } from "./dockerfile.ts";
13
14
  import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
15
+ import { DEFAULT_DOCKER_DIR } from "./types.ts";
14
16
 
15
17
  /**
16
18
  * Derive infrastructure files from normalised image pins.
@@ -28,15 +30,22 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
28
30
  );
29
31
  }
30
32
 
31
- const specs = buildSpecs(options);
33
+ const normalised: DeriveOptions = {
34
+ ...options,
35
+ composeDir: options.composeDir ?? DEFAULT_DOCKER_DIR,
36
+ includeApp: options.includeApp !== false,
37
+ };
38
+
39
+ const specs = buildSpecs(normalised);
32
40
  const { files: composeFilesContent, composeFiles } = emitComposeLayers(
33
41
  specs,
34
- options,
42
+ normalised,
35
43
  );
36
44
  const dockerfile: GeneratedFile = {
37
45
  path: "Dockerfile",
38
- content: emitDockerfile({ appPort: options.appPort }),
46
+ content: emitDockerfile({ appPort: normalised.appPort }),
39
47
  };
48
+ // Always emit Dockerfile for deploy; stack-only runs ignore it.
40
49
  const files = [dockerfile, ...composeFilesContent];
41
50
 
42
51
  for (const f of files) {
@@ -50,8 +59,8 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
50
59
 
51
60
  const stackEnv = buildStackEnv(
52
61
  specs,
53
- options.recipes ?? [],
54
- options.host ?? "127.0.0.1",
62
+ normalised.recipes ?? [],
63
+ normalised.host ?? "127.0.0.1",
55
64
  );
56
65
 
57
66
  return { specs, files, stackEnv, composeFiles };
@@ -62,22 +71,33 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
62
71
  * credential values into YAML. Optionally writes `.env.stack`.
63
72
  *
64
73
  * @param result - Derive result
65
- * @param outDir - Destination
74
+ * @param outDir - Destination for Dockerfile / compose (usually `docker/`)
66
75
  * @param options - Write controls
67
76
  */
68
77
  export async function writeDerivedFiles(
69
78
  result: DeriveResult,
70
79
  outDir: string,
71
- options: { readonly writeStackEnv?: boolean } = {},
80
+ options: {
81
+ readonly writeStackEnv?: boolean;
82
+ /**
83
+ * Directory for `.env.stack` (project root). Defaults to `outDir`.
84
+ * Prefer the project cwd when compose lives under `docker/`.
85
+ */
86
+ readonly stackEnvDir?: string;
87
+ } = {},
72
88
  ): Promise<readonly string[]> {
73
89
  const written: string[] = [];
90
+ const root = outDir.replace(/\/$/, "");
91
+ mkdirSync(root, { recursive: true });
74
92
  for (const file of result.files) {
75
- const path = `${outDir.replace(/\/$/, "")}/${file.path}`;
93
+ const path = `${root}/${file.path}`;
76
94
  await Bun.write(path, file.content);
77
95
  written.push(path);
78
96
  }
79
97
  if (options.writeStackEnv) {
80
- const envPath = `${outDir.replace(/\/$/, "")}/.env.stack`;
98
+ const envRoot = (options.stackEnvDir ?? outDir).replace(/\/$/, "");
99
+ mkdirSync(envRoot, { recursive: true });
100
+ const envPath = `${envRoot}/.env.stack`;
81
101
  await Bun.write(envPath, formatStackEnv(result.stackEnv));
82
102
  written.push(envPath);
83
103
  }
@@ -125,8 +125,13 @@ describe("deriveInfrastructure", () => {
125
125
  expect(sqlYml).toContain("pgvector/pgvector:pg17");
126
126
  expect(sqlYml).toContain("POSTGRES_PASSWORD");
127
127
  expect(sqlYml).toContain("${OKE_STORE_SQL_PASSWORD}");
128
+ expect(sqlYml).toContain("../.env.stack");
128
129
  expect(sqlYml).not.toContain(fixedCreds["store.sql"].password);
129
130
 
131
+ const baseYml = result.files.find((f) => f.path === "compose.yml")!.content;
132
+ expect(baseYml).toContain("context: \"..\"");
133
+ expect(baseYml).toContain("app:");
134
+
130
135
  for (const f of result.files) {
131
136
  assertNoCredentialsInYaml(
132
137
  f.content,
@@ -140,6 +145,20 @@ describe("deriveInfrastructure", () => {
140
145
  );
141
146
  });
142
147
 
148
+ test("includeApp false omits app service (infra-only stack)", () => {
149
+ const result = deriveInfrastructure({
150
+ images: { "store.sql": "postgres:16" },
151
+ credentials: { "store.sql": fixedCreds["store.sql"] },
152
+ includeApp: false,
153
+ app: "dev",
154
+ });
155
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
156
+ expect(base).toContain("oke-dev");
157
+ expect(base).toContain("networks:");
158
+ expect(base).not.toContain("app:");
159
+ expect(base).not.toContain("build:");
160
+ });
161
+
143
162
  test("prod overlay adds deploy.replicas and is layer 3", () => {
144
163
  const result = deriveInfrastructure({
145
164
  images: { "store.sql": "postgres:16" },
@@ -160,15 +179,20 @@ describe("deriveInfrastructure", () => {
160
179
  });
161
180
 
162
181
  test("writeDerivedFiles never writes compose.override.yml", async () => {
163
- const dir = await mkdtemp(join(tmpdir(), "oke-docker-"));
182
+ const root = await mkdtemp(join(tmpdir(), "oke-docker-"));
183
+ const dockerDir = join(root, "docker");
164
184
  const result = deriveInfrastructure({
165
185
  images: { "store.sql": "postgres:16" },
166
186
  credentials: { "store.sql": fixedCreds["store.sql"] },
167
187
  });
168
- const written = await writeDerivedFiles(result, dir, { writeStackEnv: true });
188
+ const written = await writeDerivedFiles(result, dockerDir, {
189
+ writeStackEnv: true,
190
+ stackEnvDir: root,
191
+ });
169
192
  expect(written.some((p) => p.endsWith(COMPOSE_OVERRIDE))).toBe(false);
170
- expect(await Bun.file(join(dir, ".env.stack")).exists()).toBe(true);
171
- const envText = await Bun.file(join(dir, ".env.stack")).text();
193
+ expect(await Bun.file(join(root, ".env.stack")).exists()).toBe(true);
194
+ expect(await Bun.file(join(dockerDir, "compose.yml")).exists()).toBe(true);
195
+ const envText = await Bun.file(join(root, ".env.stack")).text();
172
196
  expect(envText).toContain("DATABASE_URL=");
173
197
  expect(formatStackEnv(result.stackEnv)).toContain("OKE_STORE_SQL_PASSWORD=");
174
198
  });
@@ -82,6 +82,8 @@ console.log("listening", server.port);
82
82
  },
83
83
  },
84
84
  app: "fixture",
85
+ // Flat fixture root (build context `.`).
86
+ composeDir: ".",
85
87
  });
86
88
  await writeDerivedFiles(derived, dir);
87
89
 
@@ -19,11 +19,14 @@ export type {
19
19
  ServiceSpec,
20
20
  } from "./types.ts";
21
21
 
22
+ export { DEFAULT_DOCKER_DIR } from "./types.ts";
23
+
22
24
  export {
23
25
  COMPOSE_OVERRIDE,
24
26
  assertNoCredentialsInYaml,
25
27
  buildSpecs,
26
28
  buildStackEnv,
29
+ composePathRefs,
27
30
  emitComposeLayers,
28
31
  formatStackEnv,
29
32
  } from "./compose.ts";
@@ -43,6 +46,13 @@ export {
43
46
  resolveStack,
44
47
  type StackRow,
45
48
  } from "./stack.ts";
49
+ export {
50
+ hostPortForInstance,
51
+ loadExistingStackCredentials,
52
+ parseStackCredentials,
53
+ stackAppSlug,
54
+ stackInstanceId,
55
+ } from "./stack-id.ts";
46
56
  export {
47
57
  formatImagesLock,
48
58
  pinImages,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-project stack identity.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ hostPortForInstance,
8
+ parseStackCredentials,
9
+ stackAppSlug,
10
+ stackInstanceId,
11
+ } from "./stack-id.ts";
12
+ import { deriveInfrastructure } from "./derive.ts";
13
+
14
+ describe("stackInstanceId", () => {
15
+ test("is stable for the same cwd and differs across paths", () => {
16
+ const a = stackInstanceId("/tmp/oke-project-a");
17
+ const b = stackInstanceId("/tmp/oke-project-a");
18
+ const c = stackInstanceId("/tmp/oke-project-b");
19
+ expect(a).toBe(b);
20
+ expect(a).toHaveLength(6);
21
+ expect(a).not.toBe(c);
22
+ });
23
+
24
+ test("stackAppSlug is dev-<id>", () => {
25
+ const id = stackInstanceId("/tmp/oke-x");
26
+ expect(stackAppSlug("/tmp/oke-x")).toBe(`dev-${id}`);
27
+ });
28
+ });
29
+
30
+ describe("hostPortForInstance", () => {
31
+ test("offsets sql and kv into separate ranges", () => {
32
+ const id = "abcd12";
33
+ const sql = hostPortForInstance("store.sql", 5432, id);
34
+ const kv = hostPortForInstance("store.kv", 6379, id);
35
+ expect(sql).toBeGreaterThanOrEqual(15_000);
36
+ expect(sql).toBeLessThan(16_000);
37
+ expect(kv).toBeGreaterThanOrEqual(16_000);
38
+ expect(kv).toBeLessThan(17_000);
39
+ });
40
+ });
41
+
42
+ describe("parseStackCredentials", () => {
43
+ test("reads OKE_* credential keys", () => {
44
+ const text = `
45
+ OKE_STORE_SQL_USER=oke
46
+ OKE_STORE_SQL_PASSWORD=s3cret
47
+ OKE_STORE_SQL_DB=oke
48
+ OKE_STORE_KV_USER=oke
49
+ OKE_STORE_KV_PASSWORD=kvpass
50
+ OKE_STORE_KV_DB=oke
51
+ `;
52
+ const creds = parseStackCredentials(text, ["store.sql", "store.kv"]);
53
+ expect(creds["store.sql"]?.password).toBe("s3cret");
54
+ expect(creds["store.kv"]?.password).toBe("kvpass");
55
+ });
56
+ });
57
+
58
+ describe("deriveInfrastructure instanceId", () => {
59
+ test("unique app name and host ports land in stackEnv URLs", () => {
60
+ const result = deriveInfrastructure({
61
+ images: {
62
+ "store.sql": "postgres:18-alpine",
63
+ "store.kv": "redis:8-alpine",
64
+ },
65
+ app: "dev-abcdef",
66
+ instanceId: "abcdef",
67
+ includeApp: false,
68
+ credentials: {
69
+ "store.sql": {
70
+ user: "oke",
71
+ password: "stack-id-test-sql-password",
72
+ database: "oke",
73
+ },
74
+ "store.kv": {
75
+ user: "oke",
76
+ password: "stack-id-test-kv-password",
77
+ database: "oke",
78
+ },
79
+ },
80
+ });
81
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
82
+ expect(base).toContain("oke-dev-abcdef");
83
+ expect(result.stackEnv.DATABASE_URL).toMatch(/:15\d{3}\//);
84
+ expect(result.stackEnv.REDIS_URL).toMatch(/:16\d{3}/);
85
+ });
86
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Per-project local stack identity — unique compose project + host ports.
3
+ *
4
+ * `oke dev --stack` must not share one `oke-dev` Postgres across every app on
5
+ * the machine. Identity is a stable short hash of the project cwd.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { resolve } from "node:path";
10
+ import type { ServiceCredentials } from "./types.ts";
11
+ import { defaultHostPort, envPrefix } from "./helpers.ts";
12
+
13
+ /**
14
+ * Stable 6-hex id for a project directory (local stacks only).
15
+ *
16
+ * @param cwd - Project root
17
+ */
18
+ export function stackInstanceId(cwd: string): string {
19
+ return createHash("sha256")
20
+ .update(resolve(cwd))
21
+ .digest("hex")
22
+ .slice(0, 6);
23
+ }
24
+
25
+ /**
26
+ * Compose project / app slug: `dev-<id>` → Docker name `oke-dev-<id>`.
27
+ *
28
+ * @param cwd - Project root
29
+ */
30
+ export function stackAppSlug(cwd: string): string {
31
+ return `dev-${stackInstanceId(cwd)}`;
32
+ }
33
+
34
+ /**
35
+ * Host port for a role, offset by instance id so two stacks can run at once.
36
+ *
37
+ * @param role - Role key
38
+ * @param containerPort - Container listen port
39
+ * @param instanceId - 6-hex stack id
40
+ */
41
+ export function hostPortForInstance(
42
+ role: string,
43
+ containerPort: number,
44
+ instanceId: string,
45
+ ): number {
46
+ const n = Number.parseInt(instanceId.slice(0, 4), 16) % 1000;
47
+ if (role === "store.sql") return 15_000 + n;
48
+ if (role === "store.kv") return 16_000 + n;
49
+ if (role === "signal") return 17_000 + n;
50
+ return defaultHostPort(role, containerPort) + n;
51
+ }
52
+
53
+ /**
54
+ * Parse role credentials from an existing `.env.stack` body (reuse on restart).
55
+ *
56
+ * @param text - Dotenv contents
57
+ * @param roles - Roles to look up
58
+ */
59
+ export function parseStackCredentials(
60
+ text: string,
61
+ roles: readonly string[],
62
+ ): Record<string, ServiceCredentials> {
63
+ const map = new Map<string, string>();
64
+ for (const line of text.split("\n")) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith("#")) continue;
67
+ const eq = trimmed.indexOf("=");
68
+ if (eq <= 0) continue;
69
+ const key = trimmed.slice(0, eq);
70
+ let value = trimmed.slice(eq + 1);
71
+ if (
72
+ (value.startsWith('"') && value.endsWith('"')) ||
73
+ (value.startsWith("'") && value.endsWith("'"))
74
+ ) {
75
+ value = value.slice(1, -1);
76
+ }
77
+ map.set(key, value);
78
+ }
79
+
80
+ const out: Record<string, ServiceCredentials> = {};
81
+ for (const role of roles) {
82
+ const prefix = envPrefix(role);
83
+ const user = map.get(`${prefix}_USER`);
84
+ const password = map.get(`${prefix}_PASSWORD`);
85
+ const database = map.get(`${prefix}_DB`);
86
+ if (user && password && database) {
87
+ out[role] = { user, password, database };
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Load credentials from project `.env.stack` when present.
95
+ *
96
+ * @param cwd - Project root
97
+ * @param roles - Image roles
98
+ */
99
+ export async function loadExistingStackCredentials(
100
+ cwd: string,
101
+ roles: readonly string[],
102
+ ): Promise<Readonly<Record<string, ServiceCredentials>> | undefined> {
103
+ const path = resolve(cwd, ".env.stack");
104
+ const file = Bun.file(path);
105
+ if (!(await file.exists())) return undefined;
106
+ const parsed = parseStackCredentials(await file.text(), roles);
107
+ return Object.keys(parsed).length > 0 ? parsed : undefined;
108
+ }
@@ -32,6 +32,7 @@ describe("oke dev --stack postgres integration", () => {
32
32
  }
33
33
 
34
34
  const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
35
+ const dockerDir = join(dir, "docker");
35
36
  const project = `oke-pg-${Date.now()}`;
36
37
  try {
37
38
  const derived = deriveInfrastructure({
@@ -45,11 +46,16 @@ describe("oke dev --stack postgres integration", () => {
45
46
  },
46
47
  app: "stacktest",
47
48
  host: "127.0.0.1",
49
+ includeApp: false,
50
+ composeDir: "docker",
51
+ });
52
+ await writeDerivedFiles(derived, dockerDir, {
53
+ writeStackEnv: true,
54
+ stackEnvDir: dir,
48
55
  });
49
- await writeDerivedFiles(derived, dir, { writeStackEnv: true });
50
56
 
51
- // Stack-only: no app service just the role compose file.
52
- const composeFiles = ["compose.store.sql.yml"];
57
+ // Infra-only: network + role compose (no app build).
58
+ const composeFiles = ["compose.yml", "compose.store.sql.yml"];
53
59
  const up = Bun.spawn(
54
60
  [
55
61
  "docker",
@@ -61,7 +67,7 @@ describe("oke dev --stack postgres integration", () => {
61
67
  "-d",
62
68
  ],
63
69
  {
64
- cwd: dir,
70
+ cwd: dockerDir,
65
71
  stdout: "pipe",
66
72
  stderr: "pipe",
67
73
  env: {
@@ -92,7 +98,7 @@ describe("oke dev --stack postgres integration", () => {
92
98
  "--format",
93
99
  "json",
94
100
  ],
95
- { cwd: dir, stdout: "pipe", stderr: "pipe" },
101
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
96
102
  );
97
103
  const text = await new Response(ps.stdout).text();
98
104
  await ps.exited;
@@ -115,11 +121,12 @@ describe("oke dev --stack postgres integration", () => {
115
121
  }
116
122
 
117
123
  // Prove credentials live in .env.stack, not YAML.
118
- const yml = await Bun.file(join(dir, "compose.store.sql.yml")).text();
124
+ const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
119
125
  expect(yml).not.toContain("stack-integration-pass");
120
126
  expect(formatStackEnv(derived.stackEnv)).toContain(
121
127
  "stack-integration-pass",
122
128
  );
129
+ expect(await Bun.file(join(dir, ".env.stack")).exists()).toBe(true);
123
130
  } finally {
124
131
  await Bun.spawn(
125
132
  [
@@ -128,11 +135,13 @@ describe("oke dev --stack postgres integration", () => {
128
135
  "-p",
129
136
  project,
130
137
  "-f",
138
+ "compose.yml",
139
+ "-f",
131
140
  "compose.store.sql.yml",
132
141
  "down",
133
142
  "-v",
134
143
  ],
135
- { cwd: dir, stdout: "pipe", stderr: "pipe" },
144
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
136
145
  ).exited.catch(() => {});
137
146
  await rm(dir, { recursive: true, force: true }).catch(() => {});
138
147
  }
@@ -100,7 +100,18 @@ export interface DeriveOptions {
100
100
  readonly appPort?: number;
101
101
  /** When true, emit prod overlays (limits, secret refs, deploy.replicas). */
102
102
  readonly prod?: boolean;
103
- /** Output directory (default `.`). */
103
+ /**
104
+ * Include the `app` service in `compose.yml` (default true).
105
+ * `oke dev --stack` sets false — host Bun runs the app; Docker is infra only.
106
+ */
107
+ readonly includeApp?: boolean;
108
+ /**
109
+ * Compose artefact directory relative to the project root (default `docker`).
110
+ * Controls generated `env_file` / `build.context` paths (e.g. `../.env.stack`).
111
+ * Pass `"."` for legacy root-level layout.
112
+ */
113
+ readonly composeDir?: string;
114
+ /** Output directory (default `docker`). */
104
115
  readonly outDir?: string;
105
116
  /** Inject credentials (tests). Defaults to generated randoms. */
106
117
  readonly credentials?: Readonly<Record<string, ServiceCredentials>>;
@@ -108,8 +119,16 @@ export interface DeriveOptions {
108
119
  readonly host?: string;
109
120
  /** Extra image recipes (plugins). */
110
121
  readonly recipes?: readonly ImageRecipe[];
122
+ /**
123
+ * Local stack instance id (6-hex). When set, host ports are offset so two
124
+ * `oke dev -s` projects do not share one Postgres on `:5432`.
125
+ */
126
+ readonly instanceId?: string;
111
127
  }
112
128
 
129
+ /** Default relative directory for generated Docker / compose artefacts. */
130
+ export const DEFAULT_DOCKER_DIR = "docker";
131
+
113
132
  /** One generated file. */
114
133
  export interface GeneratedFile {
115
134
  /** Relative path under `outDir`. */