@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { names } from '@basaltkit/generator';
|
|
2
|
+
/**
|
|
3
|
+
* Real Prisma relations (Fase 6). A belongs-to relation on an entity generates:
|
|
4
|
+
* a `<name>Id` FK column, a `<name> <Model> @relation(...)` field, and — on the
|
|
5
|
+
* related model — an inverse `<plural> <This>[]` field. Both sides are required
|
|
6
|
+
* for Prisma to validate, so a relation to a model outside the plan is surfaced
|
|
7
|
+
* as a follow-up (the user adds the inverse field to their existing model).
|
|
8
|
+
*/
|
|
9
|
+
/** FK column name for a relation, e.g. `paciente` → `pacienteId`. */
|
|
10
|
+
export function foreignKey(relation) {
|
|
11
|
+
return `${relation.name}Id`;
|
|
12
|
+
}
|
|
13
|
+
/** Synthetic FK fields (String) for an entity's relations — feed the Zod schema, mapper and columns. */
|
|
14
|
+
export function relationForeignKeys(entity) {
|
|
15
|
+
return (entity.relations ?? []).map((r) => ({ name: foreignKey(r), type: 'String' }));
|
|
16
|
+
}
|
|
17
|
+
/** Prisma relation field lines for the entity's belongs-to relations. */
|
|
18
|
+
export function relationFieldLines(entity) {
|
|
19
|
+
return (entity.relations ?? []).map((r) => ` ${r.name} ${names(r.model).pascal} @relation(fields: [${foreignKey(r)}], references: [id])`);
|
|
20
|
+
}
|
|
21
|
+
/** Inverse `hasMany` lines to add to `modelName`, from every other entity that belongs to it. */
|
|
22
|
+
export function inverseRelationLines(modelName, allEntities) {
|
|
23
|
+
const target = names(modelName).pascal;
|
|
24
|
+
const lines = [];
|
|
25
|
+
for (const other of allEntities) {
|
|
26
|
+
if (names(other.name).pascal === target)
|
|
27
|
+
continue;
|
|
28
|
+
for (const r of other.relations ?? []) {
|
|
29
|
+
if (names(r.model).pascal === target) {
|
|
30
|
+
lines.push(` ${pluralCamel(other.name)} ${names(other.name).pascal}[]`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
/** Insert relation + inverse lines into a generated Prisma model, before its closing brace. */
|
|
37
|
+
export function injectPrismaRelations(content, relationLines, inverseLines) {
|
|
38
|
+
const all = [...relationLines, ...inverseLines];
|
|
39
|
+
if (all.length === 0)
|
|
40
|
+
return { content, injected: false };
|
|
41
|
+
const out = content.replace(/\n\}\s*$/, `\n${all.join('\n')}\n}\n`);
|
|
42
|
+
return { content: out, injected: out !== content };
|
|
43
|
+
}
|
|
44
|
+
/** Relation targets that aren't generated in this plan (need a manual inverse field). */
|
|
45
|
+
export function externalRelationTargets(entities) {
|
|
46
|
+
const planModels = new Set(entities.map((e) => names(e.name).pascal));
|
|
47
|
+
const external = new Set();
|
|
48
|
+
for (const e of entities) {
|
|
49
|
+
for (const r of e.relations ?? []) {
|
|
50
|
+
const target = names(r.model).pascal;
|
|
51
|
+
if (!planModels.has(target))
|
|
52
|
+
external.add(target);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...external];
|
|
56
|
+
}
|
|
57
|
+
function pluralCamel(name) {
|
|
58
|
+
return names(name)
|
|
59
|
+
.pluralKebab.split('-')
|
|
60
|
+
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
|
61
|
+
.join('');
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const REVIEW_MARK = { pass: '✓', warn: '⚠', fail: '✗' };
|
|
2
|
+
/** Render a {@link MakeResult} — what was generated, the review gate and follow-ups. */
|
|
3
|
+
export function renderMakeResult(result, io) {
|
|
4
|
+
for (const resource of result.resources) {
|
|
5
|
+
const verb = result.dryRun ? 'Would generate' : 'Generated';
|
|
6
|
+
const extras = [
|
|
7
|
+
resource.augmented ? 'domain fields' : null,
|
|
8
|
+
resource.guarded ? 'permission guards' : null,
|
|
9
|
+
resource.audited ? 'audit' : null,
|
|
10
|
+
].filter((x) => x !== null);
|
|
11
|
+
const tag = extras.length > 0 ? ` (+ ${extras.join(', ')})` : '';
|
|
12
|
+
io.log(`${verb} ${resource.name}${tag}:`);
|
|
13
|
+
const paths = result.dryRun ? resource.files.map((f) => f.path) : resource.written;
|
|
14
|
+
for (const path of paths)
|
|
15
|
+
io.log(` ${result.dryRun ? '~' : '+'} ${path}`);
|
|
16
|
+
if (!result.dryRun) {
|
|
17
|
+
io.log(resource.registered ? ' ✓ wired into src/app.ts' : ' • src/app.ts not auto-wired');
|
|
18
|
+
}
|
|
19
|
+
if (resource.note)
|
|
20
|
+
io.log(` ⚠ ${resource.note}`);
|
|
21
|
+
io.log('');
|
|
22
|
+
}
|
|
23
|
+
if (result.schema) {
|
|
24
|
+
const s = result.schema;
|
|
25
|
+
if (!s.found) {
|
|
26
|
+
io.log(`Schema: ${s.path} not found — add the model(s) manually.`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (s.merged.length > 0)
|
|
31
|
+
parts.push(`${result.dryRun ? 'would merge' : 'merged'} ${s.merged.join(', ')}`);
|
|
32
|
+
if (s.skipped.length > 0)
|
|
33
|
+
parts.push(`already present: ${s.skipped.join(', ')}`);
|
|
34
|
+
io.log(`Schema (${s.path}): ${parts.length > 0 ? parts.join('; ') : 'no change'}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (result.migration) {
|
|
38
|
+
io.log(result.migration.ok ? 'Migration: ✓ prisma db push' : 'Migration: ✗ prisma db push failed');
|
|
39
|
+
if (!result.migration.ok) {
|
|
40
|
+
for (const line of result.migration.output.split('\n').slice(-6))
|
|
41
|
+
io.log(` ${line}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (result.schema || result.migration)
|
|
45
|
+
io.log('');
|
|
46
|
+
io.log('Review:');
|
|
47
|
+
for (const item of result.review.items) {
|
|
48
|
+
io.log(` ${REVIEW_MARK[item.status]} ${item.label} — ${item.detail}`);
|
|
49
|
+
}
|
|
50
|
+
io.log('');
|
|
51
|
+
if (result.followUps.length > 0) {
|
|
52
|
+
io.log('Next steps (manual):');
|
|
53
|
+
for (const step of result.followUps)
|
|
54
|
+
io.log(` - ${step}`);
|
|
55
|
+
io.log('');
|
|
56
|
+
}
|
|
57
|
+
if (result.dryRun) {
|
|
58
|
+
io.log('Dry run — nothing was written. Re-run without --dry-run to apply.');
|
|
59
|
+
}
|
|
60
|
+
else if (result.review.ok) {
|
|
61
|
+
io.log('✓ Implementation completed.');
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
io.log('✗ Implementation completed with blocking review issues — see above.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PlanField } from '../plan/types.js';
|
|
2
|
+
export interface RepositoryOptions {
|
|
3
|
+
softDelete: boolean;
|
|
4
|
+
/** Scope every query by tenantId from context (portable — no global extension needed). */
|
|
5
|
+
tenantScoped: boolean;
|
|
6
|
+
/** Keep the generator's base `name` column. */
|
|
7
|
+
keepName: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Render a complete Prisma repository for a resource — replacing the generator's,
|
|
11
|
+
* which (a) only maps id/name/timestamps (dropping domain fields) and (b) relies
|
|
12
|
+
* on the global tenancy extension to stamp tenantId. This one maps every domain
|
|
13
|
+
* field and, when tenant-scoped, scopes tenantId explicitly from the request
|
|
14
|
+
* context, so it works even on a raw client shared with non-tenant models.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderPrismaRepository(name: string, fields: PlanField[], options: RepositoryOptions): string;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { names } from '@basaltkit/generator';
|
|
2
|
+
import { canonicalType, domainFields } from './fields.js';
|
|
3
|
+
/** TypeScript type of a Prisma row column for the mapper's parameter. */
|
|
4
|
+
function rowType(t) {
|
|
5
|
+
switch (t) {
|
|
6
|
+
case 'Int':
|
|
7
|
+
case 'Float':
|
|
8
|
+
return 'number';
|
|
9
|
+
case 'Boolean':
|
|
10
|
+
return 'boolean';
|
|
11
|
+
case 'DateTime':
|
|
12
|
+
return 'Date';
|
|
13
|
+
case 'Json':
|
|
14
|
+
return 'unknown';
|
|
15
|
+
case 'String':
|
|
16
|
+
return 'string';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Expression mapping a Prisma row column to the API type (dates → ISO strings). */
|
|
20
|
+
function mapExpr(field, t) {
|
|
21
|
+
return t === 'DateTime' ? `r.${field}.toISOString()` : `r.${field}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Render a complete Prisma repository for a resource — replacing the generator's,
|
|
25
|
+
* which (a) only maps id/name/timestamps (dropping domain fields) and (b) relies
|
|
26
|
+
* on the global tenancy extension to stamp tenantId. This one maps every domain
|
|
27
|
+
* field and, when tenant-scoped, scopes tenantId explicitly from the request
|
|
28
|
+
* context, so it works even on a raw client shared with non-tenant models.
|
|
29
|
+
*/
|
|
30
|
+
export function renderPrismaRepository(name, fields, options) {
|
|
31
|
+
const n = names(name);
|
|
32
|
+
const { softDelete, tenantScoped, keepName } = options;
|
|
33
|
+
const bodyFields = [];
|
|
34
|
+
if (keepName)
|
|
35
|
+
bodyFields.push({ name: 'name', canon: 'String' });
|
|
36
|
+
for (const f of domainFields(fields)) {
|
|
37
|
+
bodyFields.push({ name: f.name, canon: canonicalType(f.type), ...(f.enum && f.enum.length > 0 ? { values: f.enum } : {}) });
|
|
38
|
+
}
|
|
39
|
+
// Enum columns are stored as String; the mapper narrows them to the union.
|
|
40
|
+
const colType = (f) => (f.values ? 'string' : rowType(f.canon));
|
|
41
|
+
const colExpr = (f) => f.values ? `r.${f.name} as ${f.values.map((v) => `'${v}'`).join(' | ')}` : mapExpr(f.name, f.canon);
|
|
42
|
+
const rowTypeEntries = [
|
|
43
|
+
'id: string',
|
|
44
|
+
...bodyFields.map((f) => `${f.name}: ${colType(f)}`),
|
|
45
|
+
'createdAt: Date',
|
|
46
|
+
'updatedAt: Date',
|
|
47
|
+
...(softDelete ? ['deletedAt: Date | null'] : []),
|
|
48
|
+
].join('; ');
|
|
49
|
+
const mapLines = [
|
|
50
|
+
' id: r.id,',
|
|
51
|
+
...bodyFields.map((f) => ` ${f.name}: ${colExpr(f)},`),
|
|
52
|
+
' createdAt: r.createdAt.toISOString(),',
|
|
53
|
+
' updatedAt: r.updatedAt.toISOString(),',
|
|
54
|
+
...(softDelete ? [' deletedAt: r.deletedAt ? r.deletedAt.toISOString() : null,'] : []),
|
|
55
|
+
].join('\n');
|
|
56
|
+
const tenant = (soft) => ['id', tenantScoped ? 'tenantId: currentTenantId()' : '', soft && softDelete ? 'deletedAt: null' : '']
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(', ');
|
|
59
|
+
const listWhere = [tenantScoped ? 'tenantId: currentTenantId()' : '', softDelete ? 'deletedAt: null' : '']
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(', ');
|
|
62
|
+
const createData = tenantScoped ? '{ ...input, tenantId: currentTenantId() }' : 'input';
|
|
63
|
+
const updateMethod = tenantScoped
|
|
64
|
+
? ` async update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null> {
|
|
65
|
+
const res = await this.records.updateMany({ where: { ${tenant(true)} }, data: input })
|
|
66
|
+
return res.count > 0 ? this.find(id) : null
|
|
67
|
+
}`
|
|
68
|
+
: ` async update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null> {
|
|
69
|
+
try {
|
|
70
|
+
return to${n.pascal}(await this.records.update({ where: { id }, data: input }))
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}`;
|
|
75
|
+
const deleteMethod = renderDelete(tenantScoped, softDelete);
|
|
76
|
+
const restoreMethod = softDelete ? `\n\n${renderRestore(tenantScoped)}` : '';
|
|
77
|
+
const interfaceRestore = softDelete ? `\n restore(id: string): Promise<boolean>` : '';
|
|
78
|
+
const coreImport = tenantScoped ? 'createToken, tryCtx' : 'createToken';
|
|
79
|
+
const httpImport = tenantScoped ? `\nimport { HttpError } from '@basaltkit/fastify'` : '';
|
|
80
|
+
const tenantHelper = tenantScoped
|
|
81
|
+
? `
|
|
82
|
+
// Tenant-scoped model. Scoped by tenantId explicitly from the request context, so
|
|
83
|
+
// it works even on a raw client shared with non-tenant models (no global extension).
|
|
84
|
+
// A missing tenant is a client error (400) — never a silent 500.
|
|
85
|
+
const currentTenantId = (): string => {
|
|
86
|
+
const id = (tryCtx()?.['tenant'] as { id?: string } | undefined)?.id
|
|
87
|
+
if (!id) throw new HttpError(400, 'TENANT_REQUIRED', 'No tenant in context — send the x-tenant-id header (or resolve a tenant first).')
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
`
|
|
91
|
+
: '';
|
|
92
|
+
return `import { ${coreImport} } from '@basaltkit/core'
|
|
93
|
+
import { db } from '@basaltkit/prisma'${httpImport}
|
|
94
|
+
import type { PrismaClient } from '@prisma/client'
|
|
95
|
+
import type { ${n.pascal}, Create${n.pascal}Input, Update${n.pascal}Input } from './${n.kebab}.schema.js'
|
|
96
|
+
${tenantHelper}
|
|
97
|
+
// Map the Prisma row (Date columns) to the API type (ISO-string timestamps).
|
|
98
|
+
const to${n.pascal} = (r: { ${rowTypeEntries} }): ${n.pascal} => ({
|
|
99
|
+
${mapLines}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export interface ${n.pascal}Repository {
|
|
103
|
+
list(): Promise<${n.pascal}[]>
|
|
104
|
+
find(id: string): Promise<${n.pascal} | null>
|
|
105
|
+
create(input: Create${n.pascal}Input): Promise<${n.pascal}>
|
|
106
|
+
update(id: string, input: Update${n.pascal}Input): Promise<${n.pascal} | null>
|
|
107
|
+
delete(id: string): Promise<boolean>${interfaceRestore}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class Prisma${n.pascal}Repository implements ${n.pascal}Repository {
|
|
111
|
+
private get records() {
|
|
112
|
+
return db<PrismaClient>().${n.camel}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async list(): Promise<${n.pascal}[]> {
|
|
116
|
+
return (await this.records.findMany(${listWhere ? `{ where: { ${listWhere} } }` : ''})).map(to${n.pascal})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async find(id: string): Promise<${n.pascal} | null> {
|
|
120
|
+
const r = await this.records.findFirst({ where: { ${tenant(true)} } })
|
|
121
|
+
return r ? to${n.pascal}(r) : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async create(input: Create${n.pascal}Input): Promise<${n.pascal}> {
|
|
125
|
+
return to${n.pascal}(await this.records.create({ data: ${createData} }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
${updateMethod}
|
|
129
|
+
|
|
130
|
+
${deleteMethod}${restoreMethod}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const ${n.constant}_REPOSITORY = createToken<${n.pascal}Repository>('${n.kebab}.repository')
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
function renderDelete(tenantScoped, softDelete) {
|
|
137
|
+
if (tenantScoped) {
|
|
138
|
+
const where = softDelete
|
|
139
|
+
? 'id, tenantId: currentTenantId(), deletedAt: null'
|
|
140
|
+
: 'id, tenantId: currentTenantId()';
|
|
141
|
+
const op = softDelete
|
|
142
|
+
? `updateMany({ where: { ${where} }, data: { deletedAt: new Date() } })`
|
|
143
|
+
: `deleteMany({ where: { ${where} } })`;
|
|
144
|
+
return ` async delete(id: string): Promise<boolean> {
|
|
145
|
+
const res = await this.records.${op}
|
|
146
|
+
return res.count > 0
|
|
147
|
+
}`;
|
|
148
|
+
}
|
|
149
|
+
const op = softDelete
|
|
150
|
+
? `update({ where: { id }, data: { deletedAt: new Date() } })`
|
|
151
|
+
: `delete({ where: { id } })`;
|
|
152
|
+
return ` async delete(id: string): Promise<boolean> {
|
|
153
|
+
try {
|
|
154
|
+
await this.records.${op}
|
|
155
|
+
return true
|
|
156
|
+
} catch {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
}`;
|
|
160
|
+
}
|
|
161
|
+
function renderRestore(tenantScoped) {
|
|
162
|
+
if (tenantScoped) {
|
|
163
|
+
return ` async restore(id: string): Promise<boolean> {
|
|
164
|
+
const res = await this.records.updateMany({ where: { id, tenantId: currentTenantId() }, data: { deletedAt: null } })
|
|
165
|
+
return res.count > 0
|
|
166
|
+
}`;
|
|
167
|
+
}
|
|
168
|
+
return ` async restore(id: string): Promise<boolean> {
|
|
169
|
+
try {
|
|
170
|
+
await this.records.update({ where: { id }, data: { deletedAt: null } })
|
|
171
|
+
return true
|
|
172
|
+
} catch {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
}`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ModelBlock {
|
|
2
|
+
name: string;
|
|
3
|
+
block: string;
|
|
4
|
+
}
|
|
5
|
+
/** Extract the `model <Name> { … }` block from a generated `.prisma` snippet. */
|
|
6
|
+
export declare function extractModelBlock(snippet: string): ModelBlock | null;
|
|
7
|
+
export interface MergeOutcome {
|
|
8
|
+
content: string;
|
|
9
|
+
merged: string[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Append model blocks to a schema, skipping any model already declared (idempotent). */
|
|
13
|
+
export declare function mergeModelsIntoSchema(schema: string, blocks: ModelBlock[]): MergeOutcome;
|
|
14
|
+
export declare function readSchema(baseDir: string, schemaPath: string): Promise<string | null>;
|
|
15
|
+
export declare function writeSchema(baseDir: string, schemaPath: string, content: string): Promise<void>;
|
|
16
|
+
export interface PrismaPushResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
/** Tail of stdout/stderr. */
|
|
19
|
+
output: string;
|
|
20
|
+
}
|
|
21
|
+
/** Run `npx prisma db push` — creates the table(s) and regenerates the client. Best-effort. */
|
|
22
|
+
export declare function runPrismaPush(baseDir: string): Promise<PrismaPushResult>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
/** Extract the `model <Name> { … }` block from a generated `.prisma` snippet. */
|
|
7
|
+
export function extractModelBlock(snippet) {
|
|
8
|
+
const match = /model\s+(\w+)\s*\{[\s\S]*?\n\}/.exec(snippet);
|
|
9
|
+
if (!match || !match[1])
|
|
10
|
+
return null;
|
|
11
|
+
return { name: match[1], block: match[0] };
|
|
12
|
+
}
|
|
13
|
+
/** Append model blocks to a schema, skipping any model already declared (idempotent). */
|
|
14
|
+
export function mergeModelsIntoSchema(schema, blocks) {
|
|
15
|
+
let content = schema.replace(/\s*$/, '');
|
|
16
|
+
const merged = [];
|
|
17
|
+
const skipped = [];
|
|
18
|
+
for (const { name, block } of blocks) {
|
|
19
|
+
if (new RegExp(`\\bmodel\\s+${name}\\s*\\{`).test(content)) {
|
|
20
|
+
skipped.push(name);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
content += `\n\n${block.trim()}\n`;
|
|
24
|
+
merged.push(name);
|
|
25
|
+
}
|
|
26
|
+
return { content: content.endsWith('\n') ? content : `${content}\n`, merged, skipped };
|
|
27
|
+
}
|
|
28
|
+
export async function readSchema(baseDir, schemaPath) {
|
|
29
|
+
try {
|
|
30
|
+
return await readFile(join(baseDir, schemaPath), 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function writeSchema(baseDir, schemaPath, content) {
|
|
37
|
+
await writeFile(join(baseDir, schemaPath), content);
|
|
38
|
+
}
|
|
39
|
+
/** Run `npx prisma db push` — creates the table(s) and regenerates the client. Best-effort. */
|
|
40
|
+
export async function runPrismaPush(baseDir) {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await run('npx', ['prisma', 'db', 'push'], {
|
|
43
|
+
cwd: baseDir,
|
|
44
|
+
timeout: 180_000,
|
|
45
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
46
|
+
});
|
|
47
|
+
return { ok: true, output: stdout.slice(-2000) };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const e = error;
|
|
51
|
+
const output = (e.stdout ?? '') + (e.stderr ?? '') || e.message || 'unknown error';
|
|
52
|
+
return { ok: false, output: output.slice(-3000) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { GeneratedFile } from '@basaltkit/generator';
|
|
2
|
+
export interface MakeOptions {
|
|
3
|
+
/** Skip writing to disk — return what would be generated. */
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
/** Overwrite existing files instead of refusing. */
|
|
6
|
+
force?: boolean;
|
|
7
|
+
/** Project root. Defaults to process.cwd(). */
|
|
8
|
+
baseDir?: string;
|
|
9
|
+
/** Force a Prisma-backed resource (default: inferred from the stack). */
|
|
10
|
+
prisma?: boolean;
|
|
11
|
+
/** Force soft-delete (default: from the plan's generator command). */
|
|
12
|
+
softDelete?: boolean;
|
|
13
|
+
/** Path to the Prisma schema to merge models into. Default `prisma/schema.prisma`. */
|
|
14
|
+
schemaPath?: string;
|
|
15
|
+
/** After merging models, run `prisma db push` (creates tables + regenerates the client). */
|
|
16
|
+
migrate?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** How the generated models were merged into prisma/schema.prisma. */
|
|
19
|
+
export interface SchemaMerge {
|
|
20
|
+
path: string;
|
|
21
|
+
/** The schema file was found on disk. */
|
|
22
|
+
found: boolean;
|
|
23
|
+
/** Models appended. */
|
|
24
|
+
merged: string[];
|
|
25
|
+
/** Models already present (left as is). */
|
|
26
|
+
skipped: string[];
|
|
27
|
+
/** The schema file was written (false on dry-run). */
|
|
28
|
+
written: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Result of an opt-in `prisma db push`. */
|
|
31
|
+
export interface Migration {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** Tail of the command output. */
|
|
34
|
+
output: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ResourceBuild {
|
|
37
|
+
name: string;
|
|
38
|
+
prisma: boolean;
|
|
39
|
+
softDelete: boolean;
|
|
40
|
+
/** Generated (and domain-augmented) files. */
|
|
41
|
+
files: GeneratedFile[];
|
|
42
|
+
/** Domain fields were injected into schema/model. */
|
|
43
|
+
augmented: boolean;
|
|
44
|
+
/** Permission `meta.can` guards were injected into the routes. */
|
|
45
|
+
guarded: boolean;
|
|
46
|
+
/** Audit recording was injected into the service + plugin. */
|
|
47
|
+
audited: boolean;
|
|
48
|
+
/** Files actually written (empty on dry-run or on clash). */
|
|
49
|
+
written: string[];
|
|
50
|
+
/** Wired into src/app.ts. */
|
|
51
|
+
registered: boolean;
|
|
52
|
+
/** Why a step didn't complete (e.g. a file clash). */
|
|
53
|
+
note?: string;
|
|
54
|
+
}
|
|
55
|
+
export type ReviewStatus = 'pass' | 'warn' | 'fail';
|
|
56
|
+
export interface ReviewItem {
|
|
57
|
+
label: string;
|
|
58
|
+
status: ReviewStatus;
|
|
59
|
+
detail: string;
|
|
60
|
+
}
|
|
61
|
+
export interface ReviewResult {
|
|
62
|
+
items: ReviewItem[];
|
|
63
|
+
/** True when no item failed. */
|
|
64
|
+
ok: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface MakeResult {
|
|
67
|
+
request: string;
|
|
68
|
+
dryRun: boolean;
|
|
69
|
+
resources: ResourceBuild[];
|
|
70
|
+
/** How models were merged into the Prisma schema (undefined when no Prisma resource). */
|
|
71
|
+
schema?: SchemaMerge;
|
|
72
|
+
/** Result of `--migrate` (undefined when not requested). */
|
|
73
|
+
migration?: Migration;
|
|
74
|
+
/** Manual follow-ups the scaffold can't do yet. */
|
|
75
|
+
followUps: string[];
|
|
76
|
+
review: ReviewResult;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface VerifyResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
/** Command that was run, e.g. `pnpm typecheck`. */
|
|
4
|
+
command: string;
|
|
5
|
+
/** Combined stdout/stderr tail on failure. */
|
|
6
|
+
output: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Run the project's typecheck as a review gate (spec §12/§20). Best-effort: if
|
|
10
|
+
* the command is missing or errors, it reports failure rather than throwing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function verifyProject(baseDir: string, command?: string, args?: string[]): Promise<VerifyResult>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Run the project's typecheck as a review gate (spec §12/§20). Best-effort: if
|
|
6
|
+
* the command is missing or errors, it reports failure rather than throwing.
|
|
7
|
+
*/
|
|
8
|
+
export async function verifyProject(baseDir, command = 'pnpm', args = ['-s', 'typecheck']) {
|
|
9
|
+
const label = `${command} ${args.join(' ')}`;
|
|
10
|
+
try {
|
|
11
|
+
await run(command, args, { cwd: baseDir, timeout: 180_000, maxBuffer: 10 * 1024 * 1024 });
|
|
12
|
+
return { ok: true, command: label, output: '' };
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const e = error;
|
|
16
|
+
const output = (e.stdout ?? '') + (e.stderr ?? '') || e.message || 'unknown error';
|
|
17
|
+
return { ok: false, command: label, output: output.slice(-4000) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject `meta: { can: '<prefix>.<action>' }` into every generated route, so the
|
|
3
|
+
* permissions guard enforces it. `prefix` is the resource's permission namespace
|
|
4
|
+
* (e.g. `patients`) — derived per entity by the caller, so each entity in a
|
|
5
|
+
* multi-entity plan gets its own correct permission, not a shared one.
|
|
6
|
+
*/
|
|
7
|
+
export declare function injectPermissionGuards(content: string, prefix: string): {
|
|
8
|
+
content: string;
|
|
9
|
+
injected: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Inject audit recording into the generated service: the constructor receives an
|
|
13
|
+
* `Audit`, and create/update/remove record their event after the repository call.
|
|
14
|
+
*/
|
|
15
|
+
export declare function injectAuditService(content: string, resourceName: string, events: string[]): {
|
|
16
|
+
content: string;
|
|
17
|
+
injected: boolean;
|
|
18
|
+
};
|
|
19
|
+
/** Inject the `AUDIT` dependency into the generated plugin's service construction. */
|
|
20
|
+
export declare function injectAuditPlugin(content: string, resourceName: string): {
|
|
21
|
+
content: string;
|
|
22
|
+
injected: boolean;
|
|
23
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { names } from '@basaltkit/generator';
|
|
2
|
+
function insertAfterLastImport(content, line) {
|
|
3
|
+
const imports = [...content.matchAll(/^import .*$/gm)];
|
|
4
|
+
const last = imports.at(-1);
|
|
5
|
+
if (!last || last.index === undefined)
|
|
6
|
+
return `${line}\n${content}`;
|
|
7
|
+
const at = last.index + last[0].length;
|
|
8
|
+
return `${content.slice(0, at)}\n${line}${content.slice(at)}`;
|
|
9
|
+
}
|
|
10
|
+
function actionFor(method, url) {
|
|
11
|
+
switch (method.toUpperCase()) {
|
|
12
|
+
case 'GET':
|
|
13
|
+
return 'view';
|
|
14
|
+
case 'POST':
|
|
15
|
+
return url.endsWith('/restore') ? 'update' : 'create';
|
|
16
|
+
case 'PATCH':
|
|
17
|
+
case 'PUT':
|
|
18
|
+
return 'update';
|
|
19
|
+
case 'DELETE':
|
|
20
|
+
return 'delete';
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Inject `meta: { can: '<prefix>.<action>' }` into every generated route, so the
|
|
27
|
+
* permissions guard enforces it. `prefix` is the resource's permission namespace
|
|
28
|
+
* (e.g. `patients`) — derived per entity by the caller, so each entity in a
|
|
29
|
+
* multi-entity plan gets its own correct permission, not a shared one.
|
|
30
|
+
*/
|
|
31
|
+
export function injectPermissionGuards(content, prefix) {
|
|
32
|
+
if (!prefix)
|
|
33
|
+
return { content, injected: false };
|
|
34
|
+
let injected = false;
|
|
35
|
+
const out = content.replace(/route\(\{\s*\n(\s*)method: '(\w+)',\s*\n\s*url: '([^']*)',/g, (match, indent, method, url) => {
|
|
36
|
+
const action = actionFor(method, url);
|
|
37
|
+
if (!action)
|
|
38
|
+
return match;
|
|
39
|
+
injected = true;
|
|
40
|
+
return `route({\n${indent}method: '${method}',\n${indent}url: '${url}',\n${indent}meta: { can: '${prefix}.${action}' },`;
|
|
41
|
+
});
|
|
42
|
+
return { content: out, injected };
|
|
43
|
+
}
|
|
44
|
+
function eventFor(events, suffix) {
|
|
45
|
+
const match = events.find((e) => e.endsWith(`.${suffix}`));
|
|
46
|
+
if (match)
|
|
47
|
+
return match;
|
|
48
|
+
const prefix = events[0]?.split('.')[0] ?? 'record';
|
|
49
|
+
return `${prefix}.${suffix}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Inject audit recording into the generated service: the constructor receives an
|
|
53
|
+
* `Audit`, and create/update/remove record their event after the repository call.
|
|
54
|
+
*/
|
|
55
|
+
export function injectAuditService(content, resourceName, events) {
|
|
56
|
+
const n = names(resourceName);
|
|
57
|
+
const ctorAnchor = `constructor(private readonly repository: ${n.pascal}Repository) {}`;
|
|
58
|
+
if (!content.includes(ctorAnchor))
|
|
59
|
+
return { content, injected: false };
|
|
60
|
+
let out = insertAfterLastImport(content, `import type { Audit } from '@basaltkit/audit'`);
|
|
61
|
+
out = out.replace(ctorAnchor, `constructor(\n private readonly repository: ${n.pascal}Repository,\n private readonly audit: Audit,\n ) {}`);
|
|
62
|
+
out = out.replace(` create(input: Create${n.pascal}Input) {\n return this.repository.create(input)\n }`, ` async create(input: Create${n.pascal}Input) {\n const created = await this.repository.create(input)\n await this.audit.record('${eventFor(events, 'created')}', created)\n return created\n }`);
|
|
63
|
+
out = out.replace(` update(id: string, input: Update${n.pascal}Input) {\n return this.repository.update(id, input)\n }`, ` async update(id: string, input: Update${n.pascal}Input) {\n const updated = await this.repository.update(id, input)\n if (updated) await this.audit.record('${eventFor(events, 'updated')}', updated)\n return updated\n }`);
|
|
64
|
+
out = out.replace(` remove(id: string) {\n return this.repository.delete(id)\n }`, ` async remove(id: string) {\n const removed = await this.repository.delete(id)\n if (removed) await this.audit.record('${eventFor(events, 'deleted')}', { id })\n return removed\n }`);
|
|
65
|
+
return { content: out, injected: true };
|
|
66
|
+
}
|
|
67
|
+
/** Inject the `AUDIT` dependency into the generated plugin's service construction. */
|
|
68
|
+
export function injectAuditPlugin(content, resourceName) {
|
|
69
|
+
const n = names(resourceName);
|
|
70
|
+
const anchor = `new ${n.pascal}Service(c.get(${n.constant}_REPOSITORY))`;
|
|
71
|
+
if (!content.includes(anchor))
|
|
72
|
+
return { content, injected: false };
|
|
73
|
+
let out = insertAfterLastImport(content, `import { AUDIT } from '@basaltkit/audit'`);
|
|
74
|
+
out = out.replace(anchor, `new ${n.pascal}Service(c.get(${n.constant}_REPOSITORY), c.get(AUDIT))`);
|
|
75
|
+
return { content: out, injected: true };
|
|
76
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compact, relevant-only project summary for the planner (spec §18 Context
|
|
4
|
+
* Engineering — never ship the whole project to the model). Surfaces exactly what
|
|
5
|
+
* the Architect needs to ground a plan: enabled capabilities, existing models and
|
|
6
|
+
* the generator's flags.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildPlanContext(ctx: ProjectContext): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact, relevant-only project summary for the planner (spec §18 Context
|
|
3
|
+
* Engineering — never ship the whole project to the model). Surfaces exactly what
|
|
4
|
+
* the Architect needs to ground a plan: enabled capabilities, existing models and
|
|
5
|
+
* the generator's flags.
|
|
6
|
+
*/
|
|
7
|
+
export function buildPlanContext(ctx) {
|
|
8
|
+
const s = ctx.stack;
|
|
9
|
+
const caps = [
|
|
10
|
+
s.http ? `HTTP: ${s.http}` : null,
|
|
11
|
+
s.orm ? `ORM: ${s.orm}` : null,
|
|
12
|
+
s.database ? `database: ${s.database}` : null,
|
|
13
|
+
s.tenancy ? 'tenancy: ENABLED — tenant-owned models must carry tenantId' : 'tenancy: off',
|
|
14
|
+
s.auth ? 'auth: enabled' : null,
|
|
15
|
+
s.rbac ? 'RBAC: enabled — register <resource>.view/create/update/delete' : null,
|
|
16
|
+
s.audit ? 'audit: enabled — emit <resource>.created/updated/deleted' : null,
|
|
17
|
+
s.subscriptions ? 'subscriptions: enabled' : null,
|
|
18
|
+
s.payments ? 'payments: enabled' : null,
|
|
19
|
+
s.queue ? 'queue: enabled' : null,
|
|
20
|
+
s.search ? 'search: enabled' : null,
|
|
21
|
+
s.events ? 'events: enabled' : null,
|
|
22
|
+
s.scheduler ? 'scheduler: enabled' : null,
|
|
23
|
+
s.storage ? 'storage: enabled' : null,
|
|
24
|
+
].filter((line) => line !== null);
|
|
25
|
+
const models = ctx.prisma && ctx.prisma.models.length > 0
|
|
26
|
+
? ctx.prisma.models.map((m) => `${m.name}${m.tenantScoped ? ' (tenantId)' : ''}`).join(', ')
|
|
27
|
+
: '(none)';
|
|
28
|
+
const lines = [
|
|
29
|
+
'PROJECT STACK:',
|
|
30
|
+
...caps.map((c) => `- ${c}`),
|
|
31
|
+
'',
|
|
32
|
+
`EXISTING PRISMA MODELS: ${models}`,
|
|
33
|
+
'',
|
|
34
|
+
'GENERATOR: `basalt make:resource <Name>` — flags: --prisma, --soft-delete, --no-register, --dir, --force.',
|
|
35
|
+
];
|
|
36
|
+
if (ctx.app)
|
|
37
|
+
lines.push(`APP FILE: ${ctx.app.path} (wired plugins: ${ctx.app.plugins.join(', ') || 'none'}).`);
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|