create-oke 0.10.2 → 0.11.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 +2 -2
- package/package.json +2 -2
- package/src/ai-setup/apply.ts +123 -31
- package/src/ai-setup/catalog.ts +3 -3
- package/src/ai-setup/from-pref.ts +1 -1
- package/src/ai-setup/prompts.ts +1 -1
- package/src/cli.test.ts +183 -142
- package/src/cli.ts +190 -32
- package/src/create-defaults.test.ts +21 -15
- package/src/create-defaults.ts +33 -13
- package/src/customize-flow.test.ts +30 -35
- package/src/customize-flow.ts +68 -229
- package/src/drivers-catalog.ts +42 -48
- package/src/local-okengine.test.ts +50 -41
- package/src/local-okengine.ts +28 -14
- package/src/locales.test.ts +128 -0
- package/src/locales.ts +321 -0
- package/src/scaffold.ts +277 -33
- package/src/templates.ts +2 -8
- package/src/transform.test.ts +102 -38
- package/src/transform.ts +378 -132
- package/templates/advanced/.env.example +23 -3
- package/templates/advanced/README.md +1 -1
- package/templates/advanced/drizzle.config.ts +10 -13
- package/templates/advanced/oke.config.ts +21 -62
- package/templates/advanced/package.json +3 -1
- package/templates/advanced/src/app.ts +3 -10
- package/templates/advanced/src/core.ts +58 -0
- package/templates/advanced/src/db/schema.decl.ts +1 -1
- package/templates/advanced/src/db/seed/index.ts +2 -2
- package/templates/advanced/src/flows/notes/index.ts +3 -15
- package/templates/advanced/src/locales/index.ts +6 -0
- package/templates/advanced/tests/advanced.test.ts +1 -1
- package/templates/advanced/tsconfig.json +3 -0
- package/templates/standard/.env.example +23 -3
- package/templates/standard/README.md +1 -1
- package/templates/standard/drizzle.config.ts +10 -13
- package/templates/standard/oke.config.ts +18 -60
- package/templates/standard/package.json +3 -1
- package/templates/standard/src/app.ts +3 -10
- package/templates/standard/src/core.ts +55 -0
- package/templates/standard/src/db/schema.decl.ts +1 -1
- package/templates/standard/src/db/seed/index.ts +2 -2
- package/templates/standard/src/flows/notes/index.ts +3 -12
- package/templates/standard/src/locales/index.ts +6 -0
- package/templates/standard/tests/standard.test.ts +1 -1
- package/templates/standard/tsconfig.json +3 -0
- package/templates/advanced/src/core/channels.ts +0 -13
- package/templates/advanced/src/core/gates.ts +0 -12
- package/templates/advanced/src/core/index.ts +0 -12
- package/templates/advanced/src/core/store.ts +0 -8
- package/templates/advanced/src/core/vault.ts +0 -7
- package/templates/advanced/src/locales/ar.ts +0 -18
- package/templates/standard/src/core/channels.ts +0 -13
- package/templates/standard/src/core/gates.ts +0 -12
- package/templates/standard/src/core/index.ts +0 -12
- package/templates/standard/src/core/store.ts +0 -5
- package/templates/standard/src/core/vault.ts +0 -7
- package/templates/standard/src/locales/ar.ts +0 -18
package/src/transform.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* workspace root; registry version otherwise)
|
|
10
10
|
* 3. Drop monorepo-only files that import paths outside the source tree
|
|
11
11
|
* (today: `tests/docker.test.ts`)
|
|
12
|
-
* 4. Optional `--sql` / wizard choice →
|
|
12
|
+
* 4. Optional `--sql` / wizard choice → `store.sql` pins
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
@@ -25,14 +25,14 @@ import {
|
|
|
25
25
|
import { materializeLocalOkengineDependency } from "./local-okengine.ts";
|
|
26
26
|
import { packageRoot } from "./templates.ts";
|
|
27
27
|
|
|
28
|
-
/** SQL store drivers selectable at scaffold time. */
|
|
29
|
-
export const SQL_DRIVERS = ["
|
|
28
|
+
/** SQL store drivers selectable at scaffold time (SQLite removed). */
|
|
29
|
+
export const SQL_DRIVERS = ["postgres"] as const;
|
|
30
30
|
|
|
31
31
|
/** A known store.sql driver id for create-oke. */
|
|
32
32
|
export type SqlDriverId = (typeof SQL_DRIVERS)[number];
|
|
33
33
|
|
|
34
34
|
/** Default when `--sql` / wizard choice is omitted (matches template sources). */
|
|
35
|
-
export const DEFAULT_SQL_DRIVER: SqlDriverId = "
|
|
35
|
+
export const DEFAULT_SQL_DRIVER: SqlDriverId = "postgres";
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Whether `value` is a known {@link SqlDriverId}.
|
|
@@ -124,64 +124,72 @@ export function shouldSkipTemplatePath(relativePath: string): boolean {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
127
|
+
* Real `DRIVER_DEFAULTS` mirrors (okengine/config) — create-oke has no
|
|
128
|
+
* runtime dependency on okengine, so keep a local copy for sparse pins.
|
|
128
129
|
*
|
|
129
|
-
*
|
|
130
|
-
* `postgres` swaps to `pgTable` / `pg-core` (and the reverse is idempotent).
|
|
131
|
-
*
|
|
132
|
-
* @param source - Schema TypeScript source
|
|
133
|
-
* @param driver - Target store.sql driver
|
|
130
|
+
* Keys with no entry (e.g. `index`, `ai`) always emit every env column.
|
|
134
131
|
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
132
|
+
const PIN_DEFAULTS: Readonly<Partial<Record<string, EnvDriverPins>>> = {
|
|
133
|
+
sql: { dev: "postgres", test: "pglite", prod: "postgres" },
|
|
134
|
+
kv: { dev: "redis", test: "memory", prod: "redis" },
|
|
135
|
+
files: { dev: "s3", test: "memory", prod: "s3" },
|
|
136
|
+
signal: { dev: "redis", test: "memory", prod: "redis" },
|
|
137
|
+
clock: { dev: "postgres", test: "frozen", prod: "postgres" },
|
|
138
|
+
vault: { dev: "env", test: "memory", prod: "vault" },
|
|
139
|
+
email: { dev: "smtp", test: "console", prod: "smtp" },
|
|
140
|
+
};
|
|
145
141
|
|
|
146
142
|
/**
|
|
147
|
-
* Pin `store.sql` `
|
|
143
|
+
* Pin `store.sql` `dev` / `prod` in `oke.config.ts` to `driver`.
|
|
148
144
|
*
|
|
149
|
-
* Leaves `test
|
|
145
|
+
* Leaves `test` (and every other facet) untouched. When the template omits
|
|
146
|
+
* `store.sql` (defaults already match), this is a no-op for `postgres`.
|
|
150
147
|
*
|
|
151
148
|
* @param source - Config TypeScript source
|
|
152
149
|
* @param driver - Target store.sql driver
|
|
153
150
|
*/
|
|
154
151
|
export function transformConfigForSqlDriver(source: string, driver: SqlDriverId): string {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
152
|
+
const sqlRe = /^( {6})sql:\s*\{[\s\S]*?\n\1\}/m;
|
|
153
|
+
if (sqlRe.test(source)) {
|
|
154
|
+
return source.replace(sqlRe, (block) =>
|
|
155
|
+
block
|
|
156
|
+
.replace(/dev:\s*"(?:postgres|pglite|memory)"/, `dev: "${driver}"`)
|
|
157
|
+
.replace(/prod:\s*"(?:postgres|pglite|memory)"/, `prod: "${driver}"`),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// Sparse template with no sql override — defaults already cover postgres.
|
|
161
|
+
if (driver === "postgres") return source;
|
|
162
|
+
return upsertStoreFacet(source, "sql", {
|
|
163
|
+
dev: driver,
|
|
164
|
+
test: "pglite",
|
|
165
|
+
prod: driver,
|
|
166
|
+
});
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
/**
|
|
164
170
|
* Apply full customize answers to `oke.config.ts` source.
|
|
165
171
|
*
|
|
166
172
|
* Rewrites driver maps and syncs `images` keys for docker-backed roles.
|
|
173
|
+
* Pins that match {@link PIN_DEFAULTS} are omitted (or removed) so scaffolds
|
|
174
|
+
* stay override-only.
|
|
167
175
|
*
|
|
168
176
|
* @param source - Config TypeScript source
|
|
169
177
|
* @param defaults - Customize / reuse payload
|
|
170
178
|
*/
|
|
171
179
|
export function applyCreateAnswers(source: string, defaults: CreateDefaults): string {
|
|
172
180
|
let next = source;
|
|
173
|
-
next =
|
|
174
|
-
next =
|
|
175
|
-
next =
|
|
181
|
+
next = upsertEnvMap(next, "sql", defaults.drivers.store.sql, { nestedUnderStore: true });
|
|
182
|
+
next = upsertEnvMap(next, "kv", defaults.drivers.store.kv, { nestedUnderStore: true });
|
|
183
|
+
next = upsertEnvMap(next, "files", defaults.drivers.store.files, { nestedUnderStore: true });
|
|
176
184
|
|
|
177
185
|
if (defaults.drivers.store.index) {
|
|
178
186
|
next = upsertStoreIndex(next, defaults.drivers.store.index);
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
next =
|
|
182
|
-
next =
|
|
183
|
-
next =
|
|
184
|
-
next =
|
|
189
|
+
next = upsertEnvMap(next, "signal", defaults.drivers.signal, { nestedUnderStore: false });
|
|
190
|
+
next = upsertEnvMap(next, "clock", defaults.drivers.clock, { nestedUnderStore: false });
|
|
191
|
+
next = upsertEnvMap(next, "vault", defaults.drivers.vault, { nestedUnderStore: false });
|
|
192
|
+
next = upsertChannelEmail(next, defaults.drivers.channel.email);
|
|
185
193
|
|
|
186
194
|
if (defaults.drivers.ai) {
|
|
187
195
|
next = upsertAiDrivers(next, defaults.drivers.ai);
|
|
@@ -192,41 +200,175 @@ export function applyCreateAnswers(source: string, defaults: CreateDefaults): st
|
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
/**
|
|
195
|
-
*
|
|
203
|
+
* Env columns that differ from a known default map (or all columns when none).
|
|
204
|
+
*
|
|
205
|
+
* @param pins - Full three-env pins from the wizard
|
|
206
|
+
* @param defaults - Optional real defaults for this key
|
|
207
|
+
*/
|
|
208
|
+
function sparsePinEntries(
|
|
209
|
+
pins: EnvDriverPins,
|
|
210
|
+
defaults: EnvDriverPins | undefined,
|
|
211
|
+
): ReadonlyArray<readonly ["dev" | "test" | "prod", string]> {
|
|
212
|
+
const envs = ["dev", "test", "prod"] as const;
|
|
213
|
+
if (!defaults) {
|
|
214
|
+
return envs.map((env) => [env, pins[env]] as const);
|
|
215
|
+
}
|
|
216
|
+
return envs.filter((env) => pins[env] !== defaults[env]).map((env) => [env, pins[env]] as const);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Format an env driver map literal — only keys that differ from defaults.
|
|
221
|
+
*
|
|
222
|
+
* @param pins - Pins
|
|
223
|
+
* @param indentLevel - Indent of the map key (`signal` → 2, `sql`/`email` → 3;
|
|
224
|
+
* two spaces each). Inner fields use `indentLevel + 1`; the closing `}`
|
|
225
|
+
* aligns with the key.
|
|
226
|
+
* @param defaultsKey - {@link PIN_DEFAULTS} lookup key (omit for no defaults)
|
|
227
|
+
* @returns Formatted `{ … }` or `null` when every pin matches the default
|
|
228
|
+
*/
|
|
229
|
+
function formatEnvMap(
|
|
230
|
+
pins: EnvDriverPins,
|
|
231
|
+
indentLevel: number,
|
|
232
|
+
defaultsKey?: string,
|
|
233
|
+
): string | null {
|
|
234
|
+
const defaults = defaultsKey !== undefined ? PIN_DEFAULTS[defaultsKey] : undefined;
|
|
235
|
+
const entries = sparsePinEntries(pins, defaults);
|
|
236
|
+
if (entries.length === 0) return null;
|
|
237
|
+
const pad = " ".repeat(indentLevel + 1);
|
|
238
|
+
const close = " ".repeat(indentLevel);
|
|
239
|
+
const lines = entries.map(([env, value]) => `${pad}${env}: "${value}",`);
|
|
240
|
+
return `{\n${lines.join("\n")}\n${close}}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Match `key: { … }` at a fixed indentation (store facet = 6, drivers = 4).
|
|
245
|
+
*
|
|
246
|
+
* @param key - Map key
|
|
247
|
+
* @param indent - Leading spaces before the key
|
|
248
|
+
*/
|
|
249
|
+
function envMapRe(key: string, indent: number): RegExp {
|
|
250
|
+
const pad = " ".repeat(indent);
|
|
251
|
+
return new RegExp(`^${pad}${key}:\\s*\\{[\\s\\S]*?\\n${pad}\\}`, "m");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Remove a matched env-map block and its trailing comma / blank line.
|
|
256
|
+
*
|
|
257
|
+
* @param source - Config source
|
|
258
|
+
* @param re - Block matcher
|
|
259
|
+
*/
|
|
260
|
+
function removeMatchedBlock(source: string, re: RegExp): string {
|
|
261
|
+
return source.replace(re, "").replace(/\n{3,}/g, "\n\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Rewrite the `drivers: { … }` body, or throw when missing.
|
|
266
|
+
*
|
|
267
|
+
* @param source - Config source
|
|
268
|
+
* @param rewrite - Maps current body → next body
|
|
269
|
+
*/
|
|
270
|
+
function mapDriversBody(source: string, rewrite: (body: string) => string): string {
|
|
271
|
+
const drivers = findBraceBlock(source, /drivers:\s*\{/);
|
|
272
|
+
if (!drivers) {
|
|
273
|
+
throw new Error("create-oke: oke.config.ts missing drivers block");
|
|
274
|
+
}
|
|
275
|
+
const body = source.slice(drivers.bodyStart, drivers.bodyEnd);
|
|
276
|
+
return `${source.slice(0, drivers.bodyStart)}${rewrite(body)}${source.slice(drivers.bodyEnd)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Insert `store: { facet }` (or a facet into an existing store) into drivers.
|
|
281
|
+
*
|
|
282
|
+
* Scoped to the `drivers` block so `images.store` is never touched.
|
|
283
|
+
*
|
|
284
|
+
* @param source - Config source
|
|
285
|
+
* @param key - Store facet key
|
|
286
|
+
* @param pins - Pins (full; formatted sparsely inside)
|
|
287
|
+
*/
|
|
288
|
+
function upsertStoreFacet(source: string, key: string, pins: EnvDriverPins): string {
|
|
289
|
+
const block = formatEnvMap(pins, 3, key);
|
|
290
|
+
const facetRe = envMapRe(key, 6);
|
|
291
|
+
if (block === null) {
|
|
292
|
+
return mapDriversBody(source, (body) => removeMatchedBlock(body, facetRe));
|
|
293
|
+
}
|
|
294
|
+
return mapDriversBody(source, (body) => {
|
|
295
|
+
if (facetRe.test(body)) {
|
|
296
|
+
return body.replace(facetRe, ` ${key}: ${block}`);
|
|
297
|
+
}
|
|
298
|
+
const storeRe = /^( {4})store:\s*\{/m;
|
|
299
|
+
if (storeRe.test(body)) {
|
|
300
|
+
return body.replace(storeRe, (open) => `${open}\n ${key}: ${block},`);
|
|
301
|
+
}
|
|
302
|
+
const entry = ` store: {\n ${key}: ${block},\n },\n`;
|
|
303
|
+
return `${body}${body.endsWith("\n") ? "" : "\n"}${entry}`;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Upsert a top-level or store-nested env map; omit when pins match defaults.
|
|
196
309
|
*
|
|
197
310
|
* @param source - Config source
|
|
198
311
|
* @param key - Map key (`sql`, `signal`, …)
|
|
199
312
|
* @param pins - New pins
|
|
200
313
|
* @param options - Nesting
|
|
201
314
|
*/
|
|
202
|
-
function
|
|
315
|
+
function upsertEnvMap(
|
|
203
316
|
source: string,
|
|
204
317
|
key: string,
|
|
205
318
|
pins: EnvDriverPins,
|
|
206
319
|
options: { readonly nestedUnderStore: boolean },
|
|
207
320
|
): string {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
321
|
+
if (options.nestedUnderStore) {
|
|
322
|
+
return upsertStoreFacet(source, key, pins);
|
|
323
|
+
}
|
|
324
|
+
const indent = 2;
|
|
325
|
+
const block = formatEnvMap(pins, indent, key);
|
|
326
|
+
const re = envMapRe(key, 4);
|
|
327
|
+
if (block === null) {
|
|
328
|
+
return mapDriversBody(source, (body) => removeMatchedBlock(body, re));
|
|
213
329
|
}
|
|
214
|
-
return source
|
|
330
|
+
return mapDriversBody(source, (body) => {
|
|
331
|
+
if (re.test(body)) {
|
|
332
|
+
return body.replace(re, ` ${key}: ${block}`);
|
|
333
|
+
}
|
|
334
|
+
const entry = ` ${key}: ${block},\n`;
|
|
335
|
+
return `${body}${body.endsWith("\n") ? "" : "\n"}${entry}`;
|
|
336
|
+
});
|
|
215
337
|
}
|
|
216
338
|
|
|
217
339
|
/**
|
|
218
|
-
*
|
|
340
|
+
* Upsert `drivers.channel.email` (creates `drivers.channel` when needed).
|
|
341
|
+
*
|
|
342
|
+
* Never touches `images.channel` (string role pins at the same indent).
|
|
219
343
|
*
|
|
220
344
|
* @param source - Config source
|
|
221
345
|
* @param pins - Email pins
|
|
222
346
|
*/
|
|
223
|
-
function
|
|
224
|
-
const block = formatEnvMap(pins, 3);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
347
|
+
function upsertChannelEmail(source: string, pins: EnvDriverPins): string {
|
|
348
|
+
const block = formatEnvMap(pins, 3, "email");
|
|
349
|
+
// Brace-map `email: { … }` only exists under drivers.channel — images uses
|
|
350
|
+
// `email: "image:tag"`.
|
|
351
|
+
const emailRe = envMapRe("email", 6);
|
|
352
|
+
if (block === null) {
|
|
353
|
+
let next = removeMatchedBlock(source, emailRe);
|
|
354
|
+
next = next.replace(/^( {4})channel:\s*\{\s*\n\1\},?\n/m, "");
|
|
355
|
+
return next;
|
|
356
|
+
}
|
|
357
|
+
if (emailRe.test(source)) {
|
|
358
|
+
return source.replace(emailRe, ` email: ${block}`);
|
|
228
359
|
}
|
|
229
|
-
|
|
360
|
+
const drivers = findBraceBlock(source, /drivers:\s*\{/);
|
|
361
|
+
if (drivers) {
|
|
362
|
+
const body = source.slice(drivers.bodyStart, drivers.bodyEnd);
|
|
363
|
+
if (/^ {4}channel:\s*\{/m.test(body)) {
|
|
364
|
+
const nextBody = body.replace(
|
|
365
|
+
/^( {4})channel:\s*\{/m,
|
|
366
|
+
(open) => `${open}\n email: ${block},`,
|
|
367
|
+
);
|
|
368
|
+
return `${source.slice(0, drivers.bodyStart)}${nextBody}${source.slice(drivers.bodyEnd)}`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return insertDriversEntry(source, ` channel: {\n email: ${block},\n },`);
|
|
230
372
|
}
|
|
231
373
|
|
|
232
374
|
/**
|
|
@@ -239,67 +381,86 @@ function replaceChannelEmail(source: string, pins: EnvDriverPins): string {
|
|
|
239
381
|
* @param pins - Index pins
|
|
240
382
|
*/
|
|
241
383
|
function upsertStoreIndex(source: string, pins: EnvDriverPins): string {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (indexRe.test(source)) {
|
|
245
|
-
return source.replace(indexRe, `$1index: ${block}`);
|
|
246
|
-
}
|
|
247
|
-
// Insert after files block inside store.
|
|
248
|
-
const filesRe = /(files:\s*\{[\s\S]*?\n\s*\},)/;
|
|
249
|
-
if (!filesRe.test(source)) {
|
|
250
|
-
throw new Error("create-oke: oke.config.ts missing store.files to insert index");
|
|
251
|
-
}
|
|
252
|
-
return source.replace(filesRe, `$1\n index: ${block},`);
|
|
384
|
+
// No DRIVER_DEFAULTS table — emit every env column.
|
|
385
|
+
return upsertStoreFacet(source, "index", pins);
|
|
253
386
|
}
|
|
254
387
|
|
|
255
388
|
/**
|
|
256
|
-
* Insert
|
|
257
|
-
*
|
|
258
|
-
* Indentation-anchored: only matches `ai:` / `channel:` at the drivers-body
|
|
259
|
-
* indent (4 spaces). The old non-greedy `channel: { … },` regex closed on
|
|
260
|
-
* `email`'s `},` and nested `ai` inside `channel` — hero then showed `ai: —`.
|
|
389
|
+
* Insert a drivers-body entry before the closing `}` of `drivers: { … }`.
|
|
261
390
|
*
|
|
262
391
|
* @param source - Config source
|
|
263
|
-
* @param
|
|
392
|
+
* @param entry - Full indented entry including trailing comma
|
|
264
393
|
*/
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
394
|
+
function insertDriversEntry(source: string, entry: string): string {
|
|
395
|
+
return mapDriversBody(source, (body) => {
|
|
396
|
+
const needsNl = !body.endsWith("\n");
|
|
397
|
+
return `${body}${needsNl ? "\n" : ""}${entry}\n`;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Locate a `{ … }` block by opener regex and brace depth.
|
|
403
|
+
*
|
|
404
|
+
* @param source - Source text
|
|
405
|
+
* @param openRe - Matches through the opening `{`
|
|
406
|
+
*/
|
|
407
|
+
function findBraceBlock(
|
|
408
|
+
source: string,
|
|
409
|
+
openRe: RegExp,
|
|
410
|
+
): {
|
|
411
|
+
readonly start: number;
|
|
412
|
+
readonly bodyStart: number;
|
|
413
|
+
readonly bodyEnd: number;
|
|
414
|
+
readonly end: number;
|
|
415
|
+
} | null {
|
|
416
|
+
const m = openRe.exec(source);
|
|
417
|
+
if (!m) return null;
|
|
418
|
+
const start = m.index;
|
|
419
|
+
const openIdx = start + m[0].length - 1;
|
|
420
|
+
let depth = 0;
|
|
421
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
422
|
+
const ch = source[i];
|
|
423
|
+
if (ch === "{") depth++;
|
|
424
|
+
else if (ch === "}") {
|
|
425
|
+
depth--;
|
|
426
|
+
if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
|
|
427
|
+
}
|
|
274
428
|
}
|
|
275
|
-
return
|
|
429
|
+
return null;
|
|
276
430
|
}
|
|
277
431
|
|
|
278
432
|
/**
|
|
279
|
-
*
|
|
433
|
+
* Insert or replace top-level `drivers.ai` (never under `channel` / `images`).
|
|
280
434
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
435
|
+
* Replaces an existing `drivers.ai` map; otherwise appends inside `drivers`
|
|
436
|
+
* so sparse templates (no `drivers.channel`) cannot match `images.channel`.
|
|
437
|
+
*
|
|
438
|
+
* @param source - Config source
|
|
439
|
+
* @param pins - AI pins
|
|
286
440
|
*/
|
|
287
|
-
function
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
441
|
+
export function upsertAiDrivers(source: string, pins: EnvDriverPins): string {
|
|
442
|
+
// AI has no DRIVER_DEFAULTS — always emit all three envs.
|
|
443
|
+
const block = formatEnvMap(pins, 2);
|
|
444
|
+
if (block === null) {
|
|
445
|
+
throw new Error("create-oke: ai pins must not be empty");
|
|
446
|
+
}
|
|
447
|
+
const aiRe = envMapRe("ai", 4);
|
|
448
|
+
return mapDriversBody(source, (body) => {
|
|
449
|
+
if (aiRe.test(body)) {
|
|
450
|
+
return body.replace(aiRe, ` ai: ${block}`);
|
|
451
|
+
}
|
|
452
|
+
return `${body}${body.endsWith("\n") ? "" : "\n"} ai: ${block},\n`;
|
|
453
|
+
});
|
|
296
454
|
}
|
|
297
455
|
|
|
298
456
|
/** Env-column keys that must never appear under `images`. */
|
|
299
|
-
const IMAGE_ENV_COLUMNS = new Set(["
|
|
457
|
+
const IMAGE_ENV_COLUMNS = new Set(["dev", "test", "prod"]);
|
|
300
458
|
|
|
301
|
-
/** Known compose role keys written into `images
|
|
302
|
-
const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog)$/;
|
|
459
|
+
/** Known compose role keys written into `images` (dotted, post-flatten). */
|
|
460
|
+
const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog|proxy)$/;
|
|
461
|
+
|
|
462
|
+
/** `images` sub-object keys that nest role facets (mirrors `drivers` nesting). */
|
|
463
|
+
const IMAGE_NEST_KEYS = ["store", "channel"] as const;
|
|
303
464
|
|
|
304
465
|
/**
|
|
305
466
|
* Keep `images` in sync with chosen docker drivers.
|
|
@@ -319,35 +480,34 @@ function syncImages(source: string, defaults: CreateDefaults): string {
|
|
|
319
480
|
};
|
|
320
481
|
|
|
321
482
|
if (
|
|
322
|
-
d.store.sql.
|
|
483
|
+
d.store.sql.dev === "postgres" ||
|
|
323
484
|
d.store.sql.prod === "postgres" ||
|
|
324
|
-
d.store.sql.
|
|
485
|
+
d.store.sql.dev === "pgvector" ||
|
|
325
486
|
d.store.sql.prod === "pgvector"
|
|
326
487
|
) {
|
|
327
488
|
pin("store.sql", DEFAULT_IMAGES["store.sql"]!);
|
|
328
|
-
|
|
489
|
+
if (defaults.pgdog) {
|
|
490
|
+
pin("pgdog", DEFAULT_IMAGES.pgdog!);
|
|
491
|
+
}
|
|
329
492
|
}
|
|
330
|
-
if (d.store.kv.
|
|
493
|
+
if (d.store.kv.dev === "redis" || d.signal.dev === "redis") {
|
|
331
494
|
pin("store.kv", DEFAULT_IMAGES["store.kv"]!);
|
|
332
495
|
}
|
|
333
|
-
if (d.store.files.
|
|
496
|
+
if (d.store.files.dev === "s3") {
|
|
334
497
|
pin("store.files", DEFAULT_IMAGES["store.files"]!);
|
|
335
498
|
}
|
|
336
|
-
if (d.channel.email.
|
|
499
|
+
if (d.channel.email.dev === "smtp") {
|
|
337
500
|
pin("channel.email", DEFAULT_IMAGES["channel.email"]!);
|
|
338
501
|
}
|
|
339
|
-
if (d.
|
|
340
|
-
pin("vault", DEFAULT_IMAGES.vault!);
|
|
341
|
-
}
|
|
342
|
-
if (d.store.index?.docker === "meilisearch") {
|
|
502
|
+
if (d.store.index?.dev === "meilisearch") {
|
|
343
503
|
pin("store.index", DEFAULT_IMAGES["store.index"]!);
|
|
344
504
|
}
|
|
345
505
|
if (
|
|
346
506
|
d.ai &&
|
|
347
|
-
(d.ai.
|
|
348
|
-
d.ai.
|
|
349
|
-
d.ai.
|
|
350
|
-
d.ai.
|
|
507
|
+
(d.ai.dev === "ollama" ||
|
|
508
|
+
d.ai.prod === "ollama" ||
|
|
509
|
+
d.ai.dev === "openai-compatible" ||
|
|
510
|
+
d.ai.prod === "openai-compatible")
|
|
351
511
|
) {
|
|
352
512
|
pin("ai", aiImageForDefaults(defaults));
|
|
353
513
|
}
|
|
@@ -362,55 +522,141 @@ function syncImages(source: string, defaults: CreateDefaults): string {
|
|
|
362
522
|
*/
|
|
363
523
|
function aiImageForDefaults(defaults: CreateDefaults): string {
|
|
364
524
|
const provider = defaults.ai.provider;
|
|
365
|
-
if (provider === "ollama" || defaults.drivers.ai?.
|
|
525
|
+
if (provider === "ollama" || defaults.drivers.ai?.dev === "ollama") {
|
|
366
526
|
return OLLAMA_IMAGE;
|
|
367
527
|
}
|
|
368
528
|
if (provider === "vllm") return VLLM_IMAGE;
|
|
369
529
|
if (provider === "sglang") return SGLANG_IMAGE;
|
|
370
|
-
if (provider === "llama-cpp" || defaults.drivers.ai?.
|
|
530
|
+
if (provider === "llama-cpp" || defaults.drivers.ai?.dev === "openai-compatible") {
|
|
371
531
|
return LLAMA_CPP_IMAGE;
|
|
372
532
|
}
|
|
373
533
|
return DEFAULT_IMAGES.ai!;
|
|
374
534
|
}
|
|
375
535
|
|
|
376
536
|
/**
|
|
377
|
-
*
|
|
537
|
+
* Locate the `images: { … }` block by brace depth (not the first `}`) so
|
|
538
|
+
* nested `store: { … }` / `channel: { … }` sub-blocks don't close the match
|
|
539
|
+
* early.
|
|
378
540
|
*
|
|
379
|
-
*
|
|
541
|
+
* @param source - Config source
|
|
542
|
+
*/
|
|
543
|
+
export function findImagesBlock(source: string): {
|
|
544
|
+
readonly start: number;
|
|
545
|
+
readonly bodyStart: number;
|
|
546
|
+
readonly bodyEnd: number;
|
|
547
|
+
readonly end: number;
|
|
548
|
+
} | null {
|
|
549
|
+
const m = /images:\s*\{/.exec(source);
|
|
550
|
+
if (!m) return null;
|
|
551
|
+
const start = m.index;
|
|
552
|
+
const openIdx = start + m[0].length - 1;
|
|
553
|
+
let depth = 0;
|
|
554
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
555
|
+
const ch = source[i];
|
|
556
|
+
if (ch === "{") depth++;
|
|
557
|
+
else if (ch === "}") {
|
|
558
|
+
depth--;
|
|
559
|
+
if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Parse dotted role→image pins from an `images` block, flattening one level
|
|
567
|
+
* of `store` / `channel` nesting (mirrors {@link flattenImagesConfig} in
|
|
568
|
+
* `okengine/config`).
|
|
569
|
+
*
|
|
570
|
+
* Skips `//` comment lines and rejects env-column keys (`dev`/`test`/`prod`).
|
|
380
571
|
*
|
|
381
572
|
* @param source - Config source
|
|
382
573
|
*/
|
|
383
|
-
function extractImages(source: string): Record<string, string> {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
574
|
+
export function extractImages(source: string): Record<string, string> {
|
|
575
|
+
const block = findImagesBlock(source);
|
|
576
|
+
if (!block) return {};
|
|
386
577
|
const out: Record<string, string> = {};
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
578
|
+
let context: (typeof IMAGE_NEST_KEYS)[number] | null = null;
|
|
579
|
+
for (const raw of source.slice(block.bodyStart, block.bodyEnd).split("\n")) {
|
|
580
|
+
const line = raw.trim();
|
|
581
|
+
if (!line || line.startsWith("//")) continue;
|
|
582
|
+
const nestOpen = /^(store|channel):\s*\{\s*$/.exec(line);
|
|
583
|
+
if (nestOpen) {
|
|
584
|
+
context = nestOpen[1] as (typeof IMAGE_NEST_KEYS)[number];
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (line === "}," || line === "}") {
|
|
588
|
+
context = null;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const hit = /^["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(line);
|
|
391
592
|
if (!hit) continue;
|
|
392
|
-
const
|
|
393
|
-
if (IMAGE_ENV_COLUMNS.has(
|
|
593
|
+
const rawKey = hit[1]!;
|
|
594
|
+
if (IMAGE_ENV_COLUMNS.has(rawKey)) continue;
|
|
595
|
+
const key = context ? `${context}.${rawKey}` : rawKey;
|
|
596
|
+
if (!IMAGE_ROLE_KEY.test(key)) continue;
|
|
394
597
|
out[key] = hit[2]!;
|
|
395
598
|
}
|
|
396
599
|
return out;
|
|
397
600
|
}
|
|
398
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Render dotted role→image pins back into a nested `images: { … }` literal —
|
|
604
|
+
* `store.*` / `channel.*` under their sub-object, everything else flat.
|
|
605
|
+
*
|
|
606
|
+
* @param images - Dotted role → image
|
|
607
|
+
*/
|
|
608
|
+
function formatImagesBlock(images: Record<string, string>): string {
|
|
609
|
+
const store: Array<[string, string]> = [];
|
|
610
|
+
const channel: Array<[string, string]> = [];
|
|
611
|
+
const flat: Array<[string, string]> = [];
|
|
612
|
+
for (const [key, value] of Object.entries(images)) {
|
|
613
|
+
if (key.startsWith("store.")) store.push([key.slice("store.".length), value]);
|
|
614
|
+
else if (key.startsWith("channel.")) channel.push([key.slice("channel.".length), value]);
|
|
615
|
+
else flat.push([key, value]);
|
|
616
|
+
}
|
|
617
|
+
const lines: string[] = [];
|
|
618
|
+
if (store.length > 0) {
|
|
619
|
+
lines.push(" store: {");
|
|
620
|
+
for (const [k, v] of store) lines.push(` ${k}: "${v}",`);
|
|
621
|
+
lines.push(" },");
|
|
622
|
+
}
|
|
623
|
+
if (channel.length > 0) {
|
|
624
|
+
lines.push(" channel: {");
|
|
625
|
+
for (const [k, v] of channel) lines.push(` ${k}: "${v}",`);
|
|
626
|
+
lines.push(" },");
|
|
627
|
+
}
|
|
628
|
+
for (const [k, v] of flat) lines.push(` ${k}: "${v}",`);
|
|
629
|
+
return `images: {\n${lines.join("\n")}\n }`;
|
|
630
|
+
}
|
|
631
|
+
|
|
399
632
|
/**
|
|
400
633
|
* @param source - Config source
|
|
401
|
-
* @param images -
|
|
634
|
+
* @param images - Dotted role → image
|
|
402
635
|
*/
|
|
403
|
-
function replaceImagesBlock(source: string, images: Record<string, string>): string {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
return ` ${key}: "${v}",`;
|
|
407
|
-
});
|
|
408
|
-
const block = `images: {\n${lines.join("\n")}\n }`;
|
|
409
|
-
const re = /images:\s*\{[\s\S]*?\n\s*\}/;
|
|
410
|
-
if (!re.test(source)) {
|
|
636
|
+
export function replaceImagesBlock(source: string, images: Record<string, string>): string {
|
|
637
|
+
const block = findImagesBlock(source);
|
|
638
|
+
if (!block) {
|
|
411
639
|
throw new Error("create-oke: oke.config.ts missing images block");
|
|
412
640
|
}
|
|
413
|
-
return source.
|
|
641
|
+
return `${source.slice(0, block.start)}${formatImagesBlock(images)}${source.slice(block.end)}`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Add or remove the `images.pgdog` pin (PgDog in front of Postgres).
|
|
646
|
+
*
|
|
647
|
+
* @param source - `oke.config.ts` source
|
|
648
|
+
* @param enabled - When true, pin the default PgDog image
|
|
649
|
+
*/
|
|
650
|
+
export function applyPgDogToConfig(source: string, enabled: boolean): string {
|
|
651
|
+
const images = extractImages(source);
|
|
652
|
+
if (enabled) {
|
|
653
|
+
if (images.pgdog) return source;
|
|
654
|
+
images.pgdog = DEFAULT_IMAGES.pgdog!;
|
|
655
|
+
} else {
|
|
656
|
+
if (!images.pgdog) return source;
|
|
657
|
+
delete images.pgdog;
|
|
658
|
+
}
|
|
659
|
+
return replaceImagesBlock(source, images);
|
|
414
660
|
}
|
|
415
661
|
|
|
416
662
|
/**
|