create-oke 0.6.1 → 0.7.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/README.md +23 -8
- package/package.json +3 -3
- package/src/ai-setup/apply.ts +214 -0
- package/src/ai-setup/catalog.ts +263 -0
- package/src/ai-setup/detect-ollama.ts +180 -0
- package/src/ai-setup/from-pref.ts +99 -0
- package/src/ai-setup/prompts.ts +649 -0
- package/src/ai-setup/recommend.ts +203 -0
- package/src/cli.test.ts +385 -28
- package/src/cli.ts +332 -29
- package/src/create-defaults.test.ts +92 -0
- package/src/create-defaults.ts +246 -0
- package/src/customize-flow.test.ts +82 -0
- package/src/customize-flow.ts +432 -0
- package/src/drivers-catalog.ts +217 -0
- package/src/scaffold.ts +97 -10
- package/src/templates.ts +14 -9
- package/src/transform.test.ts +105 -0
- package/src/transform.ts +227 -0
- package/src/wizard-select.ts +50 -0
- package/{template → templates/advanced}/.env.example +1 -0
- package/templates/advanced/README.md +32 -0
- package/templates/advanced/oke.config.ts +74 -0
- package/templates/advanced/package.json +18 -0
- package/templates/advanced/src/app.ts +30 -0
- package/templates/advanced/src/channels.ts +13 -0
- package/templates/advanced/src/core.ts +8 -0
- package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
- package/templates/advanced/src/flows/main/shapes.ts +1 -0
- package/templates/advanced/src/flows/main/signals.ts +1 -0
- package/templates/advanced/src/flows/notes/index.ts +224 -0
- package/templates/advanced/src/flows/notes/shapes.ts +52 -0
- package/templates/advanced/src/flows/notes/signals.ts +13 -0
- package/templates/advanced/src/gates.ts +12 -0
- package/templates/advanced/src/locales/ar.ts +18 -0
- package/templates/advanced/src/locales/en.ts +22 -0
- package/templates/advanced/src/schema.decl.ts +13 -0
- package/templates/advanced/src/vault.ts +7 -0
- package/templates/advanced/tests/advanced.test.ts +42 -0
- package/templates/standard/.env.example +94 -0
- package/templates/standard/README.md +46 -0
- package/templates/standard/drizzle.config.ts +25 -0
- package/{template → templates/standard}/oke.config.ts +9 -9
- package/{template → templates/standard}/package.json +1 -1
- package/templates/standard/src/app.ts +30 -0
- package/templates/standard/src/channels.ts +13 -0
- package/{template → templates/standard}/src/core.ts +1 -0
- package/templates/standard/src/flows/main/index.ts +34 -0
- package/templates/standard/src/flows/main/shapes.ts +1 -0
- package/templates/standard/src/flows/main/signals.ts +1 -0
- package/templates/standard/src/flows/notes/index.ts +143 -0
- package/templates/standard/src/flows/notes/shapes.ts +26 -0
- package/templates/standard/src/flows/notes/signals.ts +13 -0
- package/templates/standard/src/gates.ts +12 -0
- package/templates/standard/src/locales/ar.ts +18 -0
- package/templates/standard/src/locales/en.ts +22 -0
- package/templates/standard/src/schema.decl.ts +13 -0
- package/templates/standard/src/vault.ts +7 -0
- package/templates/standard/tests/standard.test.ts +51 -0
- package/template/README.md +0 -77
- package/template/bun.lock +0 -845
- package/template/src/app.ts +0 -18
- package/template/src/channels.ts +0 -4
- package/template/src/flows/main/shapes.ts +0 -2
- package/template/src/flows/main/signals.ts +0 -4
- package/template/src/gates.ts +0 -4
- package/template/src/locales/ar.ts +0 -20
- package/template/src/locales/en.ts +0 -26
- package/template/src/schema.decl.ts +0 -12
- package/template/src/vault.ts +0 -4
- package/template/tests/standard.test.ts +0 -18
- /package/{template → templates/advanced}/drizzle.config.ts +0 -0
package/src/scaffold.ts
CHANGED
|
@@ -12,11 +12,18 @@ import {
|
|
|
12
12
|
statSync,
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from "node:fs";
|
|
15
|
-
import { join, relative, resolve } from "node:path";
|
|
16
|
-
import {
|
|
15
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
resolveLocalOkengineRoot,
|
|
18
|
+
resolveTemplateDir,
|
|
19
|
+
TEMPLATE_DEFAULT_MODE,
|
|
20
|
+
type TemplateId,
|
|
21
|
+
} from "./templates.ts";
|
|
17
22
|
import { agentsMdContent } from "./agents-md.ts";
|
|
23
|
+
import type { CreateDefaults } from "./create-defaults.ts";
|
|
18
24
|
import {
|
|
19
25
|
DEFAULT_SQL_DRIVER,
|
|
26
|
+
applyCreateAnswers,
|
|
20
27
|
sanitizeProjectName,
|
|
21
28
|
shouldSkipTemplatePath,
|
|
22
29
|
transformConfigForSqlDriver,
|
|
@@ -42,8 +49,11 @@ export type ScaffoldOptions = {
|
|
|
42
49
|
/**
|
|
43
50
|
* Store SQL driver — pins `oke.config.ts` `store.sql` local/docker/prod
|
|
44
51
|
* when `postgres`. Default `sqlite` keeps the dual-mode config.
|
|
52
|
+
* Ignored when {@link createDefaults} is set.
|
|
45
53
|
*/
|
|
46
54
|
readonly sqlDriver?: SqlDriverId;
|
|
55
|
+
/** Full customize / reuse answers — applied after copy. */
|
|
56
|
+
readonly createDefaults?: CreateDefaults;
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
/** Result of a successful scaffold. */
|
|
@@ -56,10 +66,36 @@ export type ScaffoldResult = {
|
|
|
56
66
|
readonly okengineDependency: string;
|
|
57
67
|
/** Store SQL driver applied to schema + config. */
|
|
58
68
|
readonly sqlDriver: SqlDriverId;
|
|
69
|
+
/** Customize answers applied, if any. */
|
|
70
|
+
readonly createDefaults?: CreateDefaults;
|
|
59
71
|
/** Relative paths written (POSIX), sorted. */
|
|
60
72
|
readonly files: readonly string[];
|
|
61
73
|
};
|
|
62
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Why `targetDir` cannot host a new project, or `null` when it is free / empty.
|
|
77
|
+
*
|
|
78
|
+
* Used by the wizard (early name validation) and {@link scaffold}.
|
|
79
|
+
*
|
|
80
|
+
* @param targetDir - Absolute or cwd-relative destination
|
|
81
|
+
*/
|
|
82
|
+
export function targetDirectoryBlockReason(targetDir: string): string | null {
|
|
83
|
+
const abs = resolve(targetDir);
|
|
84
|
+
if (!existsSync(abs)) return null;
|
|
85
|
+
let entries: string[];
|
|
86
|
+
try {
|
|
87
|
+
entries = readdirSync(abs);
|
|
88
|
+
} catch {
|
|
89
|
+
return `create-oke: cannot read target directory: ${abs}`;
|
|
90
|
+
}
|
|
91
|
+
if (entries.length === 0) return null;
|
|
92
|
+
const folder = basename(abs);
|
|
93
|
+
return (
|
|
94
|
+
`create-oke: "${folder}" already exists and is not empty (${abs}). ` +
|
|
95
|
+
`Pick another name, or remove that directory first.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
/**
|
|
64
100
|
* Scaffold a new okengine project from the standard template.
|
|
65
101
|
*
|
|
@@ -70,14 +106,15 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
|
|
|
70
106
|
const targetDir = resolve(options.targetDir);
|
|
71
107
|
const sourceDir = resolveTemplateDir(options.source.id);
|
|
72
108
|
const label = options.source.id;
|
|
73
|
-
const
|
|
109
|
+
const createDefaults = options.createDefaults;
|
|
110
|
+
const sqlDriver =
|
|
111
|
+
createDefaults?.drivers.store.sql.local === "postgres" || options.sqlDriver === "postgres"
|
|
112
|
+
? "postgres"
|
|
113
|
+
: (options.sqlDriver ?? DEFAULT_SQL_DRIVER);
|
|
74
114
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw new Error(`create-oke: target directory is not empty: ${targetDir}`);
|
|
79
|
-
}
|
|
80
|
-
} else {
|
|
115
|
+
const blocked = targetDirectoryBlockReason(targetDir);
|
|
116
|
+
if (blocked) throw new Error(blocked);
|
|
117
|
+
if (!existsSync(targetDir)) {
|
|
81
118
|
mkdirSync(targetDir, { recursive: true });
|
|
82
119
|
}
|
|
83
120
|
|
|
@@ -95,7 +132,11 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
|
|
|
95
132
|
const nextPkg = transformPackageJson(sourcePkg, name, okengineDependency);
|
|
96
133
|
writeFileSync(pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`, "utf8");
|
|
97
134
|
|
|
98
|
-
|
|
135
|
+
if (createDefaults) {
|
|
136
|
+
applyCreateDefaultsTransforms(targetDir, createDefaults);
|
|
137
|
+
} else {
|
|
138
|
+
applySqlDriverTransforms(targetDir, sqlDriver);
|
|
139
|
+
}
|
|
99
140
|
|
|
100
141
|
if (options.writeAgentsMd !== false) {
|
|
101
142
|
const agentsPath = join(targetDir, "AGENTS.md");
|
|
@@ -110,6 +151,10 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
|
|
|
110
151
|
if (!written.includes(".env.local")) written.push(".env.local");
|
|
111
152
|
}
|
|
112
153
|
|
|
154
|
+
// Seed `.oke/mode` so `oke dev` does not re-ask local vs docker.
|
|
155
|
+
writeProjectDevMode(targetDir, resolveInitialDevMode(createDefaults, options.source.id));
|
|
156
|
+
if (!written.includes(".oke/mode")) written.push(".oke/mode");
|
|
157
|
+
|
|
113
158
|
written.sort();
|
|
114
159
|
return {
|
|
115
160
|
targetDir,
|
|
@@ -118,6 +163,7 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
|
|
|
118
163
|
label,
|
|
119
164
|
okengineDependency,
|
|
120
165
|
sqlDriver,
|
|
166
|
+
...(createDefaults !== undefined ? { createDefaults } : {}),
|
|
121
167
|
files: written,
|
|
122
168
|
};
|
|
123
169
|
} catch (e) {
|
|
@@ -147,6 +193,47 @@ function applySqlDriverTransforms(targetDir: string, sqlDriver: SqlDriverId): vo
|
|
|
147
193
|
}
|
|
148
194
|
}
|
|
149
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Apply full customize / reuse defaults to `oke.config.ts`.
|
|
198
|
+
*
|
|
199
|
+
* @param targetDir - Scaffolded project root
|
|
200
|
+
* @param defaults - Persisted create answers
|
|
201
|
+
*/
|
|
202
|
+
function applyCreateDefaultsTransforms(targetDir: string, defaults: CreateDefaults): void {
|
|
203
|
+
const configPath = join(targetDir, "oke.config.ts");
|
|
204
|
+
if (!existsSync(configPath)) {
|
|
205
|
+
throw new Error("create-oke: scaffolded project has no oke.config.ts");
|
|
206
|
+
}
|
|
207
|
+
const next = applyCreateAnswers(readFileSync(configPath, "utf8"), defaults);
|
|
208
|
+
writeFileSync(configPath, next, "utf8");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Map create-oke profile / template → persisted `oke dev` mode.
|
|
213
|
+
*
|
|
214
|
+
* @param defaults - Customize / reuse answers, or undefined for recommended
|
|
215
|
+
* @param template - Starter id (recommended path uses template default mode)
|
|
216
|
+
*/
|
|
217
|
+
export function resolveInitialDevMode(
|
|
218
|
+
defaults: CreateDefaults | undefined,
|
|
219
|
+
template: TemplateId = "standard",
|
|
220
|
+
): "local" | "docker" {
|
|
221
|
+
if (defaults?.profile === "docker-ready") return "docker";
|
|
222
|
+
if (defaults?.profile === "local-only") return "local";
|
|
223
|
+
return TEMPLATE_DEFAULT_MODE[template];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Write project-local `.oke/mode` (same shape as `oke mode` / `oke dev`).
|
|
228
|
+
*
|
|
229
|
+
* @param targetDir - Scaffolded project root
|
|
230
|
+
* @param mode - Mode to save
|
|
231
|
+
*/
|
|
232
|
+
function writeProjectDevMode(targetDir: string, mode: "local" | "docker"): void {
|
|
233
|
+
mkdirSync(join(targetDir, ".oke"), { recursive: true });
|
|
234
|
+
writeFileSync(join(targetDir, ".oke", "mode"), `${mode}\n`, "utf8");
|
|
235
|
+
}
|
|
236
|
+
|
|
150
237
|
/**
|
|
151
238
|
* Recursively copy template files, applying {@link shouldSkipTemplatePath}.
|
|
152
239
|
*
|
package/src/templates.ts
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The only starter lives in this package at `template/`.
|
|
2
|
+
* Starter template resolution — `templates/standard` · `templates/advanced`.
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
6
|
import { dirname, join, resolve } from "node:path";
|
|
9
7
|
import { fileURLToPath } from "node:url";
|
|
10
8
|
|
|
11
|
-
/**
|
|
12
|
-
export const TEMPLATES = ["standard"] as const;
|
|
9
|
+
/** Supported starter templates. */
|
|
10
|
+
export const TEMPLATES = ["standard", "advanced"] as const;
|
|
13
11
|
|
|
14
12
|
/** A known clean starter template. */
|
|
15
13
|
export type TemplateId = (typeof TEMPLATES)[number];
|
|
16
14
|
|
|
17
|
-
/** Default when `--template` is omitted
|
|
15
|
+
/** Default when `--template` is omitted. */
|
|
18
16
|
export const DEFAULT_TEMPLATE: TemplateId = "standard";
|
|
19
17
|
|
|
20
18
|
/** One-line purpose for the starter (interactive select + help). */
|
|
21
19
|
export const TEMPLATE_PURPOSES: Readonly<Record<TemplateId, string>> = {
|
|
22
|
-
standard: "
|
|
20
|
+
standard: "Notes app — local-first (sqlite · memory · console email)",
|
|
21
|
+
advanced: "Notes app — docker-ready (postgres · redis · s3 · openbao)",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Recommended `.oke/mode` when using recommended defaults (no customize). */
|
|
25
|
+
export const TEMPLATE_DEFAULT_MODE: Readonly<Record<TemplateId, "local" | "docker">> = {
|
|
26
|
+
standard: "local",
|
|
27
|
+
advanced: "docker",
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
/**
|
|
@@ -43,7 +48,7 @@ export function packageRoot(): string {
|
|
|
43
48
|
*
|
|
44
49
|
* Order:
|
|
45
50
|
* 1. `CREATE_OKE_TEMPLATE` env (absolute path to a template)
|
|
46
|
-
* 2. Bundled `
|
|
51
|
+
* 2. Bundled `templates/<id>/`
|
|
47
52
|
*
|
|
48
53
|
* @param template - Template id
|
|
49
54
|
* @returns Absolute path to the template source tree
|
|
@@ -52,7 +57,7 @@ export function resolveTemplateDir(template: TemplateId): string {
|
|
|
52
57
|
const envRoot = process.env["CREATE_OKE_TEMPLATE"];
|
|
53
58
|
if (envRoot && existsSync(envRoot)) return resolve(envRoot);
|
|
54
59
|
const pkg = packageRoot();
|
|
55
|
-
const bundled = resolve(pkg, "template
|
|
60
|
+
const bundled = resolve(pkg, "templates", template);
|
|
56
61
|
if (existsSync(bundled)) return bundled;
|
|
57
62
|
|
|
58
63
|
throw new Error(`create-oke: template "${template}" not found at ${bundled}`);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-oke config transforms — images must stay role→image pins.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { toCreateDefaults } from "./create-defaults.ts";
|
|
9
|
+
import { pinsDockerReady, pinsLocalOnly } from "./drivers-catalog.ts";
|
|
10
|
+
import { resolveTemplateDir } from "./templates.ts";
|
|
11
|
+
import { applyCreateAnswers, upsertAiDrivers } from "./transform.ts";
|
|
12
|
+
import type { EnvDriverPins } from "./create-defaults.ts";
|
|
13
|
+
|
|
14
|
+
function templateConfig(): string {
|
|
15
|
+
return readFileSync(join(resolveTemplateDir("standard"), "oke.config.ts"), "utf8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultsWithIndex(indexLocal: string, indexDocker: string) {
|
|
19
|
+
return toCreateDefaults({
|
|
20
|
+
template: "advanced",
|
|
21
|
+
profile: "docker-ready",
|
|
22
|
+
drivers: {
|
|
23
|
+
store: {
|
|
24
|
+
sql: pinsDockerReady("libsql", "postgres", "memory"),
|
|
25
|
+
kv: pinsLocalOnly("memory", "redis", "memory"),
|
|
26
|
+
files: pinsLocalOnly("fs", "s3", "memory"),
|
|
27
|
+
index: pinsDockerReady(indexLocal, indexDocker, "memory"),
|
|
28
|
+
},
|
|
29
|
+
signal: pinsLocalOnly("memory", "redis", "memory"),
|
|
30
|
+
clock: pinsLocalOnly("memory", "file", "frozen"),
|
|
31
|
+
vault: pinsLocalOnly("env", "openbao", "memory"),
|
|
32
|
+
channel: { email: pinsLocalOnly("console", "smtp", "console") },
|
|
33
|
+
ai: null,
|
|
34
|
+
},
|
|
35
|
+
ai: { enabled: false, provider: null, driver: null },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("applyCreateAnswers images", () => {
|
|
40
|
+
test("index: libsql does not poison images with driver pins", () => {
|
|
41
|
+
const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("libsql", "libsql"));
|
|
42
|
+
expect(next).toContain('local: "libsql"');
|
|
43
|
+
expect(next).toMatch(/index:\s*\{\s*local: "libsql"/);
|
|
44
|
+
// Role pins only — never env-column keys or bare driver ids as images.
|
|
45
|
+
expect(next).not.toMatch(/images:\s*\{[^}]*\blocal:\s*"/s);
|
|
46
|
+
expect(next).not.toMatch(/images:\s*\{[^}]*\bdocker:\s*"/s);
|
|
47
|
+
expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"libsql"/s);
|
|
48
|
+
expect(next).toContain('"store.sql": "postgres:18-alpine"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("index meilisearch pins store.index image without comment leakage", () => {
|
|
52
|
+
const next = applyCreateAnswers(
|
|
53
|
+
templateConfig(),
|
|
54
|
+
defaultsWithIndex("meilisearch", "meilisearch"),
|
|
55
|
+
);
|
|
56
|
+
expect(next).toContain('"store.index": "getmeili/meilisearch:v1.37"');
|
|
57
|
+
expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("upsertAiDrivers", () => {
|
|
62
|
+
const ollamaPins: EnvDriverPins = {
|
|
63
|
+
local: "ollama",
|
|
64
|
+
docker: "ollama",
|
|
65
|
+
test: "mock",
|
|
66
|
+
prod: "ollama",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
test("inserts drivers.ai as sibling of channel (not inside email)", () => {
|
|
70
|
+
const next = upsertAiDrivers(templateConfig(), ollamaPins);
|
|
71
|
+
expect(next).toMatch(/^ {4}ai:\s*\{/m);
|
|
72
|
+
expect(next).toContain('local: "ollama"');
|
|
73
|
+
const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
|
|
74
|
+
expect(channel).toContain("email:");
|
|
75
|
+
expect(channel).not.toContain("ai:");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("applyCreateAnswers with ai pins keeps top-level drivers.ai", () => {
|
|
79
|
+
const next = applyCreateAnswers(
|
|
80
|
+
templateConfig(),
|
|
81
|
+
toCreateDefaults({
|
|
82
|
+
template: "advanced",
|
|
83
|
+
profile: "docker-ready",
|
|
84
|
+
drivers: {
|
|
85
|
+
store: {
|
|
86
|
+
sql: pinsDockerReady("libsql", "postgres", "memory"),
|
|
87
|
+
kv: pinsLocalOnly("memory", "redis", "memory"),
|
|
88
|
+
files: pinsLocalOnly("fs", "s3", "memory"),
|
|
89
|
+
index: null,
|
|
90
|
+
},
|
|
91
|
+
signal: pinsLocalOnly("memory", "redis", "memory"),
|
|
92
|
+
clock: pinsLocalOnly("memory", "file", "frozen"),
|
|
93
|
+
vault: pinsLocalOnly("env", "openbao", "memory"),
|
|
94
|
+
channel: { email: pinsLocalOnly("console", "smtp", "console") },
|
|
95
|
+
ai: ollamaPins,
|
|
96
|
+
},
|
|
97
|
+
ai: { enabled: true, provider: "ollama", driver: "ollama" },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(next).toMatch(/^ {4}ai:\s*\{/m);
|
|
101
|
+
expect(next).toContain('ai: "ollama/ollama:latest"');
|
|
102
|
+
const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
|
|
103
|
+
expect(channel).not.toContain("ai:");
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/transform.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import type { CreateDefaults, EnvDriverPins } from "./create-defaults.ts";
|
|
17
|
+
import { DEFAULT_IMAGES } from "./drivers-catalog.ts";
|
|
16
18
|
import { packageRoot } from "./templates.ts";
|
|
17
19
|
|
|
18
20
|
/** SQL store drivers selectable at scaffold time. */
|
|
@@ -148,6 +150,231 @@ export function transformConfigForSqlDriver(source: string, driver: SqlDriverId)
|
|
|
148
150
|
);
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Apply full customize answers to `oke.config.ts` source.
|
|
155
|
+
*
|
|
156
|
+
* Rewrites driver maps and syncs `images` keys for docker-backed roles.
|
|
157
|
+
*
|
|
158
|
+
* @param source - Config TypeScript source
|
|
159
|
+
* @param defaults - Customize / reuse payload
|
|
160
|
+
*/
|
|
161
|
+
export function applyCreateAnswers(source: string, defaults: CreateDefaults): string {
|
|
162
|
+
let next = source;
|
|
163
|
+
next = replaceEnvMap(next, "sql", defaults.drivers.store.sql, { nestedUnderStore: true });
|
|
164
|
+
next = replaceEnvMap(next, "kv", defaults.drivers.store.kv, { nestedUnderStore: true });
|
|
165
|
+
next = replaceEnvMap(next, "files", defaults.drivers.store.files, { nestedUnderStore: true });
|
|
166
|
+
|
|
167
|
+
if (defaults.drivers.store.index) {
|
|
168
|
+
next = upsertStoreIndex(next, defaults.drivers.store.index);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
next = replaceEnvMap(next, "signal", defaults.drivers.signal, { nestedUnderStore: false });
|
|
172
|
+
next = replaceEnvMap(next, "clock", defaults.drivers.clock, { nestedUnderStore: false });
|
|
173
|
+
next = replaceEnvMap(next, "vault", defaults.drivers.vault, { nestedUnderStore: false });
|
|
174
|
+
next = replaceChannelEmail(next, defaults.drivers.channel.email);
|
|
175
|
+
|
|
176
|
+
if (defaults.drivers.ai) {
|
|
177
|
+
next = upsertAiDrivers(next, defaults.drivers.ai);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
next = syncImages(next, defaults);
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Replace a top-level or store-nested env map block by key name.
|
|
186
|
+
*
|
|
187
|
+
* @param source - Config source
|
|
188
|
+
* @param key - Map key (`sql`, `signal`, …)
|
|
189
|
+
* @param pins - New pins
|
|
190
|
+
* @param options - Nesting
|
|
191
|
+
*/
|
|
192
|
+
function replaceEnvMap(
|
|
193
|
+
source: string,
|
|
194
|
+
key: string,
|
|
195
|
+
pins: EnvDriverPins,
|
|
196
|
+
options: { readonly nestedUnderStore: boolean },
|
|
197
|
+
): string {
|
|
198
|
+
const block = formatEnvMap(pins, options.nestedUnderStore ? 3 : 2);
|
|
199
|
+
// Match `key: { … }` at the driver indentation used in the template.
|
|
200
|
+
const re = new RegExp(`${key}:\\s*\\{[\\s\\S]*?\\n\\s*\\}`, "m");
|
|
201
|
+
if (!re.test(source)) {
|
|
202
|
+
throw new Error(`create-oke: oke.config.ts missing drivers.${key} block`);
|
|
203
|
+
}
|
|
204
|
+
return source.replace(re, `${key}: ${block}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Replace `channel.email` env map.
|
|
209
|
+
*
|
|
210
|
+
* @param source - Config source
|
|
211
|
+
* @param pins - Email pins
|
|
212
|
+
*/
|
|
213
|
+
function replaceChannelEmail(source: string, pins: EnvDriverPins): string {
|
|
214
|
+
const block = formatEnvMap(pins, 3);
|
|
215
|
+
const re = /email:\s*\{[\s\S]*?\n\s*\}/;
|
|
216
|
+
if (!re.test(source)) {
|
|
217
|
+
throw new Error("create-oke: oke.config.ts missing channel.email block");
|
|
218
|
+
}
|
|
219
|
+
return source.replace(re, `email: ${block}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Insert or replace `store.index` under `drivers.store`.
|
|
224
|
+
*
|
|
225
|
+
* Matches only a real `index:` key at store-facet indentation — never
|
|
226
|
+
* `index: { … }` inside comments (e.g. the images opt-in note).
|
|
227
|
+
*
|
|
228
|
+
* @param source - Config source
|
|
229
|
+
* @param pins - Index pins
|
|
230
|
+
*/
|
|
231
|
+
function upsertStoreIndex(source: string, pins: EnvDriverPins): string {
|
|
232
|
+
const block = formatEnvMap(pins, 3);
|
|
233
|
+
const indexRe = /^( {6})index:\s*\{[\s\S]*?\n\1\}/m;
|
|
234
|
+
if (indexRe.test(source)) {
|
|
235
|
+
return source.replace(indexRe, `$1index: ${block}`);
|
|
236
|
+
}
|
|
237
|
+
// Insert after files block inside store.
|
|
238
|
+
const filesRe = /(files:\s*\{[\s\S]*?\n\s*\},)/;
|
|
239
|
+
if (!filesRe.test(source)) {
|
|
240
|
+
throw new Error("create-oke: oke.config.ts missing store.files to insert index");
|
|
241
|
+
}
|
|
242
|
+
return source.replace(filesRe, `$1\n index: ${block},`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Insert or replace top-level `drivers.ai` (sibling of `channel`, never nested).
|
|
247
|
+
*
|
|
248
|
+
* Indentation-anchored: only matches `ai:` / `channel:` at the drivers-body
|
|
249
|
+
* indent (4 spaces). The old non-greedy `channel: { … },` regex closed on
|
|
250
|
+
* `email`'s `},` and nested `ai` inside `channel` — hero then showed `ai: —`.
|
|
251
|
+
*
|
|
252
|
+
* @param source - Config source
|
|
253
|
+
* @param pins - AI pins
|
|
254
|
+
*/
|
|
255
|
+
export function upsertAiDrivers(source: string, pins: EnvDriverPins): string {
|
|
256
|
+
const block = formatEnvMap(pins, 2);
|
|
257
|
+
const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
|
|
258
|
+
if (aiRe.test(source)) {
|
|
259
|
+
return source.replace(aiRe, `$1ai: ${block}`);
|
|
260
|
+
}
|
|
261
|
+
const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
|
|
262
|
+
if (!channelRe.test(source)) {
|
|
263
|
+
throw new Error("create-oke: oke.config.ts missing channel block to insert ai");
|
|
264
|
+
}
|
|
265
|
+
return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Format an env driver map literal.
|
|
270
|
+
*
|
|
271
|
+
* @param pins - Pins
|
|
272
|
+
* @param indentLevel - Indent depth (2 spaces each)
|
|
273
|
+
*/
|
|
274
|
+
function formatEnvMap(pins: EnvDriverPins, indentLevel: number): string {
|
|
275
|
+
const pad = " ".repeat(indentLevel);
|
|
276
|
+
const close = " ".repeat(indentLevel - 1);
|
|
277
|
+
return `{
|
|
278
|
+
${pad}local: "${pins.local}",
|
|
279
|
+
${pad}docker: "${pins.docker}",
|
|
280
|
+
${pad}test: "${pins.test}",
|
|
281
|
+
${pad}prod: "${pins.prod}",
|
|
282
|
+
${close}}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Env-column keys that must never appear under `images`. */
|
|
286
|
+
const IMAGE_ENV_COLUMNS = new Set(["local", "docker", "test", "prod"]);
|
|
287
|
+
|
|
288
|
+
/** Known compose role keys written into `images`. */
|
|
289
|
+
const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai)$/;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Keep `images` in sync with chosen docker drivers.
|
|
293
|
+
*
|
|
294
|
+
* Rebuilds from known roles only — never spreads poisoned env-column keys
|
|
295
|
+
* leaked from comments or driver maps.
|
|
296
|
+
*
|
|
297
|
+
* @param source - Config source
|
|
298
|
+
* @param defaults - Answers
|
|
299
|
+
*/
|
|
300
|
+
function syncImages(source: string, defaults: CreateDefaults): string {
|
|
301
|
+
const existing = extractImages(source);
|
|
302
|
+
const images: Record<string, string> = {};
|
|
303
|
+
const d = defaults.drivers;
|
|
304
|
+
const pin = (role: string, fallback: string): void => {
|
|
305
|
+
images[role] = existing[role] ?? fallback;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
d.store.sql.docker === "postgres" ||
|
|
310
|
+
d.store.sql.prod === "postgres" ||
|
|
311
|
+
d.store.sql.docker === "pgvector" ||
|
|
312
|
+
d.store.sql.prod === "pgvector"
|
|
313
|
+
) {
|
|
314
|
+
pin("store.sql", DEFAULT_IMAGES["store.sql"]!);
|
|
315
|
+
}
|
|
316
|
+
if (d.store.kv.docker === "redis" || d.signal.docker === "redis") {
|
|
317
|
+
pin("store.kv", DEFAULT_IMAGES["store.kv"]!);
|
|
318
|
+
}
|
|
319
|
+
if (d.store.files.docker === "s3") {
|
|
320
|
+
pin("store.files", DEFAULT_IMAGES["store.files"]!);
|
|
321
|
+
}
|
|
322
|
+
if (d.channel.email.docker === "smtp") {
|
|
323
|
+
pin("channel.email", DEFAULT_IMAGES["channel.email"]!);
|
|
324
|
+
}
|
|
325
|
+
if (d.vault.docker === "openbao") {
|
|
326
|
+
pin("vault", DEFAULT_IMAGES.vault!);
|
|
327
|
+
}
|
|
328
|
+
if (d.store.index?.docker === "meilisearch") {
|
|
329
|
+
pin("store.index", DEFAULT_IMAGES["store.index"]!);
|
|
330
|
+
}
|
|
331
|
+
if (d.ai && (d.ai.docker === "ollama" || d.ai.local === "ollama")) {
|
|
332
|
+
pin("ai", DEFAULT_IMAGES.ai!);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return replaceImagesBlock(source, images);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse role→image pins from an `images` block.
|
|
340
|
+
*
|
|
341
|
+
* Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
|
|
342
|
+
*
|
|
343
|
+
* @param source - Config source
|
|
344
|
+
*/
|
|
345
|
+
function extractImages(source: string): Record<string, string> {
|
|
346
|
+
const m = /images:\s*\{([\s\S]*?)\n\s*\},/.exec(source);
|
|
347
|
+
if (!m) return {};
|
|
348
|
+
const out: Record<string, string> = {};
|
|
349
|
+
for (const line of m[1]!.split("\n")) {
|
|
350
|
+
const trimmed = line.trim();
|
|
351
|
+
if (!trimmed || trimmed.startsWith("//")) continue;
|
|
352
|
+
const hit = /["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(trimmed);
|
|
353
|
+
if (!hit) continue;
|
|
354
|
+
const key = hit[1]!;
|
|
355
|
+
if (IMAGE_ENV_COLUMNS.has(key) || !IMAGE_ROLE_KEY.test(key)) continue;
|
|
356
|
+
out[key] = hit[2]!;
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @param source - Config source
|
|
363
|
+
* @param images - Role → image
|
|
364
|
+
*/
|
|
365
|
+
function replaceImagesBlock(source: string, images: Record<string, string>): string {
|
|
366
|
+
const lines = Object.entries(images).map(([k, v]) => {
|
|
367
|
+
const key = k.includes(".") ? `"${k}"` : k;
|
|
368
|
+
return ` ${key}: "${v}",`;
|
|
369
|
+
});
|
|
370
|
+
const block = `images: {\n${lines.join("\n")}\n }`;
|
|
371
|
+
const re = /images:\s*\{[\s\S]*?\n\s*\}/;
|
|
372
|
+
if (!re.test(source)) {
|
|
373
|
+
throw new Error("create-oke: oke.config.ts missing images block");
|
|
374
|
+
}
|
|
375
|
+
return source.replace(re, block);
|
|
376
|
+
}
|
|
377
|
+
|
|
151
378
|
/**
|
|
152
379
|
* Sanitize a directory / package name for npm.
|
|
153
380
|
*
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Clack select helpers with optional ← Back.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isCancel, select } from "@clack/prompts";
|
|
6
|
+
|
|
7
|
+
/** Sentinel — go back one wizard step (shown as "← Back" in selects). */
|
|
8
|
+
export const WIZARD_BACK = "__back__" as const;
|
|
9
|
+
/** {@link WIZARD_BACK} type alias. */
|
|
10
|
+
export type WizardBack = typeof WIZARD_BACK;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Append "← Back" when allowed — pure helper for tests + {@link selectWithBack}.
|
|
14
|
+
*
|
|
15
|
+
* @param options - Choices
|
|
16
|
+
* @param allowBack - Whether to append Back
|
|
17
|
+
*/
|
|
18
|
+
export function withBackOption(
|
|
19
|
+
options: readonly { value: string; label: string; hint?: string }[],
|
|
20
|
+
allowBack: boolean,
|
|
21
|
+
): readonly { value: string; label: string; hint?: string }[] {
|
|
22
|
+
if (!allowBack) return options;
|
|
23
|
+
return [...options, { value: WIZARD_BACK, label: "← Back" }];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Select with an optional trailing "← Back" option.
|
|
28
|
+
*
|
|
29
|
+
* @param message - Prompt
|
|
30
|
+
* @param options - Choices (without back)
|
|
31
|
+
* @param initialValue - Initial selection
|
|
32
|
+
* @param opts - Whether Back is offered
|
|
33
|
+
*/
|
|
34
|
+
export async function selectWithBack(
|
|
35
|
+
message: string,
|
|
36
|
+
options: readonly { value: string; label: string; hint?: string }[],
|
|
37
|
+
initialValue: string,
|
|
38
|
+
opts: { readonly allowBack: boolean },
|
|
39
|
+
): Promise<string | WizardBack | null> {
|
|
40
|
+
const list = [...withBackOption(options, opts.allowBack)];
|
|
41
|
+
const value = await select({
|
|
42
|
+
message,
|
|
43
|
+
options: list,
|
|
44
|
+
initialValue,
|
|
45
|
+
});
|
|
46
|
+
if (isCancel(value)) return null;
|
|
47
|
+
const picked = String(value);
|
|
48
|
+
if (picked === WIZARD_BACK) return WIZARD_BACK;
|
|
49
|
+
return picked;
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Notes (advanced)
|
|
2
|
+
|
|
3
|
+
Docker-ready [okengine](https://oke.omqkhafi.dev) **Notes** starter — same domain as
|
|
4
|
+
`standard`, plus files attach, a daily digest clock, and an AI summarize stub.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
bun install
|
|
8
|
+
oke mode docker # seeded by create-oke recommended path
|
|
9
|
+
oke dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Surface | URL |
|
|
13
|
+
| ------- | --- |
|
|
14
|
+
| App | http://127.0.0.1:6530 |
|
|
15
|
+
| Console | http://127.0.0.1:6533 |
|
|
16
|
+
| MCP | http://127.0.0.1:6535 |
|
|
17
|
+
|
|
18
|
+
## Extra routes (vs standard)
|
|
19
|
+
|
|
20
|
+
| Method | Path | Flow |
|
|
21
|
+
| ------ | ---- | ---- |
|
|
22
|
+
| POST | `/notes/:id/attach` | `notes.attach` — text blob in `files:uploads` |
|
|
23
|
+
| POST | `/notes/:id/summarize` | `notes.summarize` — `fx.ask` when AI is configured |
|
|
24
|
+
| clock | `every("1d")` | `notes.digest` — active note count |
|
|
25
|
+
|
|
26
|
+
Configure AI with `oke ai setup` or `create-oke --ai` (writes `src/ai.ts` + `drivers.ai`).
|
|
27
|
+
Without AI, summarize returns a local excerpt (`via: "fallback"`).
|
|
28
|
+
|
|
29
|
+
## Drivers
|
|
30
|
+
|
|
31
|
+
See `oke.config.ts`: local sqlite/memory/fs · docker postgres/redis/s3/openbao/smtp.
|
|
32
|
+
Optional Meilisearch index pin is commented — enable via create-oke customize.
|