@basaltkit/ai 1.6.0 → 1.6.2

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/dist/commands.js CHANGED
@@ -4,7 +4,7 @@ import { detectProject, nodeReader } from './context/project.js';
4
4
  import { hasErrors, runDoctor } from './doctor/run.js';
5
5
  import { applyFixEdits, fixableIds, planFix, renderFixes } from './doctor/fixes.js';
6
6
  import { createProvider, providerEnvFromProcess } from './provider/factory.js';
7
- import { createPlan } from './plan/plan.js';
7
+ import { createPlan, withGeneratorEntities } from './plan/plan.js';
8
8
  import { renderPlan } from './plan/render.js';
9
9
  import { packageSteps, runMake } from './make/make.js';
10
10
  import { renderMakeResult } from './make/render.js';
@@ -140,7 +140,7 @@ export function aiCommands(options = {}) {
140
140
  io.log('Planning...');
141
141
  io.log('');
142
142
  try {
143
- const plan = await createPlan(provider, ctx, request);
143
+ const plan = withGeneratorEntities(await createPlan(provider, ctx, request), ctx);
144
144
  renderPlan(plan, io);
145
145
  return 0;
146
146
  }
@@ -174,18 +174,22 @@ export function aiCommands(options = {}) {
174
174
  io.log('');
175
175
  let plan;
176
176
  try {
177
- plan = await createPlan(provider, ctx, request);
177
+ // Shown and built with an entity for every generator step.
178
+ plan = withGeneratorEntities(await createPlan(provider, ctx, request), ctx);
178
179
  }
179
180
  catch (error) {
180
181
  io.error(error.message);
181
182
  return 1;
182
183
  }
183
- renderPlan(plan, io);
184
+ renderPlan(plan, io, { footer: false });
184
185
  io.log('');
185
- // A plan that only reuses packages has nothing to scaffold — ai:make does
186
- // not install dependencies, so the package steps above are the whole job.
187
- if (plan.entities.length === 0 && packageSteps(plan).length > 0) {
188
- io.log('Nothing to scaffold — this plan reuses ecosystem packages. Run the package steps above.');
186
+ // ai:make only scaffolds entities. A plan without any (a package to
187
+ // install or wire, config, docs) is complete as shown — say so before
188
+ // the confirmation prompt instead of failing after it.
189
+ if (plan.entities.length === 0) {
190
+ io.log(packageSteps(plan).length > 0
191
+ ? 'Nothing to scaffold — this plan reuses ecosystem packages. Run the package steps above.'
192
+ : 'Nothing to scaffold — the steps above are applied by hand.');
189
193
  return 0;
190
194
  }
191
195
  // Agent review over the generated code — read-only, uses the same provider.
@@ -35,9 +35,18 @@ export interface DetectedStack {
35
35
  logger: boolean;
36
36
  ai: boolean;
37
37
  }
38
+ /** A model column as declared: `type` keeps Prisma's spelling minus `?`/`[]`. */
39
+ export interface PrismaColumn {
40
+ name: string;
41
+ type: string;
42
+ optional: boolean;
43
+ list: boolean;
44
+ }
38
45
  export interface PrismaModel {
39
46
  name: string;
40
47
  fields: string[];
48
+ /** The same fields with their types, so a resource can be generated for an existing model. */
49
+ columns?: PrismaColumn[];
41
50
  /** True when the model carries a `tenantId` column (tenant-scoped). */
42
51
  tenantScoped: boolean;
43
52
  /** The `@@map("…")` table name, when the model declares one. */
@@ -506,12 +506,17 @@ function parseModels(schema) {
506
506
  const body = match[2];
507
507
  if (!name || body === undefined)
508
508
  continue;
509
- const fields = body
510
- .split('\n')
511
- .map((line) => line.trim().split(/\s+/)[0])
512
- .filter((token) => !!token && !token.startsWith('@') && !token.startsWith('//'));
509
+ const columns = [];
510
+ for (const line of body.split('\n')) {
511
+ const [field, rawType] = line.trim().split(/\s+/);
512
+ if (!field || field.startsWith('@') || field.startsWith('//'))
513
+ continue;
514
+ const type = (rawType ?? '').replace(/[?[\]]/g, '');
515
+ columns.push({ name: field, type, optional: rawType?.endsWith('?') === true, list: rawType?.endsWith('[]') === true });
516
+ }
517
+ const fields = columns.map((c) => c.name);
513
518
  const table = /@@map\(\s*"([^"]+)"\s*\)/.exec(body)?.[1];
514
- models.push({ name, fields, tenantScoped: fields.includes('tenantId'), ...(table ? { table } : {}) });
519
+ models.push({ name, fields, columns, tenantScoped: fields.includes('tenantId'), ...(table ? { table } : {}) });
515
520
  }
516
521
  return models;
517
522
  }
package/dist/index.d.ts CHANGED
@@ -6,17 +6,17 @@ export { fetchWithRetry, type RetryOptions } from './provider/http.js';
6
6
  export { globalSseFetch, parseSseContent, type SseFetch, type SseResponse } from './provider/sse.js';
7
7
  export { createProvider, providerEnvFrom, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
8
8
  export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, type OnProgress, type WorkflowProgress, type WorkflowRunOptions, } from './generate.js';
9
- export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DefaultMemoryStore, type DetectedStack, type EnvFileInfo, type FrameworkSchema, type MigrationsInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, type SourceHint, type SourceHints, } from './context/project.js';
9
+ export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DefaultMemoryStore, type DetectedStack, type EnvFileInfo, type FrameworkSchema, type MigrationsInfo, type PrismaInfo, type PrismaColumn, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, type SourceHint, type SourceHints, } from './context/project.js';
10
10
  export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
11
11
  export { DEFAULT_RULES } from './doctor/rules.js';
12
12
  export { hasErrors, runDoctor } from './doctor/run.js';
13
13
  export { applyFixEdits, fixableIds, planFix, renderFixes, FIXES, type FileEdit, type FixApplier, type FixOutcome, type FixStatus, } from './doctor/fixes.js';
14
14
  export { analyze, type AnalysisReport } from './analyze/run.js';
15
- export { createPlan, parsePlan, type CreatePlanOptions } from './plan/plan.js';
15
+ export { createPlan, generatorTargets, parsePlan, withGeneratorEntities, type CreatePlanOptions } from './plan/plan.js';
16
16
  export { buildPlanContext } from './plan/context.js';
17
17
  export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
18
18
  export { ECOSYSTEM_PACKAGES, renderEcosystemCatalog, type EcosystemPackage } from './plan/packages.js';
19
- export { renderPlan } from './plan/render.js';
19
+ export { renderPlan, type RenderPlanOptions } from './plan/render.js';
20
20
  export type { ArchitecturePlan, PlanEntity, PlanField, PlanRelation, PlanStep, PlanStepKind, } from './plan/types.js';
21
21
  export { runMake } from './make/make.js';
22
22
  export { assertSafePlan, UnsafePlanError } from './plan/identifiers.js';
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { applyFixEdits, fixableIds, planFix, renderFixes, FIXES, } from './docto
18
18
  // Analysis report (spec §5, Analyze).
19
19
  export { analyze } from './analyze/run.js';
20
20
  // Planning (spec §3, Plan mode) — natural language → architecture plan.
21
- export { createPlan, parsePlan } from './plan/plan.js';
21
+ export { createPlan, generatorTargets, parsePlan, withGeneratorEntities } from './plan/plan.js';
22
22
  export { buildPlanContext } from './plan/context.js';
23
23
  export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
24
24
  export { ECOSYSTEM_PACKAGES, renderEcosystemCatalog } from './plan/packages.js';
@@ -11,6 +11,6 @@ import type { MakeOptions, MakeResult } from './types.js';
11
11
  * decided. No destructive operations — never overwrites without `force`, never
12
12
  * touches the database, never deletes.
13
13
  */
14
- export declare function runMake(ctx: ProjectContext, plan: ArchitecturePlan, options?: MakeOptions): Promise<MakeResult>;
14
+ export declare function runMake(ctx: ProjectContext, planned: ArchitecturePlan, options?: MakeOptions): Promise<MakeResult>;
15
15
  /** The plan's `package` steps as instructions — ai:make never installs dependencies itself. */
16
16
  export declare function packageSteps(plan: ArchitecturePlan): string[];
package/dist/make/make.js CHANGED
@@ -6,6 +6,7 @@ import { join } from 'node:path';
6
6
  import { unifiedDiff } from './diff.js';
7
7
  import { domainFields, injectPrismaFields, injectZodFields } from './fields.js';
8
8
  import { assertSafePlan } from '../plan/identifiers.js';
9
+ import { withGeneratorEntities } from '../plan/plan.js';
9
10
  import { externalRelationTargets, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './relations.js';
10
11
  import { injectOpenApiMeta } from './openapi.js';
11
12
  import { renderPermissionsFile } from './permissions.js';
@@ -22,7 +23,9 @@ import { injectAuditPlugin, injectAuditService, injectPermissionGuards } from '.
22
23
  * decided. No destructive operations — never overwrites without `force`, never
23
24
  * touches the database, never deletes.
24
25
  */
25
- export async function runMake(ctx, plan, options = {}) {
26
+ export async function runMake(ctx, planned, options = {}) {
27
+ // Generator steps whose entity the model left out still get built.
28
+ const plan = withGeneratorEntities(planned, ctx);
26
29
  // The plan is untrusted (LLM / MCP client): refuse any name that could
27
30
  // inject code into the generated sources before generating anything.
28
31
  assertSafePlan(plan);
@@ -11,7 +11,7 @@ export const BASALT_KNOWLEDGE = `You are the Architect agent for Basalt — a ba
11
11
 
12
12
  ABSOLUTE RULES — always use the framework's official APIs and generators, never invent new ones:
13
13
 
14
- 0. ECOSYSTEM FIRST. Before planning any resource, check whether an official package already covers the capability (catalog below). If one does, use it: a "package" step whose "command" installs it (\`pnpm add <package>\`, plus its Prisma store when the app uses Prisma) and whose "detail" says how to wire its plugin/routes in src/app.ts — or, when the project already has it installed, a step that only wires/uses it. NEVER generate a resource, model or module that duplicates a package (no Comment model when @basaltkit/comments exists, no Notification table, no File/Attachment model, no Team/Invitation model, no AuditLog). Do NOT list a package-covered concept in "entities" — entities are only the domain models no package provides. Only generate from scratch what the catalog does not cover, and say so in "warnings" when a package covers it only partly.
14
+ 0. ECOSYSTEM FIRST. Before planning any resource, check whether an official package already covers the capability (catalog below). If one does, use it: a "package" step whose "command" installs it (\`pnpm add <package>\`, plus its Prisma store when the app uses Prisma) and whose "detail" says how to wire its plugin/routes in src/app.ts — or, when the project already has it installed, a "package" step with no "command" that only wires/uses it. NEVER generate a resource, model or module that duplicates a package (no Comment model when @basaltkit/comments exists, no Notification table, no File/Attachment model, no Team/Invitation model, no AuditLog). Do NOT list a package-covered concept in "entities" — entities are only the domain models no package provides. Only generate from scratch what the catalog does not cover, and say so in "warnings" when a package covers it only partly.
15
15
 
16
16
  ECOSYSTEM CATALOG:
17
17
  ${renderEcosystemCatalog()}
@@ -53,4 +53,6 @@ For generator steps, put the exact command in "command" (e.g. "basalt make:resou
53
53
 
54
54
  ENUMS: a field with a fixed set of values (a status/state) MUST set "enum" to those values — e.g. "estado (pago/pendente)" → { "name": "estado", "type": "String", "enum": ["pago", "pendente"] }. It becomes a validated z.enum([...]) (stored as a String column), never a free z.string().
55
55
 
56
+ ENTITIES vs GENERATOR STEPS: every resource a "generator" step scaffolds MUST also be listed in "entities" with its fields — including when its Prisma model already exists (copy the existing fields). ai:make builds from "entities".
57
+
56
58
  RELATIONS: express a belongs-to as an entry in "relations" (e.g. an Appointment belongs to a Patient → { "name": "patient", "model": "Patient" }). Do NOT also add the "<name>Id" field to "fields" — the FK column, the @relation and the inverse field are generated. Prefer generating both sides of a relation in the same plan.`;
@@ -17,7 +17,17 @@ export interface EcosystemPackage {
17
17
  routes?: string;
18
18
  /** The Prisma-backed store package, when the default store is in-memory. */
19
19
  prisma?: string;
20
+ /**
21
+ * The wiring as the package README shows it, one line — so a plan names the
22
+ * real options instead of guessing them (it once wrote
23
+ * `app.register(mailerPlugin, { provider: 'smtp' })`).
24
+ */
25
+ example?: string;
26
+ /** The same wiring with the Prisma store, for apps on Prisma. */
27
+ prismaExample?: string;
28
+ /** The DI token a service resolves with `ctx().container.get(TOKEN)`. */
29
+ token?: string;
20
30
  }
21
31
  export declare const ECOSYSTEM_PACKAGES: readonly EcosystemPackage[];
22
- /** One catalog line per package, for the planner's system prompt. */
32
+ /** The catalog for the planner's system prompt: one entry per package, wiring indented under it. */
23
33
  export declare function renderEcosystemCatalog(packages?: readonly EcosystemPackage[]): string;
@@ -1,39 +1,166 @@
1
1
  export const ECOSYSTEM_PACKAGES = [
2
- { name: '@basaltkit/auth', covers: 'login, signup, JWT/sessions, password reset, MFA, OAuth, passkeys, API keys', plugin: 'authPlugin', prisma: '@basaltkit/auth-prisma' },
3
- { name: '@basaltkit/permissions', covers: 'roles, permissions, policies, route guards (RBAC)', plugin: 'permissionsPlugin', prisma: '@basaltkit/permissions-prisma' },
4
- { name: '@basaltkit/tenancy', covers: 'multi-tenancy: tenant resolution and tenant context', plugin: 'tenancyPlugin', prisma: '@basaltkit/tenancy-prisma' },
5
- { name: '@basaltkit/teams', covers: 'team members, roles inside a tenant, email invitations', plugin: 'teamsPlugin', routes: 'teamRoutes', prisma: '@basaltkit/teams-prisma' },
6
- { name: '@basaltkit/comments', covers: 'comment threads on any resource, @mentions, resolve/reopen', plugin: 'commentsPlugin', routes: 'commentRoutes', prisma: '@basaltkit/comments-prisma' },
7
- { name: '@basaltkit/activity', covers: 'user-facing activity feed / history of changes', plugin: 'activityPlugin', prisma: '@basaltkit/activity-prisma' },
8
- { name: '@basaltkit/audit', covers: 'append-only audit trail of state changes', plugin: 'auditPlugin', prisma: '@basaltkit/audit-prisma' },
9
- { name: '@basaltkit/notifications', covers: 'in-app and email notifications, per-user preferences', plugin: 'notificationsPlugin', routes: 'inAppRoutes', prisma: '@basaltkit/notifications-prisma' },
10
- { name: '@basaltkit/files', covers: 'file uploads and attachments, quotas, signed URLs', plugin: 'filesPlugin', routes: 'fileRoutes', prisma: '@basaltkit/files-prisma' },
11
- { name: '@basaltkit/files-versions', covers: 'document versions / revisions on top of files', plugin: 'fileVersionsPlugin' },
12
- { name: '@basaltkit/storage', covers: 'disk/object storage (local, S3, Azure, GCS)', plugin: 'storagePlugin' },
13
- { name: '@basaltkit/drives', covers: 'import from Google Drive, OneDrive/SharePoint, Dropbox', plugin: 'drivesPlugin' },
14
- { name: '@basaltkit/subscriptions', covers: 'billing: plans, subscriptions, trials, usage limits, payment gateways', plugin: 'subscriptionsPlugin', prisma: '@basaltkit/subscriptions-prisma' },
15
- { name: '@basaltkit/webhooks', covers: 'outbound webhooks with signing and retries', plugin: 'webhooksPlugin', prisma: '@basaltkit/webhooks-prisma' },
16
- { name: '@basaltkit/events', covers: 'typed domain events, listeners, transactional outbox', plugin: 'eventsPlugin', prisma: '@basaltkit/events-prisma' },
17
- { name: '@basaltkit/queue', covers: 'background jobs (BullMQ, RabbitMQ, SQS, Kafka)', plugin: 'queuePlugin' },
18
- { name: '@basaltkit/scheduler', covers: 'scheduled / recurring tasks (cron-like)', plugin: 'schedulerPlugin' },
19
- { name: '@basaltkit/mailer', covers: 'sending email (SMTP, Resend, SES, Mailgun)', plugin: 'mailerPlugin' },
20
- { name: '@basaltkit/inbound-mail', covers: 'receiving email', routes: 'inboundMailRoutes' },
21
- { name: '@basaltkit/realtime', covers: 'live updates over WebSocket/SSE, presence', plugin: 'realtimePlugin' },
22
- { name: '@basaltkit/search', covers: 'full-text search (Postgres, Elasticsearch, Meilisearch)', plugin: 'searchPlugin' },
23
- { name: '@basaltkit/cache', covers: 'caching with tags and tenant scoping', plugin: 'cachePlugin' },
24
- { name: '@basaltkit/flags', covers: 'feature flags and percentage rollouts', plugin: 'flagsPlugin' },
25
- { name: '@basaltkit/exports', covers: 'CSV/JSON/XLSX exports and CSV import', plugin: 'exportsPlugin' },
26
- { name: '@basaltkit/i18n', covers: 'translations, locale, number/date formatting' },
27
- { name: '@basaltkit/backup', covers: 'PostgreSQL backups', plugin: 'backupPlugin' },
28
- { name: '@basaltkit/mcp', covers: 'expose routes as MCP tools for AI agents', plugin: 'mcpPlugin' },
2
+ {
3
+ name: '@basaltkit/auth', covers: 'login, signup, JWT/sessions, password reset, MFA, OAuth, passkeys, API keys',
4
+ plugin: 'authPlugin', routes: 'authRoutes', prisma: '@basaltkit/auth-prisma', token: 'AUTH',
5
+ example: 'authPlugin({ users: new MemoryUserSource(), secret: env.AUTH_SECRET }) + fastifyPlugin({ routes: [...authRoutes()] })',
6
+ prismaExample: 'const s = prismaAuthStores(prisma); authPlugin({ secret: env.AUTH_SECRET, users: s.users, sessions: s.sessions, refreshTokens: s.refreshTokens, tokens: s.tokens, mfa: s.mfa })',
7
+ },
8
+ {
9
+ name: '@basaltkit/permissions', covers: 'roles, permissions, policies, route guards (RBAC)',
10
+ plugin: 'permissionsPlugin', prisma: '@basaltkit/permissions-prisma', token: 'GATE',
11
+ example: 'permissionsPlugin({ store })',
12
+ prismaExample: 'const p = prismaAccessStore(prisma); permissionsPlugin({ store: p.store })',
13
+ },
14
+ {
15
+ name: '@basaltkit/tenancy', covers: 'multi-tenancy: tenant resolution and tenant context',
16
+ plugin: 'tenancyPlugin', prisma: '@basaltkit/tenancy-prisma', token: 'TENANCY',
17
+ example: 'tenancyPlugin({ source, resolvers: [headerResolver()] })',
18
+ prismaExample: 'tenancyPlugin({ source: prismaTenantSource(prisma), resolvers: [subdomainResolver({ base: env.APP_DOMAIN })] })',
19
+ },
20
+ {
21
+ name: '@basaltkit/teams', covers: 'team members, roles inside a tenant, email invitations',
22
+ plugin: 'teamsPlugin', routes: 'teamRoutes', prisma: '@basaltkit/teams-prisma', token: 'TEAMS',
23
+ example: 'teamsPlugin() + fastifyPlugin({ routes: [...teamRoutes()] })',
24
+ prismaExample: 'const t = prismaTeamsStores(prisma); teamsPlugin({ memberships: t.memberships, invitations: t.invitations })',
25
+ },
26
+ {
27
+ name: '@basaltkit/comments', covers: 'comment threads on any resource, @mentions, resolve/reopen',
28
+ plugin: 'commentsPlugin', routes: 'commentRoutes', prisma: '@basaltkit/comments-prisma', token: 'COMMENTS',
29
+ example: 'commentsPlugin() + fastifyPlugin({ routes: [...commentRoutes()] })',
30
+ prismaExample: 'commentsPlugin({ store: prismaCommentsStore(prisma).store })',
31
+ },
32
+ {
33
+ name: '@basaltkit/activity', covers: 'user-facing activity feed / history of changes',
34
+ plugin: 'activityPlugin', prisma: '@basaltkit/activity-prisma', token: 'ACTIVITY',
35
+ example: "activityPlugin(); then container.get(ACTIVITY).in('project').performedOn('project', id).log('published')",
36
+ prismaExample: 'activityPlugin({ store: prismaActivityStore(prisma).store })',
37
+ },
38
+ {
39
+ name: '@basaltkit/audit', covers: 'append-only audit trail of state changes',
40
+ plugin: 'auditPlugin', prisma: '@basaltkit/audit-prisma', token: 'AUDIT',
41
+ example: 'eventsPlugin() + auditPlugin()',
42
+ prismaExample: 'auditPlugin({ store: prismaAuditStore(prisma).store })',
43
+ },
44
+ {
45
+ name: '@basaltkit/notifications', covers: 'in-app and email notifications, per-user preferences',
46
+ plugin: 'notificationsPlugin', routes: 'inAppRoutes', prisma: '@basaltkit/notifications-prisma', token: 'NOTIFIER',
47
+ example: 'mailerPlugin({ driver: \'log\', from: env.MAIL_FROM }) + notificationsPlugin()',
48
+ prismaExample: 'notificationsPlugin({ inApp: prismaInAppStore(prisma).store, preferences: prismaPreferenceStore(prisma) })',
49
+ },
50
+ {
51
+ name: '@basaltkit/files', covers: 'file uploads and attachments, quotas, signed URLs',
52
+ plugin: 'filesPlugin', routes: 'fileRoutes', prisma: '@basaltkit/files-prisma', token: 'FILES',
53
+ example: "storagePlugin({ disks: { uploads: { driver: 'local', root: './storage' } } }) + filesPlugin({ disk: 'uploads', validate: { maxSize: 5_000_000 } }) + fastifyPlugin({ routes: [...fileRoutes()] })",
54
+ prismaExample: "filesPlugin({ disk: 'uploads', store: prismaFilesStore(prisma).store })",
55
+ },
56
+ {
57
+ name: '@basaltkit/files-versions', covers: 'document versions / revisions on top of files',
58
+ plugin: 'fileVersionsPlugin', token: 'FILE_VERSIONS',
59
+ example: 'fileVersionsPlugin({ store: prismaFileVersionsStore(prisma).store }) // store from @basaltkit/files-prisma/versions',
60
+ },
61
+ {
62
+ name: '@basaltkit/storage', covers: 'disk/object storage (local, S3, Azure, GCS)',
63
+ plugin: 'storagePlugin', token: 'STORAGE',
64
+ example: "storagePlugin({ default: 'uploads', disks: { uploads: { driver: 'local', root: './storage' } } })",
65
+ },
66
+ {
67
+ name: '@basaltkit/drives', covers: 'import from Google Drive, OneDrive/SharePoint, Dropbox',
68
+ plugin: 'drivesPlugin', token: 'DRIVES',
69
+ example: "drivesPlugin({ providers: [dropboxDrive({ clientId, clientSecret })], keys: [{ id: '2026-09', key: env.DRIVES_ENCRYPTION_KEY }], secret: env.APP_SECRET })",
70
+ },
71
+ {
72
+ name: '@basaltkit/subscriptions', covers: 'billing: plans, subscriptions, trials, usage limits, payment gateways',
73
+ plugin: 'subscriptionsPlugin', prisma: '@basaltkit/subscriptions-prisma', token: 'SUBSCRIPTIONS',
74
+ example: "subscriptionsPlugin({ plans, fallbackPlan: 'free', gateway }) + routes billingRoutes({ successUrl, cancelUrl }), invoiceRoutes(); guard with meta: { subscribed: 'pro' } / { feature: 'api' }",
75
+ prismaExample: 'const s = prismaSubscriptionsStores(prisma); subscriptionsPlugin({ plans, store: s.store, usage: s.usage, webhooks: s.webhooks })',
76
+ },
77
+ {
78
+ name: '@basaltkit/webhooks', covers: 'outbound webhooks with signing and retries',
79
+ plugin: 'webhooksPlugin', prisma: '@basaltkit/webhooks-prisma', token: 'WEBHOOKS',
80
+ example: "eventsPlugin() + webhooksPlugin({ secret: env.WEBHOOK_SECRET, events: ['invoice.*'] })",
81
+ prismaExample: 'webhooksPlugin({ store: prismaWebhookStore(prisma).store, secret: env.WEBHOOK_SECRET })',
82
+ },
83
+ {
84
+ name: '@basaltkit/events', covers: 'typed domain events, listeners, transactional outbox',
85
+ plugin: 'eventsPlugin', prisma: '@basaltkit/events-prisma', token: 'EVENTS',
86
+ example: "eventsPlugin() + outboxPlugin({ store, captureEvents: ['invoice.*'], dispatch: async (entry) => { … } })",
87
+ prismaExample: 'outboxPlugin({ store: prismaOutboxStore(prisma, { claim: true }).store, dispatch })',
88
+ },
89
+ {
90
+ name: '@basaltkit/queue', covers: 'background jobs (BullMQ, RabbitMQ, SQS, Kafka)',
91
+ plugin: 'queuePlugin', token: 'QUEUE',
92
+ example: 'queuePlugin({ jobs: [SendWelcomeEmail] }) // no connection = sync driver; bullmqQueuePlugin({ jobs, connection: env.REDIS_URL }) from @basaltkit/queue-bullmq',
93
+ },
94
+ {
95
+ name: '@basaltkit/scheduler', covers: 'scheduled / recurring tasks (cron-like)',
96
+ plugin: 'schedulerPlugin', token: 'SCHEDULER',
97
+ example: "schedulerPlugin({ define: (schedule) => { schedule.call('backup', async () => { … }).daily().at('03:00') } })",
98
+ },
99
+ {
100
+ name: '@basaltkit/mailer', covers: 'sending email (SMTP, Resend, SES, Mailgun)',
101
+ plugin: 'mailerPlugin', token: 'MAILER',
102
+ example: "mailerPlugin({ driver: 'log', from: env.MAIL_FROM }) // or driver: 'resend', resend: { apiKey }; SMTP: driver: smtpMailer({ url }) from @basaltkit/mailer-smtp; send with container.get(MAILER)",
103
+ },
104
+ {
105
+ name: '@basaltkit/inbound-mail', covers: 'receiving email',
106
+ routes: 'inboundMailRoutes',
107
+ example: "fastifyPlugin({ routes: [...inboundMailRoutes({ driver: signedDriver({ secret: env.INBOUND_SECRET }), routes: [{ address: '{tenant}@in.example.com', handler }] })] })",
108
+ },
109
+ {
110
+ name: '@basaltkit/realtime', covers: 'live updates over WebSocket/SSE, presence',
111
+ plugin: 'realtimePlugin', token: 'REALTIME',
112
+ example: "realtimePlugin({ bridge: [bridgeRule({ hook: 'note:created', channel: 'notes', event: 'created' })] })",
113
+ },
114
+ {
115
+ name: '@basaltkit/search', covers: 'full-text search (Postgres, Elasticsearch, Meilisearch)',
116
+ plugin: 'searchPlugin', token: 'SEARCH',
117
+ example: "searchPlugin({ indexes: [defineIndex({ name: 'notes', fields: ['title', 'body'] })] })",
118
+ },
119
+ {
120
+ name: '@basaltkit/cache', covers: 'caching with tags and tenant scoping',
121
+ plugin: 'cachePlugin', token: 'CACHE',
122
+ example: "cachePlugin({ driver: 'memory' }) // shared: driver: redisCache(env.REDIS_URL) from @basaltkit/cache-redis",
123
+ },
124
+ {
125
+ name: '@basaltkit/flags', covers: 'feature flags and percentage rollouts',
126
+ plugin: 'flagsPlugin', token: 'FLAGS',
127
+ example: 'flagsPlugin(defineFlags({ newDashboard: { default: false, tenants: { acme: true } }, betaSearch: { default: false, rollout: 25 } }))',
128
+ },
129
+ {
130
+ name: '@basaltkit/exports', covers: 'CSV/JSON/XLSX exports and CSV import',
131
+ plugin: 'exportsPlugin', token: 'EXPORTS',
132
+ example: "exportsPlugin({ formatters: [createCsvFormatter({ delimiter: ';' })] })",
133
+ },
134
+ {
135
+ name: '@basaltkit/i18n', covers: 'translations, locale, number/date formatting',
136
+ plugin: 'i18nPlugin', token: 'I18N',
137
+ example: "i18nPlugin({ locales: { en, pt }, defaultLocale: 'en' })",
138
+ },
139
+ {
140
+ name: '@basaltkit/backup', covers: 'PostgreSQL backups',
141
+ plugin: 'backupPlugin', token: 'BACKUP',
142
+ example: "storagePlugin({ disks: { backups: { driver: 'local', root: './backups', scope: null } } }) + backupPlugin({ connectionUrl: env.DATABASE_URL, disk: 'backups', retention: 7 })",
143
+ },
144
+ {
145
+ name: '@basaltkit/mcp', covers: 'expose routes as MCP tools for AI agents',
146
+ plugin: 'mcpPlugin', token: 'MCP',
147
+ example: "mcpPlugin({ routes, serverInfo: { name: 'app', version: '1.0.0' } }) + fastifyPlugin({ routes: [...routes, ...mcpRoutes()] }); opt a route in with meta: { mcp: { name } }",
148
+ },
29
149
  ];
30
- /** One catalog line per package, for the planner's system prompt. */
150
+ /** The catalog for the planner's system prompt: one entry per package, wiring indented under it. */
31
151
  export function renderEcosystemCatalog(packages = ECOSYSTEM_PACKAGES) {
32
152
  return packages
33
153
  .map((p) => {
34
- const wiring = [p.plugin, p.routes ? `${p.routes}()` : undefined].filter(Boolean).join(' + ');
35
- const store = p.prisma ? `; Prisma store: ${p.prisma}` : '';
36
- return `- ${p.name} — ${p.covers}${wiring ? ` (wire: ${wiring}${store})` : store ? ` (${store.slice(2)})` : ''}`;
154
+ const lines = [`- ${p.name} — ${p.covers}`];
155
+ if (p.example)
156
+ lines.push(` wire: ${p.example}`);
157
+ if (p.prismaExample)
158
+ lines.push(` with Prisma (${p.prisma}): ${p.prismaExample}`);
159
+ else if (p.prisma)
160
+ lines.push(` Prisma store: ${p.prisma}`);
161
+ if (p.token)
162
+ lines.push(` use: ctx().container.get(${p.token})`);
163
+ return lines.join('\n');
37
164
  })
38
165
  .join('\n');
39
166
  }
@@ -18,3 +18,13 @@ export interface CreatePlanOptions {
18
18
  export declare function createPlan(provider: AIProvider, ctx: ProjectContext, request: string, options?: CreatePlanOptions): Promise<ArchitecturePlan>;
19
19
  /** Parse + normalize the model's JSON into a validated plan. Tolerant of fences and missing arrays. */
20
20
  export declare function parsePlan(raw: string, request: string): ArchitecturePlan;
21
+ /** Resource names a `basalt make:resource <Name>` generator step scaffolds. */
22
+ export declare function generatorTargets(plan: ArchitecturePlan): string[];
23
+ /**
24
+ * The plan with an entity for every generator step. Models sometimes leave
25
+ * `entities` empty when the Prisma models already exist and only list
26
+ * `make:resource` steps — ai:make then had nothing to build. A missing entity is
27
+ * taken from the existing model (scalar and enum columns; relations and lists
28
+ * are left to the schema), or created bare when there is none.
29
+ */
30
+ export declare function withGeneratorEntities(plan: ArchitecturePlan, ctx: ProjectContext): ArchitecturePlan;
package/dist/plan/plan.js CHANGED
@@ -160,3 +160,41 @@ function normalizeKind(value) {
160
160
  ? value
161
161
  : 'other';
162
162
  }
163
+ /** Resource names a `basalt make:resource <Name>` generator step scaffolds. */
164
+ export function generatorTargets(plan) {
165
+ const names = [];
166
+ for (const step of plan.steps) {
167
+ if (step.kind !== 'generator' || !step.command)
168
+ continue;
169
+ const name = /make:resource\s+(\w+)/.exec(step.command)?.[1];
170
+ if (name && !names.some((n) => n.toLowerCase() === name.toLowerCase()))
171
+ names.push(name);
172
+ }
173
+ return names;
174
+ }
175
+ const PRISMA_SCALARS = new Set(['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes']);
176
+ /**
177
+ * The plan with an entity for every generator step. Models sometimes leave
178
+ * `entities` empty when the Prisma models already exist and only list
179
+ * `make:resource` steps — ai:make then had nothing to build. A missing entity is
180
+ * taken from the existing model (scalar and enum columns; relations and lists
181
+ * are left to the schema), or created bare when there is none.
182
+ */
183
+ export function withGeneratorEntities(plan, ctx) {
184
+ const known = new Set(plan.entities.map((e) => e.name.toLowerCase()));
185
+ const missing = generatorTargets(plan).filter((name) => !known.has(name.toLowerCase()));
186
+ if (missing.length === 0)
187
+ return plan;
188
+ const models = ctx.prisma?.models ?? [];
189
+ const modelNames = new Set(models.map((m) => m.name));
190
+ const added = missing.map((name) => {
191
+ const model = models.find((m) => m.name.toLowerCase() === name.toLowerCase());
192
+ if (!model)
193
+ return { name, fields: [], tenantScoped: ctx.stack.tenancy };
194
+ const fields = (model.columns ?? [])
195
+ .filter((c) => !c.list && !modelNames.has(c.type))
196
+ .map((c) => ({ name: c.name, type: PRISMA_SCALARS.has(c.type) ? c.type : 'String' }));
197
+ return { name: model.name, fields, tenantScoped: model.tenantScoped };
198
+ });
199
+ return { ...plan, entities: [...plan.entities, ...added] };
200
+ }
@@ -1,4 +1,8 @@
1
1
  import type { LineWriter } from '../render.js';
2
2
  import type { ArchitecturePlan } from './types.js';
3
+ export interface RenderPlanOptions {
4
+ /** Print the "what next" footer (default `true`); ai:make, which acts next itself, turns it off. */
5
+ footer?: boolean;
6
+ }
3
7
  /** Render an {@link ArchitecturePlan} in the spec's `ARCHITECTURE PLAN` style. */
4
- export declare function renderPlan(plan: ArchitecturePlan, io: LineWriter): void;
8
+ export declare function renderPlan(plan: ArchitecturePlan, io: LineWriter, options?: RenderPlanOptions): void;
@@ -1,5 +1,6 @@
1
+ import { generatorTargets } from './plan.js';
1
2
  /** Render an {@link ArchitecturePlan} in the spec's `ARCHITECTURE PLAN` style. */
2
- export function renderPlan(plan, io) {
3
+ export function renderPlan(plan, io, options = {}) {
3
4
  io.log('ARCHITECTURE PLAN');
4
5
  io.log('');
5
6
  io.log(`Request: "${plan.request}"`);
@@ -49,7 +50,13 @@ export function renderPlan(plan, io) {
49
50
  for (const warning of plan.warnings)
50
51
  io.log(` - ${warning}`);
51
52
  }
53
+ if (options.footer === false)
54
+ return;
52
55
  io.log('');
53
56
  io.log('This is a plan only — nothing was changed.');
54
- io.log(`Next: basalt ai:make "${plan.request}" implements it.`);
57
+ // ai:make only scaffolds entities: suggesting it for a plan without any
58
+ // would end in "nothing to generate" after a confirmation prompt.
59
+ io.log(plan.entities.length > 0 || generatorTargets(plan).length > 0
60
+ ? `Next: basalt ai:make "${plan.request}" implements it.`
61
+ : 'Nothing for ai:make to generate — the steps above are applied by hand.');
55
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/ai",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },