@basaltkit/ai 0.11.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -3
  2. package/dist/analyze/run.d.ts +19 -0
  3. package/dist/analyze/run.js +58 -0
  4. package/dist/commands.d.ts +19 -0
  5. package/dist/commands.js +272 -0
  6. package/dist/context/project.d.ts +78 -0
  7. package/dist/context/project.js +202 -0
  8. package/dist/doctor/fixes.d.ts +31 -0
  9. package/dist/doctor/fixes.js +82 -0
  10. package/dist/doctor/rules.d.ts +7 -0
  11. package/dist/doctor/rules.js +230 -0
  12. package/dist/doctor/run.d.ts +9 -0
  13. package/dist/doctor/run.js +16 -0
  14. package/dist/doctor/types.d.ts +27 -0
  15. package/dist/doctor/types.js +3 -0
  16. package/dist/index.d.ts +35 -791
  17. package/dist/index.js +40 -2389
  18. package/dist/make/fields.d.ts +35 -0
  19. package/dist/make/fields.js +139 -0
  20. package/dist/make/make.d.ts +14 -0
  21. package/dist/make/make.js +316 -0
  22. package/dist/make/openapi.d.ts +9 -0
  23. package/dist/make/openapi.js +36 -0
  24. package/dist/make/permissions.d.ts +13 -0
  25. package/dist/make/permissions.js +38 -0
  26. package/dist/make/relations.d.ts +23 -0
  27. package/dist/make/relations.js +62 -0
  28. package/dist/make/render.d.ts +4 -0
  29. package/dist/make/render.js +66 -0
  30. package/dist/make/repository.d.ts +16 -0
  31. package/dist/make/repository.js +176 -0
  32. package/dist/make/schema.d.ts +22 -0
  33. package/dist/make/schema.js +54 -0
  34. package/dist/make/types.d.ts +77 -0
  35. package/dist/make/types.js +1 -0
  36. package/dist/make/verify.d.ts +12 -0
  37. package/dist/make/verify.js +19 -0
  38. package/dist/make/wire.d.ts +23 -0
  39. package/dist/make/wire.js +76 -0
  40. package/dist/plan/context.d.ts +8 -0
  41. package/dist/plan/context.js +39 -0
  42. package/dist/plan/knowledge.d.ts +9 -0
  43. package/dist/plan/knowledge.js +50 -0
  44. package/dist/plan/plan.d.ts +15 -0
  45. package/dist/plan/plan.js +146 -0
  46. package/dist/plan/render.d.ts +4 -0
  47. package/dist/plan/render.js +55 -0
  48. package/dist/plan/types.d.ts +45 -0
  49. package/dist/plan/types.js +1 -0
  50. package/dist/provider/anthropic.d.ts +26 -0
  51. package/dist/provider/anthropic.js +72 -0
  52. package/dist/provider/factory.d.ts +27 -0
  53. package/dist/provider/factory.js +58 -0
  54. package/dist/provider/http.d.ts +24 -0
  55. package/dist/provider/http.js +36 -0
  56. package/dist/provider/ollama.d.ts +22 -0
  57. package/dist/provider/ollama.js +42 -0
  58. package/dist/provider/openai.d.ts +48 -0
  59. package/dist/provider/openai.js +94 -0
  60. package/dist/provider/sse.d.ts +28 -0
  61. package/dist/provider/sse.js +57 -0
  62. package/dist/provider/types.d.ts +49 -0
  63. package/dist/provider/types.js +9 -0
  64. package/dist/render.d.ts +10 -0
  65. package/dist/render.js +70 -0
  66. package/dist/review/knowledge.d.ts +6 -0
  67. package/dist/review/knowledge.js +25 -0
  68. package/dist/review/render.d.ts +4 -0
  69. package/dist/review/render.js +12 -0
  70. package/dist/review/review.d.ts +17 -0
  71. package/dist/review/review.js +80 -0
  72. package/dist/review/types.d.ts +12 -0
  73. package/dist/review/types.js +1 -0
  74. package/package.json +10 -11
package/dist/index.js CHANGED
@@ -1,2389 +1,40 @@
1
- // src/provider/types.ts
2
- async function* singleChunkStream(run3) {
3
- yield await run3();
4
- }
5
-
6
- // src/provider/http.ts
7
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
- function isRetryableStatus(status) {
9
- return status >= 500 || status === 429;
10
- }
11
- async function fetchWithRetry(fetchImpl, url, init, options = {}) {
12
- const retries = options.retries ?? 2;
13
- const baseDelayMs = options.baseDelayMs ?? 300;
14
- let lastError;
15
- for (let attempt = 0; attempt <= retries; attempt++) {
16
- try {
17
- const res = await fetchImpl(url, init);
18
- if (isRetryableStatus(res.status) && attempt < retries) {
19
- await sleep(baseDelayMs * 2 ** attempt);
20
- continue;
21
- }
22
- return { ok: res.ok, status: res.status, body: await res.text() };
23
- } catch (error) {
24
- lastError = error;
25
- if (attempt >= retries) throw error;
26
- await sleep(baseDelayMs * 2 ** attempt);
27
- }
28
- }
29
- throw lastError instanceof Error ? lastError : new Error("fetchWithRetry: exhausted retries");
30
- }
31
-
32
- // src/provider/anthropic.ts
33
- var DEFAULT_MODEL = "claude-sonnet-5";
34
- var API_VERSION = "2023-06-01";
35
- var AnthropicProvider = class {
36
- name = "anthropic";
37
- model;
38
- apiKey;
39
- baseUrl;
40
- fetchImpl;
41
- constructor(options) {
42
- if (!options.apiKey) {
43
- throw new Error("AnthropicProvider: apiKey is required (set AI_API_KEY)");
44
- }
45
- this.apiKey = options.apiKey;
46
- this.model = options.model ?? DEFAULT_MODEL;
47
- this.baseUrl = (options.baseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
48
- const f = options.fetch ?? globalThis.fetch;
49
- if (!f) throw new Error("AnthropicProvider: no fetch available \u2014 pass options.fetch");
50
- this.fetchImpl = f;
51
- }
52
- async generate(options) {
53
- const system = options.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
54
- const messages = options.messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
55
- const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.baseUrl + "/v1/messages", {
56
- method: "POST",
57
- headers: {
58
- "content-type": "application/json",
59
- "x-api-key": this.apiKey,
60
- "anthropic-version": API_VERSION
61
- },
62
- body: JSON.stringify({
63
- model: this.model,
64
- max_tokens: options.maxTokens ?? 4096,
65
- ...options.temperature !== void 0 ? { temperature: options.temperature } : {},
66
- ...system ? { system } : {},
67
- messages
68
- })
69
- });
70
- if (!ok) {
71
- const detail = safeError(body);
72
- throw new Error(`AnthropicProvider: ${status}${detail ? ` \u2014 ${detail}` : ""}`);
73
- }
74
- const json = JSON.parse(body);
75
- return (json.content ?? []).filter((block) => block.type === "text").map((block) => block.text ?? "").join("");
76
- }
77
- stream(options) {
78
- return singleChunkStream(() => this.generate(options));
79
- }
80
- };
81
- function safeError(text) {
82
- try {
83
- return JSON.parse(text).error?.message ?? text.slice(0, 200);
84
- } catch {
85
- return text.slice(0, 200);
86
- }
87
- }
88
-
89
- // src/provider/ollama.ts
90
- var DEFAULT_MODEL2 = "llama3.1";
91
- var OllamaProvider = class {
92
- name = "ollama";
93
- model;
94
- baseUrl;
95
- fetchImpl;
96
- constructor(options = {}) {
97
- this.model = options.model ?? DEFAULT_MODEL2;
98
- this.baseUrl = (options.baseUrl ?? "http://localhost:11434").replace(/\/$/, "");
99
- const f = options.fetch ?? globalThis.fetch;
100
- if (!f) throw new Error("OllamaProvider: no fetch available \u2014 pass options.fetch");
101
- this.fetchImpl = f;
102
- }
103
- async generate(options) {
104
- const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.baseUrl + "/api/chat", {
105
- method: "POST",
106
- headers: { "content-type": "application/json" },
107
- body: JSON.stringify({
108
- model: this.model,
109
- stream: false,
110
- messages: options.messages.map((m) => ({ role: m.role, content: m.content })),
111
- ...options.temperature !== void 0 ? { options: { temperature: options.temperature } } : {}
112
- })
113
- });
114
- if (!ok) throw new Error(`OllamaProvider: ${status} \u2014 ${body.slice(0, 200)}`);
115
- const json = JSON.parse(body);
116
- if (json.error) throw new Error(`OllamaProvider: ${json.error}`);
117
- return json.message?.content ?? "";
118
- }
119
- stream(options) {
120
- return singleChunkStream(() => this.generate(options));
121
- }
122
- };
123
-
124
- // src/provider/sse.ts
125
- var globalSseFetch = async (url, init) => {
126
- const f = globalThis.fetch;
127
- if (!f) throw new Error("globalSseFetch: no global fetch available");
128
- const res = await f(url, {
129
- method: init.method,
130
- headers: init.headers,
131
- body: init.body
132
- });
133
- const stream = res.body;
134
- const decoder = new TextDecoder();
135
- async function* chunks() {
136
- if (!stream) return;
137
- for await (const part of stream) yield decoder.decode(part, { stream: true });
138
- }
139
- return { ok: res.ok, status: res.status, text: () => res.text(), chunks: chunks() };
140
- };
141
- async function* parseSseContent(chunks) {
142
- let buffer = "";
143
- for await (const chunk of chunks) {
144
- buffer += chunk;
145
- const lines = buffer.split("\n");
146
- buffer = lines.pop() ?? "";
147
- for (const line of lines) {
148
- const trimmed = line.trim();
149
- if (!trimmed.startsWith("data:")) continue;
150
- const payload = trimmed.slice(5).trim();
151
- if (payload === "" || payload === "[DONE]") continue;
152
- try {
153
- const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
154
- if (delta) yield delta;
155
- } catch {
156
- }
157
- }
158
- }
159
- }
160
-
161
- // src/provider/openai.ts
162
- var DEFAULT_MODEL3 = "gpt-4o-mini";
163
- var DEFAULT_BASE_URL = "https://api.openai.com/v1";
164
- var OpenAICompatibleProvider = class {
165
- name = "openai";
166
- model;
167
- apiKey;
168
- baseUrl;
169
- streaming;
170
- fetchImpl;
171
- sseFetchImpl;
172
- constructor(options) {
173
- if (!options.apiKey) {
174
- throw new Error("OpenAICompatibleProvider: apiKey is required (set AI_API_KEY)");
175
- }
176
- this.apiKey = options.apiKey;
177
- this.model = options.model ?? DEFAULT_MODEL3;
178
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
179
- this.streaming = options.stream ?? true;
180
- this.fetchImpl = options.fetch ?? globalThis.fetch ?? missingFetch;
181
- this.sseFetchImpl = options.sseFetch ?? globalSseFetch;
182
- }
183
- async generate(options) {
184
- if (!this.streaming) return this.viaJson(options);
185
- let out = "";
186
- for await (const delta of this.viaStream(options)) out += delta;
187
- return out;
188
- }
189
- stream(options) {
190
- if (!this.streaming) return singleChunkStream(() => this.viaJson(options));
191
- return this.viaStream(options);
192
- }
193
- get endpoint() {
194
- return this.baseUrl + "/chat/completions";
195
- }
196
- headers() {
197
- return { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` };
198
- }
199
- requestBody(options, stream) {
200
- return JSON.stringify({
201
- model: this.model,
202
- messages: options.messages.map((m) => ({ role: m.role, content: m.content })),
203
- max_tokens: options.maxTokens ?? 4096,
204
- ...options.temperature !== void 0 ? { temperature: options.temperature } : {},
205
- ...stream ? { stream: true } : {}
206
- });
207
- }
208
- async *viaStream(options) {
209
- const res = await this.sseFetchImpl(this.endpoint, {
210
- method: "POST",
211
- headers: this.headers(),
212
- body: this.requestBody(options, true)
213
- });
214
- if (!res.ok) {
215
- throw new Error(`OpenAICompatibleProvider: ${res.status} \u2014 ${safeError2(await res.text())}`);
216
- }
217
- yield* parseSseContent(res.chunks);
218
- }
219
- async viaJson(options) {
220
- const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.endpoint, {
221
- method: "POST",
222
- headers: this.headers(),
223
- body: this.requestBody(options, false)
224
- });
225
- if (!ok) {
226
- const detail = safeError2(body);
227
- throw new Error(`OpenAICompatibleProvider: ${status}${detail ? ` \u2014 ${detail}` : ""}`);
228
- }
229
- return JSON.parse(body).choices?.[0]?.message?.content ?? "";
230
- }
231
- };
232
- var missingFetch = () => {
233
- throw new Error("OpenAICompatibleProvider: no fetch available \u2014 pass options.fetch");
234
- };
235
- function safeError2(text) {
236
- try {
237
- return JSON.parse(text).error?.message ?? text.slice(0, 200);
238
- } catch {
239
- return text.slice(0, 200);
240
- }
241
- }
242
-
243
- // src/provider/factory.ts
244
- function providerEnvFromProcess() {
245
- const e = process.env;
246
- return {
247
- ...e.AI_PROVIDER !== void 0 ? { AI_PROVIDER: e.AI_PROVIDER } : {},
248
- ...e.AI_MODEL !== void 0 ? { AI_MODEL: e.AI_MODEL } : {},
249
- ...e.AI_API_KEY !== void 0 ? { AI_API_KEY: e.AI_API_KEY } : {},
250
- ...e.AI_BASE_URL !== void 0 ? { AI_BASE_URL: e.AI_BASE_URL } : {},
251
- ...e.AI_STREAM !== void 0 ? { AI_STREAM: e.AI_STREAM } : {}
252
- };
253
- }
254
- function createProvider(env = {}, options = {}) {
255
- const name = (env.AI_PROVIDER ?? "anthropic").toLowerCase();
256
- const fetchOpt = options.fetch ? { fetch: options.fetch } : {};
257
- switch (name) {
258
- case "anthropic":
259
- return new AnthropicProvider({
260
- apiKey: env.AI_API_KEY ?? "",
261
- ...env.AI_MODEL ? { model: env.AI_MODEL } : {},
262
- ...env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {},
263
- ...fetchOpt
264
- });
265
- case "ollama":
266
- return new OllamaProvider({
267
- ...env.AI_MODEL ? { model: env.AI_MODEL } : {},
268
- ...env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {},
269
- ...fetchOpt
270
- });
271
- case "openai":
272
- case "openai-compatible":
273
- return new OpenAICompatibleProvider({
274
- apiKey: env.AI_API_KEY ?? "",
275
- ...env.AI_MODEL ? { model: env.AI_MODEL } : {},
276
- ...env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {},
277
- ...env.AI_STREAM === "false" ? { stream: false } : {},
278
- ...fetchOpt
279
- });
280
- case "google":
281
- throw new Error(
282
- `createProvider: 'google' is not implemented in @basaltkit/ai@0.1. Available now: 'anthropic', 'ollama', 'openai' (OpenAI-compatible).`
283
- );
284
- default:
285
- throw new Error(
286
- `createProvider: unknown AI_PROVIDER='${name}'. Use 'anthropic', 'ollama' or 'openai'.`
287
- );
288
- }
289
- }
290
-
291
- // src/context/project.ts
292
- import { existsSync, readFileSync } from "fs";
293
- import { join } from "path";
294
- function nodeReader(root) {
295
- return {
296
- read(relPath) {
297
- try {
298
- return readFileSync(join(root, relPath), "utf8");
299
- } catch {
300
- return null;
301
- }
302
- },
303
- exists: (relPath) => existsSync(join(root, relPath))
304
- };
305
- }
306
- function memoryReader(files) {
307
- return {
308
- read: (relPath) => files[relPath] ?? null,
309
- exists: (relPath) => relPath in files
310
- };
311
- }
312
- var APP_CANDIDATES = ["src/app.ts", "src/app.js", "app.ts"];
313
- var SERVER_CANDIDATES = ["src/server.ts", "src/server.js", "src/main.ts", "server.ts"];
314
- var ENV_CANDIDATES = ["src/env.ts", "src/env.js", "src/config/env.ts"];
315
- var SCHEMA_CANDIDATES = ["prisma/schema.prisma", "schema.prisma"];
316
- var PLUGIN_TO_CAPABILITY = {
317
- fastifyPlugin: "http",
318
- prismaPlugin: "orm",
319
- tenancyPlugin: "tenancy",
320
- authPlugin: "auth",
321
- permissionsPlugin: "rbac",
322
- subscriptionsPlugin: "subscriptions",
323
- paymentsPlugin: "payments",
324
- storagePlugin: "storage",
325
- queuePlugin: "queue",
326
- searchPlugin: "search",
327
- auditPlugin: "audit",
328
- eventsPlugin: "events",
329
- schedulerPlugin: "scheduler",
330
- loggerPlugin: "logger"
331
- };
332
- var PLUGIN_FACTORIES = Object.keys(PLUGIN_TO_CAPABILITY);
333
- function detectProject(root, reader = nodeReader(root)) {
334
- const installed = detectInstalled(reader);
335
- const app = detectAppFile(reader);
336
- const server = detectServerFile(reader);
337
- const env = detectEnvFile(reader);
338
- const prisma = detectPrisma(reader);
339
- const stack = buildStack(installed, app, prisma);
340
- return { root, installed, stack, prisma, app, server, env };
341
- }
342
- function detectInstalled(reader) {
343
- const raw = reader.read("package.json");
344
- if (!raw) return [];
345
- try {
346
- const pkg = JSON.parse(raw);
347
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
348
- return Object.keys(deps).filter((name) => name.startsWith("@basaltkit/")).sort();
349
- } catch {
350
- return [];
351
- }
352
- }
353
- function firstExisting(reader, candidates) {
354
- for (const path of candidates) {
355
- const content = reader.read(path);
356
- if (content !== null) return { path, content };
357
- }
358
- return null;
359
- }
360
- function detectAppFile(reader) {
361
- const found = firstExisting(reader, APP_CANDIDATES);
362
- if (!found) return null;
363
- const plugins = PLUGIN_FACTORIES.filter(
364
- (factory) => new RegExp(`\\b${factory}\\s*\\(`).test(found.content)
365
- );
366
- const fastifyLoggerConfigured = /fastify\s*:\s*\{[^}]*\blogger\b/.test(found.content);
367
- const memorySources = [
368
- "MemoryTenantSource",
369
- "MemoryUserSource",
370
- // Security-critical in-memory stores: auth/authorization/domain state that
371
- // must survive restarts and be shared across instances in production.
372
- "MemoryPasskeyStore",
373
- "MemoryWebAuthnChallengeStore",
374
- "MemoryAccessStore",
375
- "MemoryDomainStore"
376
- ].filter(
377
- (source) => found.content.includes(source)
378
- );
379
- const pluginCalls = [
380
- ...new Set(Array.from(found.content.matchAll(/\b([A-Za-z][A-Za-z0-9]*Plugin)\s*\(/g), (m) => m[1]))
381
- ];
382
- return { path: found.path, plugins, fastifyLoggerConfigured, memorySources, pluginCalls };
383
- }
384
- function detectServerFile(reader) {
385
- const found = firstExisting(reader, SERVER_CANDIDATES);
386
- if (!found) return null;
387
- return {
388
- path: found.path,
389
- connectsAtBoot: /\.\$connect\s*\(/.test(found.content),
390
- startsServer: /\.listen\s*\(/.test(found.content)
391
- };
392
- }
393
- function detectEnvFile(reader) {
394
- const found = firstExisting(reader, ENV_CANDIDATES);
395
- if (!found) return null;
396
- return {
397
- path: found.path,
398
- appSecretDefault: captureDefault(found.content, "APP_SECRET"),
399
- redisUrlDefault: captureDefault(found.content, "REDIS_URL")
400
- };
401
- }
402
- function captureDefault(content, key) {
403
- const match = new RegExp(`${key}\\s*:[^\\n]*?\\.default\\(\\s*['"\`]([^'"\`]*)['"\`]`).exec(content);
404
- return match ? match[1] ?? null : null;
405
- }
406
- function detectPrisma(reader) {
407
- const found = firstExisting(reader, SCHEMA_CANDIDATES);
408
- if (!found) return null;
409
- const providerMatch = /datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/s.exec(found.content);
410
- const provider = normalizeProvider(providerMatch?.[1]);
411
- const models = parseModels(found.content);
412
- return { provider, models };
413
- }
414
- function normalizeProvider(raw) {
415
- switch (raw) {
416
- case "postgresql":
417
- return "postgresql";
418
- case "mysql":
419
- return "mysql";
420
- case "sqlite":
421
- return "sqlite";
422
- default:
423
- return null;
424
- }
425
- }
426
- function parseModels(schema) {
427
- const models = [];
428
- const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs;
429
- let match;
430
- while ((match = modelRegex.exec(schema)) !== null) {
431
- const name = match[1];
432
- const body = match[2];
433
- if (!name || body === void 0) continue;
434
- const fields = body.split("\n").map((line) => line.trim().split(/\s+/)[0]).filter((token) => !!token && !token.startsWith("@") && !token.startsWith("//"));
435
- models.push({ name, fields, tenantScoped: fields.includes("tenantId") });
436
- }
437
- return models;
438
- }
439
- function buildStack(installed, app, prisma) {
440
- const stack = {
441
- http: null,
442
- orm: null,
443
- database: prisma?.provider ?? null,
444
- tenancy: false,
445
- auth: false,
446
- rbac: false,
447
- subscriptions: false,
448
- payments: false,
449
- storage: false,
450
- queue: false,
451
- search: false,
452
- audit: false,
453
- events: false,
454
- scheduler: false,
455
- logger: false,
456
- ai: installed.includes("@basaltkit/ai")
457
- };
458
- const enabled = new Set(app?.plugins ?? []);
459
- const flags = stack;
460
- for (const [factory, capability] of Object.entries(PLUGIN_TO_CAPABILITY)) {
461
- if (!enabled.has(factory)) continue;
462
- if (capability === "http") stack.http = "fastify";
463
- else if (capability === "orm") stack.orm = "prisma";
464
- else flags[capability] = true;
465
- }
466
- if (!stack.http && installed.includes("@basaltkit/fastify")) stack.http = "fastify";
467
- if (!stack.orm && installed.includes("@basaltkit/prisma")) stack.orm = "prisma";
468
- if (!stack.rbac && installed.includes("@basaltkit/permissions")) stack.rbac = true;
469
- return stack;
470
- }
471
-
472
- // src/doctor/types.ts
473
- function defineRule(rule) {
474
- return rule;
475
- }
476
-
477
- // src/doctor/rules.ts
478
- var DEFAULT_RULES = [
479
- // ── observability ─────────────────────────────────────────────────────────
480
- defineRule({
481
- id: "prisma-lazy-boot",
482
- category: "observability",
483
- check(ctx) {
484
- if (!ctx.stack.orm || !ctx.server) return null;
485
- if (ctx.server.connectsAtBoot) return null;
486
- return {
487
- id: "prisma-lazy-boot",
488
- title: "Database connection is not validated at boot",
489
- severity: "warning",
490
- category: "observability",
491
- detected: `${ctx.server.path} never calls prisma.$connect() before listen()`,
492
- recommended: "await the Prisma client's $connect() right after boot()",
493
- reason: "Prisma connects lazily on the first query, so an unreachable database (wrong host/port/credentials) does NOT crash at startup \u2014 it fails silently on the first request instead.",
494
- fix: [
495
- "const app = await buildApp({ /* \u2026 */ }).boot()",
496
- "await app.container.get(PRISMA).$connect() // fail loudly if the DB is unreachable"
497
- ].join("\n"),
498
- docs: "/guide/observability"
499
- };
500
- }
501
- }),
502
- defineRule({
503
- id: "fastify-logger-off",
504
- category: "observability",
505
- check(ctx) {
506
- if (ctx.stack.http !== "fastify" || !ctx.app) return null;
507
- if (ctx.app.fastifyLoggerConfigured) return null;
508
- return {
509
- id: "fastify-logger-off",
510
- title: "Fastify's request logger is disabled",
511
- severity: "warning",
512
- category: "observability",
513
- detected: `fastifyPlugin in ${ctx.app.path} has no fastify.logger option`,
514
- recommended: "enable Fastify's logger (at least in development)",
515
- reason: "Unhandled request errors are logged via request.log.error, but Fastify's built-in logger defaults to OFF \u2014 so 500s are returned to the client while the terminal stays silent.",
516
- fix: "fastifyPlugin({ routes: [...], fastify: { logger: env.NODE_ENV === 'development' } })",
517
- docs: "/guide/observability"
518
- };
519
- }
520
- }),
521
- // ── security ──────────────────────────────────────────────────────────────
522
- defineRule({
523
- id: "insecure-app-secret",
524
- category: "security",
525
- check(ctx) {
526
- const value = ctx.env?.appSecretDefault;
527
- if (!value) return null;
528
- if (!/change|me|example|secret|placeholder|xxx/i.test(value)) return null;
529
- return {
530
- id: "insecure-app-secret",
531
- title: "APP_SECRET has an insecure default",
532
- severity: "error",
533
- category: "security",
534
- detected: `${ctx.env?.path} defaults APP_SECRET to "${value}"`,
535
- recommended: "require APP_SECRET from the environment with no fallback in production",
536
- reason: "A committed default secret lets anyone forge sessions/JWTs if it ever reaches a deployed environment. Secrets must come from the environment.",
537
- fix: "APP_SECRET: z.string().min(32) // no .default() \u2014 fail fast if unset",
538
- docs: "/guide/security"
539
- };
540
- }
541
- }),
542
- defineRule({
543
- id: "missing-security-plugin",
544
- category: "security",
545
- check(ctx) {
546
- if (!ctx.app) return null;
547
- if (ctx.app.pluginCalls.includes("securityPlugin")) return null;
548
- return {
549
- id: "missing-security-plugin",
550
- title: "No securityPlugin \u2014 responses ship without secure headers",
551
- severity: "warning",
552
- category: "security",
553
- detected: `${ctx.app.path} registers no securityPlugin()`,
554
- recommended: "add securityPlugin() so secure response headers are set, and enable rate limiting + a CORS allow-list for production",
555
- reason: "Without it the API returns no security headers (HSTS, X-Frame-Options, nosniff, \u2026) and applies no rate limiting \u2014 a fresh deploy is unprotected at the edge. The security primitives exist but are off until wired.",
556
- fix: "securityPlugin({ /* headers on by default */ rateLimit: { limit: 120, windowMs: 60_000 } })",
557
- docs: "/guide/security"
558
- };
559
- }
560
- }),
561
- // ── tenancy ───────────────────────────────────────────────────────────────
562
- defineRule({
563
- id: "missing-tenant-membership",
564
- category: "tenancy",
565
- check(ctx) {
566
- if (!ctx.app || !ctx.stack.tenancy || !ctx.stack.auth) return null;
567
- const teamsAvailable = ctx.app.pluginCalls.includes("teamsPlugin") || ctx.installed.includes("@basaltkit/teams");
568
- if (!teamsAvailable) return null;
569
- if (ctx.app.pluginCalls.includes("tenantMembershipPlugin")) return null;
570
- return {
571
- id: "missing-tenant-membership",
572
- title: "Tenant is resolved from the request but membership is never enforced",
573
- severity: "error",
574
- category: "tenancy",
575
- detected: `${ctx.app.path} wires tenancy + auth (+ teams) but no tenantMembershipPlugin`,
576
- recommended: "register tenantMembershipPlugin() so every authenticated, tenant-scoped request verifies the user belongs to the resolved tenant",
577
- reason: "Resolvers take the tenant from client input (x-tenant-id / Host). Without a membership check, any authenticated user can act on another tenant just by changing that header \u2014 a cross-tenant data breach. Tenant resolution is identification, never authorization.",
578
- fix: "tenantMembershipPlugin() // central routes opt out with meta: { central: true }",
579
- docs: "/guide/security"
580
- };
581
- }
582
- }),
583
- defineRule({
584
- id: "tenant-scoping-missing",
585
- category: "tenancy",
586
- check(ctx) {
587
- if (!ctx.stack.tenancy || !ctx.prisma) return null;
588
- const unscoped = ctx.prisma.models.filter(
589
- (model) => !model.tenantScoped && !GLOBAL_MODELS.has(model.name)
590
- );
591
- if (unscoped.length === 0) return null;
592
- const names7 = unscoped.map((m) => m.name).join(", ");
593
- return {
594
- id: "tenant-scoping-missing",
595
- title: "Tenant-scoped app has models without a tenantId",
596
- severity: "warning",
597
- category: "tenancy",
598
- detected: `models without a tenantId column: ${names7}`,
599
- recommended: "add a tenantId to tenant-owned models so queries can never leak across tenants",
600
- reason: "With tenancy enabled, a model that lacks tenantId cannot be isolated by the tenant-scoping layer \u2014 a query can return rows from other tenants.",
601
- fix: "tenantId String // + @@index([tenantId]); scope every query by the current tenant",
602
- docs: "/guide/tenancy"
603
- };
604
- }
605
- }),
606
- // ── durability ────────────────────────────────────────────────────────────
607
- defineRule({
608
- id: "memory-sources-in-use",
609
- category: "durability",
610
- check(ctx) {
611
- const sources = ctx.app?.memorySources ?? [];
612
- if (sources.length === 0) return null;
613
- return {
614
- id: "memory-sources-in-use",
615
- title: "Non-durable in-memory sources are wired",
616
- severity: "info",
617
- category: "durability",
618
- detected: `${ctx.app?.path} uses ${sources.join(", ")}`,
619
- recommended: "replace in-memory sources with database-backed ones before production",
620
- reason: "In-memory sources reset on every restart and are not shared across instances \u2014 fine for local dev, data loss in production.",
621
- docs: "/guide/tenancy"
622
- };
623
- }
624
- }),
625
- defineRule({
626
- id: "redis-localhost-default",
627
- category: "config",
628
- check(ctx) {
629
- const value = ctx.env?.redisUrlDefault;
630
- if (!value || !/localhost|127\.0\.0\.1/.test(value)) return null;
631
- if (!ctx.stack.queue && !ctx.stack.subscriptions) return null;
632
- return {
633
- id: "redis-localhost-default",
634
- title: "Redis URL defaults to localhost",
635
- severity: "info",
636
- category: "config",
637
- detected: `${ctx.env?.path} defaults REDIS_URL to "${value}"`,
638
- recommended: "set REDIS_URL from the environment in every non-local deployment",
639
- reason: "A localhost default silently falls back to a dev-only queue/dedupe backend in production if the env var is missing.",
640
- docs: "/guide/configuration"
641
- };
642
- }
643
- }),
644
- defineRule({
645
- id: "in-memory-security-store",
646
- category: "security",
647
- check(ctx) {
648
- const SECURITY_STORES = {
649
- MemoryPasskeyStore: "WebAuthn passkeys",
650
- MemoryWebAuthnChallengeStore: "WebAuthn challenges",
651
- MemoryAccessStore: "roles & permissions",
652
- MemoryDomainStore: "verified custom domains"
653
- };
654
- const hit = (ctx.app?.memorySources ?? []).find((source) => source in SECURITY_STORES);
655
- if (!hit) return null;
656
- return {
657
- id: "in-memory-security-store",
658
- title: "Security state is kept in an in-memory store",
659
- severity: "warning",
660
- category: "security",
661
- detected: `${hit} holds ${SECURITY_STORES[hit]} in memory.`,
662
- recommended: "Back it with a durable store (database/Redis) in production.",
663
- reason: "In-memory security stores are wiped on restart and are not shared across instances \u2014 passkeys/permissions/verified domains would silently reset or diverge per replica, which can lock users out or bypass authorization.",
664
- fix: `// swap ${hit} for a durable implementation of the same interface`,
665
- docs: "/guide/security"
666
- };
667
- }
668
- })
669
- ];
670
- var GLOBAL_MODELS = /* @__PURE__ */ new Set(["Tenant", "User", "Plan", "WebhookEvent", "Migration"]);
671
-
672
- // src/doctor/run.ts
673
- var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
674
- function runDoctor(ctx, rules = DEFAULT_RULES) {
675
- return rules.map((rule) => rule.check(ctx)).filter((d) => d !== null).sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
676
- }
677
- function hasErrors(diagnostics) {
678
- return diagnostics.some((d) => d.severity === "error");
679
- }
680
-
681
- // src/doctor/fixes.ts
682
- import { writeFile } from "fs/promises";
683
- import { join as join2 } from "path";
684
- var FIXES = {
685
- "fastify-logger-off": (read, ctx) => {
686
- if (!ctx.app) return null;
687
- const content = read(ctx.app.path);
688
- if (content === null) return null;
689
- if (/fastify\s*:\s*\{[^}]*\blogger\b/.test(content)) return [];
690
- const after = content.replace(
691
- /fastifyPlugin\(\s*\{/,
692
- (m) => `${m} fastify: { logger: process.env.NODE_ENV !== 'production' },`
693
- );
694
- return after === content ? [] : [{ path: ctx.app.path, before: content, after }];
695
- },
696
- "insecure-app-secret": (read, ctx) => {
697
- if (!ctx.env) return null;
698
- const value = ctx.env.appSecretDefault;
699
- if (!value || !/change|me|example|secret|placeholder|xxx/i.test(value)) return [];
700
- const content = read(ctx.env.path);
701
- if (content === null) return null;
702
- const after = content.replace(/^(\s*)APP_SECRET:\s*z\.string\(\)[^\n]*$/m, "$1APP_SECRET: z.string().min(32),");
703
- return after === content ? [] : [{ path: ctx.env.path, before: content, after }];
704
- }
705
- };
706
- function fixableIds() {
707
- return Object.keys(FIXES);
708
- }
709
- function planFix(id, ctx, read) {
710
- const applier = FIXES[id];
711
- if (!applier) {
712
- return { id, status: "unfixable", edits: [], message: "no auto-fix \u2014 apply manually (see `basalt ai:doctor`)" };
713
- }
714
- const edits = applier(read, ctx);
715
- if (edits === null) return { id, status: "unfixable", edits: [], message: "target file not found" };
716
- const changed = edits.filter((e) => e.before !== e.after);
717
- if (changed.length === 0) return { id, status: "noop", edits: [], message: "nothing to change (already fixed?)" };
718
- return { id, status: "ready", edits: changed, message: `edits ${changed.map((e) => e.path).join(", ")}` };
719
- }
720
- async function applyFixEdits(edits, baseDir) {
721
- for (const edit of edits) await writeFile(join2(baseDir, edit.path), edit.after);
722
- }
723
- function renderFixes(outcomes, io) {
724
- const mark = { ready: "\u2713", noop: "\u2022", unfixable: "\u26A0" };
725
- for (const outcome of outcomes) {
726
- io.log(`${mark[outcome.status]} ${outcome.id} \u2014 ${outcome.message}`);
727
- for (const edit of outcome.edits) {
728
- io.log(` ${edit.path}:`);
729
- for (const [removed, added] of lineDiff(edit.before, edit.after)) {
730
- if (removed !== void 0) io.log(` - ${removed.trim()}`);
731
- if (added !== void 0) io.log(` + ${added.trim()}`);
732
- }
733
- }
734
- }
735
- }
736
- function lineDiff(before, after) {
737
- const b = before.split("\n");
738
- const a = after.split("\n");
739
- const out = [];
740
- for (let i = 0; i < Math.max(b.length, a.length); i++) {
741
- if (b[i] !== a[i]) out.push([b[i], a[i]]);
742
- }
743
- return out;
744
- }
745
-
746
- // src/analyze/run.ts
747
- function analyze(ctx) {
748
- const { stack, prisma } = ctx;
749
- const capabilities = [];
750
- if (stack.http === "fastify") capabilities.push("Fastify detected");
751
- if (stack.orm === "prisma") capabilities.push("Prisma detected");
752
- if (stack.database) capabilities.push(`${databaseLabel(stack.database)} detected`);
753
- if (stack.tenancy) capabilities.push("Tenancy enabled");
754
- if (stack.auth) capabilities.push("Authentication enabled");
755
- if (stack.rbac) capabilities.push("RBAC enabled");
756
- if (stack.subscriptions) capabilities.push("Subscriptions enabled");
757
- if (stack.payments) capabilities.push("Payments enabled");
758
- if (stack.queue) capabilities.push("Queue enabled");
759
- if (stack.search) capabilities.push("Search enabled");
760
- if (stack.audit) capabilities.push("Audit enabled");
761
- if (stack.events) capabilities.push("Events enabled");
762
- if (stack.scheduler) capabilities.push("Scheduler enabled");
763
- if (stack.storage) capabilities.push("Storage enabled");
764
- const models = prisma?.models ?? [];
765
- return {
766
- root: ctx.root,
767
- capabilities,
768
- installed: ctx.installed,
769
- database: stack.database,
770
- models: models.map((m) => m.name),
771
- tenantScopedModels: models.filter((m) => m.tenantScoped).map((m) => m.name),
772
- unscopedModels: models.filter((m) => !m.tenantScoped).map((m) => m.name),
773
- diagnostics: runDoctor(ctx)
774
- };
775
- }
776
- function databaseLabel(db) {
777
- switch (db) {
778
- case "postgresql":
779
- return "PostgreSQL";
780
- case "mysql":
781
- return "MySQL";
782
- case "sqlite":
783
- return "SQLite";
784
- }
785
- }
786
-
787
- // src/plan/context.ts
788
- function buildPlanContext(ctx) {
789
- const s = ctx.stack;
790
- const caps = [
791
- s.http ? `HTTP: ${s.http}` : null,
792
- s.orm ? `ORM: ${s.orm}` : null,
793
- s.database ? `database: ${s.database}` : null,
794
- s.tenancy ? "tenancy: ENABLED \u2014 tenant-owned models must carry tenantId" : "tenancy: off",
795
- s.auth ? "auth: enabled" : null,
796
- s.rbac ? "RBAC: enabled \u2014 register <resource>.view/create/update/delete" : null,
797
- s.audit ? "audit: enabled \u2014 emit <resource>.created/updated/deleted" : null,
798
- s.subscriptions ? "subscriptions: enabled" : null,
799
- s.payments ? "payments: enabled" : null,
800
- s.queue ? "queue: enabled" : null,
801
- s.search ? "search: enabled" : null,
802
- s.events ? "events: enabled" : null,
803
- s.scheduler ? "scheduler: enabled" : null,
804
- s.storage ? "storage: enabled" : null
805
- ].filter((line) => line !== null);
806
- const models = ctx.prisma && ctx.prisma.models.length > 0 ? ctx.prisma.models.map((m) => `${m.name}${m.tenantScoped ? " (tenantId)" : ""}`).join(", ") : "(none)";
807
- const lines = [
808
- "PROJECT STACK:",
809
- ...caps.map((c) => `- ${c}`),
810
- "",
811
- `EXISTING PRISMA MODELS: ${models}`,
812
- "",
813
- "GENERATOR: `basalt make:resource <Name>` \u2014 flags: --prisma, --soft-delete, --no-register, --dir, --force."
814
- ];
815
- if (ctx.app) lines.push(`APP FILE: ${ctx.app.path} (wired plugins: ${ctx.app.plugins.join(", ") || "none"}).`);
816
- return lines.join("\n");
817
- }
818
-
819
- // src/plan/knowledge.ts
820
- var BASALT_KNOWLEDGE = `You are the Architect agent for Basalt \u2014 a batteries-included SaaS framework for Node.js/TypeScript (Fastify + Prisma + Zod). You turn a developer's natural-language request into a precise, convention-following implementation PLAN. You PLAN only; you never write code here.
821
-
822
- ABSOLUTE RULES \u2014 always use the framework's official APIs and generators, never invent new ones:
823
-
824
- 1. SCAFFOLDING. Create a resource with the generator, not by hand:
825
- \`basalt make:resource <Name>\` \u2014 generates a full vertical: Zod schema, repository, service, DI plugin, typed routes and a test, all on-convention and auto-wired into src/app.ts.
826
- Flags: --prisma (Prisma-backed repository + a schema.prisma model with id/createdAt/updatedAt), --soft-delete (adds deletedAt, a restore() method + route), --no-register (skip wiring), --dir=<path>, --force.
827
-
828
- 2. PERSISTENCE. Prisma via prismaPlugin. Models live in schema.prisma. After editing the schema, run \`basalt prisma:sync\` to generate the client + migration. Use --prisma so the generator emits the model for you.
829
-
830
- 3. MULTI-TENANCY. When tenancy is enabled, every tenant-owned model MUST carry a \`tenantId\` column, and the tenancy layer scopes queries by the current tenant. NEVER plan a query that ignores tenant isolation. Platform-global tables (users, tenants, plans) are the only exceptions.
831
-
832
- 4. RBAC / PERMISSIONS. For each resource, register \`<resource>.view\`, \`<resource>.create\`, \`<resource>.update\`, \`<resource>.delete\` and guard the matching routes with the permission check. Use the resource name in plural, lowercase (e.g. \`patients.create\`).
833
-
834
- 5. AUDIT. Emit an audit event for each state change: \`<resource>.created\`, \`<resource>.updated\`, \`<resource>.deleted\` (singular resource).
835
-
836
- 6. VALIDATION + DOCS. Every route carries a Zod schema; OpenAPI is generated from it automatically \u2014 no separate doc wiring.
837
-
838
- 7. TESTS. Every resource ships a test (the generator creates one; extend it for domain rules).
839
-
840
- GROUNDING: Reuse what already exists in the project. Avoid name collisions with existing models. Only add what is missing. Prefer editing the schema + running the generator over hand-writing files.
841
-
842
- OUTPUT: Return ONLY a single JSON object (no prose, no markdown fences) matching exactly:
843
- {
844
- "summary": string, // 1-2 sentences on the approach
845
- "entities": [ // domain entities to create
846
- { "name": string, "fields": [{ "name": string, "type": string, "enum"?: string[] }], "tenantScoped": boolean,
847
- "relations": [{ "name": string, "model": string }] } // belongs-to: this model gets a <name>Id FK \u2192 <model>
848
- ],
849
- "steps": [ // ordered implementation steps
850
- { "order": number, "title": string, "kind": "generator"|"schema"|"migration"|"service"|"routes"|"permissions"|"audit"|"test"|"docs"|"other", "detail": string, "command": string, "files": string[] }
851
- ],
852
- "permissions": string[], // e.g. ["patients.view","patients.create",...]
853
- "auditEvents": string[], // e.g. ["patient.created",...]
854
- "tenantScoped": boolean, // does this feature involve tenant-owned data
855
- "warnings": string[] // risks, decisions to confirm, missing info
856
- }
857
- For generator steps, put the exact command in "command" (e.g. "basalt make:resource Patient --prisma --soft-delete"). Omit "command"/"files" when not applicable.
858
-
859
- ENUMS: a field with a fixed set of values (a status/state) MUST set "enum" to those values \u2014 e.g. "estado (pago/pendente)" \u2192 { "name": "estado", "type": "String", "enum": ["pago", "pendente"] }. It becomes a validated z.enum([...]) (stored as a String column), never a free z.string().
860
-
861
- RELATIONS: express a belongs-to as an entry in "relations" (e.g. an Appointment belongs to a Patient \u2192 { "name": "patient", "model": "Patient" }). Do NOT also add the "<name>Id" field to "fields" \u2014 the FK column, the @relation and the inverse field are generated. Prefer generating both sides of a relation in the same plan.`;
862
-
863
- // src/plan/plan.ts
864
- var STEP_KINDS = [
865
- "generator",
866
- "schema",
867
- "migration",
868
- "service",
869
- "routes",
870
- "permissions",
871
- "audit",
872
- "test",
873
- "docs",
874
- "other"
875
- ];
876
- async function createPlan(provider, ctx, request, options = {}) {
877
- const raw = await provider.generate({
878
- messages: [
879
- { role: "system", content: BASALT_KNOWLEDGE },
880
- {
881
- role: "user",
882
- content: `REQUEST:
883
- ${request}
884
-
885
- ${buildPlanContext(ctx)}
886
-
887
- Return the plan as a single JSON object only.`
888
- }
889
- ],
890
- temperature: options.temperature ?? 0,
891
- maxTokens: options.maxTokens ?? 4096
892
- });
893
- return parsePlan(raw, request);
894
- }
895
- function parsePlan(raw, request) {
896
- const json = extractJson(raw);
897
- let obj;
898
- try {
899
- obj = JSON.parse(json);
900
- } catch {
901
- throw new Error(`ai:plan \u2014 the model did not return valid JSON. Got: ${raw.slice(0, 160)}\u2026`);
902
- }
903
- return {
904
- request,
905
- summary: typeof obj["summary"] === "string" ? obj["summary"] : "",
906
- entities: normalizeEntities(obj["entities"]),
907
- steps: normalizeSteps(obj["steps"]),
908
- permissions: stringArray(obj["permissions"]),
909
- auditEvents: stringArray(obj["auditEvents"]),
910
- tenantScoped: obj["tenantScoped"] === true,
911
- warnings: stringArray(obj["warnings"])
912
- };
913
- }
914
- function extractJson(raw) {
915
- const withoutFence = raw.replace(/```(?:json)?/gi, "").trim();
916
- const start = withoutFence.indexOf("{");
917
- const end = withoutFence.lastIndexOf("}");
918
- if (start === -1 || end === -1 || end < start) return withoutFence;
919
- return withoutFence.slice(start, end + 1);
920
- }
921
- function stringArray(value) {
922
- return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
923
- }
924
- function normalizeEntities(value) {
925
- if (!Array.isArray(value)) return [];
926
- return value.filter((e) => typeof e === "object" && e !== null).map((e) => {
927
- const relations = normalizeRelations(e["relations"]);
928
- const entity = {
929
- name: typeof e["name"] === "string" ? e["name"] : "Unnamed",
930
- fields: normalizeFields(e["fields"]),
931
- tenantScoped: e["tenantScoped"] === true,
932
- ...relations.length > 0 ? { relations } : {}
933
- };
934
- return entity;
935
- });
936
- }
937
- var lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
938
- var upperFirst = (s) => s.charAt(0).toUpperCase() + s.slice(1);
939
- function normalizeRelations(value) {
940
- if (!Array.isArray(value)) return [];
941
- const out = [];
942
- for (const r of value) {
943
- if (typeof r === "string" && r.trim() !== "") {
944
- const model = r.trim();
945
- out.push({ name: lowerFirst(model), model });
946
- } else if (typeof r === "object" && r !== null) {
947
- const obj = r;
948
- const rawModel = typeof obj["model"] === "string" ? obj["model"] : "";
949
- const rawName = typeof obj["name"] === "string" ? obj["name"] : "";
950
- const model = rawModel !== "" ? rawModel : rawName !== "" ? upperFirst(rawName) : "";
951
- if (model === "") continue;
952
- out.push({ name: rawName !== "" ? rawName : lowerFirst(model), model });
953
- }
954
- }
955
- return out;
956
- }
957
- function normalizeFields(value) {
958
- if (!Array.isArray(value)) return [];
959
- return value.filter((f) => typeof f === "object" && f !== null).map((f) => {
960
- const values = Array.isArray(f["enum"]) ? f["enum"].filter((v) => typeof v === "string") : [];
961
- return {
962
- name: typeof f["name"] === "string" ? f["name"] : "",
963
- type: typeof f["type"] === "string" ? f["type"] : "String",
964
- ...values.length > 0 ? { enum: values } : {}
965
- };
966
- }).filter((f) => f.name !== "");
967
- }
968
- function normalizeSteps(value) {
969
- if (!Array.isArray(value)) return [];
970
- return value.filter((s) => typeof s === "object" && s !== null).map((s, index) => {
971
- const command = typeof s["command"] === "string" ? s["command"] : void 0;
972
- const files = stringArray(s["files"]);
973
- const step = {
974
- order: typeof s["order"] === "number" ? s["order"] : index + 1,
975
- title: typeof s["title"] === "string" ? s["title"] : `Step ${index + 1}`,
976
- kind: normalizeKind(s["kind"]),
977
- detail: typeof s["detail"] === "string" ? s["detail"] : "",
978
- ...command ? { command } : {},
979
- ...files.length > 0 ? { files } : {}
980
- };
981
- return step;
982
- }).sort((a, b) => a.order - b.order);
983
- }
984
- function normalizeKind(value) {
985
- return typeof value === "string" && STEP_KINDS.includes(value) ? value : "other";
986
- }
987
-
988
- // src/plan/render.ts
989
- function renderPlan(plan, io) {
990
- io.log("ARCHITECTURE PLAN");
991
- io.log("");
992
- io.log(`Request: "${plan.request}"`);
993
- if (plan.summary) {
994
- io.log("");
995
- io.log(plan.summary);
996
- }
997
- if (plan.entities.length > 0) {
998
- io.log("");
999
- io.log("Entities:");
1000
- for (const entity of plan.entities) {
1001
- const scope = entity.tenantScoped ? " (tenant-scoped)" : "";
1002
- const fields = entity.fields.map((f) => `${f.name}: ${f.type}`).join(", ");
1003
- io.log(` \u2022 ${entity.name}${scope}${fields ? ` \u2014 ${fields}` : ""}`);
1004
- if (entity.relations && entity.relations.length > 0) {
1005
- io.log(` relations: ${entity.relations.map((r) => `${r.name} \u2192 ${r.model}`).join(", ")}`);
1006
- }
1007
- }
1008
- }
1009
- io.log("");
1010
- io.log("Steps:");
1011
- if (plan.steps.length === 0) {
1012
- io.log(" (none proposed)");
1013
- } else {
1014
- for (const step of plan.steps) {
1015
- const n = String(step.order).padStart(2, " ");
1016
- io.log(` ${n}. [${step.kind}] ${step.title}`);
1017
- if (step.detail) io.log(` ${step.detail}`);
1018
- if (step.command) io.log(` $ ${step.command}`);
1019
- if (step.files && step.files.length > 0) io.log(` files: ${step.files.join(", ")}`);
1020
- }
1021
- }
1022
- if (plan.permissions.length > 0) {
1023
- io.log("");
1024
- io.log(`Permissions: ${plan.permissions.join(", ")}`);
1025
- }
1026
- if (plan.auditEvents.length > 0) {
1027
- io.log(`Audit events: ${plan.auditEvents.join(", ")}`);
1028
- }
1029
- if (plan.warnings.length > 0) {
1030
- io.log("");
1031
- io.log("\u26A0 Warnings:");
1032
- for (const warning of plan.warnings) io.log(` - ${warning}`);
1033
- }
1034
- io.log("");
1035
- io.log("This is a plan only \u2014 nothing was changed.");
1036
- io.log("Next: `basalt ai:make` will execute it (arrives in the next phase).");
1037
- }
1038
-
1039
- // src/make/make.ts
1040
- import {
1041
- FileExistsError,
1042
- generateResource,
1043
- names as names6,
1044
- registerResourceInApp,
1045
- writeGenerated
1046
- } from "@basaltkit/generator";
1047
-
1048
- // src/make/fields.ts
1049
- var RESERVED = /* @__PURE__ */ new Set(["id", "name", "createdat", "updatedat", "deletedat", "tenantid"]);
1050
- function canonicalType(raw) {
1051
- switch (raw.trim().toLowerCase()) {
1052
- case "int":
1053
- case "integer":
1054
- case "number":
1055
- case "bigint":
1056
- return "Int";
1057
- case "float":
1058
- case "decimal":
1059
- case "double":
1060
- return "Float";
1061
- case "bool":
1062
- case "boolean":
1063
- return "Boolean";
1064
- case "date":
1065
- case "datetime":
1066
- case "timestamp":
1067
- return "DateTime";
1068
- case "json":
1069
- case "object":
1070
- return "Json";
1071
- default:
1072
- return "String";
1073
- }
1074
- }
1075
- function prismaType(t) {
1076
- return t;
1077
- }
1078
- function zodValidator(t, create) {
1079
- switch (t) {
1080
- case "Int":
1081
- return "z.number().int()";
1082
- case "Float":
1083
- return "z.number()";
1084
- case "Boolean":
1085
- return "z.boolean()";
1086
- case "DateTime":
1087
- return create ? "z.coerce.date()" : "z.string()";
1088
- case "Json":
1089
- return "z.unknown()";
1090
- case "String":
1091
- return create ? "z.string().min(1)" : "z.string()";
1092
- }
1093
- }
1094
- function usable(field) {
1095
- return field.name.trim() !== "" && !RESERVED.has(field.name.toLowerCase());
1096
- }
1097
- function zodForField(field, create) {
1098
- if (field.enum && field.enum.length > 0) {
1099
- return `z.enum([${field.enum.map((v) => `'${v}'`).join(", ")}])`;
1100
- }
1101
- return zodValidator(canonicalType(field.type), create);
1102
- }
1103
- function domainFields(fields) {
1104
- const seen = /* @__PURE__ */ new Set();
1105
- const out = [];
1106
- for (const field of fields) {
1107
- const key = field.name.toLowerCase();
1108
- if (!usable(field) || seen.has(key)) continue;
1109
- seen.add(key);
1110
- out.push(field);
1111
- }
1112
- return out;
1113
- }
1114
- function hasPrismaField(content, name) {
1115
- return new RegExp(`^[ \\t]*${name}[ \\t]+`, "m").test(content);
1116
- }
1117
- function injectPrismaFields(content, fields, tenantScoped, removeName = false) {
1118
- const nameAnchor = /^([ \t]*)name[ \t]+String.*$/m;
1119
- if (!nameAnchor.test(content)) return { content, injected: false };
1120
- const extras = [];
1121
- if (tenantScoped && !hasPrismaField(content, "tenantId")) extras.push(" tenantId String");
1122
- for (const field of domainFields(fields)) {
1123
- if (hasPrismaField(content, field.name)) continue;
1124
- extras.push(` ${field.name} ${prismaType(canonicalType(field.type))}`);
1125
- }
1126
- if (extras.length === 0) return { content, injected: false };
1127
- let out = content.replace(nameAnchor, (line) => `${line}
1128
- ${extras.join("\n")}`);
1129
- if (tenantScoped) out = out.replace(/\n\}\s*$/, `
1130
-
1131
- @@index([tenantId])
1132
- }
1133
- `);
1134
- if (removeName) out = out.replace(/^[ \t]*name[ \t]+String\b.*\n/m, "");
1135
- return { content: out, injected: true };
1136
- }
1137
- function injectZodFields(content, fields, removeName = false) {
1138
- const usableFields = domainFields(fields);
1139
- if (usableFields.length === 0) return { content, injected: false };
1140
- let out = content;
1141
- let injected = false;
1142
- const entityAnchor = /^([ \t]*)name: z\.string\(\),.*$/m;
1143
- const entityLines = usableFields.map((f) => ` ${f.name}: ${zodForField(f, false)},`);
1144
- if (entityAnchor.test(out)) {
1145
- out = out.replace(entityAnchor, (line) => `${line}
1146
- ${entityLines.join("\n")}`);
1147
- injected = true;
1148
- }
1149
- const createAnchor = /^([ \t]*)name: z\.string\(\)\.min\(1\),.*$/m;
1150
- const createLines = usableFields.map((f) => ` ${f.name}: ${zodForField(f, true)},`);
1151
- if (createAnchor.test(out)) {
1152
- out = out.replace(createAnchor, (line) => `${line}
1153
- ${createLines.join("\n")}`);
1154
- injected = true;
1155
- }
1156
- if (removeName) {
1157
- out = out.replace(/^[ \t]*name: z\.string\(\),.*\n/m, "");
1158
- out = out.replace(/^[ \t]*name: z\.string\(\)\.min\(1\),.*\n/m, "");
1159
- }
1160
- return { content: out, injected };
1161
- }
1162
-
1163
- // src/make/relations.ts
1164
- import { names } from "@basaltkit/generator";
1165
- function foreignKey(relation) {
1166
- return `${relation.name}Id`;
1167
- }
1168
- function relationForeignKeys(entity) {
1169
- return (entity.relations ?? []).map((r) => ({ name: foreignKey(r), type: "String" }));
1170
- }
1171
- function relationFieldLines(entity) {
1172
- return (entity.relations ?? []).map(
1173
- (r) => ` ${r.name} ${names(r.model).pascal} @relation(fields: [${foreignKey(r)}], references: [id])`
1174
- );
1175
- }
1176
- function inverseRelationLines(modelName, allEntities) {
1177
- const target = names(modelName).pascal;
1178
- const lines = [];
1179
- for (const other of allEntities) {
1180
- if (names(other.name).pascal === target) continue;
1181
- for (const r of other.relations ?? []) {
1182
- if (names(r.model).pascal === target) {
1183
- lines.push(` ${pluralCamel(other.name)} ${names(other.name).pascal}[]`);
1184
- }
1185
- }
1186
- }
1187
- return lines;
1188
- }
1189
- function injectPrismaRelations(content, relationLines, inverseLines) {
1190
- const all = [...relationLines, ...inverseLines];
1191
- if (all.length === 0) return { content, injected: false };
1192
- const out = content.replace(/\n\}\s*$/, `
1193
- ${all.join("\n")}
1194
- }
1195
- `);
1196
- return { content: out, injected: out !== content };
1197
- }
1198
- function externalRelationTargets(entities) {
1199
- const planModels = new Set(entities.map((e) => names(e.name).pascal));
1200
- const external = /* @__PURE__ */ new Set();
1201
- for (const e of entities) {
1202
- for (const r of e.relations ?? []) {
1203
- const target = names(r.model).pascal;
1204
- if (!planModels.has(target)) external.add(target);
1205
- }
1206
- }
1207
- return [...external];
1208
- }
1209
- function pluralCamel(name) {
1210
- return names(name).pluralKebab.split("-").map((part, i) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
1211
- }
1212
-
1213
- // src/make/openapi.ts
1214
- import { names as names2 } from "@basaltkit/generator";
1215
- function summaryFor(method, url, singular, plural) {
1216
- switch (method.toUpperCase()) {
1217
- case "GET":
1218
- return url.includes(":id") ? `Get a ${singular}` : `List ${plural}`;
1219
- case "POST":
1220
- return url.endsWith("/restore") ? `Restore a ${singular}` : `Create a ${singular}`;
1221
- case "PATCH":
1222
- case "PUT":
1223
- return `Update a ${singular}`;
1224
- case "DELETE":
1225
- return `Delete a ${singular}`;
1226
- default:
1227
- return `${singular} operation`;
1228
- }
1229
- }
1230
- function injectOpenApiMeta(content, entityName) {
1231
- const n = names2(entityName);
1232
- const singular = n.kebab.replace(/-/g, " ");
1233
- const plural = n.pluralKebab.replace(/-/g, " ");
1234
- const tag = n.pascal;
1235
- let injected = false;
1236
- const out = content.replace(
1237
- /route\(\{\s*\n(\s*)method: '(\w+)',\s*\n\s*url: '([^']*)',(?:\s*\n\s*meta: \{([^}]*)\},)?/g,
1238
- (_match, indent, method, url, metaInner) => {
1239
- const extra = `summary: '${summaryFor(method, url, singular, plural)}', tags: ['${tag}']`;
1240
- const meta = metaInner !== void 0 ? `{ ${metaInner.trim()}, ${extra} }` : `{ ${extra} }`;
1241
- injected = true;
1242
- return `route({
1243
- ${indent}method: '${method}',
1244
- ${indent}url: '${url}',
1245
- ${indent}meta: ${meta},`;
1246
- }
1247
- );
1248
- return { content: out, injected };
1249
- }
1250
-
1251
- // src/make/permissions.ts
1252
- import { names as names3 } from "@basaltkit/generator";
1253
- function permissionList(name) {
1254
- const prefix = names3(name).pluralKebab;
1255
- return ["view", "create", "update", "delete"].map((action) => `${prefix}.${action}`);
1256
- }
1257
- function renderPermissionsFile(name) {
1258
- const n = names3(name);
1259
- const listLines = permissionList(name).map((p) => ` '${p}',`).join("\n");
1260
- return `import { GLOBAL_SCOPE, type AccessStore } from '@basaltkit/permissions'
1261
-
1262
- /** RBAC permissions for the ${n.pascal} resource \u2014 the ones its routes guard on. */
1263
- export const ${n.constant}_PERMISSIONS = [
1264
- ${listLines}
1265
- ] as const
1266
-
1267
- /** Grant every ${n.pascal} permission to a role (default scope: global). */
1268
- export async function grant${n.pascal}Permissions(
1269
- store: AccessStore,
1270
- role: string,
1271
- scope: string = GLOBAL_SCOPE,
1272
- ): Promise<void> {
1273
- await store.grantToRole(role, [...${n.constant}_PERMISSIONS], scope)
1274
- }
1275
- `;
1276
- }
1277
-
1278
- // src/make/repository.ts
1279
- import { names as names4 } from "@basaltkit/generator";
1280
- function rowType(t) {
1281
- switch (t) {
1282
- case "Int":
1283
- case "Float":
1284
- return "number";
1285
- case "Boolean":
1286
- return "boolean";
1287
- case "DateTime":
1288
- return "Date";
1289
- case "Json":
1290
- return "unknown";
1291
- case "String":
1292
- return "string";
1293
- }
1294
- }
1295
- function mapExpr(field, t) {
1296
- return t === "DateTime" ? `r.${field}.toISOString()` : `r.${field}`;
1297
- }
1298
- function renderPrismaRepository(name, fields, options) {
1299
- const n = names4(name);
1300
- const { softDelete, tenantScoped, keepName } = options;
1301
- const bodyFields = [];
1302
- if (keepName) bodyFields.push({ name: "name", canon: "String" });
1303
- for (const f of domainFields(fields)) {
1304
- bodyFields.push({ name: f.name, canon: canonicalType(f.type), ...f.enum && f.enum.length > 0 ? { values: f.enum } : {} });
1305
- }
1306
- const colType = (f) => f.values ? "string" : rowType(f.canon);
1307
- const colExpr = (f) => f.values ? `r.${f.name} as ${f.values.map((v) => `'${v}'`).join(" | ")}` : mapExpr(f.name, f.canon);
1308
- const rowTypeEntries = [
1309
- "id: string",
1310
- ...bodyFields.map((f) => `${f.name}: ${colType(f)}`),
1311
- "createdAt: Date",
1312
- "updatedAt: Date",
1313
- ...softDelete ? ["deletedAt: Date | null"] : []
1314
- ].join("; ");
1315
- const mapLines = [
1316
- " id: r.id,",
1317
- ...bodyFields.map((f) => ` ${f.name}: ${colExpr(f)},`),
1318
- " createdAt: r.createdAt.toISOString(),",
1319
- " updatedAt: r.updatedAt.toISOString(),",
1320
- ...softDelete ? [" deletedAt: r.deletedAt ? r.deletedAt.toISOString() : null,"] : []
1321
- ].join("\n");
1322
- const tenant = (soft) => ["id", tenantScoped ? "tenantId: currentTenantId()" : "", soft && softDelete ? "deletedAt: null" : ""].filter(Boolean).join(", ");
1323
- const listWhere = [tenantScoped ? "tenantId: currentTenantId()" : "", softDelete ? "deletedAt: null" : ""].filter(Boolean).join(", ");
1324
- const createData = tenantScoped ? "{ ...input, tenantId: currentTenantId() }" : "input";
1325
- const updateMethod = tenantScoped ? ` async update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null> {
1326
- const res = await this.records.updateMany({ where: { ${tenant(true)} }, data: input })
1327
- return res.count > 0 ? this.find(id) : null
1328
- }` : ` async update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null> {
1329
- try {
1330
- return to${n.pascal}(await this.records.update({ where: { id }, data: input }))
1331
- } catch {
1332
- return null
1333
- }
1334
- }`;
1335
- const deleteMethod = renderDelete(tenantScoped, softDelete);
1336
- const restoreMethod = softDelete ? `
1337
-
1338
- ${renderRestore(tenantScoped)}` : "";
1339
- const interfaceRestore = softDelete ? `
1340
- restore(id: string): Promise<boolean>` : "";
1341
- const coreImport = tenantScoped ? "createToken, tryCtx" : "createToken";
1342
- const httpImport = tenantScoped ? `
1343
- import { HttpError } from '@basaltkit/fastify'` : "";
1344
- const tenantHelper = tenantScoped ? `
1345
- // Tenant-scoped model. Scoped by tenantId explicitly from the request context, so
1346
- // it works even on a raw client shared with non-tenant models (no global extension).
1347
- // A missing tenant is a client error (400) \u2014 never a silent 500.
1348
- const currentTenantId = (): string => {
1349
- const id = (tryCtx()?.['tenant'] as { id?: string } | undefined)?.id
1350
- if (!id) throw new HttpError(400, 'TENANT_REQUIRED', 'No tenant in context \u2014 send the x-tenant-id header (or resolve a tenant first).')
1351
- return id
1352
- }
1353
- ` : "";
1354
- return `import { ${coreImport} } from '@basaltkit/core'
1355
- import { db } from '@basaltkit/prisma'${httpImport}
1356
- import type { PrismaClient } from '@prisma/client'
1357
- import type { ${n.pascal}, Create${n.pascal}Input, Update${n.pascal}Input } from './${n.kebab}.schema.js'
1358
- ${tenantHelper}
1359
- // Map the Prisma row (Date columns) to the API type (ISO-string timestamps).
1360
- const to${n.pascal} = (r: { ${rowTypeEntries} }): ${n.pascal} => ({
1361
- ${mapLines}
1362
- })
1363
-
1364
- export interface ${n.pascal}Repository {
1365
- list(): Promise<${n.pascal}[]>
1366
- find(id: string): Promise<${n.pascal} | null>
1367
- create(input: Create${n.pascal}Input): Promise<${n.pascal}>
1368
- update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null>
1369
- delete(id: string): Promise<boolean>${interfaceRestore}
1370
- }
1371
-
1372
- export class Prisma${n.pascal}Repository implements ${n.pascal}Repository {
1373
- private get records() {
1374
- return db<PrismaClient>().${n.camel}
1375
- }
1376
-
1377
- async list(): Promise<${n.pascal}[]> {
1378
- return (await this.records.findMany(${listWhere ? `{ where: { ${listWhere} } }` : ""})).map(to${n.pascal})
1379
- }
1380
-
1381
- async find(id: string): Promise<${n.pascal} | null> {
1382
- const r = await this.records.findFirst({ where: { ${tenant(true)} } })
1383
- return r ? to${n.pascal}(r) : null
1384
- }
1385
-
1386
- async create(input: Create${n.pascal}Input): Promise<${n.pascal}> {
1387
- return to${n.pascal}(await this.records.create({ data: ${createData} }))
1388
- }
1389
-
1390
- ${updateMethod}
1391
-
1392
- ${deleteMethod}${restoreMethod}
1393
- }
1394
-
1395
- export const ${n.constant}_REPOSITORY = createToken<${n.pascal}Repository>('${n.kebab}.repository')
1396
- `;
1397
- }
1398
- function renderDelete(tenantScoped, softDelete) {
1399
- if (tenantScoped) {
1400
- const where = softDelete ? "id, tenantId: currentTenantId(), deletedAt: null" : "id, tenantId: currentTenantId()";
1401
- const op2 = softDelete ? `updateMany({ where: { ${where} }, data: { deletedAt: new Date() } })` : `deleteMany({ where: { ${where} } })`;
1402
- return ` async delete(id: string): Promise<boolean> {
1403
- const res = await this.records.${op2}
1404
- return res.count > 0
1405
- }`;
1406
- }
1407
- const op = softDelete ? `update({ where: { id }, data: { deletedAt: new Date() } })` : `delete({ where: { id } })`;
1408
- return ` async delete(id: string): Promise<boolean> {
1409
- try {
1410
- await this.records.${op}
1411
- return true
1412
- } catch {
1413
- return false
1414
- }
1415
- }`;
1416
- }
1417
- function renderRestore(tenantScoped) {
1418
- if (tenantScoped) {
1419
- return ` async restore(id: string): Promise<boolean> {
1420
- const res = await this.records.updateMany({ where: { id, tenantId: currentTenantId() }, data: { deletedAt: null } })
1421
- return res.count > 0
1422
- }`;
1423
- }
1424
- return ` async restore(id: string): Promise<boolean> {
1425
- try {
1426
- await this.records.update({ where: { id }, data: { deletedAt: null } })
1427
- return true
1428
- } catch {
1429
- return false
1430
- }
1431
- }`;
1432
- }
1433
-
1434
- // src/make/schema.ts
1435
- import { execFile } from "child_process";
1436
- import { readFile, writeFile as writeFile2 } from "fs/promises";
1437
- import { join as join3 } from "path";
1438
- import { promisify } from "util";
1439
- var run = promisify(execFile);
1440
- function extractModelBlock(snippet) {
1441
- const match = /model\s+(\w+)\s*\{[\s\S]*?\n\}/.exec(snippet);
1442
- if (!match || !match[1]) return null;
1443
- return { name: match[1], block: match[0] };
1444
- }
1445
- function mergeModelsIntoSchema(schema, blocks) {
1446
- let content = schema.replace(/\s*$/, "");
1447
- const merged = [];
1448
- const skipped = [];
1449
- for (const { name, block } of blocks) {
1450
- if (new RegExp(`\\bmodel\\s+${name}\\s*\\{`).test(content)) {
1451
- skipped.push(name);
1452
- continue;
1453
- }
1454
- content += `
1455
-
1456
- ${block.trim()}
1457
- `;
1458
- merged.push(name);
1459
- }
1460
- return { content: content.endsWith("\n") ? content : `${content}
1461
- `, merged, skipped };
1462
- }
1463
- async function readSchema(baseDir, schemaPath) {
1464
- try {
1465
- return await readFile(join3(baseDir, schemaPath), "utf8");
1466
- } catch {
1467
- return null;
1468
- }
1469
- }
1470
- async function writeSchema(baseDir, schemaPath, content) {
1471
- await writeFile2(join3(baseDir, schemaPath), content);
1472
- }
1473
- async function runPrismaPush(baseDir) {
1474
- try {
1475
- const { stdout } = await run("npx", ["prisma", "db", "push"], {
1476
- cwd: baseDir,
1477
- timeout: 18e4,
1478
- maxBuffer: 10 * 1024 * 1024
1479
- });
1480
- return { ok: true, output: stdout.slice(-2e3) };
1481
- } catch (error) {
1482
- const e = error;
1483
- const output = (e.stdout ?? "") + (e.stderr ?? "") || e.message || "unknown error";
1484
- return { ok: false, output: output.slice(-3e3) };
1485
- }
1486
- }
1487
-
1488
- // src/make/wire.ts
1489
- import { names as names5 } from "@basaltkit/generator";
1490
- function insertAfterLastImport(content, line) {
1491
- const imports = [...content.matchAll(/^import .*$/gm)];
1492
- const last = imports.at(-1);
1493
- if (!last || last.index === void 0) return `${line}
1494
- ${content}`;
1495
- const at = last.index + last[0].length;
1496
- return `${content.slice(0, at)}
1497
- ${line}${content.slice(at)}`;
1498
- }
1499
- function actionFor(method, url) {
1500
- switch (method.toUpperCase()) {
1501
- case "GET":
1502
- return "view";
1503
- case "POST":
1504
- return url.endsWith("/restore") ? "update" : "create";
1505
- case "PATCH":
1506
- case "PUT":
1507
- return "update";
1508
- case "DELETE":
1509
- return "delete";
1510
- default:
1511
- return null;
1512
- }
1513
- }
1514
- function injectPermissionGuards(content, prefix) {
1515
- if (!prefix) return { content, injected: false };
1516
- let injected = false;
1517
- const out = content.replace(
1518
- /route\(\{\s*\n(\s*)method: '(\w+)',\s*\n\s*url: '([^']*)',/g,
1519
- (match, indent, method, url) => {
1520
- const action = actionFor(method, url);
1521
- if (!action) return match;
1522
- injected = true;
1523
- return `route({
1524
- ${indent}method: '${method}',
1525
- ${indent}url: '${url}',
1526
- ${indent}meta: { can: '${prefix}.${action}' },`;
1527
- }
1528
- );
1529
- return { content: out, injected };
1530
- }
1531
- function eventFor(events, suffix) {
1532
- const match = events.find((e) => e.endsWith(`.${suffix}`));
1533
- if (match) return match;
1534
- const prefix = events[0]?.split(".")[0] ?? "record";
1535
- return `${prefix}.${suffix}`;
1536
- }
1537
- function injectAuditService(content, resourceName, events) {
1538
- const n = names5(resourceName);
1539
- const ctorAnchor = `constructor(private readonly repository: ${n.pascal}Repository) {}`;
1540
- if (!content.includes(ctorAnchor)) return { content, injected: false };
1541
- let out = insertAfterLastImport(content, `import type { Audit } from '@basaltkit/audit'`);
1542
- out = out.replace(
1543
- ctorAnchor,
1544
- `constructor(
1545
- private readonly repository: ${n.pascal}Repository,
1546
- private readonly audit: Audit,
1547
- ) {}`
1548
- );
1549
- out = out.replace(
1550
- ` create(input: Create${n.pascal}Input) {
1551
- return this.repository.create(input)
1552
- }`,
1553
- ` async create(input: Create${n.pascal}Input) {
1554
- const created = await this.repository.create(input)
1555
- await this.audit.record('${eventFor(events, "created")}', created)
1556
- return created
1557
- }`
1558
- );
1559
- out = out.replace(
1560
- ` update(id: string, input: Update${n.pascal}Input) {
1561
- return this.repository.update(id, input)
1562
- }`,
1563
- ` async update(id: string, input: Update${n.pascal}Input) {
1564
- const updated = await this.repository.update(id, input)
1565
- if (updated) await this.audit.record('${eventFor(events, "updated")}', updated)
1566
- return updated
1567
- }`
1568
- );
1569
- out = out.replace(
1570
- ` remove(id: string) {
1571
- return this.repository.delete(id)
1572
- }`,
1573
- ` async remove(id: string) {
1574
- const removed = await this.repository.delete(id)
1575
- if (removed) await this.audit.record('${eventFor(events, "deleted")}', { id })
1576
- return removed
1577
- }`
1578
- );
1579
- return { content: out, injected: true };
1580
- }
1581
- function injectAuditPlugin(content, resourceName) {
1582
- const n = names5(resourceName);
1583
- const anchor = `new ${n.pascal}Service(c.get(${n.constant}_REPOSITORY))`;
1584
- if (!content.includes(anchor)) return { content, injected: false };
1585
- let out = insertAfterLastImport(content, `import { AUDIT } from '@basaltkit/audit'`);
1586
- out = out.replace(anchor, `new ${n.pascal}Service(c.get(${n.constant}_REPOSITORY), c.get(AUDIT))`);
1587
- return { content: out, injected: true };
1588
- }
1589
-
1590
- // src/make/make.ts
1591
- async function runMake(ctx, plan, options = {}) {
1592
- const entities = plan.entities.filter((e) => e.name.trim() !== "");
1593
- if (entities.length === 0) {
1594
- throw new Error("ai:make \u2014 the plan has no entity to generate.");
1595
- }
1596
- const baseDir = options.baseDir ?? process.cwd();
1597
- const prismaDefault = ctx.stack.orm === "prisma";
1598
- const resources = [];
1599
- for (const entity of entities) {
1600
- const gen = findGeneratorCommand(plan, entity.name);
1601
- const prisma = options.prisma ?? gen?.prisma ?? prismaDefault;
1602
- const softDelete = options.softDelete ?? gen?.softDelete ?? false;
1603
- const generated = generateResource(entity.name, { prisma, softDelete });
1604
- const { files, augmented, guarded, audited } = augmentFiles(generated, entity, plan, ctx, { prisma, softDelete });
1605
- const build = {
1606
- name: entity.name,
1607
- prisma,
1608
- softDelete,
1609
- files,
1610
- augmented,
1611
- guarded,
1612
- audited,
1613
- written: [],
1614
- registered: false
1615
- };
1616
- if (!options.dryRun) {
1617
- try {
1618
- build.written = await writeGenerated(files, {
1619
- baseDir,
1620
- ...options.force ? { force: true } : {}
1621
- });
1622
- const reg = await registerResourceInApp(entity.name, { baseDir });
1623
- build.registered = reg.registered;
1624
- if (!reg.registered && reg.reason && reg.reason !== "already registered") {
1625
- build.note = `not auto-wired: ${reg.reason}`;
1626
- }
1627
- } catch (error) {
1628
- if (error instanceof FileExistsError) {
1629
- build.note = error.message.split("\n")[0] ?? error.message;
1630
- } else {
1631
- throw error;
1632
- }
1633
- }
1634
- }
1635
- resources.push(build);
1636
- }
1637
- const { schema, migration } = await mergeSchema(resources, baseDir, options);
1638
- const followUps = buildFollowUps(plan, resources, schema, migration);
1639
- const review = reviewBuild(ctx, plan, resources, schema, migration);
1640
- return {
1641
- request: plan.request,
1642
- dryRun: options.dryRun === true,
1643
- resources,
1644
- ...schema ? { schema } : {},
1645
- ...migration ? { migration } : {},
1646
- followUps,
1647
- review
1648
- };
1649
- }
1650
- async function mergeSchema(resources, baseDir, options) {
1651
- const prismaResources = resources.filter((r) => r.prisma);
1652
- if (prismaResources.length === 0) return { schema: void 0, migration: void 0 };
1653
- const schemaPath = options.schemaPath ?? "prisma/schema.prisma";
1654
- const blocks = prismaResources.map((r) => r.files.find((f) => f.path.endsWith(".prisma"))).map((f) => f ? extractModelBlock(f.content) : null).filter((b) => b !== null);
1655
- const existing = await readSchema(baseDir, schemaPath);
1656
- if (existing === null) {
1657
- return { schema: { path: schemaPath, found: false, merged: [], skipped: [], written: false }, migration: void 0 };
1658
- }
1659
- const outcome = mergeModelsIntoSchema(existing, blocks);
1660
- const schema = {
1661
- path: schemaPath,
1662
- found: true,
1663
- merged: outcome.merged,
1664
- skipped: outcome.skipped,
1665
- written: false
1666
- };
1667
- if (options.dryRun) return { schema, migration: void 0 };
1668
- if (outcome.merged.length > 0) {
1669
- await writeSchema(baseDir, schemaPath, outcome.content);
1670
- schema.written = true;
1671
- }
1672
- if (options.migrate && (schema.written || outcome.skipped.length > 0)) {
1673
- const push = await runPrismaPush(baseDir);
1674
- return { schema, migration: { ok: push.ok, output: push.output } };
1675
- }
1676
- return { schema, migration: void 0 };
1677
- }
1678
- function findGeneratorCommand(plan, entityName) {
1679
- for (const step of plan.steps) {
1680
- if (step.kind !== "generator" || !step.command) continue;
1681
- const parsed = parseGeneratorCommand(step.command);
1682
- if (parsed && parsed.name.toLowerCase() === entityName.toLowerCase()) return parsed;
1683
- }
1684
- return null;
1685
- }
1686
- function parseGeneratorCommand(command) {
1687
- const match = /make:resource\s+(\w+)([^\n]*)/.exec(command);
1688
- if (!match || !match[1]) return null;
1689
- const rest = match[2] ?? "";
1690
- return { name: match[1], prisma: /--prisma\b/.test(rest), softDelete: /--soft-delete\b/.test(rest) };
1691
- }
1692
- function augmentFiles(files, entity, plan, ctx, opts) {
1693
- const tenantScoped = ctx.stack.tenancy && entity.tenantScoped;
1694
- const effectiveFields = [...entity.fields, ...relationForeignKeys(entity)];
1695
- const removeName = domainFields(effectiveFields).length > 0 && !effectiveFields.some((f) => f.name.toLowerCase() === "name");
1696
- const keepName = !removeName;
1697
- const guardPrefix = names6(entity.name).pluralKebab;
1698
- const wireGuards = ctx.stack.rbac && plan.permissions.length > 0;
1699
- const wireAudit = ctx.stack.audit && plan.auditEvents.length > 0;
1700
- let augmented = false;
1701
- let guarded = false;
1702
- let audited = false;
1703
- const out = files.map((file) => {
1704
- if (file.path.endsWith(".prisma")) {
1705
- const withFields = injectPrismaFields(file.content, effectiveFields, tenantScoped, removeName);
1706
- const withRelations = injectPrismaRelations(
1707
- withFields.content,
1708
- relationFieldLines(entity),
1709
- inverseRelationLines(entity.name, plan.entities)
1710
- );
1711
- augmented = augmented || withFields.injected || withRelations.injected;
1712
- return { ...file, content: withRelations.content };
1713
- }
1714
- if (file.path.endsWith(".schema.ts")) {
1715
- const result = injectZodFields(file.content, effectiveFields, removeName);
1716
- augmented = augmented || result.injected;
1717
- return { ...file, content: result.content };
1718
- }
1719
- if (file.path.endsWith(".repository.ts") && opts.prisma) {
1720
- const content = renderPrismaRepository(entity.name, effectiveFields, {
1721
- softDelete: opts.softDelete,
1722
- tenantScoped,
1723
- keepName
1724
- });
1725
- return { ...file, content };
1726
- }
1727
- if (file.path.endsWith(".routes.ts")) {
1728
- let content = file.content;
1729
- if (wireGuards) {
1730
- const result = injectPermissionGuards(content, guardPrefix);
1731
- content = result.content;
1732
- guarded = guarded || result.injected;
1733
- }
1734
- content = injectOpenApiMeta(content, entity.name).content;
1735
- return { ...file, content };
1736
- }
1737
- if (file.path.endsWith(".service.ts") && wireAudit) {
1738
- const result = injectAuditService(file.content, entity.name, plan.auditEvents);
1739
- audited = audited || result.injected;
1740
- return { ...file, content: result.content };
1741
- }
1742
- if (file.path.endsWith(".plugin.ts") && wireAudit) {
1743
- const result = injectAuditPlugin(file.content, entity.name);
1744
- return { ...file, content: result.content };
1745
- }
1746
- return file;
1747
- });
1748
- if (guarded) {
1749
- const kebab2 = names6(entity.name).kebab;
1750
- out.push({ path: `src/modules/${kebab2}/${kebab2}.permissions.ts`, content: renderPermissionsFile(entity.name) });
1751
- }
1752
- return { files: out, augmented, guarded, audited };
1753
- }
1754
- function buildFollowUps(plan, resources, schema, migration) {
1755
- const followUps = [];
1756
- const prismaResources = resources.filter((r) => r.prisma);
1757
- if (prismaResources.length > 0) {
1758
- if (!schema?.found) {
1759
- const list = prismaResources.map((r) => `src/modules/${kebab(r.name)}/${kebab(r.name)}.prisma`).join(", ");
1760
- followUps.push(
1761
- `Add the generated model(s) (${list}) to prisma/schema.prisma, then run \`npx prisma db push\` and restart the server.`
1762
- );
1763
- } else if (migration) {
1764
- followUps.push(
1765
- migration.ok ? "Restart the server so it loads the regenerated Prisma client." : "`prisma db push` failed \u2014 check the output, fix it, and re-run `npx prisma db push`."
1766
- );
1767
- } else {
1768
- followUps.push(
1769
- "Run `npx prisma db push` (or re-run `ai:make --migrate`) to create the table(s) + regenerate the client, then restart the server."
1770
- );
1771
- }
1772
- }
1773
- const external = externalRelationTargets(plan.entities);
1774
- if (external.length > 0) {
1775
- followUps.push(
1776
- `Relations reference model(s) not in this plan (${external.join(", ")}). Add the inverse field (e.g. \`items <This>[]\`) to those existing models in schema.prisma so Prisma can validate the relation.`
1777
- );
1778
- }
1779
- const guardedResources = resources.filter((r) => r.guarded);
1780
- const audited = resources.some((r) => r.audited);
1781
- if (plan.permissions.length > 0 && guardedResources.length === 0) {
1782
- followUps.push(`Register RBAC permissions (${plan.permissions.join(", ")}) and guard the routes.`);
1783
- } else if (guardedResources.length > 0) {
1784
- const helpers = guardedResources.map((r) => `grant${names6(r.name).pascal}Permissions(store, 'admin')`).join(", ");
1785
- followUps.push(`Grant permissions to your roles during seed/setup \u2014 call ${helpers} (declared in each module's *.permissions.ts).`);
1786
- }
1787
- if (plan.auditEvents.length > 0 && !audited) {
1788
- followUps.push(`Emit audit events (${plan.auditEvents.join(", ")}) on create/update/delete.`);
1789
- }
1790
- return followUps;
1791
- }
1792
- function reviewBuild(ctx, plan, resources, schema, migration) {
1793
- const items = [];
1794
- if (ctx.stack.tenancy) {
1795
- const tenantEntities = plan.entities.filter((e) => e.tenantScoped);
1796
- if (tenantEntities.length === 0) {
1797
- items.push({ label: "Tenant isolation", status: "warn", detail: "no entity marked tenant-scoped \u2014 confirm that is intended" });
1798
- } else {
1799
- const missing = resources.filter(
1800
- (r) => plan.entities.find((e) => e.name === r.name)?.tenantScoped && !hasTenantId(r)
1801
- );
1802
- items.push(
1803
- missing.length === 0 ? { label: "Tenant isolation", status: "pass", detail: "tenantId present on tenant-scoped models" } : { label: "Tenant isolation", status: "fail", detail: `missing tenantId: ${missing.map((r) => r.name).join(", ")}` }
1804
- );
1805
- }
1806
- } else {
1807
- items.push({ label: "Tenant isolation", status: "pass", detail: "tenancy is off \u2014 not applicable" });
1808
- }
1809
- const hasRoutes = resources.every((r) => r.files.some((f) => f.path.endsWith(".routes.ts")));
1810
- const hasSchema = resources.every((r) => r.files.some((f) => f.path.endsWith(".schema.ts")));
1811
- items.push(
1812
- hasRoutes && hasSchema ? { label: "Validation & routes", status: "pass", detail: "Zod schema + typed routes generated" } : { label: "Validation & routes", status: "fail", detail: "schema or routes missing" }
1813
- );
1814
- const hasTests = resources.every((r) => r.files.some((f) => f.path.endsWith(".test.ts")));
1815
- items.push(
1816
- hasTests ? { label: "Tests", status: "pass", detail: "a test file was generated per resource" } : { label: "Tests", status: "fail", detail: "no test generated" }
1817
- );
1818
- if (plan.permissions.length > 0) {
1819
- const guarded = resources.some((r) => r.guarded);
1820
- items.push(
1821
- guarded ? { label: "Permissions", status: "pass", detail: "routes guarded + permissions declared in *.permissions.ts (grant via the helper)" } : {
1822
- label: "Permissions",
1823
- status: "warn",
1824
- detail: ctx.stack.rbac ? "planned but not auto-wired" : "RBAC (@basaltkit/permissions) not enabled \u2014 wire manually"
1825
- }
1826
- );
1827
- }
1828
- if (plan.auditEvents.length > 0) {
1829
- const audited = resources.some((r) => r.audited);
1830
- items.push(
1831
- audited ? { label: "Audit", status: "pass", detail: "AUDIT.record() wired into create/update/delete" } : {
1832
- label: "Audit",
1833
- status: "warn",
1834
- detail: ctx.stack.audit ? "planned but not auto-wired" : "audit (@basaltkit/audit) not enabled \u2014 wire manually"
1835
- }
1836
- );
1837
- }
1838
- if (resources.some((r) => r.prisma)) {
1839
- if (migration?.ok) {
1840
- items.push({ label: "Migration", status: "pass", detail: "model merged into schema.prisma + prisma db push ran" });
1841
- } else if (migration) {
1842
- items.push({ label: "Migration", status: "fail", detail: "prisma db push failed \u2014 see output" });
1843
- } else if (schema?.found) {
1844
- items.push({ label: "Migration", status: "warn", detail: "model merged into schema.prisma \u2014 run npx prisma db push" });
1845
- } else {
1846
- items.push({ label: "Migration", status: "warn", detail: "add the model to schema.prisma + run npx prisma db push" });
1847
- }
1848
- }
1849
- return { items, ok: !items.some((i) => i.status === "fail") };
1850
- }
1851
- function hasTenantId(resource) {
1852
- const model = resource.files.find((f) => f.path.endsWith(".prisma"));
1853
- if (!model) return false;
1854
- return /^[ \t]*tenantId[ \t]+/m.test(model.content);
1855
- }
1856
- function kebab(name) {
1857
- return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
1858
- }
1859
-
1860
- // src/make/render.ts
1861
- var REVIEW_MARK = { pass: "\u2713", warn: "\u26A0", fail: "\u2717" };
1862
- function renderMakeResult(result, io) {
1863
- for (const resource of result.resources) {
1864
- const verb = result.dryRun ? "Would generate" : "Generated";
1865
- const extras = [
1866
- resource.augmented ? "domain fields" : null,
1867
- resource.guarded ? "permission guards" : null,
1868
- resource.audited ? "audit" : null
1869
- ].filter((x) => x !== null);
1870
- const tag = extras.length > 0 ? ` (+ ${extras.join(", ")})` : "";
1871
- io.log(`${verb} ${resource.name}${tag}:`);
1872
- const paths = result.dryRun ? resource.files.map((f) => f.path) : resource.written;
1873
- for (const path of paths) io.log(` ${result.dryRun ? "~" : "+"} ${path}`);
1874
- if (!result.dryRun) {
1875
- io.log(resource.registered ? " \u2713 wired into src/app.ts" : " \u2022 src/app.ts not auto-wired");
1876
- }
1877
- if (resource.note) io.log(` \u26A0 ${resource.note}`);
1878
- io.log("");
1879
- }
1880
- if (result.schema) {
1881
- const s = result.schema;
1882
- if (!s.found) {
1883
- io.log(`Schema: ${s.path} not found \u2014 add the model(s) manually.`);
1884
- } else {
1885
- const parts = [];
1886
- if (s.merged.length > 0) parts.push(`${result.dryRun ? "would merge" : "merged"} ${s.merged.join(", ")}`);
1887
- if (s.skipped.length > 0) parts.push(`already present: ${s.skipped.join(", ")}`);
1888
- io.log(`Schema (${s.path}): ${parts.length > 0 ? parts.join("; ") : "no change"}`);
1889
- }
1890
- }
1891
- if (result.migration) {
1892
- io.log(result.migration.ok ? "Migration: \u2713 prisma db push" : "Migration: \u2717 prisma db push failed");
1893
- if (!result.migration.ok) {
1894
- for (const line of result.migration.output.split("\n").slice(-6)) io.log(` ${line}`);
1895
- }
1896
- }
1897
- if (result.schema || result.migration) io.log("");
1898
- io.log("Review:");
1899
- for (const item of result.review.items) {
1900
- io.log(` ${REVIEW_MARK[item.status]} ${item.label} \u2014 ${item.detail}`);
1901
- }
1902
- io.log("");
1903
- if (result.followUps.length > 0) {
1904
- io.log("Next steps (manual):");
1905
- for (const step of result.followUps) io.log(` - ${step}`);
1906
- io.log("");
1907
- }
1908
- if (result.dryRun) {
1909
- io.log("Dry run \u2014 nothing was written. Re-run without --dry-run to apply.");
1910
- } else if (result.review.ok) {
1911
- io.log("\u2713 Implementation completed.");
1912
- } else {
1913
- io.log("\u2717 Implementation completed with blocking review issues \u2014 see above.");
1914
- }
1915
- }
1916
-
1917
- // src/make/verify.ts
1918
- import { execFile as execFile2 } from "child_process";
1919
- import { promisify as promisify2 } from "util";
1920
- var run2 = promisify2(execFile2);
1921
- async function verifyProject(baseDir, command = "pnpm", args = ["-s", "typecheck"]) {
1922
- const label = `${command} ${args.join(" ")}`;
1923
- try {
1924
- await run2(command, args, { cwd: baseDir, timeout: 18e4, maxBuffer: 10 * 1024 * 1024 });
1925
- return { ok: true, command: label, output: "" };
1926
- } catch (error) {
1927
- const e = error;
1928
- const output = (e.stdout ?? "") + (e.stderr ?? "") || e.message || "unknown error";
1929
- return { ok: false, command: label, output: output.slice(-4e3) };
1930
- }
1931
- }
1932
-
1933
- // src/review/knowledge.ts
1934
- var REVIEW_KNOWLEDGE = `You are the Review agent for Basalt, a SaaS framework (Fastify + Prisma + Zod). Given a feature request, its plan, the deterministic review results and the generated code, decide whether the implementation is correct and on-convention. You do NOT rewrite code \u2014 you review it, precisely.
1935
-
1936
- SCOPE: \`ai:make\` generates a BACKEND resource vertical only \u2014 Prisma model, Zod schema, typed routes, service, repository, permissions and a test. It does NOT generate web/UI, React, frontend pages, background jobs, emails or infrastructure; other tools do. Review ONLY the backend vertical, on its own terms. If the request also asks for something outside this scope (a web page, a component, a job\u2026), mention it as a "warning" at most \u2014 NEVER an "error" \u2014 since \`ai:make\` is not the tool that produces it.
1937
-
1938
- Check these dimensions and only raise an issue you can point to in the code:
1939
-
1940
- - tenancy: a tenant-scoped model MUST carry a tenantId column, and every query in its repository MUST be scoped by tenantId (create stamps it; list/find/update/delete filter by it). Raise an ERROR if a tenant-scoped resource can leak or write across tenants.
1941
- - security: no hardcoded secrets; request bodies validated by Zod; no obvious injection.
1942
- - rbac: routes carry \`meta: { can: '<resource>.<action>' }\` guards, and the permissions are declared. ERROR if a mutating route is unguarded when RBAC is enabled.
1943
- - validation: every route has a Zod schema; the repository maps Prisma rows to the API type (no Date leaking as a Date, no raw row returned).
1944
- - audit: state changes record audit events when audit is enabled (warning if not).
1945
- - tests: a test file exists.
1946
- - fit: the generated backend matches the request's DATA MODEL \u2014 the right entities, fields, relations and tenancy. Do NOT raise an "error" for artifacts outside the backend vertical (web UI, frontend, jobs); those are out of scope.
1947
-
1948
- Return ONLY a single JSON object (no prose, no markdown fences):
1949
- {
1950
- "summary": string, // one or two sentences
1951
- "issues": [ { "dimension": string, "severity": "error" | "warning", "message": string } ]
1952
- }
1953
- Raise "error" only for a real, blocking convention violation you can cite; use "warning" for improvements. An empty issues array means the implementation is approved.`;
1954
-
1955
- // src/review/review.ts
1956
- var REVIEWABLE = /\.prisma$|\.(schema|routes|service|repository|permissions)\.ts$/;
1957
- function buildReviewContext(plan, result) {
1958
- const lines = [
1959
- `REQUEST: ${plan.request}`,
1960
- "",
1961
- "PLAN:",
1962
- ` summary: ${plan.summary}`,
1963
- ` entities: ${plan.entities.map((e) => `${e.name}${e.tenantScoped ? " (tenant-scoped)" : ""}`).join(", ")}`
1964
- ];
1965
- if (plan.permissions.length > 0) lines.push(` permissions: ${plan.permissions.join(", ")}`);
1966
- if (plan.auditEvents.length > 0) lines.push(` auditEvents: ${plan.auditEvents.join(", ")}`);
1967
- lines.push("", "DETERMINISTIC REVIEW:");
1968
- for (const item of result.review.items) lines.push(` [${item.status}] ${item.label}: ${item.detail}`);
1969
- lines.push("", "GENERATED CODE:");
1970
- for (const resource of result.resources) {
1971
- for (const file of resource.files) {
1972
- if (REVIEWABLE.test(file.path)) lines.push(`--- ${file.path} ---`, file.content);
1973
- }
1974
- }
1975
- return lines.join("\n");
1976
- }
1977
- async function reviewImplementation(provider, plan, result, options = {}) {
1978
- const raw = await provider.generate({
1979
- messages: [
1980
- { role: "system", content: REVIEW_KNOWLEDGE },
1981
- { role: "user", content: `${buildReviewContext(plan, result)}
1982
-
1983
- Return the verdict as a single JSON object.` }
1984
- ],
1985
- temperature: options.temperature ?? 0,
1986
- maxTokens: options.maxTokens ?? 2048
1987
- });
1988
- return parseReview(raw);
1989
- }
1990
- function parseReview(raw) {
1991
- const json = extractJson2(raw);
1992
- let obj;
1993
- try {
1994
- obj = JSON.parse(json);
1995
- } catch {
1996
- throw new Error(`ai review \u2014 the model did not return valid JSON. Got: ${raw.slice(0, 160)}\u2026`);
1997
- }
1998
- const issues = normalizeIssues(obj["issues"]);
1999
- return {
2000
- approved: !issues.some((i) => i.severity === "error"),
2001
- summary: typeof obj["summary"] === "string" ? obj["summary"] : "",
2002
- issues
2003
- };
2004
- }
2005
- function normalizeIssues(value) {
2006
- if (!Array.isArray(value)) return [];
2007
- return value.filter((i) => typeof i === "object" && i !== null).map((i) => ({
2008
- dimension: typeof i["dimension"] === "string" ? i["dimension"] : "general",
2009
- severity: i["severity"] === "error" ? "error" : "warning",
2010
- message: typeof i["message"] === "string" ? i["message"] : ""
2011
- })).filter((i) => i.message !== "");
2012
- }
2013
- function extractJson2(raw) {
2014
- const withoutFence = raw.replace(/```(?:json)?/gi, "").trim();
2015
- const start = withoutFence.indexOf("{");
2016
- const end = withoutFence.lastIndexOf("}");
2017
- if (start === -1 || end === -1 || end < start) return withoutFence;
2018
- return withoutFence.slice(start, end + 1);
2019
- }
2020
-
2021
- // src/review/render.ts
2022
- function renderAgentReview(review, io) {
2023
- io.log(review.approved ? "\u2713 Review agent: approved" : "\u2717 Review agent: changes requested");
2024
- if (review.summary) io.log(` ${review.summary}`);
2025
- if (review.issues.length > 0) {
2026
- io.log(" Issues:");
2027
- for (const issue of review.issues) {
2028
- io.log(` ${issue.severity === "error" ? "\u2717" : "\u26A0"} [${issue.dimension}] ${issue.message}`);
2029
- }
2030
- }
2031
- }
2032
-
2033
- // src/render.ts
2034
- var MARK = { error: "\u2717", warning: "\u26A0", info: "\u2139" };
2035
- function renderAnalysis(report, io) {
2036
- io.log("Analyzing project...");
2037
- io.log("");
2038
- if (report.capabilities.length === 0) {
2039
- io.log(" No Basalt capabilities detected \u2014 is this a Basalt app? (looked for src/app.ts)");
2040
- } else {
2041
- for (const line of report.capabilities) io.log(`\u2713 ${line}`);
2042
- }
2043
- if (report.models.length > 0) {
2044
- io.log("");
2045
- io.log(`Data model (${report.models.length} model${report.models.length === 1 ? "" : "s"}):`);
2046
- for (const name of report.models) {
2047
- const scoped = report.tenantScopedModels.includes(name);
2048
- io.log(` \u2022 ${name}${scoped ? " (tenant-scoped)" : ""}`);
2049
- }
2050
- }
2051
- io.log("");
2052
- if (report.diagnostics.length === 0) {
2053
- io.log("\u2713 No issues found.");
2054
- } else {
2055
- const errors = report.diagnostics.filter((d) => d.severity === "error").length;
2056
- const warnings = report.diagnostics.filter((d) => d.severity === "warning").length;
2057
- const infos = report.diagnostics.filter((d) => d.severity === "info").length;
2058
- io.log(`Diagnostics: ${errors} error(s), ${warnings} warning(s), ${infos} info.`);
2059
- io.log("Run `basalt ai:doctor` for details and fixes.");
2060
- }
2061
- }
2062
- function renderDoctor(diagnostics, io) {
2063
- io.log("AI DIAGNOSTICS");
2064
- io.log("");
2065
- if (diagnostics.length === 0) {
2066
- io.log("\u2713 Everything looks healthy.");
2067
- return;
2068
- }
2069
- for (const d of diagnostics) {
2070
- io.log(`${MARK[d.severity]} ${d.title} [${d.category}]`);
2071
- io.log("");
2072
- io.log(" Detected:");
2073
- io.log(` ${d.detected}`);
2074
- io.log("");
2075
- io.log(" Recommended:");
2076
- io.log(` ${d.recommended}`);
2077
- io.log("");
2078
- io.log(" Reason:");
2079
- io.log(` ${d.reason}`);
2080
- if (d.fix) {
2081
- io.log("");
2082
- io.log(" Fix:");
2083
- for (const line of d.fix.split("\n")) io.log(` ${line}`);
2084
- }
2085
- if (d.docs) {
2086
- io.log("");
2087
- io.log(` Docs: https://basaltkit.dev${d.docs}`);
2088
- }
2089
- io.log("");
2090
- io.log(" " + "\u2500".repeat(60));
2091
- io.log("");
2092
- }
2093
- const errors = diagnostics.filter((d) => d.severity === "error").length;
2094
- const warnings = diagnostics.filter((d) => d.severity === "warning").length;
2095
- io.log(
2096
- `${diagnostics.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${diagnostics.length - errors - warnings} info.`
2097
- );
2098
- }
2099
-
2100
- // src/commands.ts
2101
- import { defineCommand } from "@basaltkit/cli";
2102
- function aiCommands(options = {}) {
2103
- const rootOf = (flags) => typeof flags["dir"] === "string" ? flags["dir"] : options.cwd ?? process.cwd();
2104
- return [
2105
- defineCommand({
2106
- name: "ai",
2107
- description: "AI-native DX overview: detected stack and available commands",
2108
- handle({ args, flags, io }) {
2109
- const ctx = detectProject(rootOf(flags));
2110
- const report = analyze(ctx);
2111
- renderAnalysis(report, io);
2112
- io.log("");
2113
- if (args.length > 0) {
2114
- io.log(`You asked: "${args.join(" ")}"`);
2115
- io.log("Natural-language planning & scaffolding (ai:plan / ai:make) land in the next phase.");
2116
- io.log("");
2117
- }
2118
- io.log("Available now:");
2119
- io.log(" basalt ai:analyze Static analysis report");
2120
- io.log(" basalt ai:doctor Diagnostics with suggested fixes");
2121
- io.log(" basalt ai:fix [id] Apply an auto-fixable diagnostic");
2122
- io.log(' basalt ai:plan "<request>" Architecture plan from a description');
2123
- io.log(' basalt ai:make "<request>" Plan + implement (scaffold + review gate)');
2124
- return 0;
2125
- }
2126
- }),
2127
- defineCommand({
2128
- name: "ai:analyze",
2129
- description: "Analyze the project: stack, data model and diagnostics (read-only)",
2130
- handle({ flags, io }) {
2131
- const ctx = detectProject(rootOf(flags));
2132
- renderAnalysis(analyze(ctx), io);
2133
- return 0;
2134
- }
2135
- }),
2136
- defineCommand({
2137
- name: "ai:doctor",
2138
- description: "Diagnose configuration, security and tenancy issues (read-only)",
2139
- handle({ flags, io }) {
2140
- const ctx = detectProject(rootOf(flags));
2141
- const diagnostics = runDoctor(ctx);
2142
- renderDoctor(diagnostics, io);
2143
- return hasErrors(diagnostics) ? 1 : 0;
2144
- }
2145
- }),
2146
- defineCommand({
2147
- name: "ai:fix",
2148
- description: "Apply a doctor fix (or all auto-fixable issues). --dry-run to preview, --yes to skip confirm",
2149
- async handle({ args, flags, io }) {
2150
- const root = rootOf(flags);
2151
- const ctx = detectProject(root);
2152
- const read = (rel) => nodeReader(root).read(rel);
2153
- const id = args[0];
2154
- let outcomes;
2155
- if (id) {
2156
- outcomes = [planFix(id, ctx, read)];
2157
- } else {
2158
- const firing = new Set(runDoctor(ctx).map((d) => d.id));
2159
- const targets = fixableIds().filter((f) => firing.has(f));
2160
- if (targets.length === 0) {
2161
- io.log("Nothing to auto-fix. Run `basalt ai:doctor` for the full picture.");
2162
- return 0;
2163
- }
2164
- outcomes = targets.map((f) => planFix(f, ctx, read));
2165
- }
2166
- renderFixes(outcomes, io);
2167
- const ready = outcomes.filter((o) => o.status === "ready");
2168
- if (ready.length === 0) {
2169
- return id && outcomes[0]?.status === "unfixable" ? 1 : 0;
2170
- }
2171
- if (flags["dry-run"] === true) {
2172
- io.log("");
2173
- io.log("Dry run \u2014 nothing written.");
2174
- return 0;
2175
- }
2176
- if (flags["yes"] !== true) {
2177
- const ok = await io.confirm(`Apply ${ready.length} fix(es)?`);
2178
- if (!ok) {
2179
- io.log("Aborted \u2014 nothing changed.");
2180
- return 0;
2181
- }
2182
- }
2183
- for (const outcome of ready) await applyFixEdits(outcome.edits, root);
2184
- io.log("");
2185
- io.log(`\u2713 Applied ${ready.length} fix(es). Review the changes and restart the server.`);
2186
- return 0;
2187
- }
2188
- }),
2189
- defineCommand({
2190
- name: "ai:plan",
2191
- description: "Turn a natural-language request into an architecture plan (read-only)",
2192
- async handle({ args, flags, io }) {
2193
- const request = args.join(" ").trim();
2194
- if (!request) {
2195
- io.error('Usage: basalt ai:plan "<what you want to build>"');
2196
- return 1;
2197
- }
2198
- let provider;
2199
- try {
2200
- provider = createProvider(providerEnvFromProcess());
2201
- } catch (error) {
2202
- io.error(`ai:plan needs an AI provider \u2014 ${error.message}`);
2203
- io.error("Set AI_API_KEY (AI_PROVIDER=anthropic) or run Ollama locally (AI_PROVIDER=ollama).");
2204
- return 1;
2205
- }
2206
- const ctx = detectProject(rootOf(flags));
2207
- io.log("Planning...");
2208
- io.log("");
2209
- try {
2210
- const plan = await createPlan(provider, ctx, request);
2211
- renderPlan(plan, io);
2212
- return 0;
2213
- } catch (error) {
2214
- io.error(error.message);
2215
- return 1;
2216
- }
2217
- }
2218
- }),
2219
- defineCommand({
2220
- name: "ai:make",
2221
- description: "Plan then implement a feature: scaffold + domain fields, with a review gate",
2222
- async handle({ args, flags, io }) {
2223
- const request = args.join(" ").trim();
2224
- if (!request) {
2225
- io.error('Usage: basalt ai:make "<what to build>" [--dry-run] [--yes] [--force] [--migrate|--no-migrate] [--review] [--verify]');
2226
- return 1;
2227
- }
2228
- let provider;
2229
- try {
2230
- provider = createProvider(providerEnvFromProcess());
2231
- } catch (error) {
2232
- io.error(`ai:make needs an AI provider \u2014 ${error.message}`);
2233
- io.error("Set AI_API_KEY (AI_PROVIDER=anthropic) or run Ollama locally (AI_PROVIDER=ollama).");
2234
- return 1;
2235
- }
2236
- const baseDir = rootOf(flags);
2237
- const ctx = detectProject(baseDir);
2238
- io.log("Planning...");
2239
- io.log("");
2240
- let plan;
2241
- try {
2242
- plan = await createPlan(provider, ctx, request);
2243
- } catch (error) {
2244
- io.error(error.message);
2245
- return 1;
2246
- }
2247
- renderPlan(plan, io);
2248
- io.log("");
2249
- const agentReview = async (result2) => {
2250
- if (flags["review"] !== true) return true;
2251
- io.log("");
2252
- io.log("Reviewing (agent)...");
2253
- io.log("");
2254
- try {
2255
- const verdict = await reviewImplementation(provider, plan, result2);
2256
- renderAgentReview(verdict, io);
2257
- return verdict.approved;
2258
- } catch (error) {
2259
- io.error(`Review inconclusive \u2014 ${error.message}`);
2260
- return true;
2261
- }
2262
- };
2263
- const dryRun = flags["dry-run"] === true;
2264
- if (dryRun) {
2265
- const result2 = await runMake(ctx, plan, { dryRun: true, baseDir });
2266
- renderMakeResult(result2, io);
2267
- return await agentReview(result2) ? 0 : 1;
2268
- }
2269
- if (flags["yes"] !== true) {
2270
- const ok = await io.confirm("Implement this plan? Files will be created and src/app.ts wired.");
2271
- if (!ok) {
2272
- io.log("Aborted \u2014 nothing was changed.");
2273
- return 0;
2274
- }
2275
- }
2276
- io.log("");
2277
- io.log("Implementing...");
2278
- io.log("");
2279
- let result;
2280
- try {
2281
- result = await runMake(ctx, plan, {
2282
- baseDir,
2283
- force: flags["force"] === true,
2284
- // --migrate, or --yes (pre-consented), runs prisma db push inside runMake.
2285
- migrate: flags["migrate"] === true || flags["yes"] === true
2286
- });
2287
- } catch (error) {
2288
- io.error(error.message);
2289
- return 1;
2290
- }
2291
- renderMakeResult(result, io);
2292
- if (!result.migration && result.schema?.found && flags["no-migrate"] !== true && result.resources.some((r) => r.prisma)) {
2293
- io.log("");
2294
- const ok = await io.confirm(
2295
- "Run `prisma db push` now to create the table(s) + regenerate the Prisma client? (the routes 500 until you do)"
2296
- );
2297
- if (ok) {
2298
- io.log("Running prisma db push...");
2299
- const push = await runPrismaPush(baseDir);
2300
- io.log(
2301
- push.ok ? "\u2713 prisma db push done \u2014 restart the dev server to load the regenerated client." : `\u2717 prisma db push failed:
2302
- ${push.output}`
2303
- );
2304
- if (!push.ok) return 1;
2305
- } else {
2306
- io.log("Skipped \u2014 run `npx prisma db push` and restart the server before using the routes.");
2307
- }
2308
- }
2309
- const approved = await agentReview(result);
2310
- if (flags["verify"] === true) {
2311
- io.log("");
2312
- io.log("Verifying (typecheck)...");
2313
- const verdict = await verifyProject(baseDir);
2314
- if (verdict.ok) {
2315
- io.log(`\u2713 ${verdict.command} passed`);
2316
- } else {
2317
- io.error(`\u2717 ${verdict.command} failed:`);
2318
- io.error(verdict.output);
2319
- return 1;
2320
- }
2321
- }
2322
- return result.review.ok && approved ? 0 : 1;
2323
- }
2324
- })
2325
- ];
2326
- }
2327
- export {
2328
- AnthropicProvider,
2329
- BASALT_KNOWLEDGE,
2330
- DEFAULT_RULES,
2331
- FIXES,
2332
- OllamaProvider,
2333
- OpenAICompatibleProvider,
2334
- REVIEW_KNOWLEDGE,
2335
- aiCommands,
2336
- analyze,
2337
- applyFixEdits,
2338
- buildPlanContext,
2339
- buildReviewContext,
2340
- canonicalType,
2341
- createPlan,
2342
- createProvider,
2343
- defineRule,
2344
- detectProject,
2345
- domainFields,
2346
- externalRelationTargets,
2347
- extractModelBlock,
2348
- fetchWithRetry,
2349
- fixableIds,
2350
- foreignKey,
2351
- globalSseFetch,
2352
- hasErrors,
2353
- injectAuditPlugin,
2354
- injectAuditService,
2355
- injectOpenApiMeta,
2356
- injectPermissionGuards,
2357
- injectPrismaFields,
2358
- injectPrismaRelations,
2359
- injectZodFields,
2360
- inverseRelationLines,
2361
- memoryReader,
2362
- mergeModelsIntoSchema,
2363
- nodeReader,
2364
- parsePlan,
2365
- parseReview,
2366
- parseSseContent,
2367
- permissionList,
2368
- planFix,
2369
- prismaType,
2370
- providerEnvFromProcess,
2371
- relationFieldLines,
2372
- relationForeignKeys,
2373
- renderAgentReview,
2374
- renderAnalysis,
2375
- renderDoctor,
2376
- renderFixes,
2377
- renderMakeResult,
2378
- renderPermissionsFile,
2379
- renderPlan,
2380
- renderPrismaRepository,
2381
- reviewImplementation,
2382
- runDoctor,
2383
- runMake,
2384
- runPrismaPush,
2385
- singleChunkStream,
2386
- verifyProject,
2387
- zodForField,
2388
- zodValidator
2389
- };
1
+ // Provider layer — the vendor-agnostic AI surface (spec §17).
2
+ export { singleChunkStream, } from './provider/types.js';
3
+ export { AnthropicProvider } from './provider/anthropic.js';
4
+ export { OllamaProvider } from './provider/ollama.js';
5
+ export { OpenAICompatibleProvider, } from './provider/openai.js';
6
+ export { fetchWithRetry } from './provider/http.js';
7
+ export { globalSseFetch, parseSseContent } from './provider/sse.js';
8
+ export { createProvider, providerEnvFromProcess, } from './provider/factory.js';
9
+ // Project context — stack/schema/config detection (spec §18, Context Engineering).
10
+ export { detectProject, nodeReader, memoryReader, } from './context/project.js';
11
+ // Diagnostics engine (spec §6, Doctor).
12
+ export { defineRule, } from './doctor/types.js';
13
+ export { DEFAULT_RULES } from './doctor/rules.js';
14
+ export { hasErrors, runDoctor } from './doctor/run.js';
15
+ export { applyFixEdits, fixableIds, planFix, renderFixes, FIXES, } from './doctor/fixes.js';
16
+ // Analysis report (spec §5, Analyze).
17
+ export { analyze } from './analyze/run.js';
18
+ // Planning (spec §3, Plan mode) — natural language → architecture plan.
19
+ export { createPlan, parsePlan } from './plan/plan.js';
20
+ export { buildPlanContext } from './plan/context.js';
21
+ export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
22
+ export { renderPlan } from './plan/render.js';
23
+ // Implementation (spec §4 Implement) — execute a plan via the generator + review gate.
24
+ export { runMake } from './make/make.js';
25
+ export { renderMakeResult } from './make/render.js';
26
+ export { verifyProject } from './make/verify.js';
27
+ export { canonicalType, domainFields, injectPrismaFields, injectZodFields, prismaType, zodForField, zodValidator, } from './make/fields.js';
28
+ export { renderPrismaRepository } from './make/repository.js';
29
+ export { permissionList, renderPermissionsFile } from './make/permissions.js';
30
+ export { injectOpenApiMeta } from './make/openapi.js';
31
+ export { externalRelationTargets, foreignKey, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './make/relations.js';
32
+ export { injectAuditPlugin, injectAuditService, injectPermissionGuards } from './make/wire.js';
33
+ export { extractModelBlock, mergeModelsIntoSchema, runPrismaPush, } from './make/schema.js';
34
+ // Review agent (spec §19/§20) — LLM critique of the generated code.
35
+ export { buildReviewContext, parseReview, reviewImplementation } from './review/review.js';
36
+ export { REVIEW_KNOWLEDGE } from './review/knowledge.js';
37
+ export { renderAgentReview } from './review/render.js';
38
+ // Rendering + CLI wiring.
39
+ export { renderAnalysis, renderDoctor } from './render.js';
40
+ export { aiCommands } from './commands.js';