create-oke 0.11.1 → 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.
- package/package.json +1 -1
- package/src/agents-md.ts +4 -1
- package/src/ai-setup/apply.ts +64 -6
- package/src/ai-setup/from-pref.ts +4 -4
- package/src/ai-setup/prompts.ts +1 -1
- package/src/cli.test.ts +133 -11
- package/src/cli.ts +180 -29
- package/src/create-defaults.test.ts +9 -0
- package/src/create-defaults.ts +27 -0
- package/src/customize-flow.test.ts +14 -7
- package/src/customize-flow.ts +2 -1
- package/src/drivers-catalog.ts +18 -9
- package/src/local-okengine.test.ts +2 -0
- package/src/local-okengine.ts +3 -0
- package/src/scaffold.ts +17 -5
- package/src/transform.test.ts +6 -3
- package/src/transform.ts +24 -1
- package/templates/advanced/.env.example +13 -5
- package/templates/advanced/README.md +3 -0
- package/templates/advanced/oke.config.ts +6 -5
- package/templates/advanced/package.json +14 -3
- package/templates/advanced/src/app.ts +2 -1
- package/templates/advanced/src/core.ts +12 -0
- package/templates/advanced/src/db/seed/index.ts +3 -1
- package/templates/advanced/src/flows/main/index.ts +4 -4
- package/templates/advanced/src/flows/notes/index.ts +73 -26
- package/templates/advanced/src/flows/notes/shapes.ts +9 -4
- package/templates/advanced/tests/advanced.test.ts +3 -2
- package/templates/advanced/web/index.html +12 -0
- package/templates/advanced/web/src/App.css +78 -0
- package/templates/advanced/web/src/App.tsx +100 -0
- package/templates/advanced/web/src/client.ts +38 -0
- package/templates/advanced/web/src/index.css +18 -0
- package/templates/advanced/web/src/main.tsx +13 -0
- package/templates/advanced/web/src/vite-env.d.ts +17 -0
- package/templates/advanced/web/tsconfig.app.json +24 -0
- package/templates/advanced/web/tsconfig.json +4 -0
- package/templates/advanced/web/tsconfig.node.json +20 -0
- package/templates/advanced/web/vite.config.ts +32 -0
- package/templates/standard/.env.example +13 -5
- package/templates/standard/README.md +3 -0
- package/templates/standard/oke.config.ts +4 -3
- package/templates/standard/package.json +14 -3
- package/templates/standard/src/app.ts +2 -1
- package/templates/standard/src/core.ts +12 -0
- package/templates/standard/src/db/seed/index.ts +2 -0
- package/templates/standard/src/flows/main/index.ts +4 -4
- package/templates/standard/src/flows/notes/index.ts +12 -12
- package/templates/standard/src/flows/notes/shapes.ts +2 -3
- package/templates/standard/tests/standard.test.ts +3 -2
- package/templates/standard/web/index.html +12 -0
- package/templates/standard/web/src/App.css +78 -0
- package/templates/standard/web/src/App.tsx +100 -0
- package/templates/standard/web/src/client.ts +38 -0
- package/templates/standard/web/src/index.css +18 -0
- package/templates/standard/web/src/main.tsx +13 -0
- package/templates/standard/web/src/vite-env.d.ts +17 -0
- package/templates/standard/web/tsconfig.app.json +24 -0
- package/templates/standard/web/tsconfig.json +4 -0
- package/templates/standard/web/tsconfig.node.json +20 -0
- package/templates/standard/web/vite.config.ts +32 -0
package/src/scaffold.ts
CHANGED
|
@@ -16,13 +16,14 @@ import { basename, join, relative, resolve } from "node:path";
|
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
17
|
import { resolveLocalOkengineRoot, resolveTemplateDir, type TemplateId } from "./templates.ts";
|
|
18
18
|
import { agentsMdContent } from "./agents-md.ts";
|
|
19
|
-
import type { CreateDefaults } from "./create-defaults.ts";
|
|
19
|
+
import type { CreateDefaults, CreateProxyId } from "./create-defaults.ts";
|
|
20
20
|
import { DEFAULT_IMAGES, TEMPLATE_DEV } from "./drivers-catalog.ts";
|
|
21
21
|
import { applyLocalesToProject } from "./locales.ts";
|
|
22
22
|
import {
|
|
23
23
|
DEFAULT_SQL_DRIVER,
|
|
24
24
|
applyCreateAnswers,
|
|
25
25
|
applyPgDogToConfig,
|
|
26
|
+
applyProxyToConfig,
|
|
26
27
|
extractImages,
|
|
27
28
|
sanitizeProjectName,
|
|
28
29
|
shouldSkipTemplatePath,
|
|
@@ -64,6 +65,11 @@ export type ScaffoldOptions = {
|
|
|
64
65
|
* {@link CreateDefaults.pgdog} or `false`.
|
|
65
66
|
*/
|
|
66
67
|
readonly pgdog?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Pin `images.proxy`. When omitted, uses {@link CreateDefaults.proxy} or
|
|
70
|
+
* `none`.
|
|
71
|
+
*/
|
|
72
|
+
readonly proxy?: CreateProxyId;
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
/** Result of a successful scaffold. */
|
|
@@ -82,6 +88,8 @@ export type ScaffoldResult = {
|
|
|
82
88
|
readonly locales: readonly string[];
|
|
83
89
|
/** Whether `images.pgdog` was pinned. */
|
|
84
90
|
readonly pgdog: boolean;
|
|
91
|
+
/** Proxy wizard id applied (`none` = unset). */
|
|
92
|
+
readonly proxy: CreateProxyId;
|
|
85
93
|
/** Relative paths written (POSIX), sorted. */
|
|
86
94
|
readonly files: readonly string[];
|
|
87
95
|
};
|
|
@@ -178,10 +186,13 @@ export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult
|
|
|
178
186
|
}
|
|
179
187
|
|
|
180
188
|
const pgdog = options.pgdog ?? createDefaults?.pgdog ?? false;
|
|
189
|
+
const proxy = options.proxy ?? createDefaults?.proxy ?? "none";
|
|
181
190
|
const configPath = join(targetDir, "oke.config.ts");
|
|
182
191
|
if (existsSync(configPath)) {
|
|
192
|
+
let next = readFileSync(configPath, "utf8");
|
|
193
|
+
next = applyPgDogToConfig(next, pgdog);
|
|
194
|
+
next = applyProxyToConfig(next, proxy);
|
|
183
195
|
const prev = readFileSync(configPath, "utf8");
|
|
184
|
-
const next = applyPgDogToConfig(prev, pgdog);
|
|
185
196
|
if (next !== prev) writeFileSync(configPath, next, "utf8");
|
|
186
197
|
}
|
|
187
198
|
|
|
@@ -204,6 +215,7 @@ export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult
|
|
|
204
215
|
...(createDefaults !== undefined ? { createDefaults } : {}),
|
|
205
216
|
locales,
|
|
206
217
|
pgdog,
|
|
218
|
+
proxy,
|
|
207
219
|
files: written,
|
|
208
220
|
};
|
|
209
221
|
} catch (e) {
|
|
@@ -393,7 +405,7 @@ services:
|
|
|
393
405
|
ports:
|
|
394
406
|
- "6530:6530"
|
|
395
407
|
env_file:
|
|
396
|
-
-
|
|
408
|
+
- ../.env.local
|
|
397
409
|
depends_on:
|
|
398
410
|
store-sql:
|
|
399
411
|
condition: service_healthy
|
|
@@ -427,7 +439,7 @@ services:
|
|
|
427
439
|
networks:
|
|
428
440
|
- oke
|
|
429
441
|
env_file:
|
|
430
|
-
-
|
|
442
|
+
- ../.env.local
|
|
431
443
|
environment:
|
|
432
444
|
POSTGRES_USER: \${OKE_STORE_SQL_USER}
|
|
433
445
|
POSTGRES_PASSWORD: \${OKE_STORE_SQL_PASSWORD}
|
|
@@ -453,7 +465,7 @@ services:
|
|
|
453
465
|
networks:
|
|
454
466
|
- oke
|
|
455
467
|
env_file:
|
|
456
|
-
-
|
|
468
|
+
- ../.env.local
|
|
457
469
|
command:
|
|
458
470
|
- sh
|
|
459
471
|
- -c
|
package/src/transform.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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-
|
|
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 →
|
|
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 —
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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-
|
|
34
|
-
// index: "getmeili/meilisearch:v1.
|
|
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.
|
|
37
|
+
email: "axllent/mailpit:v1.30.7",
|
|
38
38
|
},
|
|
39
|
-
// pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
40
|
-
//
|
|
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). */
|
|
@@ -53,7 +53,7 @@ async function sampleNotes(fx: Fx) {
|
|
|
53
53
|
{
|
|
54
54
|
id: "sample-summarize",
|
|
55
55
|
title: "Try summarize",
|
|
56
|
-
body: "POST /notes/:id/summarize uses fx.ask
|
|
56
|
+
body: "POST /notes/:id/summarize uses fx.ask with the prompt's via recovery chain.",
|
|
57
57
|
archivedAt: null,
|
|
58
58
|
createdAt: 4,
|
|
59
59
|
},
|
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
@@ -14,36 +14,78 @@ import {
|
|
|
14
14
|
NoteSummarizeIn,
|
|
15
15
|
NoteSummarizeOut,
|
|
16
16
|
NotFound,
|
|
17
|
+
Unavailable,
|
|
17
18
|
} from "./shapes";
|
|
18
19
|
import { noteCreated } from "./signals";
|
|
19
20
|
|
|
20
21
|
import "./shapes";
|
|
21
22
|
import "./signals";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Pull a usable summary string from an `fx.ask` payload.
|
|
26
|
+
*
|
|
27
|
+
* @param out - Model output object
|
|
28
|
+
*/
|
|
29
|
+
function extractSummary(out: unknown): string {
|
|
30
|
+
if (typeof out === "string") return unwrapSummaryText(out);
|
|
31
|
+
if (!out || typeof out !== "object") return "";
|
|
32
|
+
const record = out as Record<string, unknown>;
|
|
33
|
+
if (typeof record.summary === "string") return record.summary.trim();
|
|
34
|
+
if (typeof record.text === "string") return unwrapSummaryText(record.text);
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
|
|
40
|
+
* Peel one or two JSON layers, then fall back to the raw text.
|
|
41
|
+
*
|
|
42
|
+
* @param text - Model text payload
|
|
43
|
+
*/
|
|
44
|
+
function unwrapSummaryText(text: string): string {
|
|
45
|
+
let current = text.trim();
|
|
46
|
+
for (let i = 0; i < 2; i++) {
|
|
47
|
+
if (!(current.startsWith("{") || current.startsWith('"'))) break;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(current) as unknown;
|
|
50
|
+
if (typeof parsed === "string") {
|
|
51
|
+
current = parsed.trim();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (parsed && typeof parsed === "object" && "summary" in parsed) {
|
|
55
|
+
return String((parsed as { summary: unknown }).summary).trim();
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
} catch {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
|
|
23
65
|
/** List active (non-archived) notes, newest first. */
|
|
24
66
|
export const list = on(
|
|
25
|
-
http.get("/notes").gate
|
|
67
|
+
http.get("/notes").gate.public,
|
|
26
68
|
flow("notes.list", {
|
|
27
69
|
out: NoteListOut,
|
|
28
|
-
do: async (
|
|
70
|
+
do: async (input, fx) => {
|
|
29
71
|
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
const data = [...rows]
|
|
73
|
+
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
74
|
+
.map((r) => ({
|
|
33
75
|
id: String(r.id),
|
|
34
76
|
title: String(r.title),
|
|
35
77
|
body: String(r.body),
|
|
36
78
|
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
37
79
|
createdAt: Number(r.createdAt),
|
|
38
|
-
}))
|
|
39
|
-
|
|
80
|
+
}));
|
|
81
|
+
return fx.json.withQuery(data, input);
|
|
40
82
|
},
|
|
41
83
|
}),
|
|
42
84
|
);
|
|
43
85
|
|
|
44
86
|
/** Create a note, emit `note-created`, touch vault. */
|
|
45
87
|
export const create = on(
|
|
46
|
-
http.post("/notes").gate(
|
|
88
|
+
http.post("/notes").gate(notesMutate),
|
|
47
89
|
flow("notes.create", {
|
|
48
90
|
in: NoteCreateIn,
|
|
49
91
|
out: NoteOut,
|
|
@@ -73,7 +115,7 @@ export const create = on(
|
|
|
73
115
|
|
|
74
116
|
/** Fetch one note by id. */
|
|
75
117
|
export const get = on(
|
|
76
|
-
http.get("/notes/:id").gate
|
|
118
|
+
http.get("/notes/:id").gate.public,
|
|
77
119
|
flow("notes.get", {
|
|
78
120
|
in: NoteIdIn,
|
|
79
121
|
out: NoteOut,
|
|
@@ -94,7 +136,7 @@ export const get = on(
|
|
|
94
136
|
|
|
95
137
|
/** Soft-archive a note. */
|
|
96
138
|
export const archive = on(
|
|
97
|
-
http.post("/notes/:id/archive").gate(
|
|
139
|
+
http.post("/notes/:id/archive").gate(notesMutate),
|
|
98
140
|
flow("notes.archive", {
|
|
99
141
|
in: NoteIdIn,
|
|
100
142
|
out: NoteOut,
|
|
@@ -134,7 +176,7 @@ export const onCreated = on(
|
|
|
134
176
|
|
|
135
177
|
/** Store a text attachment next to a note (`files:uploads`). */
|
|
136
178
|
export const attach = on(
|
|
137
|
-
http.post("/notes/:id/attach").gate(
|
|
179
|
+
http.post("/notes/:id/attach").gate(notesMutate),
|
|
138
180
|
flow("notes.attach", {
|
|
139
181
|
in: NoteAttachIn,
|
|
140
182
|
out: NoteAttachOut,
|
|
@@ -162,32 +204,37 @@ export const digest = on(
|
|
|
162
204
|
);
|
|
163
205
|
|
|
164
206
|
/**
|
|
165
|
-
* Summarize a note via
|
|
166
|
-
*
|
|
207
|
+
* Summarize a note via the prompt's declared recovery chain.
|
|
208
|
+
* Exhausted / failed asks surface as Unavailable — never a body excerpt.
|
|
167
209
|
*/
|
|
168
210
|
export const summarize = on(
|
|
169
|
-
http.post("/notes/:id/summarize").gate(
|
|
211
|
+
http.post("/notes/:id/summarize").gate(notesMutate),
|
|
170
212
|
flow("notes.summarize", {
|
|
171
213
|
in: NoteSummarizeIn,
|
|
172
214
|
out: NoteSummarizeOut,
|
|
173
|
-
errors: { NotFound },
|
|
215
|
+
errors: { NotFound, Unavailable },
|
|
174
216
|
do: async (input, fx) => {
|
|
175
217
|
const row = await fx.store(db).findById(notes, input.id);
|
|
176
218
|
if (!row) return fail("NotFound", { id: input.id });
|
|
177
|
-
const body = String(row.body);
|
|
178
219
|
try {
|
|
179
220
|
const out = await fx.ask("summarize-note", {
|
|
221
|
+
instruction:
|
|
222
|
+
'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
|
|
180
223
|
title: String(row.title),
|
|
181
|
-
body,
|
|
224
|
+
body: String(row.body),
|
|
182
225
|
});
|
|
183
|
-
const summary =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
const summary = extractSummary(out);
|
|
227
|
+
const via = typeof out.via === "string" ? out.via.trim() : "";
|
|
228
|
+
if (!summary || !via) {
|
|
229
|
+
return fail("Unavailable", {
|
|
230
|
+
message: "AI service unavailable. Try again later.",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { id: input.id, summary, via };
|
|
188
234
|
} catch {
|
|
189
|
-
|
|
190
|
-
|
|
235
|
+
return fail("Unavailable", {
|
|
236
|
+
message: "AI service unavailable. Try again later.",
|
|
237
|
+
});
|
|
191
238
|
}
|
|
192
239
|
},
|
|
193
240
|
}),
|
|
@@ -13,9 +13,8 @@ export const NoteOut = z.object({
|
|
|
13
13
|
createdAt: z.number(),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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),
|
|
@@ -48,5 +47,11 @@ export const NoteSummarizeIn = z.object({
|
|
|
48
47
|
export const NoteSummarizeOut = z.object({
|
|
49
48
|
id: z.string(),
|
|
50
49
|
summary: z.string(),
|
|
51
|
-
|
|
50
|
+
/** Logical model name that answered (`smart`, `local`, …). */
|
|
51
|
+
via: z.string().min(1),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Both recovery links failed — no silent text excerpt. */
|
|
55
|
+
export const Unavailable = z.object({
|
|
56
|
+
message: z.string(),
|
|
52
57
|
});
|
|
@@ -18,7 +18,7 @@ test("boots — health flow is named main.health", async () => {
|
|
|
18
18
|
expect(data).toEqual({ ok: true });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("notes create → attach → summarize
|
|
21
|
+
test("notes create → attach → summarize → archive", async () => {
|
|
22
22
|
const created = await t.api.notes!.create!({
|
|
23
23
|
title: "Advanced",
|
|
24
24
|
body: "Body long enough to exercise attach and summarize paths in the advanced starter.",
|
|
@@ -32,10 +32,11 @@ test("notes create → attach → summarize fallback → archive", async () => {
|
|
|
32
32
|
expect(attached.error).toBeNull();
|
|
33
33
|
expect((attached.data as { key: string }).key).toBe(`notes/${id}/attachment.txt`);
|
|
34
34
|
|
|
35
|
+
t.ai.mock("summarize-note", { summary: "Advanced starter summary." });
|
|
35
36
|
const summary = await t.api.notes!.summarize!({ id });
|
|
36
37
|
expect(summary.error).toBeNull();
|
|
37
38
|
const out = summary.data as { via: string; summary: string };
|
|
38
|
-
expect(out.via).
|
|
39
|
+
expect(out.via.length).toBeGreaterThan(0);
|
|
39
40
|
expect(out.summary.length).toBeGreaterThan(0);
|
|
40
41
|
|
|
41
42
|
const archived = await t.api.notes!.archive!({ id });
|
|
@@ -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>
|