@basaltkit/ai 0.10.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/analyze/run.d.ts +19 -0
- package/dist/analyze/run.js +58 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +272 -0
- package/dist/context/project.d.ts +78 -0
- package/dist/context/project.js +202 -0
- package/dist/doctor/fixes.d.ts +31 -0
- package/dist/doctor/fixes.js +82 -0
- package/dist/doctor/rules.d.ts +7 -0
- package/dist/doctor/rules.js +230 -0
- package/dist/doctor/run.d.ts +9 -0
- package/dist/doctor/run.js +16 -0
- package/dist/doctor/types.d.ts +27 -0
- package/dist/doctor/types.js +3 -0
- package/dist/index.d.ts +35 -791
- package/dist/index.js +40 -2355
- package/dist/make/fields.d.ts +35 -0
- package/dist/make/fields.js +139 -0
- package/dist/make/make.d.ts +14 -0
- package/dist/make/make.js +316 -0
- package/dist/make/openapi.d.ts +9 -0
- package/dist/make/openapi.js +36 -0
- package/dist/make/permissions.d.ts +13 -0
- package/dist/make/permissions.js +38 -0
- package/dist/make/relations.d.ts +23 -0
- package/dist/make/relations.js +62 -0
- package/dist/make/render.d.ts +4 -0
- package/dist/make/render.js +66 -0
- package/dist/make/repository.d.ts +16 -0
- package/dist/make/repository.js +176 -0
- package/dist/make/schema.d.ts +22 -0
- package/dist/make/schema.js +54 -0
- package/dist/make/types.d.ts +77 -0
- package/dist/make/types.js +1 -0
- package/dist/make/verify.d.ts +12 -0
- package/dist/make/verify.js +19 -0
- package/dist/make/wire.d.ts +23 -0
- package/dist/make/wire.js +76 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +39 -0
- package/dist/plan/knowledge.d.ts +9 -0
- package/dist/plan/knowledge.js +50 -0
- package/dist/plan/plan.d.ts +15 -0
- package/dist/plan/plan.js +146 -0
- package/dist/plan/render.d.ts +4 -0
- package/dist/plan/render.js +55 -0
- package/dist/plan/types.d.ts +45 -0
- package/dist/plan/types.js +1 -0
- package/dist/provider/anthropic.d.ts +26 -0
- package/dist/provider/anthropic.js +72 -0
- package/dist/provider/factory.d.ts +27 -0
- package/dist/provider/factory.js +58 -0
- package/dist/provider/http.d.ts +24 -0
- package/dist/provider/http.js +36 -0
- package/dist/provider/ollama.d.ts +22 -0
- package/dist/provider/ollama.js +42 -0
- package/dist/provider/openai.d.ts +48 -0
- package/dist/provider/openai.js +94 -0
- package/dist/provider/sse.d.ts +28 -0
- package/dist/provider/sse.js +57 -0
- package/dist/provider/types.d.ts +49 -0
- package/dist/provider/types.js +9 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +70 -0
- package/dist/review/knowledge.d.ts +6 -0
- package/dist/review/knowledge.js +25 -0
- package/dist/review/render.d.ts +4 -0
- package/dist/review/render.js +12 -0
- package/dist/review/review.d.ts +17 -0
- package/dist/review/review.js +80 -0
- package/dist/review/types.d.ts +12 -0
- package/dist/review/types.js +1 -0
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@ AI-native developer experience for Basalt. A provider-agnostic engine plus a
|
|
|
5
5
|
diagnoses issues — following the framework's own conventions instead of inventing
|
|
6
6
|
new ones.
|
|
7
7
|
|
|
8
|
-
> **Status: 0
|
|
9
|
-
>
|
|
10
|
-
>
|
|
8
|
+
> **Status: 1.0 (stable).** The provider abstraction and the full `basalt ai`
|
|
9
|
+
> command surface — `ai:analyze`, `ai:doctor`, `ai:plan`, `ai:make`, `ai:review` —
|
|
10
|
+
> are settled and covered by semver: breaking changes land only in a new major.
|
|
11
|
+
> The AI layer stays **dev-only** — it is never a runtime dependency of your app.
|
|
11
12
|
|
|
12
13
|
## Install
|
|
13
14
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
import type { Diagnostic } from '../doctor/types.js';
|
|
3
|
+
export interface AnalysisReport {
|
|
4
|
+
root: string;
|
|
5
|
+
/** Human-readable capability lines, e.g. `'Fastify detected'`. */
|
|
6
|
+
capabilities: string[];
|
|
7
|
+
installed: string[];
|
|
8
|
+
database: string | null;
|
|
9
|
+
models: string[];
|
|
10
|
+
tenantScopedModels: string[];
|
|
11
|
+
unscopedModels: string[];
|
|
12
|
+
/** Diagnostics from the doctor rules, summarised into the report. */
|
|
13
|
+
diagnostics: Diagnostic[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Static, offline analysis of a project: what's wired, the data model, and the
|
|
17
|
+
* doctor's findings — the data behind the `ai:analyze` report.
|
|
18
|
+
*/
|
|
19
|
+
export declare function analyze(ctx: ProjectContext): AnalysisReport;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { runDoctor } from '../doctor/run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Static, offline analysis of a project: what's wired, the data model, and the
|
|
4
|
+
* doctor's findings — the data behind the `ai:analyze` report.
|
|
5
|
+
*/
|
|
6
|
+
export function analyze(ctx) {
|
|
7
|
+
const { stack, prisma } = ctx;
|
|
8
|
+
const capabilities = [];
|
|
9
|
+
if (stack.http === 'fastify')
|
|
10
|
+
capabilities.push('Fastify detected');
|
|
11
|
+
if (stack.orm === 'prisma')
|
|
12
|
+
capabilities.push('Prisma detected');
|
|
13
|
+
if (stack.database)
|
|
14
|
+
capabilities.push(`${databaseLabel(stack.database)} detected`);
|
|
15
|
+
if (stack.tenancy)
|
|
16
|
+
capabilities.push('Tenancy enabled');
|
|
17
|
+
if (stack.auth)
|
|
18
|
+
capabilities.push('Authentication enabled');
|
|
19
|
+
if (stack.rbac)
|
|
20
|
+
capabilities.push('RBAC enabled');
|
|
21
|
+
if (stack.subscriptions)
|
|
22
|
+
capabilities.push('Subscriptions enabled');
|
|
23
|
+
if (stack.payments)
|
|
24
|
+
capabilities.push('Payments enabled');
|
|
25
|
+
if (stack.queue)
|
|
26
|
+
capabilities.push('Queue enabled');
|
|
27
|
+
if (stack.search)
|
|
28
|
+
capabilities.push('Search enabled');
|
|
29
|
+
if (stack.audit)
|
|
30
|
+
capabilities.push('Audit enabled');
|
|
31
|
+
if (stack.events)
|
|
32
|
+
capabilities.push('Events enabled');
|
|
33
|
+
if (stack.scheduler)
|
|
34
|
+
capabilities.push('Scheduler enabled');
|
|
35
|
+
if (stack.storage)
|
|
36
|
+
capabilities.push('Storage enabled');
|
|
37
|
+
const models = prisma?.models ?? [];
|
|
38
|
+
return {
|
|
39
|
+
root: ctx.root,
|
|
40
|
+
capabilities,
|
|
41
|
+
installed: ctx.installed,
|
|
42
|
+
database: stack.database,
|
|
43
|
+
models: models.map((m) => m.name),
|
|
44
|
+
tenantScopedModels: models.filter((m) => m.tenantScoped).map((m) => m.name),
|
|
45
|
+
unscopedModels: models.filter((m) => !m.tenantScoped).map((m) => m.name),
|
|
46
|
+
diagnostics: runDoctor(ctx),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function databaseLabel(db) {
|
|
50
|
+
switch (db) {
|
|
51
|
+
case 'postgresql':
|
|
52
|
+
return 'PostgreSQL';
|
|
53
|
+
case 'mysql':
|
|
54
|
+
return 'MySQL';
|
|
55
|
+
case 'sqlite':
|
|
56
|
+
return 'SQLite';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CommandDefinition } from '@basaltkit/cli';
|
|
2
|
+
export interface AiCommandsOptions {
|
|
3
|
+
/** Project root to analyze. Defaults to the current working directory. */
|
|
4
|
+
cwd?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* CLI commands for the AI-native DX (foundation phase):
|
|
8
|
+
*
|
|
9
|
+
* - `basalt ai` — overview: detected stack + what the agent can do
|
|
10
|
+
* - `basalt ai:analyze` — static analysis report (read-only, offline)
|
|
11
|
+
* - `basalt ai:doctor` — diagnostics with fixes (read-only, offline; exits 1 on errors)
|
|
12
|
+
* - `basalt ai:fix` — apply a doctor fix, or all auto-fixable ones (offline; --dry-run/--yes)
|
|
13
|
+
* - `basalt ai:plan` — natural language → architecture plan (read-only; needs a provider)
|
|
14
|
+
* - `basalt ai:make` — plan then implement: scaffold + domain fields, review gate (writes; needs a provider)
|
|
15
|
+
*
|
|
16
|
+
* Wire them in like the generator commands:
|
|
17
|
+
* `commandsPlugin([...generatorCommands(), ...aiCommands()])`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function aiCommands(options?: AiCommandsOptions): CommandDefinition[];
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { defineCommand } from '@basaltkit/cli';
|
|
2
|
+
import { analyze } from './analyze/run.js';
|
|
3
|
+
import { detectProject, nodeReader } from './context/project.js';
|
|
4
|
+
import { hasErrors, runDoctor } from './doctor/run.js';
|
|
5
|
+
import { applyFixEdits, fixableIds, planFix, renderFixes } from './doctor/fixes.js';
|
|
6
|
+
import { createProvider, providerEnvFromProcess } from './provider/factory.js';
|
|
7
|
+
import { createPlan } from './plan/plan.js';
|
|
8
|
+
import { renderPlan } from './plan/render.js';
|
|
9
|
+
import { runMake } from './make/make.js';
|
|
10
|
+
import { renderMakeResult } from './make/render.js';
|
|
11
|
+
import { runPrismaPush } from './make/schema.js';
|
|
12
|
+
import { verifyProject } from './make/verify.js';
|
|
13
|
+
import { reviewImplementation } from './review/review.js';
|
|
14
|
+
import { renderAgentReview } from './review/render.js';
|
|
15
|
+
import { renderAnalysis, renderDoctor } from './render.js';
|
|
16
|
+
/**
|
|
17
|
+
* CLI commands for the AI-native DX (foundation phase):
|
|
18
|
+
*
|
|
19
|
+
* - `basalt ai` — overview: detected stack + what the agent can do
|
|
20
|
+
* - `basalt ai:analyze` — static analysis report (read-only, offline)
|
|
21
|
+
* - `basalt ai:doctor` — diagnostics with fixes (read-only, offline; exits 1 on errors)
|
|
22
|
+
* - `basalt ai:fix` — apply a doctor fix, or all auto-fixable ones (offline; --dry-run/--yes)
|
|
23
|
+
* - `basalt ai:plan` — natural language → architecture plan (read-only; needs a provider)
|
|
24
|
+
* - `basalt ai:make` — plan then implement: scaffold + domain fields, review gate (writes; needs a provider)
|
|
25
|
+
*
|
|
26
|
+
* Wire them in like the generator commands:
|
|
27
|
+
* `commandsPlugin([...generatorCommands(), ...aiCommands()])`.
|
|
28
|
+
*/
|
|
29
|
+
export function aiCommands(options = {}) {
|
|
30
|
+
const rootOf = (flags) => typeof flags['dir'] === 'string' ? flags['dir'] : options.cwd ?? process.cwd();
|
|
31
|
+
return [
|
|
32
|
+
defineCommand({
|
|
33
|
+
name: 'ai',
|
|
34
|
+
description: 'AI-native DX overview: detected stack and available commands',
|
|
35
|
+
handle({ args, flags, io }) {
|
|
36
|
+
const ctx = detectProject(rootOf(flags));
|
|
37
|
+
const report = analyze(ctx);
|
|
38
|
+
renderAnalysis(report, io);
|
|
39
|
+
io.log('');
|
|
40
|
+
if (args.length > 0) {
|
|
41
|
+
io.log(`You asked: "${args.join(' ')}"`);
|
|
42
|
+
io.log('Natural-language planning & scaffolding (ai:plan / ai:make) land in the next phase.');
|
|
43
|
+
io.log('');
|
|
44
|
+
}
|
|
45
|
+
io.log('Available now:');
|
|
46
|
+
io.log(' basalt ai:analyze Static analysis report');
|
|
47
|
+
io.log(' basalt ai:doctor Diagnostics with suggested fixes');
|
|
48
|
+
io.log(' basalt ai:fix [id] Apply an auto-fixable diagnostic');
|
|
49
|
+
io.log(' basalt ai:plan "<request>" Architecture plan from a description');
|
|
50
|
+
io.log(' basalt ai:make "<request>" Plan + implement (scaffold + review gate)');
|
|
51
|
+
return 0;
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
defineCommand({
|
|
55
|
+
name: 'ai:analyze',
|
|
56
|
+
description: 'Analyze the project: stack, data model and diagnostics (read-only)',
|
|
57
|
+
handle({ flags, io }) {
|
|
58
|
+
const ctx = detectProject(rootOf(flags));
|
|
59
|
+
renderAnalysis(analyze(ctx), io);
|
|
60
|
+
return 0;
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
defineCommand({
|
|
64
|
+
name: 'ai:doctor',
|
|
65
|
+
description: 'Diagnose configuration, security and tenancy issues (read-only)',
|
|
66
|
+
handle({ flags, io }) {
|
|
67
|
+
const ctx = detectProject(rootOf(flags));
|
|
68
|
+
const diagnostics = runDoctor(ctx);
|
|
69
|
+
renderDoctor(diagnostics, io);
|
|
70
|
+
// Non-zero exit on errors so CI can gate on `basalt ai:doctor`.
|
|
71
|
+
return hasErrors(diagnostics) ? 1 : 0;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
defineCommand({
|
|
75
|
+
name: 'ai:fix',
|
|
76
|
+
description: 'Apply a doctor fix (or all auto-fixable issues). --dry-run to preview, --yes to skip confirm',
|
|
77
|
+
async handle({ args, flags, io }) {
|
|
78
|
+
const root = rootOf(flags);
|
|
79
|
+
const ctx = detectProject(root);
|
|
80
|
+
const read = (rel) => nodeReader(root).read(rel);
|
|
81
|
+
const id = args[0];
|
|
82
|
+
let outcomes;
|
|
83
|
+
if (id) {
|
|
84
|
+
outcomes = [planFix(id, ctx, read)];
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// No id: fix every currently-firing rule that has an auto-fixer.
|
|
88
|
+
const firing = new Set(runDoctor(ctx).map((d) => d.id));
|
|
89
|
+
const targets = fixableIds().filter((f) => firing.has(f));
|
|
90
|
+
if (targets.length === 0) {
|
|
91
|
+
io.log('Nothing to auto-fix. Run `basalt ai:doctor` for the full picture.');
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
outcomes = targets.map((f) => planFix(f, ctx, read));
|
|
95
|
+
}
|
|
96
|
+
renderFixes(outcomes, io);
|
|
97
|
+
const ready = outcomes.filter((o) => o.status === 'ready');
|
|
98
|
+
if (ready.length === 0) {
|
|
99
|
+
// Nothing to write; exit 1 only if the user asked for an unfixable id.
|
|
100
|
+
return id && outcomes[0]?.status === 'unfixable' ? 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
if (flags['dry-run'] === true) {
|
|
103
|
+
io.log('');
|
|
104
|
+
io.log('Dry run — nothing written.');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (flags['yes'] !== true) {
|
|
108
|
+
const ok = await io.confirm(`Apply ${ready.length} fix(es)?`);
|
|
109
|
+
if (!ok) {
|
|
110
|
+
io.log('Aborted — nothing changed.');
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const outcome of ready)
|
|
115
|
+
await applyFixEdits(outcome.edits, root);
|
|
116
|
+
io.log('');
|
|
117
|
+
io.log(`✓ Applied ${ready.length} fix(es). Review the changes and restart the server.`);
|
|
118
|
+
return 0;
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
defineCommand({
|
|
122
|
+
name: 'ai:plan',
|
|
123
|
+
description: 'Turn a natural-language request into an architecture plan (read-only)',
|
|
124
|
+
async handle({ args, flags, io }) {
|
|
125
|
+
const request = args.join(' ').trim();
|
|
126
|
+
if (!request) {
|
|
127
|
+
io.error('Usage: basalt ai:plan "<what you want to build>"');
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
let provider;
|
|
131
|
+
try {
|
|
132
|
+
provider = createProvider(providerEnvFromProcess());
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
io.error(`ai:plan needs an AI provider — ${error.message}`);
|
|
136
|
+
io.error('Set AI_API_KEY (AI_PROVIDER=anthropic) or run Ollama locally (AI_PROVIDER=ollama).');
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const ctx = detectProject(rootOf(flags));
|
|
140
|
+
io.log('Planning...');
|
|
141
|
+
io.log('');
|
|
142
|
+
try {
|
|
143
|
+
const plan = await createPlan(provider, ctx, request);
|
|
144
|
+
renderPlan(plan, io);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
io.error(error.message);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
defineCommand({
|
|
154
|
+
name: 'ai:make',
|
|
155
|
+
description: 'Plan then implement a feature: scaffold + domain fields, with a review gate',
|
|
156
|
+
async handle({ args, flags, io }) {
|
|
157
|
+
const request = args.join(' ').trim();
|
|
158
|
+
if (!request) {
|
|
159
|
+
io.error('Usage: basalt ai:make "<what to build>" [--dry-run] [--yes] [--force] [--migrate|--no-migrate] [--review] [--verify]');
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
let provider;
|
|
163
|
+
try {
|
|
164
|
+
provider = createProvider(providerEnvFromProcess());
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
io.error(`ai:make needs an AI provider — ${error.message}`);
|
|
168
|
+
io.error('Set AI_API_KEY (AI_PROVIDER=anthropic) or run Ollama locally (AI_PROVIDER=ollama).');
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const baseDir = rootOf(flags);
|
|
172
|
+
const ctx = detectProject(baseDir);
|
|
173
|
+
io.log('Planning...');
|
|
174
|
+
io.log('');
|
|
175
|
+
let plan;
|
|
176
|
+
try {
|
|
177
|
+
plan = await createPlan(provider, ctx, request);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
io.error(error.message);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
renderPlan(plan, io);
|
|
184
|
+
io.log('');
|
|
185
|
+
// Agent review over the generated code — read-only, uses the same provider.
|
|
186
|
+
const agentReview = async (result) => {
|
|
187
|
+
if (flags['review'] !== true)
|
|
188
|
+
return true;
|
|
189
|
+
io.log('');
|
|
190
|
+
io.log('Reviewing (agent)...');
|
|
191
|
+
io.log('');
|
|
192
|
+
try {
|
|
193
|
+
const verdict = await reviewImplementation(provider, plan, result);
|
|
194
|
+
renderAgentReview(verdict, io);
|
|
195
|
+
return verdict.approved;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
io.error(`Review inconclusive — ${error.message}`);
|
|
199
|
+
return true; // don't block the build on a review hiccup
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const dryRun = flags['dry-run'] === true;
|
|
203
|
+
if (dryRun) {
|
|
204
|
+
const result = await runMake(ctx, plan, { dryRun: true, baseDir });
|
|
205
|
+
renderMakeResult(result, io);
|
|
206
|
+
return (await agentReview(result)) ? 0 : 1;
|
|
207
|
+
}
|
|
208
|
+
// Safety: writes require confirmation unless --yes (spec §10).
|
|
209
|
+
if (flags['yes'] !== true) {
|
|
210
|
+
const ok = await io.confirm('Implement this plan? Files will be created and src/app.ts wired.');
|
|
211
|
+
if (!ok) {
|
|
212
|
+
io.log('Aborted — nothing was changed.');
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
io.log('');
|
|
217
|
+
io.log('Implementing...');
|
|
218
|
+
io.log('');
|
|
219
|
+
let result;
|
|
220
|
+
try {
|
|
221
|
+
result = await runMake(ctx, plan, {
|
|
222
|
+
baseDir,
|
|
223
|
+
force: flags['force'] === true,
|
|
224
|
+
// --migrate, or --yes (pre-consented), runs prisma db push inside runMake.
|
|
225
|
+
migrate: flags['migrate'] === true || flags['yes'] === true,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
io.error(error.message);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
renderMakeResult(result, io);
|
|
233
|
+
// The step most often forgotten: without db push the Prisma client has no
|
|
234
|
+
// delegate for the new model, so every route 500s. Offer it right here.
|
|
235
|
+
if (!result.migration &&
|
|
236
|
+
result.schema?.found &&
|
|
237
|
+
flags['no-migrate'] !== true &&
|
|
238
|
+
result.resources.some((r) => r.prisma)) {
|
|
239
|
+
io.log('');
|
|
240
|
+
const ok = await io.confirm('Run `prisma db push` now to create the table(s) + regenerate the Prisma client? (the routes 500 until you do)');
|
|
241
|
+
if (ok) {
|
|
242
|
+
io.log('Running prisma db push...');
|
|
243
|
+
const push = await runPrismaPush(baseDir);
|
|
244
|
+
io.log(push.ok
|
|
245
|
+
? '✓ prisma db push done — restart the dev server to load the regenerated client.'
|
|
246
|
+
: `✗ prisma db push failed:\n${push.output}`);
|
|
247
|
+
if (!push.ok)
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
io.log('Skipped — run `npx prisma db push` and restart the server before using the routes.');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const approved = await agentReview(result);
|
|
255
|
+
if (flags['verify'] === true) {
|
|
256
|
+
io.log('');
|
|
257
|
+
io.log('Verifying (typecheck)...');
|
|
258
|
+
const verdict = await verifyProject(baseDir);
|
|
259
|
+
if (verdict.ok) {
|
|
260
|
+
io.log(`✓ ${verdict.command} passed`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
io.error(`✗ ${verdict.command} failed:`);
|
|
264
|
+
io.error(verdict.output);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return result.review.ok && approved ? 0 : 1;
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
];
|
|
272
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads project files relative to a root. Injectable so the whole detection layer
|
|
3
|
+
* is unit-testable with an in-memory map (no disk).
|
|
4
|
+
*/
|
|
5
|
+
export interface ProjectReader {
|
|
6
|
+
read(relPath: string): string | null;
|
|
7
|
+
exists(relPath: string): boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Default reader backed by the filesystem. */
|
|
10
|
+
export declare function nodeReader(root: string): ProjectReader;
|
|
11
|
+
/** In-memory reader for tests: keys are relative paths. */
|
|
12
|
+
export declare function memoryReader(files: Record<string, string>): ProjectReader;
|
|
13
|
+
/** Which capabilities the app actually wires (from its plugin list), not just installs. */
|
|
14
|
+
export interface DetectedStack {
|
|
15
|
+
http: 'fastify' | null;
|
|
16
|
+
orm: 'prisma' | null;
|
|
17
|
+
database: 'postgresql' | 'mysql' | 'sqlite' | null;
|
|
18
|
+
tenancy: boolean;
|
|
19
|
+
auth: boolean;
|
|
20
|
+
rbac: boolean;
|
|
21
|
+
subscriptions: boolean;
|
|
22
|
+
payments: boolean;
|
|
23
|
+
storage: boolean;
|
|
24
|
+
queue: boolean;
|
|
25
|
+
search: boolean;
|
|
26
|
+
audit: boolean;
|
|
27
|
+
events: boolean;
|
|
28
|
+
scheduler: boolean;
|
|
29
|
+
logger: boolean;
|
|
30
|
+
ai: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface PrismaModel {
|
|
33
|
+
name: string;
|
|
34
|
+
fields: string[];
|
|
35
|
+
/** True when the model carries a `tenantId` column (tenant-scoped). */
|
|
36
|
+
tenantScoped: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface PrismaInfo {
|
|
39
|
+
provider: DetectedStack['database'];
|
|
40
|
+
models: PrismaModel[];
|
|
41
|
+
}
|
|
42
|
+
export interface AppFileInfo {
|
|
43
|
+
path: string;
|
|
44
|
+
/** Plugin factories detected in the `plugins:` array, e.g. `'fastifyPlugin'`. */
|
|
45
|
+
plugins: string[];
|
|
46
|
+
/** `fastifyPlugin({ fastify: { logger … } })` present. */
|
|
47
|
+
fastifyLoggerConfigured: boolean;
|
|
48
|
+
/** In-memory (non-durable) sources still wired, e.g. `MemoryTenantSource`. */
|
|
49
|
+
memorySources: string[];
|
|
50
|
+
/** Every `<name>Plugin(` factory call in the app file (unfiltered by capability). */
|
|
51
|
+
pluginCalls: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface ServerFileInfo {
|
|
54
|
+
path: string;
|
|
55
|
+
/** Calls `.$connect()` somewhere in bootstrap (fail-loud DB check). */
|
|
56
|
+
connectsAtBoot: boolean;
|
|
57
|
+
/** Calls `.listen(` (actually starts a server). */
|
|
58
|
+
startsServer: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface EnvFileInfo {
|
|
61
|
+
path: string;
|
|
62
|
+
/** Default value baked into `APP_SECRET`, if any. */
|
|
63
|
+
appSecretDefault: string | null;
|
|
64
|
+
/** Default value baked into `REDIS_URL`, if any. */
|
|
65
|
+
redisUrlDefault: string | null;
|
|
66
|
+
}
|
|
67
|
+
export interface ProjectContext {
|
|
68
|
+
root: string;
|
|
69
|
+
/** All `@basaltkit/*` packages found in package.json deps. */
|
|
70
|
+
installed: string[];
|
|
71
|
+
stack: DetectedStack;
|
|
72
|
+
prisma: PrismaInfo | null;
|
|
73
|
+
app: AppFileInfo | null;
|
|
74
|
+
server: ServerFileInfo | null;
|
|
75
|
+
env: EnvFileInfo | null;
|
|
76
|
+
}
|
|
77
|
+
/** Detect the project's stack and key files. Pure over the injected reader. */
|
|
78
|
+
export declare function detectProject(root: string, reader?: ProjectReader): ProjectContext;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Default reader backed by the filesystem. */
|
|
4
|
+
export function nodeReader(root) {
|
|
5
|
+
return {
|
|
6
|
+
read(relPath) {
|
|
7
|
+
try {
|
|
8
|
+
return readFileSync(join(root, relPath), 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
exists: (relPath) => existsSync(join(root, relPath)),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** In-memory reader for tests: keys are relative paths. */
|
|
18
|
+
export function memoryReader(files) {
|
|
19
|
+
return {
|
|
20
|
+
read: (relPath) => files[relPath] ?? null,
|
|
21
|
+
exists: (relPath) => relPath in files,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const APP_CANDIDATES = ['src/app.ts', 'src/app.js', 'app.ts'];
|
|
25
|
+
const SERVER_CANDIDATES = ['src/server.ts', 'src/server.js', 'src/main.ts', 'server.ts'];
|
|
26
|
+
const ENV_CANDIDATES = ['src/env.ts', 'src/env.js', 'src/config/env.ts'];
|
|
27
|
+
const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma'];
|
|
28
|
+
/** Maps a plugin factory name → its `DetectedStack` boolean key. */
|
|
29
|
+
const PLUGIN_TO_CAPABILITY = {
|
|
30
|
+
fastifyPlugin: 'http',
|
|
31
|
+
prismaPlugin: 'orm',
|
|
32
|
+
tenancyPlugin: 'tenancy',
|
|
33
|
+
authPlugin: 'auth',
|
|
34
|
+
permissionsPlugin: 'rbac',
|
|
35
|
+
subscriptionsPlugin: 'subscriptions',
|
|
36
|
+
paymentsPlugin: 'payments',
|
|
37
|
+
storagePlugin: 'storage',
|
|
38
|
+
queuePlugin: 'queue',
|
|
39
|
+
searchPlugin: 'search',
|
|
40
|
+
auditPlugin: 'audit',
|
|
41
|
+
eventsPlugin: 'events',
|
|
42
|
+
schedulerPlugin: 'scheduler',
|
|
43
|
+
loggerPlugin: 'logger',
|
|
44
|
+
};
|
|
45
|
+
const PLUGIN_FACTORIES = Object.keys(PLUGIN_TO_CAPABILITY);
|
|
46
|
+
/** Detect the project's stack and key files. Pure over the injected reader. */
|
|
47
|
+
export function detectProject(root, reader = nodeReader(root)) {
|
|
48
|
+
const installed = detectInstalled(reader);
|
|
49
|
+
const app = detectAppFile(reader);
|
|
50
|
+
const server = detectServerFile(reader);
|
|
51
|
+
const env = detectEnvFile(reader);
|
|
52
|
+
const prisma = detectPrisma(reader);
|
|
53
|
+
const stack = buildStack(installed, app, prisma);
|
|
54
|
+
return { root, installed, stack, prisma, app, server, env };
|
|
55
|
+
}
|
|
56
|
+
function detectInstalled(reader) {
|
|
57
|
+
const raw = reader.read('package.json');
|
|
58
|
+
if (!raw)
|
|
59
|
+
return [];
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(raw);
|
|
62
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
63
|
+
return Object.keys(deps)
|
|
64
|
+
.filter((name) => name.startsWith('@basaltkit/'))
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function firstExisting(reader, candidates) {
|
|
72
|
+
for (const path of candidates) {
|
|
73
|
+
const content = reader.read(path);
|
|
74
|
+
if (content !== null)
|
|
75
|
+
return { path, content };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
function detectAppFile(reader) {
|
|
80
|
+
const found = firstExisting(reader, APP_CANDIDATES);
|
|
81
|
+
if (!found)
|
|
82
|
+
return null;
|
|
83
|
+
const plugins = PLUGIN_FACTORIES.filter((factory) => new RegExp(`\\b${factory}\\s*\\(`).test(found.content));
|
|
84
|
+
const fastifyLoggerConfigured = /fastify\s*:\s*\{[^}]*\blogger\b/.test(found.content);
|
|
85
|
+
const memorySources = [
|
|
86
|
+
'MemoryTenantSource',
|
|
87
|
+
'MemoryUserSource',
|
|
88
|
+
// Security-critical in-memory stores: auth/authorization/domain state that
|
|
89
|
+
// must survive restarts and be shared across instances in production.
|
|
90
|
+
'MemoryPasskeyStore',
|
|
91
|
+
'MemoryWebAuthnChallengeStore',
|
|
92
|
+
'MemoryAccessStore',
|
|
93
|
+
'MemoryDomainStore',
|
|
94
|
+
].filter((source) => found.content.includes(source));
|
|
95
|
+
const pluginCalls = [
|
|
96
|
+
...new Set(Array.from(found.content.matchAll(/\b([A-Za-z][A-Za-z0-9]*Plugin)\s*\(/g), (m) => m[1])),
|
|
97
|
+
];
|
|
98
|
+
return { path: found.path, plugins, fastifyLoggerConfigured, memorySources, pluginCalls };
|
|
99
|
+
}
|
|
100
|
+
function detectServerFile(reader) {
|
|
101
|
+
const found = firstExisting(reader, SERVER_CANDIDATES);
|
|
102
|
+
if (!found)
|
|
103
|
+
return null;
|
|
104
|
+
return {
|
|
105
|
+
path: found.path,
|
|
106
|
+
connectsAtBoot: /\.\$connect\s*\(/.test(found.content),
|
|
107
|
+
startsServer: /\.listen\s*\(/.test(found.content),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function detectEnvFile(reader) {
|
|
111
|
+
const found = firstExisting(reader, ENV_CANDIDATES);
|
|
112
|
+
if (!found)
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
path: found.path,
|
|
116
|
+
appSecretDefault: captureDefault(found.content, 'APP_SECRET'),
|
|
117
|
+
redisUrlDefault: captureDefault(found.content, 'REDIS_URL'),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Pull the literal from `KEY: z.string()….default('literal')`. */
|
|
121
|
+
function captureDefault(content, key) {
|
|
122
|
+
const match = new RegExp(`${key}\\s*:[^\\n]*?\\.default\\(\\s*['"\`]([^'"\`]*)['"\`]`).exec(content);
|
|
123
|
+
return match ? (match[1] ?? null) : null;
|
|
124
|
+
}
|
|
125
|
+
function detectPrisma(reader) {
|
|
126
|
+
const found = firstExisting(reader, SCHEMA_CANDIDATES);
|
|
127
|
+
if (!found)
|
|
128
|
+
return null;
|
|
129
|
+
const providerMatch = /datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/s.exec(found.content);
|
|
130
|
+
const provider = normalizeProvider(providerMatch?.[1]);
|
|
131
|
+
const models = parseModels(found.content);
|
|
132
|
+
return { provider, models };
|
|
133
|
+
}
|
|
134
|
+
function normalizeProvider(raw) {
|
|
135
|
+
switch (raw) {
|
|
136
|
+
case 'postgresql':
|
|
137
|
+
return 'postgresql';
|
|
138
|
+
case 'mysql':
|
|
139
|
+
return 'mysql';
|
|
140
|
+
case 'sqlite':
|
|
141
|
+
return 'sqlite';
|
|
142
|
+
default:
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function parseModels(schema) {
|
|
147
|
+
const models = [];
|
|
148
|
+
const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs;
|
|
149
|
+
let match;
|
|
150
|
+
while ((match = modelRegex.exec(schema)) !== null) {
|
|
151
|
+
const name = match[1];
|
|
152
|
+
const body = match[2];
|
|
153
|
+
if (!name || body === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
const fields = body
|
|
156
|
+
.split('\n')
|
|
157
|
+
.map((line) => line.trim().split(/\s+/)[0])
|
|
158
|
+
.filter((token) => !!token && !token.startsWith('@') && !token.startsWith('//'));
|
|
159
|
+
models.push({ name, fields, tenantScoped: fields.includes('tenantId') });
|
|
160
|
+
}
|
|
161
|
+
return models;
|
|
162
|
+
}
|
|
163
|
+
function buildStack(installed, app, prisma) {
|
|
164
|
+
const stack = {
|
|
165
|
+
http: null,
|
|
166
|
+
orm: null,
|
|
167
|
+
database: prisma?.provider ?? null,
|
|
168
|
+
tenancy: false,
|
|
169
|
+
auth: false,
|
|
170
|
+
rbac: false,
|
|
171
|
+
subscriptions: false,
|
|
172
|
+
payments: false,
|
|
173
|
+
storage: false,
|
|
174
|
+
queue: false,
|
|
175
|
+
search: false,
|
|
176
|
+
audit: false,
|
|
177
|
+
events: false,
|
|
178
|
+
scheduler: false,
|
|
179
|
+
logger: false,
|
|
180
|
+
ai: installed.includes('@basaltkit/ai'),
|
|
181
|
+
};
|
|
182
|
+
// Prefer the plugins actually wired in app.ts; fall back to installed packages.
|
|
183
|
+
const enabled = new Set(app?.plugins ?? []);
|
|
184
|
+
const flags = stack;
|
|
185
|
+
for (const [factory, capability] of Object.entries(PLUGIN_TO_CAPABILITY)) {
|
|
186
|
+
if (!enabled.has(factory))
|
|
187
|
+
continue;
|
|
188
|
+
if (capability === 'http')
|
|
189
|
+
stack.http = 'fastify';
|
|
190
|
+
else if (capability === 'orm')
|
|
191
|
+
stack.orm = 'prisma';
|
|
192
|
+
else
|
|
193
|
+
flags[capability] = true;
|
|
194
|
+
}
|
|
195
|
+
if (!stack.http && installed.includes('@basaltkit/fastify'))
|
|
196
|
+
stack.http = 'fastify';
|
|
197
|
+
if (!stack.orm && installed.includes('@basaltkit/prisma'))
|
|
198
|
+
stack.orm = 'prisma';
|
|
199
|
+
if (!stack.rbac && installed.includes('@basaltkit/permissions'))
|
|
200
|
+
stack.rbac = true;
|
|
201
|
+
return stack;
|
|
202
|
+
}
|