create-oke 0.11.2 → 0.12.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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/agents-md.ts +4 -1
  3. package/src/ai-setup/apply.ts +3 -5
  4. package/src/ai-setup/from-pref.ts +4 -4
  5. package/src/ai-setup/prompts.ts +1 -1
  6. package/src/cli.test.ts +86 -14
  7. package/src/cli.ts +145 -29
  8. package/src/create-defaults.test.ts +9 -0
  9. package/src/create-defaults.ts +27 -0
  10. package/src/customize-flow.ts +2 -1
  11. package/src/drivers-catalog.ts +18 -9
  12. package/src/local-okengine.test.ts +2 -0
  13. package/src/local-okengine.ts +3 -0
  14. package/src/scaffold.ts +17 -5
  15. package/src/transform.test.ts +6 -3
  16. package/src/transform.ts +24 -1
  17. package/templates/advanced/.env.example +13 -5
  18. package/templates/advanced/README.md +3 -0
  19. package/templates/advanced/oke.config.ts +6 -5
  20. package/templates/advanced/package.json +14 -3
  21. package/templates/advanced/src/app.ts +2 -1
  22. package/templates/advanced/src/core.ts +12 -0
  23. package/templates/advanced/src/db/seed/index.ts +2 -0
  24. package/templates/advanced/src/flows/main/index.ts +4 -4
  25. package/templates/advanced/src/flows/notes/index.ts +14 -14
  26. package/templates/advanced/src/flows/notes/shapes.ts +2 -3
  27. package/templates/advanced/web/index.html +12 -0
  28. package/templates/advanced/web/src/App.css +78 -0
  29. package/templates/advanced/web/src/App.tsx +100 -0
  30. package/templates/advanced/web/src/client.ts +38 -0
  31. package/templates/advanced/web/src/index.css +18 -0
  32. package/templates/advanced/web/src/main.tsx +13 -0
  33. package/templates/advanced/web/src/vite-env.d.ts +17 -0
  34. package/templates/advanced/web/tsconfig.app.json +24 -0
  35. package/templates/advanced/web/tsconfig.json +4 -0
  36. package/templates/advanced/web/tsconfig.node.json +20 -0
  37. package/templates/advanced/web/vite.config.ts +32 -0
  38. package/templates/standard/.env.example +13 -5
  39. package/templates/standard/README.md +3 -0
  40. package/templates/standard/oke.config.ts +4 -3
  41. package/templates/standard/package.json +14 -3
  42. package/templates/standard/src/app.ts +2 -1
  43. package/templates/standard/src/core.ts +12 -0
  44. package/templates/standard/src/db/seed/index.ts +2 -0
  45. package/templates/standard/src/flows/main/index.ts +4 -4
  46. package/templates/standard/src/flows/notes/index.ts +12 -12
  47. package/templates/standard/src/flows/notes/shapes.ts +2 -3
  48. package/templates/standard/tests/standard.test.ts +3 -2
  49. package/templates/standard/web/index.html +12 -0
  50. package/templates/standard/web/src/App.css +78 -0
  51. package/templates/standard/web/src/App.tsx +100 -0
  52. package/templates/standard/web/src/client.ts +38 -0
  53. package/templates/standard/web/src/index.css +18 -0
  54. package/templates/standard/web/src/main.tsx +13 -0
  55. package/templates/standard/web/src/vite-env.d.ts +17 -0
  56. package/templates/standard/web/tsconfig.app.json +24 -0
  57. package/templates/standard/web/tsconfig.json +4 -0
  58. package/templates/standard/web/tsconfig.node.json +20 -0
  59. package/templates/standard/web/vite.config.ts +32 -0
@@ -54,6 +54,7 @@ function defaultsWithIndex(indexDev: string) {
54
54
  ai: { enabled: false, provider: null, driver: null },
55
55
  locales: [],
56
56
  pgdog: false,
57
+ proxy: "none",
57
58
  });
58
59
  }
59
60
 
@@ -71,7 +72,7 @@ describe("applyCreateAnswers images", () => {
71
72
 
72
73
  test("index meilisearch pins store.index image without comment leakage", () => {
73
74
  const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("meilisearch"));
74
- expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bindex: "getmeili\/meilisearch:v1.37"/s);
75
+ expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bindex: "getmeili\/meilisearch:v1.53"/s);
75
76
  expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
76
77
  });
77
78
  });
@@ -128,6 +129,7 @@ describe("vault backend defaults", () => {
128
129
  ai: { enabled: false, provider: null, driver: null },
129
130
  locales: [],
130
131
  pgdog: false,
132
+ proxy: "none",
131
133
  }),
132
134
  );
133
135
  expect(next).toMatch(/vault:\s*\{\s*dev: "managed"/);
@@ -148,7 +150,7 @@ describe("upsertAiDrivers", () => {
148
150
  expect(config.drivers?.ai).toEqual(ollamaPins);
149
151
  expect(config.drivers?.channel?.ai).toBeUndefined();
150
152
  // Sparse templates omit drivers.channel — images.channel stays a string pin.
151
- expect(config.images?.channel?.email).toBe("axllent/mailpit:v1.22.3");
153
+ expect(config.images?.channel?.email).toBe("axllent/mailpit:v1.30.7");
152
154
  expect(config.images?.ai).toBeUndefined();
153
155
  });
154
156
 
@@ -176,6 +178,7 @@ describe("upsertAiDrivers", () => {
176
178
  ai: { enabled: true, provider: "llama-cpp", driver: "openai-compatible" },
177
179
  locales: [],
178
180
  pgdog: false,
181
+ proxy: "none",
179
182
  }),
180
183
  );
181
184
  const config = evalConfig(next);
@@ -185,7 +188,7 @@ describe("upsertAiDrivers", () => {
185
188
  prod: "openai-compatible",
186
189
  });
187
190
  expect(config.drivers?.channel?.ai, id).toBeUndefined();
188
- expect(config.images?.ai, id).toBe("ghcr.io/ggml-org/llama.cpp:server-b10290");
191
+ expect(config.images?.ai, id).toBe("ghcr.io/ggml-org/llama.cpp:server-b10450");
189
192
  }
190
193
  });
191
194
  });
package/src/transform.ts CHANGED
@@ -14,11 +14,12 @@
14
14
 
15
15
  import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import type { CreateDefaults, EnvDriverPins } from "./create-defaults.ts";
17
+ import type { CreateDefaults, CreateProxyId, EnvDriverPins } from "./create-defaults.ts";
18
18
  import {
19
19
  DEFAULT_IMAGES,
20
20
  LLAMA_CPP_IMAGE,
21
21
  OLLAMA_IMAGE,
22
+ PROXY_IMAGES,
22
23
  SGLANG_IMAGE,
23
24
  VLLM_IMAGE,
24
25
  } from "./drivers-catalog.ts";
@@ -511,6 +512,9 @@ function syncImages(source: string, defaults: CreateDefaults): string {
511
512
  ) {
512
513
  pin("ai", aiImageForDefaults(defaults));
513
514
  }
515
+ if (defaults.proxy !== "none") {
516
+ pin("proxy", PROXY_IMAGES[defaults.proxy]);
517
+ }
514
518
 
515
519
  return replaceImagesBlock(source, images);
516
520
  }
@@ -659,6 +663,25 @@ export function applyPgDogToConfig(source: string, enabled: boolean): string {
659
663
  return replaceImagesBlock(source, images);
660
664
  }
661
665
 
666
+ /**
667
+ * Add or remove the `images.proxy` pin (Caddy / Traefik / nginx).
668
+ *
669
+ * @param source - `oke.config.ts` source
670
+ * @param proxy - Wizard / CLI proxy id (`none` clears the pin)
671
+ */
672
+ export function applyProxyToConfig(source: string, proxy: CreateProxyId): string {
673
+ const images = extractImages(source);
674
+ if (proxy === "none") {
675
+ if (!images.proxy) return source;
676
+ delete images.proxy;
677
+ } else {
678
+ const image = PROXY_IMAGES[proxy];
679
+ if (images.proxy === image) return source;
680
+ images.proxy = image;
681
+ }
682
+ return replaceImagesBlock(source, images);
683
+ }
684
+
662
685
  /**
663
686
  * Sanitize a directory / package name for npm.
664
687
  *
@@ -1,14 +1,15 @@
1
1
  # Environment template — create-oke copies this to `.env.local` when you scaffold.
2
2
  #
3
3
  # Resolution order (first hit wins):
4
- # process.env → .env.local → docker/.env.docker → vault driver → dev fallback
4
+ # vault driver → process.env → .env.local → dev fallback
5
5
  #
6
6
  # --- Vault (app secrets) ---
7
7
  # Declare secrets in src/vault.ts with `vault.secret(...)`, then uncomment lines here.
8
8
  #
9
9
  # ── vault — built-in encrypted-at-rest store ────────────────
10
10
  # `drivers.vault` is "vault": secrets live encrypted in Postgres, no extra service.
11
- # Nothing below is required to start — the dotenv layers above resolve first.
11
+ # Nothing below is required to start — a value already in the vault backend wins
12
+ # over leftover process.env / dotenv pins.
12
13
  #
13
14
  # oke vault init # creates the store, prints the master key ONCE
14
15
  # export OKE_VAULT_MASTER_KEY=… # every later command / boot unseals with this
@@ -19,20 +20,27 @@
19
20
  # secret manager at boot. Committing it defeats encryption at rest.
20
21
  # OKE_VAULT_MASTER_KEY=
21
22
  #
22
- # For `drivers.vault: "managed"` instead, point at your provider secret store:
23
+ # For `drivers.vault: "managed"` instead, point at an official provider:
24
+ # aws-secrets-manager · azure-key-vault · gcp-secret-manager · doppler · 1password
23
25
  # OKE_VAULT_PROVIDER=aws-secrets-manager
24
26
  # OKE_VAULT_REGION=us-east-1
25
- # Optional prefix for secret names (defaults to oke/<env>/):
27
+ # Scope: AWS/Azure prefix, GCP project[/prefix], Doppler project/config, 1Password vault
26
28
  # OKE_VAULT_MOUNT=oke/prod/
29
+ # Azure Key Vault URI / 1Password Connect host / optional Doppler origin:
30
+ # OKE_VAULT_URL=
31
+ # Doppler token / 1Password Connect token:
32
+ # OKE_VAULT_TOKEN=
27
33
  #
28
34
  # --- Infrastructure ---
29
- # Same keys as `docker/.env.docker` after `oke dev --docker`.
35
+ # Same keys `oke dev --docker` merges into `.env.local`.
30
36
  # Uncomment to override compose credentials for a host-managed service.
31
37
  # Host ports under docker mode are unique per project — do not assume defaults.
32
38
 
33
39
  # ── App / Console ───────────────────────────────────────────
34
40
  # PORT=6530
35
41
  # OKE_APP_URL=http://127.0.0.1:6530
42
+ # Vite web (`bun run web`): empty = same-origin proxy to oke dev
43
+ # VITE_API_URL=
36
44
  # OKE_CONSOLE_SECRET=
37
45
  # MAINTENANCE_MODE=0
38
46
  # APP_WEBHOOK_SECRET=dev-webhook-secret-change-me
@@ -8,6 +8,7 @@ Scaffold, not a finished product: keep the Flows you want, replace the rest.
8
8
  bun install
9
9
  oke mode docker # seeded by create-oke recommended path
10
10
  oke dev # auto db push; asks once whether to seed
11
+ bun run web # Vite SPA — proxies /notes · /health to the app
11
12
  ```
12
13
 
13
14
  | Surface | URL |
@@ -15,6 +16,7 @@ oke dev # auto db push; asks once whether to seed
15
16
  | App | http://127.0.0.1:6530 |
16
17
  | Console | http://127.0.0.1:6533 |
17
18
  | MCP | http://127.0.0.1:6535 |
19
+ | Web | `bun run web` (Vite; API via proxy) |
18
20
 
19
21
  ## Extra routes (vs standard)
20
22
 
@@ -43,6 +45,7 @@ Without AI, summarize returns a local excerpt (`via: "fallback"`).
43
45
  | `src/core/` | `store` (db + files) · gates · vault · channels |
44
46
  | `src/db/` | schema · seed · migrations |
45
47
  | `.github/workflows/ci.yml` | `bun run typecheck` + `bun test` on push/PR |
48
+ | `web/` | Vite 8 SPA — same as standard |
46
49
 
47
50
  ## Drivers
48
51
 
@@ -30,14 +30,15 @@ export default defineConfig({
30
30
  store: {
31
31
  sql: "postgres:18-alpine",
32
32
  kv: "redis:8-alpine",
33
- files: "rustfs/rustfs:1.0.0-beta.11",
34
- // index: "getmeili/meilisearch:v1.37",
33
+ files: "rustfs/rustfs:1.0.0-rc.2",
34
+ // index: "getmeili/meilisearch:v1.53",
35
35
  },
36
36
  channel: {
37
- email: "axllent/mailpit:v1.22.3",
37
+ email: "axllent/mailpit:v1.30.7",
38
38
  },
39
- // pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51", // create-oke wizard / --pgdog
40
- // ai: "ghcr.io/ggml-org/llama.cpp:server-b10290", // or ollama/ollama:0.32.6
39
+ // pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.53", // create-oke wizard / --pgdog
40
+ // proxy: "caddy:2-alpine", // or traefik:v3.7 / nginx:1.31-alpine
41
+ // ai: "ghcr.io/ggml-org/llama.cpp:server-b10450", // or ollama/ollama:0.32.13
41
42
  },
42
43
  i18n: { locales: ["en"], default: "en" },
43
44
  });
@@ -5,19 +5,30 @@
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "okengine": "file:../../../..",
8
+ "@duckdb/node-api": "^1.5.5-r.2",
8
9
  "drizzle-orm": "1.0.0-rc.4",
10
+ "react": "^19.2.8",
11
+ "react-dom": "^19.2.8",
9
12
  "zod": "^4.4.3"
10
13
  },
11
14
  "devDependencies": {
12
15
  "@electric-sql/pglite": "^0.5.4",
13
16
  "@electric-sql/pglite-pgvector": "^0.0.5",
14
17
  "@types/bun": "latest",
18
+ "@types/react": "^19.2.18",
19
+ "@types/react-dom": "^19.2.4",
20
+ "@vitejs/plugin-react": "^6.0.5",
15
21
  "drizzle-kit": "1.0.0-rc.4",
16
- "typescript": "^7.0.2"
22
+ "typescript": "^7.0.2",
23
+ "vite": "^8.2.0"
17
24
  },
25
+ "trustedDependencies": ["@duckdb/node-api"],
18
26
  "scripts": {
19
- "typecheck": "tsc --noEmit",
27
+ "typecheck": "tsc --noEmit && tsc -b -p web/tsconfig.json",
20
28
  "test": "oke test",
21
- "dev": "oke dev"
29
+ "dev": "oke dev",
30
+ "web": "bunx --bun vite --config web/vite.config.ts",
31
+ "web:build": "tsc -b -p web/tsconfig.json && bunx --bun vite build --config web/vite.config.ts",
32
+ "web:preview": "bunx --bun vite preview --config web/vite.config.ts"
22
33
  }
23
34
  }
@@ -1,8 +1,9 @@
1
1
  import "@/core";
2
+ import { notesMutate } from "@/core";
2
3
 
3
4
  import { oke } from "okengine/http";
4
5
  import * as routes from "@/flows/generated";
5
6
 
6
- export const app = oke({ name: "notes" }).adopt(routes);
7
+ export const app = oke({ name: "notes", gate: { policies: [notesMutate] } }).adopt(routes);
7
8
 
8
9
  export type App = typeof app;
@@ -29,10 +29,22 @@ export const files = store.files("uploads");
29
29
  * export const notesWrite = gate.policy("notes:write", ({ auth }) =>
30
30
  * auth.scopes.has("notes:write"),
31
31
  * );
32
+ * export const notesMutate = gate.all(notesWrite, notesWriteRate);
32
33
  * ```
33
34
  */
34
35
  export const notesWrite = gate.policy("notes:write", () => true);
35
36
 
37
+ /** Note write throttle. */
38
+ export const notesWriteRate = gate.rate({
39
+ max: 60,
40
+ per: "1m",
41
+ keyBy: "ip",
42
+ description: "Note write throttle",
43
+ });
44
+
45
+ /** Reuse on every notes mutate route. */
46
+ export const notesMutate = gate.all(notesWrite, notesWriteRate);
47
+
36
48
  // --- Vault -------------------------------------------------------------------
37
49
 
38
50
  /** HMAC secret for outbound note webhooks (`fx.vault.get` on create). */
@@ -61,6 +61,8 @@ async function sampleNotes(fx: Fx) {
61
61
  }
62
62
 
63
63
  export default defineSeed({
64
+ name: "notes",
65
+ description: "Welcome + sample notes",
64
66
  essential: welcomeNote,
65
67
  dev: sampleNotes,
66
68
  // prod: async (fx) => { /* real production-only data, e.g. register a webhook */ },
@@ -1,9 +1,9 @@
1
- import { on, flow, http, gate } from "okengine";
1
+ import { on, flow, http } from "okengine";
2
2
  import { z } from "zod";
3
3
 
4
- /** First-run welcome — visit :6530/ after `oke dev`. */
4
+ /** First-run welcome — visit :6530/ after `oke dev` (browser code block; curl stays JSON). */
5
5
  export const root = on(
6
- http.get("/").gate(gate.public),
6
+ http.get("/").gate.public,
7
7
  flow("main.root", {
8
8
  out: z.object({
9
9
  ok: z.literal(true),
@@ -22,7 +22,7 @@ export const root = on(
22
22
 
23
23
  /** Liveness for probes and `bun test`. */
24
24
  export const health = on(
25
- http.get("/health").gate(gate.public),
25
+ http.get("/health").gate.public,
26
26
  flow("main.health", {
27
27
  out: z.object({ ok: z.literal(true) }),
28
28
  do: () => ({ ok: true as const }),
@@ -1,7 +1,7 @@
1
- import { on, flow, http, every, gate, fail } from "okengine";
1
+ import { on, flow, http, every, fail } from "okengine";
2
2
  import { eq, isNull } from "drizzle-orm";
3
3
 
4
- import { db, files, noteCreatedMail, webhookSecret } from "@/core";
4
+ import { db, files, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
5
5
  import { notes } from "@/db/schema.decl";
6
6
  import {
7
7
  NoteAttachIn,
@@ -64,28 +64,28 @@ function unwrapSummaryText(text: string): string {
64
64
 
65
65
  /** List active (non-archived) notes, newest first. */
66
66
  export const list = on(
67
- http.get("/notes").gate(gate.public),
67
+ http.get("/notes").gate.public,
68
68
  flow("notes.list", {
69
69
  out: NoteListOut,
70
- do: async (_input, fx) => {
70
+ do: async (input, fx) => {
71
71
  const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
72
- const sorted = [...rows].sort((a, b) => Number(b.createdAt) - Number(a.createdAt));
73
- return {
74
- notes: sorted.map((r) => ({
72
+ const data = [...rows]
73
+ .sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
74
+ .map((r) => ({
75
75
  id: String(r.id),
76
76
  title: String(r.title),
77
77
  body: String(r.body),
78
78
  archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
79
79
  createdAt: Number(r.createdAt),
80
- })),
81
- };
80
+ }));
81
+ return fx.json.withQuery(data, input);
82
82
  },
83
83
  }),
84
84
  );
85
85
 
86
86
  /** Create a note, emit `note-created`, touch vault. */
87
87
  export const create = on(
88
- http.post("/notes").gate(gate.public),
88
+ http.post("/notes").gate(notesMutate),
89
89
  flow("notes.create", {
90
90
  in: NoteCreateIn,
91
91
  out: NoteOut,
@@ -115,7 +115,7 @@ export const create = on(
115
115
 
116
116
  /** Fetch one note by id. */
117
117
  export const get = on(
118
- http.get("/notes/:id").gate(gate.public),
118
+ http.get("/notes/:id").gate.public,
119
119
  flow("notes.get", {
120
120
  in: NoteIdIn,
121
121
  out: NoteOut,
@@ -136,7 +136,7 @@ export const get = on(
136
136
 
137
137
  /** Soft-archive a note. */
138
138
  export const archive = on(
139
- http.post("/notes/:id/archive").gate(gate.public),
139
+ http.post("/notes/:id/archive").gate(notesMutate),
140
140
  flow("notes.archive", {
141
141
  in: NoteIdIn,
142
142
  out: NoteOut,
@@ -176,7 +176,7 @@ export const onCreated = on(
176
176
 
177
177
  /** Store a text attachment next to a note (`files:uploads`). */
178
178
  export const attach = on(
179
- http.post("/notes/:id/attach").gate(gate.public),
179
+ http.post("/notes/:id/attach").gate(notesMutate),
180
180
  flow("notes.attach", {
181
181
  in: NoteAttachIn,
182
182
  out: NoteAttachOut,
@@ -208,7 +208,7 @@ export const digest = on(
208
208
  * Exhausted / failed asks surface as Unavailable — never a body excerpt.
209
209
  */
210
210
  export const summarize = on(
211
- http.post("/notes/:id/summarize").gate(gate.public),
211
+ http.post("/notes/:id/summarize").gate(notesMutate),
212
212
  flow("notes.summarize", {
213
213
  in: NoteSummarizeIn,
214
214
  out: NoteSummarizeOut,
@@ -13,9 +13,8 @@ export const NoteOut = z.object({
13
13
  createdAt: z.number(),
14
14
  });
15
15
 
16
- export const NoteListOut = z.object({
17
- notes: z.array(NoteOut),
18
- });
16
+ /** List `data` is the item array. Pagination lives in HTTP `meta`. */
17
+ export const NoteListOut = z.array(NoteOut);
19
18
 
20
19
  export const NoteIdIn = z.object({
21
20
  id: z.string().min(1),
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Notes</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,78 @@
1
+ .page {
2
+ max-width: 40rem;
3
+ margin: 0 auto;
4
+ padding: 2rem 1.25rem 4rem;
5
+ }
6
+
7
+ .head {
8
+ display: flex;
9
+ align-items: baseline;
10
+ justify-content: space-between;
11
+ gap: 1rem;
12
+ }
13
+
14
+ .head h1 {
15
+ margin: 0;
16
+ font-size: 1.5rem;
17
+ }
18
+
19
+ .ok,
20
+ .down {
21
+ margin: 0;
22
+ font-size: 0.875rem;
23
+ }
24
+
25
+ .ok {
26
+ color: #047857;
27
+ }
28
+
29
+ .down {
30
+ color: #b45309;
31
+ }
32
+
33
+ .compose {
34
+ display: grid;
35
+ gap: 0.5rem;
36
+ margin: 1.5rem 0;
37
+ }
38
+
39
+ .compose input,
40
+ .compose textarea,
41
+ .compose button,
42
+ .list button {
43
+ font: inherit;
44
+ padding: 0.5rem 0.75rem;
45
+ border: 1px solid #cbd5e1;
46
+ border-radius: 0.375rem;
47
+ background: #fff;
48
+ }
49
+
50
+ .compose button,
51
+ .list button {
52
+ cursor: pointer;
53
+ width: fit-content;
54
+ }
55
+
56
+ .err {
57
+ color: #b91c1c;
58
+ }
59
+
60
+ .list {
61
+ list-style: none;
62
+ padding: 0;
63
+ margin: 0;
64
+ display: grid;
65
+ gap: 0.75rem;
66
+ }
67
+
68
+ .list li {
69
+ display: flex;
70
+ justify-content: space-between;
71
+ gap: 1rem;
72
+ padding: 0.75rem 0;
73
+ border-top: 1px solid #e2e8f0;
74
+ }
75
+
76
+ .list p {
77
+ margin: 0.25rem 0 0;
78
+ }
@@ -0,0 +1,100 @@
1
+ import { useCallback, useEffect, useState, type FormEvent, type JSX } from "react";
2
+ import { isOk } from "okengine/client";
3
+ import { api, type Note } from "./client.ts";
4
+ import "./App.css";
5
+
6
+ /**
7
+ * Notes SPA — health + list + create + archive against the oke app.
8
+ */
9
+ export function App(): JSX.Element {
10
+ const [health, setHealth] = useState<"unknown" | "ok" | "down">("unknown");
11
+ const [notes, setNotes] = useState<readonly Note[]>([]);
12
+ const [title, setTitle] = useState("");
13
+ const [body, setBody] = useState("");
14
+ const [message, setMessage] = useState<string | null>(null);
15
+
16
+ const refresh = useCallback(async () => {
17
+ const live = await api.main.health({});
18
+ setHealth(isOk(live) && live.data.ok ? "ok" : "down");
19
+ const listed = await api.notes.list({});
20
+ if (isOk(listed) && Array.isArray(listed.data)) {
21
+ setNotes(listed.data as readonly Note[]);
22
+ setMessage(null);
23
+ return;
24
+ }
25
+ setMessage(listed.error?.code ?? "notes.list failed");
26
+ }, []);
27
+
28
+ useEffect(() => {
29
+ void refresh();
30
+ }, [refresh]);
31
+
32
+ async function onCreate(event: FormEvent<HTMLFormElement>): Promise<void> {
33
+ event.preventDefault();
34
+ const created = await api.notes.create({ title, body });
35
+ if (!isOk(created)) {
36
+ setMessage(created.error.code);
37
+ return;
38
+ }
39
+ setTitle("");
40
+ setBody("");
41
+ await refresh();
42
+ }
43
+
44
+ async function onArchive(id: string): Promise<void> {
45
+ const archived = await api.notes.archive({ id });
46
+ if (!isOk(archived)) {
47
+ setMessage(archived.error.code);
48
+ return;
49
+ }
50
+ await refresh();
51
+ }
52
+
53
+ return (
54
+ <main className="page">
55
+ <header className="head">
56
+ <h1>Notes</h1>
57
+ <p className={health === "ok" ? "ok" : "down"}>
58
+ {health === "unknown" ? "checking…" : health === "ok" ? "app up" : "app down — start oke dev"}
59
+ </p>
60
+ </header>
61
+
62
+ <form className="compose" onSubmit={(event) => void onCreate(event)}>
63
+ <input
64
+ name="title"
65
+ placeholder="Title"
66
+ value={title}
67
+ onChange={(event) => setTitle(event.target.value)}
68
+ required
69
+ maxLength={200}
70
+ />
71
+ <textarea
72
+ name="body"
73
+ placeholder="Body"
74
+ value={body}
75
+ onChange={(event) => setBody(event.target.value)}
76
+ required
77
+ maxLength={10_000}
78
+ rows={4}
79
+ />
80
+ <button type="submit">Create</button>
81
+ </form>
82
+
83
+ {message ? <p className="err">{message}</p> : null}
84
+
85
+ <ul className="list">
86
+ {notes.map((note) => (
87
+ <li key={note.id}>
88
+ <div>
89
+ <strong>{note.title}</strong>
90
+ <p>{note.body}</p>
91
+ </div>
92
+ <button type="button" onClick={() => void onArchive(note.id)}>
93
+ Archive
94
+ </button>
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ </main>
99
+ );
100
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Browser client — Vite proxies these paths to `oke dev` (:6530).
3
+ *
4
+ * `$routes` is passed at runtime so HTTP triggers use REST, not `/_oke` RPC.
5
+ * Do not import `src/app.ts` here — that graph is Bun/server-only.
6
+ */
7
+
8
+ import { createClient } from "okengine/client";
9
+
10
+ /** One note from `notes.list` / `notes.create`. */
11
+ export type Note = {
12
+ readonly id: string;
13
+ readonly title: string;
14
+ readonly body: string;
15
+ readonly archivedAt: number | null;
16
+ readonly createdAt: number;
17
+ };
18
+
19
+ const $routes = {
20
+ main: {
21
+ health: { method: "GET", path: "/health" },
22
+ },
23
+ notes: {
24
+ list: { method: "GET", path: "/notes" },
25
+ create: { method: "POST", path: "/notes" },
26
+ get: { method: "GET", path: "/notes/:id" },
27
+ archive: { method: "POST", path: "/notes/:id/archive" },
28
+ },
29
+ } as const;
30
+
31
+ type AppRoutes = { readonly $routes: typeof $routes };
32
+
33
+ /**
34
+ * Typed caller for starter Flows. `VITE_API_URL` is empty in dev (proxy).
35
+ */
36
+ export const api = createClient<AppRoutes>(import.meta.env.VITE_API_URL ?? "", {
37
+ $routes,
38
+ });
@@ -0,0 +1,18 @@
1
+ :root {
2
+ color: #0f172a;
3
+ background: #f8fafc;
4
+ font-family: ui-sans-serif, system-ui, sans-serif;
5
+ line-height: 1.5;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ margin: 0;
14
+ }
15
+
16
+ #root {
17
+ min-height: 100vh;
18
+ }
@@ -0,0 +1,13 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App.tsx";
4
+ import "./index.css";
5
+
6
+ const root = document.getElementById("root");
7
+ if (!root) throw new Error("web: #root missing");
8
+
9
+ createRoot(root).render(
10
+ <StrictMode>
11
+ <App />
12
+ </StrictMode>,
13
+ );
@@ -0,0 +1,17 @@
1
+ interface ViteTypeOptions {
2
+ // By adding this line, you can make the type of ImportMetaEnv strict
3
+ // to disallow unknown keys.
4
+ // strictImportMetaEnv: unknown
5
+ }
6
+
7
+ interface ImportMetaEnv {
8
+ /**
9
+ * App origin for `createClient`. Unset / empty = same origin so Vite's
10
+ * `server.proxy` forwards `/notes` · `/health` · `/_oke` to `oke dev`.
11
+ */
12
+ readonly VITE_API_URL?: string;
13
+ }
14
+
15
+ interface ImportMeta {
16
+ readonly env: ImportMetaEnv;
17
+ }