create-oke 0.18.4 → 0.19.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 (62) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/agents-md.ts +6 -4
  4. package/src/ai-setup/apply.test.ts +278 -0
  5. package/src/ai-setup/apply.ts +430 -52
  6. package/src/ai-setup/catalog.ts +250 -1343
  7. package/src/ai-setup/from-pref.ts +3 -71
  8. package/src/ai-setup/prompts.ts +60 -443
  9. package/src/cli.test.ts +21 -15
  10. package/src/cli.ts +20 -11
  11. package/src/create-defaults.test.ts +4 -4
  12. package/src/create-defaults.ts +3 -3
  13. package/src/customize-flow.test.ts +9 -15
  14. package/src/customize-flow.ts +59 -66
  15. package/src/drivers-catalog.ts +18 -23
  16. package/src/transform.test.ts +69 -10
  17. package/src/transform.ts +10 -35
  18. package/templates/advanced/.env.example +28 -11
  19. package/templates/advanced/.github/workflows/ci.yml +1 -1
  20. package/templates/advanced/oke.config.ts +3 -4
  21. package/templates/advanced/package.json +11 -10
  22. package/templates/advanced/src/app.ts +36 -1
  23. package/templates/advanced/src/core.ts +12 -11
  24. package/templates/advanced/src/db/schema.decl.ts +6 -2
  25. package/templates/advanced/src/db/seed/index.ts +4 -4
  26. package/templates/advanced/src/flows/main/route.ts +3 -2
  27. package/templates/advanced/src/flows/notes/[id]/archive.ts +5 -8
  28. package/templates/advanced/src/flows/notes/[id]/attach.ts +1 -4
  29. package/templates/advanced/src/flows/notes/[id]/get.ts +4 -7
  30. package/templates/advanced/src/flows/notes/[id]/summarize.ts +3 -4
  31. package/templates/advanced/src/flows/notes/create.ts +4 -6
  32. package/templates/advanced/src/flows/notes/digest.ts +6 -5
  33. package/templates/advanced/src/flows/notes/list.ts +4 -5
  34. package/templates/advanced/src/flows/notes/shapes.ts +13 -3
  35. package/templates/advanced/src/flows/notes/signals.ts +1 -2
  36. package/templates/advanced/src/vault.ts +138 -0
  37. package/templates/advanced/tests/advanced.test.ts +11 -8
  38. package/templates/advanced/web/src/App.css +7 -0
  39. package/templates/advanced/web/src/App.tsx +101 -1
  40. package/templates/advanced/web/src/client.ts +16 -3
  41. package/templates/advanced/web/vite.config.ts +1 -0
  42. package/templates/standard/.env.example +22 -11
  43. package/templates/standard/.github/workflows/ci.yml +1 -1
  44. package/templates/standard/oke.config.ts +3 -3
  45. package/templates/standard/package.json +11 -10
  46. package/templates/standard/src/app.ts +6 -1
  47. package/templates/standard/src/core.ts +10 -11
  48. package/templates/standard/src/db/schema.decl.ts +6 -2
  49. package/templates/standard/src/db/seed/index.ts +3 -3
  50. package/templates/standard/src/flows/main/route.ts +3 -2
  51. package/templates/standard/src/flows/notes/[id]/archive.ts +5 -8
  52. package/templates/standard/src/flows/notes/[id]/get.ts +4 -7
  53. package/templates/standard/src/flows/notes/create.ts +4 -6
  54. package/templates/standard/src/flows/notes/list.ts +4 -5
  55. package/templates/standard/src/flows/notes/shapes.ts +12 -2
  56. package/templates/standard/src/flows/notes/signals.ts +1 -2
  57. package/templates/standard/src/vault.ts +123 -0
  58. package/templates/standard/tests/standard.test.ts +3 -1
  59. package/templates/standard/web/src/client.ts +2 -2
  60. package/templates/standard/web/vite.config.ts +1 -0
  61. package/src/ai-setup/detect-ollama.ts +0 -228
  62. package/src/ai-setup/recommend.ts +0 -220
@@ -2,15 +2,12 @@ import { on, flow, http, fail } from "okengine/http";
2
2
 
3
3
  import { db } from "@/core";
4
4
  import { notes } from "@/db/schema.decl";
5
- import { NoteIdIn, NoteOut, NotFound } from "../shapes";
5
+ import { NoteIdIn, NoteOut, NotFound, toIsoInstant } from "../shapes";
6
6
 
7
7
  /** Fetch one note by id. */
8
8
  export const get = on(
9
- http.get().public(),
9
+ http.get({ in: NoteIdIn, out: NoteOut, errors: { NotFound } }).public(),
10
10
  flow({
11
- in: NoteIdIn,
12
- out: NoteOut,
13
- errors: { NotFound },
14
11
  do: async (input, fx) => {
15
12
  const row = await fx.store(db).findById(notes, input.id);
16
13
  if (!row) return fail("NotFound", { id: input.id });
@@ -18,8 +15,8 @@ export const get = on(
18
15
  id: String(row.id),
19
16
  title: String(row.title),
20
17
  body: String(row.body),
21
- archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
22
- createdAt: Number(row.createdAt),
18
+ archivedAt: row.archivedAt == null ? null : toIsoInstant(row.archivedAt),
19
+ createdAt: toIsoInstant(row.createdAt),
23
20
  };
24
21
  },
25
22
  }),
@@ -2,19 +2,17 @@ import { on, flow, http } from "okengine/http";
2
2
 
3
3
  import { db, notesMutate, webhookSecret } from "@/core";
4
4
  import { notes } from "@/db/schema.decl";
5
- import { NoteCreateIn, NoteOut } from "./shapes";
5
+ import { NoteCreateIn, NoteOut, toIsoInstant } from "./shapes";
6
6
  import { noteCreated } from "./signals";
7
7
 
8
8
  /** Create a note, emit `note-created`, touch vault. */
9
9
  export const create = on(
10
- http.post().gate(notesMutate),
10
+ http.post({ in: NoteCreateIn, out: NoteOut }).gate(notesMutate),
11
11
  flow({
12
- in: NoteCreateIn,
13
- out: NoteOut,
14
12
  do: async (input, fx) => {
15
13
  await fx.vault.get(webhookSecret);
16
14
  const id = fx.id();
17
- const createdAt = fx.clock.now();
15
+ const createdAt = new Date(fx.clock.now());
18
16
  await fx.store(db).insert(notes).values({
19
17
  id,
20
18
  title: input.title,
@@ -28,7 +26,7 @@ export const create = on(
28
26
  title: input.title,
29
27
  body: input.body,
30
28
  archivedAt: null,
31
- createdAt,
29
+ createdAt: toIsoInstant(createdAt),
32
30
  };
33
31
  },
34
32
  }),
@@ -3,13 +3,12 @@ import { isNull } from "drizzle-orm";
3
3
 
4
4
  import { db } from "@/core";
5
5
  import { notes } from "@/db/schema.decl";
6
- import { NoteListOut } from "./shapes";
6
+ import { NoteListOut, toIsoInstant } from "./shapes";
7
7
 
8
8
  /** List active (non-archived) notes, newest first. */
9
9
  export const list = on(
10
- http.get().public(),
10
+ http.get({ out: NoteListOut }).public(),
11
11
  flow({
12
- out: NoteListOut,
13
12
  do: async (input, fx) => {
14
13
  const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
14
  const data = [...rows]
@@ -18,8 +17,8 @@ export const list = on(
18
17
  id: String(r.id),
19
18
  title: String(r.title),
20
19
  body: String(r.body),
21
- archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
22
- createdAt: Number(r.createdAt),
20
+ archivedAt: r.archivedAt == null ? null : toIsoInstant(r.archivedAt),
21
+ createdAt: toIsoInstant(r.createdAt),
23
22
  }));
24
23
  return fx.json.withQuery(data, input);
25
24
  },
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /** Instant on the HTTP wire — ISO-8601 UTC. */
4
+ export const IsoInstant = z.iso.datetime();
5
+
3
6
  export const NoteCreateIn = z.object({
4
7
  title: z.string().min(1).max(200),
5
8
  body: z.string().min(1).max(10_000),
@@ -9,8 +12,8 @@ export const NoteOut = z.object({
9
12
  id: z.string(),
10
13
  title: z.string(),
11
14
  body: z.string(),
12
- archivedAt: z.number().nullable(),
13
- createdAt: z.number(),
15
+ archivedAt: IsoInstant.nullable(),
16
+ createdAt: IsoInstant,
14
17
  });
15
18
 
16
19
  /** List `data` is the item array. Pagination lives in HTTP `meta`. */
@@ -23,3 +26,10 @@ export const NoteIdIn = z.object({
23
26
  export const NotFound = z.object({
24
27
  id: z.string(),
25
28
  });
29
+
30
+ /** Map a store temporal (`Date` / ISO / epoch-ms) to an ISO wire string. */
31
+ export function toIsoInstant(value: unknown): string {
32
+ if (value instanceof Date) return value.toISOString();
33
+ if (typeof value === "string") return new Date(value).toISOString();
34
+ return new Date(Number(value)).toISOString();
35
+ }
@@ -2,8 +2,7 @@ import { signal } from "okengine";
2
2
  import { z } from "zod";
3
3
 
4
4
  /** Fired after a note is persisted — subscriber sends email. */
5
- export const noteCreated = signal("note-created", {
6
- delivery: "once",
5
+ export const noteCreated = signal.once("note-created", {
7
6
  retries: 3,
8
7
  deadLetter: true,
9
8
  schema: z.object({
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Notes vault contracts — secrets and cleartext config.
3
+ *
4
+ * Values resolve through the driver chain (built-in store → process.env →
5
+ * `.env.local` → `dev:` / `vault.fromDocker`). Declare every stack / app name
6
+ * here so Console Vault lists it; put values in `.env.local`, `oke vault set`,
7
+ * or leave the local fallback for Docker-first `oke dev`.
8
+ */
9
+
10
+ import { vault } from "okengine";
11
+
12
+ // --- Secrets (fingerprinted) -------------------------------------------------
13
+
14
+ /** HMAC secret for outbound note webhooks (`fx.vault.get` on create). */
15
+ export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
16
+ description: "HMAC secret for outbound note webhooks",
17
+ rotate: "never",
18
+ dev: "dev-webhook-secret-change-me",
19
+ });
20
+
21
+ /** Console operator secret. */
22
+ export const okeConsoleSecret = vault.secret("OKE_CONSOLE_SECRET", {
23
+ description: "Console operator secret",
24
+ rotate: "90d",
25
+ dev: "oke-dev-notes-console",
26
+ });
27
+
28
+ /** Mailpit SMTP URL. */
29
+ export const channelEmailUrl = vault.secret("OKE_CHANNEL_EMAIL_URL", {
30
+ description: "Mailpit SMTP URL",
31
+ rotate: "never",
32
+ dev: vault.fromDocker("channel.email"),
33
+ });
34
+
35
+ /** SMTP alias. */
36
+ export const smtpUrl = vault.secret("SMTP_URL", {
37
+ description: "SMTP URL",
38
+ rotate: "never",
39
+ dev: vault.fromDocker("channel.email"),
40
+ });
41
+
42
+ /** Object storage URL. */
43
+ export const storeFilesUrl = vault.secret("OKE_STORE_FILES_URL", {
44
+ description: "Object storage URL",
45
+ rotate: "never",
46
+ dev: vault.fromDocker("store.files"),
47
+ });
48
+
49
+ /** Redis URL. */
50
+ export const storeKvUrl = vault.secret("OKE_STORE_KV_URL", {
51
+ description: "Redis URL",
52
+ rotate: "never",
53
+ dev: vault.fromDocker("store.kv"),
54
+ });
55
+
56
+ /** Redis alias. */
57
+ export const redisUrl = vault.secret("REDIS_URL", {
58
+ description: "Redis URL",
59
+ rotate: "never",
60
+ dev: vault.fromDocker("store.kv"),
61
+ });
62
+
63
+ /** Direct Postgres URL. */
64
+ export const storeSqlUrl = vault.secret("OKE_STORE_SQL_URL", {
65
+ description: "Direct Postgres URL",
66
+ rotate: "never",
67
+ dev: vault.fromDocker("store.sql"),
68
+ });
69
+
70
+ /** Postgres URL (compose / PgDog may rewrite this). */
71
+ export const databaseUrl = vault.secret("DATABASE_URL", {
72
+ description: "Postgres URL",
73
+ rotate: "never",
74
+ dev: vault.fromDocker("store.sql"),
75
+ });
76
+
77
+ // --- Config (shown in the clear) ---------------------------------------------
78
+
79
+ /** App listen origin. */
80
+ export const okeAppUrl = vault.config("OKE_APP_URL", {
81
+ description: "App listen origin",
82
+ dev: "http://127.0.0.1:6530",
83
+ });
84
+
85
+ /** Public API origin (Vite web keeps `VITE_API_URL` empty for same-origin proxy). */
86
+ export const publicApiUrl = vault.config("PUBLIC_API_URL", {
87
+ description: "Public API origin",
88
+ dev: "http://127.0.0.1:6530",
89
+ });
90
+
91
+ /** Mailpit UI origin. */
92
+ export const mailpitUiUrl = vault.config("MAILPIT_UI_URL", {
93
+ description: "Mailpit UI origin",
94
+ dev: "http://127.0.0.1:8025",
95
+ });
96
+
97
+ /** Maintenance flag (`1` / `0`). */
98
+ export const maintenanceMode = vault.config("MAINTENANCE_MODE", {
99
+ description: "Maintenance mode flag",
100
+ dev: "0",
101
+ });
102
+
103
+ /**
104
+ * Full contract list for `oke({ secrets })`.
105
+ *
106
+ * `vault.secret` auto-registers; `vault.config` does not — pass this array so
107
+ * configs resolve in boot / Console the same way secrets do.
108
+ */
109
+ export const NOTES_VAULT = [
110
+ webhookSecret,
111
+ okeConsoleSecret,
112
+ channelEmailUrl,
113
+ smtpUrl,
114
+ storeFilesUrl,
115
+ storeKvUrl,
116
+ redisUrl,
117
+ storeSqlUrl,
118
+ databaseUrl,
119
+ okeAppUrl,
120
+ publicApiUrl,
121
+ mailpitUiUrl,
122
+ maintenanceMode,
123
+ ] as const;
@@ -39,7 +39,9 @@ test("notes create → list → archive", async () => {
39
39
 
40
40
  const archived = await t.api.notes!.archive!({ id: row.id });
41
41
  expect(archived.error).toBeNull();
42
- expect((archived.data as { archivedAt: number }).archivedAt).toBeTypeOf("number");
42
+ const archivedAt = (archived.data as { archivedAt: string }).archivedAt;
43
+ expect(archivedAt).toBeTypeOf("string");
44
+ expect(Number.isNaN(Date.parse(archivedAt))).toBe(false);
43
45
 
44
46
  const after = await t.api.notes!.list!({});
45
47
  const afterNotes = after.data as { id: string }[];
@@ -12,8 +12,8 @@ export type Note = {
12
12
  readonly id: string;
13
13
  readonly title: string;
14
14
  readonly body: string;
15
- readonly archivedAt: number | null;
16
- readonly createdAt: number;
15
+ readonly archivedAt: string | null;
16
+ readonly createdAt: string;
17
17
  };
18
18
 
19
19
  const $routes = {
@@ -20,6 +20,7 @@ const APP_ORIGIN = "http://127.0.0.1:6530";
20
20
  const proxy: Record<string, ProxyOptions> = {
21
21
  "/health": { target: APP_ORIGIN, changeOrigin: true },
22
22
  "/notes": { target: APP_ORIGIN, changeOrigin: true },
23
+ "/auth": { target: APP_ORIGIN, changeOrigin: true },
23
24
  "/_oke": { target: APP_ORIGIN, changeOrigin: true },
24
25
  };
25
26
 
@@ -1,228 +0,0 @@
1
- /**
2
- * Detect local Ollama — CLI (`ollama list` / `ps`) and HTTP `/api/tags`.
3
- */
4
-
5
- import { ALL_CURATED, type CatalogModel } from "./catalog.ts";
6
-
7
- /** Default Ollama HTTP origin (create-oke copy — no okengine driver import). */
8
- const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
9
-
10
- /**
11
- * Normalize an Ollama base URL (strip trailing slash / accidental `/v1`).
12
- *
13
- * @param raw - User or env value
14
- */
15
- function normalizeOllamaBaseUrl(raw: string): string {
16
- const trimmed = raw.trim().replace(/\/$/, "");
17
- if (!trimmed) return OLLAMA_DEFAULT_BASE_URL;
18
- if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/v1\/?$/i, "");
19
- return `http://${trimmed}`;
20
- }
21
-
22
- /** Result of probing the local Ollama install. */
23
- export type OllamaDetectResult = {
24
- readonly available: boolean;
25
- readonly baseUrl: string;
26
- readonly installed: readonly string[];
27
- readonly running: readonly string[];
28
- readonly curatedInstalled: readonly CatalogModel[];
29
- readonly curatedMissing: readonly CatalogModel[];
30
- };
31
-
32
- /**
33
- * Parse `ollama list` tabular output into model names.
34
- *
35
- * @param stdout - CLI stdout
36
- */
37
- export function parseOllamaList(stdout: string): string[] {
38
- const lines = stdout
39
- .split("\n")
40
- .map((l) => l.trim())
41
- .filter(Boolean);
42
- const out: string[] = [];
43
- for (const line of lines) {
44
- if (/^NAME\b/i.test(line)) continue;
45
- const name = line.split(/\s+/)[0];
46
- if (name) out.push(name);
47
- }
48
- return out;
49
- }
50
-
51
- /**
52
- * Parse `ollama ps` tabular output into running model names.
53
- *
54
- * @param stdout - CLI stdout
55
- */
56
- export function parseOllamaPs(stdout: string): string[] {
57
- return parseOllamaList(stdout);
58
- }
59
-
60
- /**
61
- * Normalize installed tag for comparison (`qwen3.5:9b` vs `qwen3.5:9b-mlx`).
62
- *
63
- * @param id - Catalog or installed id
64
- * @param installed - Installed names
65
- */
66
- export function isInstalled(id: string, installed: readonly string[]): boolean {
67
- const base = id.split(":")[0] ?? id;
68
- return installed.some((name) => {
69
- if (name === id) return true;
70
- if (name.startsWith(`${id}-`)) return true;
71
- if (name.startsWith(`${base}:`) && id.startsWith(`${base}:`)) {
72
- // treat mlx / quant suffixes as matching the same family:tag when prefix matches
73
- const instTag = name.slice(base.length + 1);
74
- const wantTag = id.slice(base.length + 1);
75
- return (
76
- instTag === wantTag || instTag.startsWith(`${wantTag}-`) || wantTag.startsWith(instTag)
77
- );
78
- }
79
- return false;
80
- });
81
- }
82
-
83
- /**
84
- * Detect Ollama via CLI then HTTP fallback.
85
- *
86
- * @param options - Base URL / spawn / fetch seams
87
- */
88
- export async function detectOllama(
89
- options: {
90
- readonly baseUrl?: string;
91
- readonly fetch?: typeof globalThis.fetch;
92
- readonly run?: (cmd: readonly string[]) => Promise<{ code: number; stdout: string }>;
93
- } = {},
94
- ): Promise<OllamaDetectResult> {
95
- const baseUrl = normalizeOllamaBaseUrl(options.baseUrl ?? OLLAMA_DEFAULT_BASE_URL);
96
- const run =
97
- options.run ??
98
- (async (cmd) => {
99
- const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "pipe" });
100
- const stdout = await new Response(proc.stdout).text();
101
- const code = await proc.exited;
102
- return { code, stdout };
103
- });
104
-
105
- let installed: string[] = [];
106
- let running: string[] = [];
107
- let available = false;
108
-
109
- try {
110
- const list = await run(["ollama", "list"]);
111
- if (list.code === 0) {
112
- available = true;
113
- installed = parseOllamaList(list.stdout);
114
- }
115
- const ps = await run(["ollama", "ps"]);
116
- if (ps.code === 0) {
117
- running = parseOllamaPs(ps.stdout);
118
- }
119
- } catch {
120
- // CLI missing — try HTTP
121
- }
122
-
123
- if (!available) {
124
- const fetchFn = options.fetch ?? globalThis.fetch;
125
- try {
126
- const res = await fetchFn(`${baseUrl}/api/tags`, {
127
- method: "GET",
128
- signal: AbortSignal.timeout(2_000),
129
- });
130
- if (res.ok) {
131
- available = true;
132
- const json = (await res.json()) as { models?: readonly { name?: string }[] };
133
- installed = (json.models ?? [])
134
- .map((m) => m.name)
135
- .filter((n): n is string => typeof n === "string");
136
- }
137
- } catch {
138
- // unavailable
139
- }
140
- }
141
-
142
- const curatedInstalled = ALL_CURATED.filter((m) => isInstalled(m.id, installed));
143
- const curatedMissing = ALL_CURATED.filter((m) => !isInstalled(m.id, installed));
144
-
145
- return {
146
- available,
147
- baseUrl,
148
- installed,
149
- running,
150
- curatedInstalled,
151
- curatedMissing,
152
- };
153
- }
154
-
155
- /** Detected host hardware for the Ollama banner. */
156
- export type MachineInfo = {
157
- readonly osName: string;
158
- readonly cpuCount: number | null;
159
- readonly ramGb: number | null;
160
- };
161
-
162
- /**
163
- * Best-effort total system RAM in GB.
164
- */
165
- export function detectTotalRamGb(): number | null {
166
- try {
167
- if (process.platform === "darwin") {
168
- const proc = Bun.spawnSync(["sysctl", "-n", "hw.memsize"], { stdout: "pipe" });
169
- if (proc.exitCode === 0) {
170
- const bytes = Number(proc.stdout.toString().trim());
171
- if (Number.isFinite(bytes) && bytes > 0) return Math.round(bytes / 1024 ** 3);
172
- }
173
- }
174
- if (process.platform === "linux") {
175
- const proc = Bun.spawnSync(["awk", "/MemTotal/ {print $2}", "/proc/meminfo"], {
176
- stdout: "pipe",
177
- });
178
- if (proc.exitCode === 0) {
179
- const kb = Number(proc.stdout.toString().trim());
180
- if (Number.isFinite(kb) && kb > 0) return Math.round(kb / 1024 ** 2);
181
- }
182
- }
183
- } catch {
184
- // ignore
185
- }
186
- return null;
187
- }
188
-
189
- /**
190
- * Best-effort OS name · CPU count · RAM for the Ollama banner.
191
- */
192
- export function detectMachineInfo(): MachineInfo {
193
- const osName =
194
- process.platform === "darwin"
195
- ? "macOS"
196
- : process.platform === "linux"
197
- ? "Linux"
198
- : process.platform === "win32"
199
- ? "Windows"
200
- : process.platform;
201
-
202
- let cpuCount: number | null = null;
203
- try {
204
- if (process.platform === "darwin") {
205
- const proc = Bun.spawnSync(["sysctl", "-n", "hw.ncpu"], { stdout: "pipe" });
206
- if (proc.exitCode === 0) {
207
- const n = Number(proc.stdout.toString().trim());
208
- if (Number.isFinite(n) && n > 0) cpuCount = n;
209
- }
210
- } else if (typeof navigator !== "undefined" && "hardwareConcurrency" in navigator) {
211
- const n = Number(navigator.hardwareConcurrency);
212
- if (Number.isFinite(n) && n > 0) cpuCount = n;
213
- }
214
- } catch {
215
- // ignore
216
- }
217
- if (cpuCount === null) {
218
- try {
219
- // Bun / Node expose this on process when available
220
- const n = (process as { availableParallelism?: () => number }).availableParallelism?.();
221
- if (typeof n === "number" && Number.isFinite(n) && n > 0) cpuCount = n;
222
- } catch {
223
- // ignore
224
- }
225
- }
226
-
227
- return { osName, cpuCount, ramGb: detectTotalRamGb() };
228
- }