okengine 0.6.1 → 0.8.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 (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  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 +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  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 +595 -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 +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -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/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
package/src/cli/dev.ts CHANGED
@@ -101,7 +101,7 @@ export interface DevServeDocsMcpOptions {
101
101
  /** Live `oke dev` session — returned when {@link DevOptions.keepAlive} is false. */
102
102
  export interface DevSession {
103
103
  readonly plan: DevPlan;
104
- /** Stop app · Console · MCP · watcher. */
104
+ /** Stop app · Console · MCP · watcher · docker compose (when this session started it). */
105
105
  readonly stop: () => void;
106
106
  readonly appPort: number;
107
107
  readonly consolePort: number;
@@ -185,6 +185,19 @@ export interface DevOptions {
185
185
  * @param cwd - Compose directory (`docker/`)
186
186
  */
187
187
  readonly composeUp?: (composeFiles: readonly string[], cwd: string) => Promise<void>;
188
+ /**
189
+ * Stop compose containers on session exit (injectable). Keeps volumes.
190
+ * Default: `docker compose … stop` with the same `-f` / cwd / env as up.
191
+ *
192
+ * @param composeFiles - `-f` list used at up
193
+ * @param cwd - Compose directory (`docker/`)
194
+ * @param env - Stack env merged into the process env for compose
195
+ */
196
+ readonly composeStop?: (
197
+ composeFiles: readonly string[],
198
+ cwd: string,
199
+ env: Readonly<Record<string, string>>,
200
+ ) => void;
188
201
  /**
189
202
  * Regenerate client types (injectable).
190
203
  *
@@ -330,6 +343,12 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
330
343
  let stackRoles: string[] | null = null;
331
344
  let composeFiles: string[] | null = null;
332
345
  let stackEnv: Record<string, string> | null = null;
346
+ /** Set only after a successful `compose up` — torn down on SIGINT / stop. */
347
+ let dockerStarted: {
348
+ files: readonly string[];
349
+ cwd: string;
350
+ env: Record<string, string>;
351
+ } | null = null;
333
352
  let stackSqlDriver = "postgres";
334
353
  let stackKvDriver = "redis";
335
354
  let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
@@ -437,6 +456,12 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
437
456
  });
438
457
  // Compose files live under docker/; cwd for compose is that directory.
439
458
  await up(composeFiles, dockerOut);
459
+ // Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
460
+ dockerStarted = {
461
+ files: composeFiles,
462
+ cwd: dockerOut,
463
+ env: { ...stackEnv! },
464
+ };
440
465
 
441
466
  // Real OpenBao vault: init once / unseal every start, then expose the
442
467
  // least-privilege app token to the app + Console (never root).
@@ -450,6 +475,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
450
475
  names: vaultNames,
451
476
  });
452
477
  Object.assign(stackEnv!, openbaoStackEnv(boot));
478
+ Object.assign(dockerStarted.env, openbaoStackEnv(boot));
453
479
  const envPath = resolve(dockerOut, ".env.docker");
454
480
  const current = await Bun.file(envPath).text();
455
481
  const tokenLine = `OKE_VAULT_TOKEN=${boot.appToken}`;
@@ -779,8 +805,34 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
779
805
  consoleServer.stop();
780
806
  mcpServer?.stop();
781
807
  docsMcpServer?.stop();
808
+ if (dockerStarted) {
809
+ const started = dockerStarted;
810
+ dockerStarted = null;
811
+ const stopCompose =
812
+ options.composeStop ??
813
+ ((files, dir, env) => {
814
+ const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
815
+ const result = Bun.spawnSync(["docker", ...args], {
816
+ cwd: dir,
817
+ stdout: "inherit",
818
+ stderr: "inherit",
819
+ env: { ...process.env, ...env },
820
+ });
821
+ if (result.exitCode !== 0) {
822
+ console.error(
823
+ `oke dev: docker compose stop exited ${result.exitCode ?? "unknown"} (containers may still be running)`,
824
+ );
825
+ }
826
+ });
827
+ try {
828
+ stopCompose(started.files, started.cwd, started.env);
829
+ } catch (err) {
830
+ console.error(err instanceof Error ? err.message : String(err));
831
+ }
832
+ }
782
833
  process.off("SIGINT", onSignal);
783
834
  process.off("SIGTERM", onSignal);
835
+ process.off("SIGHUP", onSignal);
784
836
  };
785
837
 
786
838
  const onSignal = () => {
@@ -789,6 +841,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
789
841
  };
790
842
  process.on("SIGINT", onSignal);
791
843
  process.on("SIGTERM", onSignal);
844
+ // Terminal close often delivers SIGHUP rather than SIGINT.
845
+ process.on("SIGHUP", onSignal);
792
846
 
793
847
  const session: DevSession = {
794
848
  plan: {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `oke docker clean` — non-interactive flag matrix + wiring.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdtemp } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { composeProjectName, type DockerRunner } from "../docker/cleanup.ts";
10
+ import { dockerCli } from "./docker.ts";
11
+ import { dockerCleanHelp, parseDockerCleanArgs, runDockerClean } from "./docker-clean.ts";
12
+ import { EXIT_OK, EXIT_USAGE } from "./exit.ts";
13
+ import { formatOkeHelp, flagTokensFor, OKE_COMMANDS } from "./registry.ts";
14
+
15
+ function fakeStacksRunner(projects: readonly string[]): DockerRunner {
16
+ return async (args) => {
17
+ const key = args.join(" ");
18
+ if (key.startsWith("compose ls")) {
19
+ return {
20
+ exitCode: 0,
21
+ stdout: JSON.stringify(projects.map((Name) => ({ Name, Status: "exited" }))),
22
+ stderr: "",
23
+ };
24
+ }
25
+ if (key.includes("label=com.docker.compose.project=") && key.startsWith("ps")) {
26
+ const filter = args[args.indexOf("--filter")! + 1]!;
27
+ const project = filter.replace(/^label=com\.docker\.compose\.project=/, "");
28
+ if (!projects.includes(project)) return { exitCode: 0, stdout: "", stderr: "" };
29
+ return {
30
+ exitCode: 0,
31
+ stdout: JSON.stringify({
32
+ ID: "id",
33
+ Names: `${project}-store-sql-1`,
34
+ State: "exited",
35
+ Labels: `com.docker.compose.project=${project},com.docker.compose.service=store-sql`,
36
+ }),
37
+ stderr: "",
38
+ };
39
+ }
40
+ if (key.startsWith("ps -a --filter label=com.docker.compose.project --format")) {
41
+ return {
42
+ exitCode: 0,
43
+ stdout: projects
44
+ .map((project) =>
45
+ JSON.stringify({
46
+ ID: "id",
47
+ Names: `${project}-store-sql-1`,
48
+ State: "exited",
49
+ Labels: `com.docker.compose.project=${project},com.docker.compose.service=store-sql`,
50
+ }),
51
+ )
52
+ .join("\n"),
53
+ stderr: "",
54
+ };
55
+ }
56
+ if (key.startsWith("volume ls") || key.startsWith("network ls")) {
57
+ return { exitCode: 0, stdout: "", stderr: "" };
58
+ }
59
+ if (key.startsWith("compose -p ") && key.includes("down")) {
60
+ return { exitCode: 0, stdout: "", stderr: "" };
61
+ }
62
+ return { exitCode: 1, stdout: "", stderr: `unexpected: ${key}` };
63
+ };
64
+ }
65
+
66
+ describe("oke docker clean", () => {
67
+ test("parseDockerCleanArgs", () => {
68
+ expect(parseDockerCleanArgs(["--yes", "--all"])).toEqual({ yes: true, all: true });
69
+ expect(parseDockerCleanArgs(["-y"])).toEqual({ yes: true, all: false });
70
+ expect(parseDockerCleanArgs(["--help"]).help).toBe(true);
71
+ expect(() => parseDockerCleanArgs(["--nope"])).toThrow(/unknown flag/);
72
+ });
73
+
74
+ test("non-TTY outside without --all --yes → usage", async () => {
75
+ const err: string[] = [];
76
+ const code = await runDockerClean({
77
+ cwd: await mkdtemp(join(tmpdir(), "oke-clean-out-")),
78
+ stdinIsTTY: false,
79
+ yes: true,
80
+ all: false,
81
+ runDocker: fakeStacksRunner(["oke-dev-aaaaaa"]),
82
+ writeErr: (t) => err.push(t),
83
+ });
84
+ expect(code).toBe(EXIT_USAGE);
85
+ expect(err.join("")).toContain("--all --yes");
86
+ });
87
+
88
+ test("non-TTY outside --all --yes tears down every stack", async () => {
89
+ const downs: string[] = [];
90
+ const run: DockerRunner = async (args) => {
91
+ if (args[0] === "compose" && args[1] === "-p" && args.includes("down")) {
92
+ downs.push(args[2]!);
93
+ return { exitCode: 0, stdout: "", stderr: "" };
94
+ }
95
+ return fakeStacksRunner(["oke-dev-aaaaaa", "oke-dev-bbbbbb"])(args);
96
+ };
97
+ const logs: string[] = [];
98
+ const code = await runDockerClean({
99
+ cwd: await mkdtemp(join(tmpdir(), "oke-clean-all-")),
100
+ stdinIsTTY: false,
101
+ yes: true,
102
+ all: true,
103
+ runDocker: run,
104
+ write: (t) => logs.push(t),
105
+ });
106
+ expect(code).toBe(EXIT_OK);
107
+ expect(downs.sort()).toEqual(["oke-dev-aaaaaa", "oke-dev-bbbbbb"]);
108
+ expect(logs.join("")).toContain("down -v");
109
+ });
110
+
111
+ test("non-TTY inside --yes cleans current project only", async () => {
112
+ const dir = await mkdtemp(join(tmpdir(), "oke-clean-in-"));
113
+ await Bun.write(join(dir, "oke.config.ts"), "export default {};\n");
114
+ const current = composeProjectName(dir);
115
+ const other = "oke-dev-ffffff";
116
+ const downs: string[] = [];
117
+ const run: DockerRunner = async (args) => {
118
+ if (args[0] === "compose" && args[1] === "-p" && args.includes("down")) {
119
+ downs.push(args[2]!);
120
+ return { exitCode: 0, stdout: "", stderr: "" };
121
+ }
122
+ return fakeStacksRunner([current, other])(args);
123
+ };
124
+ const code = await runDockerClean({
125
+ cwd: dir,
126
+ stdinIsTTY: false,
127
+ yes: true,
128
+ runDocker: run,
129
+ write: () => {},
130
+ });
131
+ expect(code).toBe(EXIT_OK);
132
+ expect(downs).toEqual([current]);
133
+ });
134
+
135
+ test("non-TTY without --yes → usage", async () => {
136
+ const code = await runDockerClean({
137
+ cwd: await mkdtemp(join(tmpdir(), "oke-clean-noy-")),
138
+ stdinIsTTY: false,
139
+ runDocker: fakeStacksRunner(["oke-dev-aaaaaa"]),
140
+ writeErr: () => {},
141
+ });
142
+ expect(code).toBe(EXIT_USAGE);
143
+ });
144
+
145
+ test("TTY empty selection exits 0", async () => {
146
+ const code = await runDockerClean({
147
+ cwd: await mkdtemp(join(tmpdir(), "oke-clean-empty-")),
148
+ stdinIsTTY: true,
149
+ runDocker: fakeStacksRunner(["oke-dev-aaaaaa"]),
150
+ pickKeys: async () => [],
151
+ write: () => {},
152
+ });
153
+ expect(code).toBe(EXIT_OK);
154
+ });
155
+
156
+ test("TTY cancel exits 0", async () => {
157
+ const code = await runDockerClean({
158
+ cwd: await mkdtemp(join(tmpdir(), "oke-clean-cancel-")),
159
+ stdinIsTTY: true,
160
+ runDocker: fakeStacksRunner(["oke-dev-aaaaaa"]),
161
+ pickKeys: async () => null,
162
+ write: () => {},
163
+ });
164
+ expect(code).toBe(EXIT_OK);
165
+ });
166
+
167
+ test("dockerCli routes clean subcommand", async () => {
168
+ const code = await dockerCli(["clean", "--help"]);
169
+ expect(code).toBe(EXIT_OK);
170
+ expect(dockerCleanHelp()).toContain("--all");
171
+ });
172
+
173
+ test("registry lists clean flags and help mentions clean", () => {
174
+ const docker = OKE_COMMANDS.find((c) => c.name === "docker")!;
175
+ expect(flagTokensFor(docker, "clean")).toContain("--yes");
176
+ expect(flagTokensFor(docker, "clean")).toContain("--all");
177
+ expect(formatOkeHelp()).toContain("clean");
178
+ });
179
+ });
@@ -0,0 +1,406 @@
1
+ /**
2
+ * `oke docker clean` — remove leftover `oke-dev-*` compose stacks.
3
+ */
4
+
5
+ import { cancel, confirm, intro, isCancel, multiselect, outro } from "@clack/prompts";
6
+ import {
7
+ composeProjectName,
8
+ defaultDockerRunner,
9
+ downStack,
10
+ isOkeProjectRoot,
11
+ listOkeComposeProjects,
12
+ resolveSelection,
13
+ selectionKey,
14
+ type DockerRunner,
15
+ type OkeContainer,
16
+ type OkeStack,
17
+ } from "../docker/cleanup.ts";
18
+ import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts";
19
+
20
+ /** Parsed flags for {@link runDockerClean}. */
21
+ export interface DockerCleanFlags {
22
+ readonly yes?: boolean;
23
+ readonly all?: boolean;
24
+ }
25
+
26
+ /** Options for {@link runDockerClean} (injectable for tests). */
27
+ export interface DockerCleanOptions extends DockerCleanFlags {
28
+ readonly cwd?: string;
29
+ readonly stdinIsTTY?: boolean;
30
+ readonly runDocker?: DockerRunner;
31
+ readonly write?: (text: string) => void;
32
+ readonly writeErr?: (text: string) => void;
33
+ /** Override interactive container pick (tests). `null` = cancel. */
34
+ readonly pickKeys?: (
35
+ stacks: readonly OkeStack[],
36
+ scope: "current" | "other" | "all",
37
+ ) => Promise<readonly string[] | null>;
38
+ /** Override confirm (tests). */
39
+ readonly confirmDown?: (projects: readonly string[]) => Promise<boolean>;
40
+ /** When inside a project and other stacks exist — ask to include them. */
41
+ readonly askIncludeOthers?: () => Promise<boolean | null>;
42
+ }
43
+
44
+ /**
45
+ * Help text for `oke docker clean`.
46
+ */
47
+ export function dockerCleanHelp(): string {
48
+ return `oke docker clean [--yes|-y] [--all]
49
+
50
+ Remove leftover OKE docker compose stacks (containers, networks, volumes).
51
+
52
+ Inside a project: focuses the current stack (oke-dev-<id>); optionally
53
+ include other stacks on this machine.
54
+ Outside a project: list every oke-dev-* stack and pick what to remove.
55
+
56
+ --yes, -y Non-interactive; required when stdin is not a TTY
57
+ --all With --yes outside a project: remove every oke-dev-* stack
58
+ `;
59
+ }
60
+
61
+ /**
62
+ * Parse args after `docker clean`.
63
+ *
64
+ * @param args - Flag tokens
65
+ */
66
+ export function parseDockerCleanArgs(args: readonly string[]): DockerCleanFlags & {
67
+ readonly help?: boolean;
68
+ } {
69
+ let yes = false;
70
+ let all = false;
71
+ for (const a of args) {
72
+ if (a === "--help" || a === "-h") return { help: true };
73
+ if (a === "--yes" || a === "-y") yes = true;
74
+ else if (a === "--all") all = true;
75
+ else if (a.startsWith("-")) {
76
+ throw new Error(`oke docker clean: unknown flag ${a}`);
77
+ } else {
78
+ throw new Error(`oke docker clean: unexpected argument ${a}`);
79
+ }
80
+ }
81
+ return { yes, all };
82
+ }
83
+
84
+ /**
85
+ * Run stack cleanup.
86
+ *
87
+ * @param options - Flags + seams
88
+ * @returns Exit code
89
+ */
90
+ export async function runDockerClean(options: DockerCleanOptions = {}): Promise<number> {
91
+ const write = options.write ?? ((t) => process.stdout.write(t));
92
+ const writeErr = options.writeErr ?? ((t) => process.stderr.write(t));
93
+ const cwd = options.cwd ?? process.cwd();
94
+ const run = options.runDocker ?? defaultDockerRunner;
95
+ const tty = options.stdinIsTTY ?? Boolean(process.stdin.isTTY);
96
+ const yes = options.yes === true;
97
+ const all = options.all === true;
98
+
99
+ let stacks: readonly OkeStack[];
100
+ try {
101
+ stacks = await listOkeComposeProjects(run);
102
+ } catch (err) {
103
+ writeErr(`${err instanceof Error ? err.message : String(err)}\n`);
104
+ return EXIT_RUNTIME;
105
+ }
106
+
107
+ const inside = await isOkeProjectRoot(cwd);
108
+ const currentProject = inside ? composeProjectName(cwd) : null;
109
+
110
+ if (stacks.length === 0) {
111
+ write("oke docker clean: no oke-dev-* stacks found\n");
112
+ return EXIT_OK;
113
+ }
114
+
115
+ if (!tty) {
116
+ return runNonInteractive({
117
+ stacks,
118
+ inside,
119
+ currentProject,
120
+ yes,
121
+ all,
122
+ run,
123
+ write,
124
+ writeErr,
125
+ });
126
+ }
127
+
128
+ return runInteractive({
129
+ stacks,
130
+ inside,
131
+ currentProject,
132
+ run,
133
+ write,
134
+ writeErr,
135
+ pickKeys: options.pickKeys,
136
+ confirmDown: options.confirmDown,
137
+ askIncludeOthers: options.askIncludeOthers,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * CLI entry for `oke docker clean …`.
143
+ *
144
+ * @param args - Args after `clean`
145
+ */
146
+ export async function dockerCleanCli(args: readonly string[]): Promise<number> {
147
+ let flags: ReturnType<typeof parseDockerCleanArgs>;
148
+ try {
149
+ flags = parseDockerCleanArgs(args);
150
+ } catch (err) {
151
+ console.error(err instanceof Error ? err.message : String(err));
152
+ console.error(dockerCleanHelp());
153
+ return EXIT_USAGE;
154
+ }
155
+ if (flags.help) {
156
+ console.log(dockerCleanHelp());
157
+ return EXIT_OK;
158
+ }
159
+ return runDockerClean(flags);
160
+ }
161
+
162
+ async function runNonInteractive(options: {
163
+ readonly stacks: readonly OkeStack[];
164
+ readonly inside: boolean;
165
+ readonly currentProject: string | null;
166
+ readonly yes: boolean;
167
+ readonly all: boolean;
168
+ readonly run: DockerRunner;
169
+ readonly write: (t: string) => void;
170
+ readonly writeErr: (t: string) => void;
171
+ }): Promise<number> {
172
+ const { stacks, inside, currentProject, yes, all, run, write, writeErr } = options;
173
+
174
+ if (!yes) {
175
+ writeErr("oke docker clean: non-TTY requires --yes (and --all when outside a project)\n");
176
+ return EXIT_USAGE;
177
+ }
178
+
179
+ let projects: string[];
180
+ if (inside && currentProject) {
181
+ if (all) {
182
+ projects = stacks.map((s) => s.project);
183
+ } else {
184
+ const hasCurrent = stacks.some((s) => s.project === currentProject);
185
+ if (!hasCurrent) {
186
+ write(`oke docker clean: no stack for this project (${currentProject})\n`);
187
+ return EXIT_OK;
188
+ }
189
+ projects = [currentProject];
190
+ }
191
+ } else {
192
+ if (!all) {
193
+ writeErr(
194
+ "oke docker clean: outside a project, pass --all --yes to remove every oke-dev-* stack\n",
195
+ );
196
+ return EXIT_USAGE;
197
+ }
198
+ projects = stacks.map((s) => s.project);
199
+ }
200
+
201
+ return tearDownProjects(projects, run, write, writeErr);
202
+ }
203
+
204
+ async function runInteractive(options: {
205
+ readonly stacks: readonly OkeStack[];
206
+ readonly inside: boolean;
207
+ readonly currentProject: string | null;
208
+ readonly run: DockerRunner;
209
+ readonly write: (t: string) => void;
210
+ readonly writeErr: (t: string) => void;
211
+ readonly pickKeys?: DockerCleanOptions["pickKeys"];
212
+ readonly confirmDown?: DockerCleanOptions["confirmDown"];
213
+ readonly askIncludeOthers?: DockerCleanOptions["askIncludeOthers"];
214
+ }): Promise<number> {
215
+ const {
216
+ stacks,
217
+ inside,
218
+ currentProject,
219
+ run,
220
+ write,
221
+ writeErr,
222
+ pickKeys,
223
+ confirmDown,
224
+ askIncludeOthers,
225
+ } = options;
226
+
227
+ intro("oke docker clean");
228
+
229
+ const selectedKeys: string[] = [];
230
+
231
+ if (inside && currentProject) {
232
+ const current = stacks.filter((s) => s.project === currentProject);
233
+ const others = stacks.filter((s) => s.project !== currentProject);
234
+
235
+ if (current.length === 0) {
236
+ write(`No stack for this project (${currentProject}).\n`);
237
+ } else {
238
+ const keys = await pickContainerKeys({
239
+ stacks: current,
240
+ scope: "current",
241
+ message: `Containers for this project (${currentProject})`,
242
+ initialAll: true,
243
+ pickKeys,
244
+ });
245
+ if (keys === null) {
246
+ cancel("Cancelled.");
247
+ return EXIT_OK;
248
+ }
249
+ selectedKeys.push(...keys);
250
+ }
251
+
252
+ if (others.length > 0) {
253
+ const include = askIncludeOthers ? await askIncludeOthers() : await askIncludeOthersPrompt();
254
+ if (include === null) {
255
+ cancel("Cancelled.");
256
+ return EXIT_OK;
257
+ }
258
+ if (include) {
259
+ const keys = await pickContainerKeys({
260
+ stacks: others,
261
+ scope: "other",
262
+ message: "Other OKE stacks on this machine",
263
+ initialAll: false,
264
+ pickKeys,
265
+ });
266
+ if (keys === null) {
267
+ cancel("Cancelled.");
268
+ return EXIT_OK;
269
+ }
270
+ selectedKeys.push(...keys);
271
+ }
272
+ }
273
+ } else {
274
+ const keys = await pickContainerKeys({
275
+ stacks,
276
+ scope: "all",
277
+ message: "OKE docker stacks on this machine",
278
+ initialAll: false,
279
+ pickKeys,
280
+ });
281
+ if (keys === null) {
282
+ cancel("Cancelled.");
283
+ return EXIT_OK;
284
+ }
285
+ selectedKeys.push(...keys);
286
+ }
287
+
288
+ if (selectedKeys.length === 0) {
289
+ outro("Nothing selected.");
290
+ return EXIT_OK;
291
+ }
292
+
293
+ const { projects } = resolveSelection(selectedKeys, stacks);
294
+ if (projects.length === 0) {
295
+ outro("Nothing selected.");
296
+ return EXIT_OK;
297
+ }
298
+
299
+ const summary = projects
300
+ .map((p) => {
301
+ const stack = stacks.find((s) => s.project === p);
302
+ const n = stack?.containers.length ?? 0;
303
+ return n === 0 ? ` ${p} (volumes/networks only)` : ` ${p} (${n} container(s))`;
304
+ })
305
+ .join("\n");
306
+
307
+ const ok = confirmDown
308
+ ? await confirmDown(projects)
309
+ : await confirm({
310
+ message: `Remove these stack(s) — containers, networks, and volumes?\n${summary}`,
311
+ initialValue: false,
312
+ });
313
+
314
+ if (isCancel(ok) || ok === false) {
315
+ cancel("Cancelled.");
316
+ return EXIT_OK;
317
+ }
318
+
319
+ const code = await tearDownProjects(projects, run, write, writeErr);
320
+ if (code === EXIT_OK) outro(`Removed ${projects.length} stack(s).`);
321
+ return code;
322
+ }
323
+
324
+ async function askIncludeOthersPrompt(): Promise<boolean | null> {
325
+ const value = await confirm({
326
+ message: "Also clean other OKE stacks on this machine?",
327
+ initialValue: false,
328
+ });
329
+ if (isCancel(value)) return null;
330
+ return value;
331
+ }
332
+
333
+ async function pickContainerKeys(options: {
334
+ readonly stacks: readonly OkeStack[];
335
+ readonly scope: "current" | "other" | "all";
336
+ readonly message: string;
337
+ readonly initialAll: boolean;
338
+ readonly pickKeys?: DockerCleanOptions["pickKeys"];
339
+ }): Promise<readonly string[] | null> {
340
+ const { stacks, scope, message, initialAll, pickKeys } = options;
341
+ if (pickKeys) return pickKeys(stacks, scope);
342
+
343
+ const entries = selectionEntries(stacks);
344
+ if (entries.length === 0) return [];
345
+
346
+ const value = await multiselect({
347
+ message: `${message} — selecting any container removes its whole stack`,
348
+ options: entries.map((e) => ({
349
+ value: e.key,
350
+ label: e.label,
351
+ hint: e.hint,
352
+ })),
353
+ initialValues: initialAll ? entries.map((e) => e.key) : [],
354
+ required: false,
355
+ });
356
+ if (isCancel(value)) return null;
357
+ return value;
358
+ }
359
+
360
+ function selectionEntries(stacks: readonly OkeStack[]): readonly {
361
+ readonly key: string;
362
+ readonly label: string;
363
+ readonly hint?: string;
364
+ }[] {
365
+ const out: { key: string; label: string; hint?: string }[] = [];
366
+ for (const stack of stacks) {
367
+ if (stack.containers.length === 0) {
368
+ out.push({
369
+ key: selectionKey(stack.project),
370
+ label: stack.project,
371
+ hint: "no containers · volumes/networks may remain",
372
+ });
373
+ continue;
374
+ }
375
+ for (const c of stack.containers) {
376
+ out.push({
377
+ key: selectionKey(stack.project, c.name),
378
+ label: `${stack.project} · ${displayContainer(c)}`,
379
+ hint: c.state || undefined,
380
+ });
381
+ }
382
+ }
383
+ return out;
384
+ }
385
+
386
+ function displayContainer(c: OkeContainer): string {
387
+ return c.service && c.service !== c.name ? `${c.service} (${c.name})` : c.name;
388
+ }
389
+
390
+ async function tearDownProjects(
391
+ projects: readonly string[],
392
+ run: DockerRunner,
393
+ write: (t: string) => void,
394
+ writeErr: (t: string) => void,
395
+ ): Promise<number> {
396
+ for (const project of projects) {
397
+ try {
398
+ write(`oke docker clean: down -v ${project}\n`);
399
+ await downStack(project, run);
400
+ } catch (err) {
401
+ writeErr(`${err instanceof Error ? err.message : String(err)}\n`);
402
+ return EXIT_RUNTIME;
403
+ }
404
+ }
405
+ return EXIT_OK;
406
+ }