@veloxts/cli 0.7.3 → 0.7.5

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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Sync Prompter
3
+ *
4
+ * Interactive prompts for the `velox sync` command.
5
+ * Collects user choices for each Prisma model: action, CRUD operations,
6
+ * output strategy, relation inclusion, and field visibility.
7
+ */
8
+ import type { ExistingCodeMap, ModelChoices, SyncModelInfo } from './types.js';
9
+ /**
10
+ * Run the full interactive flow for all models.
11
+ * Returns null if user cancels at any point.
12
+ */
13
+ export declare function promptAllModels(models: readonly SyncModelInfo[], existing: ExistingCodeMap): Promise<readonly ModelChoices[] | null>;
14
+ /**
15
+ * Run interactive prompts for a single model.
16
+ * Returns null if user cancels (Ctrl+C).
17
+ */
18
+ export declare function promptForModel(model: SyncModelInfo, existing: ExistingCodeMap): Promise<ModelChoices | null>;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Sync Prompter
3
+ *
4
+ * Interactive prompts for the `velox sync` command.
5
+ * Collects user choices for each Prisma model: action, CRUD operations,
6
+ * output strategy, relation inclusion, and field visibility.
7
+ */
8
+ import * as p from '@clack/prompts';
9
+ import pc from 'picocolors';
10
+ // ============================================================================
11
+ // Constants
12
+ // ============================================================================
13
+ /** Models that default to resource output strategy */
14
+ const RESOURCE_DEFAULT_MODELS = new Set(['User', 'Message']);
15
+ /** Horizontal rule width for model headers */
16
+ const HEADER_WIDTH = 54;
17
+ // ============================================================================
18
+ // Public API
19
+ // ============================================================================
20
+ /**
21
+ * Run the full interactive flow for all models.
22
+ * Returns null if user cancels at any point.
23
+ */
24
+ export async function promptAllModels(models, existing) {
25
+ const results = [];
26
+ for (const model of models) {
27
+ const choices = await promptForModel(model, existing);
28
+ if (choices === null) {
29
+ return null;
30
+ }
31
+ results.push(choices);
32
+ }
33
+ return results;
34
+ }
35
+ /**
36
+ * Run interactive prompts for a single model.
37
+ * Returns null if user cancels (Ctrl+C).
38
+ */
39
+ export async function promptForModel(model, existing) {
40
+ // Step 1: Display model header
41
+ displayModelHeader(model, existing);
42
+ // Step 2: Action selection
43
+ const action = await promptAction(model, existing);
44
+ if (action === null)
45
+ return null;
46
+ if (action === 'skip') {
47
+ return buildSkipChoices(model.name);
48
+ }
49
+ // Step 3: Output strategy
50
+ const outputStrategy = await promptOutputStrategy(model);
51
+ if (outputStrategy === null)
52
+ return null;
53
+ // Step 4: CRUD operations
54
+ const crud = await promptCrudOperations(model);
55
+ if (crud === null)
56
+ return null;
57
+ // Step 5: Schema relations (only if model has relations)
58
+ let schemaRelations = [];
59
+ if (model.relations.length > 0) {
60
+ const selected = await promptSchemaRelations(model);
61
+ if (selected === null)
62
+ return null;
63
+ schemaRelations = selected;
64
+ }
65
+ // Step 6: Include relations (only if schema relations were selected)
66
+ let includeRelations = [];
67
+ if (schemaRelations.length > 0) {
68
+ const selected = await promptIncludeRelations(schemaRelations);
69
+ if (selected === null)
70
+ return null;
71
+ includeRelations = selected;
72
+ }
73
+ // Step 7: Field visibility (only if resource strategy)
74
+ let fieldVisibility;
75
+ if (outputStrategy === 'resource') {
76
+ const visibility = await promptFieldVisibility(model);
77
+ if (visibility === null)
78
+ return null;
79
+ fieldVisibility = visibility;
80
+ }
81
+ return {
82
+ model: model.name,
83
+ action,
84
+ outputStrategy,
85
+ crud,
86
+ schemaRelations,
87
+ includeRelations,
88
+ fieldVisibility,
89
+ };
90
+ }
91
+ // ============================================================================
92
+ // Step 1: Model Header
93
+ // ============================================================================
94
+ /**
95
+ * Display a formatted header for the model being prompted.
96
+ */
97
+ function displayModelHeader(model, existing) {
98
+ const titlePart = `── ${model.name} `;
99
+ const remainingWidth = Math.max(0, HEADER_WIDTH - titlePart.length);
100
+ const ruler = titlePart + '─'.repeat(remainingWidth);
101
+ const nonAutoFields = model.fields.filter((f) => !f.isAutoManaged);
102
+ const fieldNames = nonAutoFields.map((f) => f.name).join(', ');
103
+ const relationLabels = model.relations.map((r) => formatRelationLabel(r)).join(', ');
104
+ const lines = [pc.bold(ruler), ` Fields: ${fieldNames || pc.dim('(none)')}`];
105
+ if (model.relations.length > 0) {
106
+ lines.push(` Relations: ${relationLabels}`);
107
+ }
108
+ const existingPath = existing.procedures.get(model.name);
109
+ if (existingPath) {
110
+ lines.push(` Existing: ${pc.dim(existingPath)}`);
111
+ }
112
+ p.log.info(lines.join('\n'));
113
+ }
114
+ /**
115
+ * Format a relation for display: `name(->RelatedModel)` or `name(->RelatedModel[])`.
116
+ */
117
+ function formatRelationLabel(relation) {
118
+ const suffix = relation.kind === 'hasMany' ? '[]' : '';
119
+ return `${relation.name}(→${relation.relatedModel}${suffix})`;
120
+ }
121
+ // ============================================================================
122
+ // Step 2: Action Selection
123
+ // ============================================================================
124
+ /**
125
+ * Prompt the user for the action to take on a model.
126
+ * Returns 'generate', 'regenerate', or 'skip', or null if cancelled.
127
+ */
128
+ async function promptAction(model, existing) {
129
+ const hasExisting = existing.procedures.has(model.name);
130
+ if (hasExisting) {
131
+ const action = await p.select({
132
+ message: `Action for ${model.name}?`,
133
+ options: [
134
+ { value: 'skip', label: 'Skip (keep existing)' },
135
+ { value: 'regenerate', label: 'Regenerate (overwrite)' },
136
+ ],
137
+ });
138
+ if (p.isCancel(action))
139
+ return null;
140
+ return action;
141
+ }
142
+ const action = await p.select({
143
+ message: `Generate procedures for ${model.name}?`,
144
+ options: [
145
+ { value: 'generate', label: 'Yes, generate' },
146
+ { value: 'skip', label: 'Skip' },
147
+ ],
148
+ });
149
+ if (p.isCancel(action))
150
+ return null;
151
+ return action;
152
+ }
153
+ // ============================================================================
154
+ // Step 3: Output Strategy
155
+ // ============================================================================
156
+ /**
157
+ * Prompt for output strategy (plain .output() vs resource schema).
158
+ */
159
+ async function promptOutputStrategy(model) {
160
+ const hasSensitiveFields = model.fields.some((f) => f.isSensitive);
161
+ const defaultToResource = hasSensitiveFields || RESOURCE_DEFAULT_MODELS.has(model.name);
162
+ const outputStrategy = await p.select({
163
+ message: 'Output strategy?',
164
+ options: [
165
+ { value: 'output', label: '.output() \u2014 Same fields for all users' },
166
+ { value: 'resource', label: '.resource() \u2014 Different fields per access level' },
167
+ ],
168
+ initialValue: defaultToResource ? 'resource' : 'output',
169
+ });
170
+ if (p.isCancel(outputStrategy))
171
+ return null;
172
+ return outputStrategy;
173
+ }
174
+ /**
175
+ * Prompt for which CRUD operations to generate.
176
+ */
177
+ async function promptCrudOperations(model) {
178
+ const allOps = ['get', 'list', 'create', 'update', 'delete'];
179
+ const crudOps = await p.multiselect({
180
+ message: 'CRUD operations:',
181
+ options: [
182
+ { value: 'get', label: 'get' },
183
+ { value: 'list', label: 'list' },
184
+ { value: 'create', label: 'create' },
185
+ { value: 'update', label: 'update' },
186
+ { value: 'delete', label: 'delete' },
187
+ ],
188
+ initialValues: model.isJoinTable ? ['create', 'delete'] : allOps,
189
+ required: true,
190
+ });
191
+ if (p.isCancel(crudOps))
192
+ return null;
193
+ const selected = new Set(crudOps);
194
+ return {
195
+ get: selected.has('get'),
196
+ list: selected.has('list'),
197
+ create: selected.has('create'),
198
+ update: selected.has('update'),
199
+ delete: selected.has('delete'),
200
+ };
201
+ }
202
+ // ============================================================================
203
+ // Step 5: Schema Relations
204
+ // ============================================================================
205
+ /**
206
+ * Prompt for which relations to include in the Zod schema.
207
+ */
208
+ async function promptSchemaRelations(model) {
209
+ const schemaRelations = await p.multiselect({
210
+ message: 'Include relations in Zod schema?',
211
+ options: model.relations.map((r) => ({
212
+ value: r.name,
213
+ label: `${r.name} (→${r.relatedModel}${r.kind === 'hasMany' ? '[]' : ''})`,
214
+ })),
215
+ initialValues: model.relations.filter((r) => r.kind === 'belongsTo').map((r) => r.name),
216
+ required: false,
217
+ });
218
+ if (p.isCancel(schemaRelations))
219
+ return null;
220
+ return schemaRelations;
221
+ }
222
+ // ============================================================================
223
+ // Step 6: Include Relations
224
+ // ============================================================================
225
+ /**
226
+ * Prompt for which of the selected schema relations to also fetch via Prisma includes.
227
+ */
228
+ async function promptIncludeRelations(schemaRelations) {
229
+ const includeRelations = await p.multiselect({
230
+ message: 'Fetch relations in queries? (Prisma includes)',
231
+ options: schemaRelations.map((name) => ({
232
+ value: name,
233
+ label: name,
234
+ })),
235
+ initialValues: [...schemaRelations],
236
+ required: false,
237
+ });
238
+ if (p.isCancel(includeRelations))
239
+ return null;
240
+ return includeRelations;
241
+ }
242
+ /**
243
+ * Prompt for field visibility configuration (resource strategy only).
244
+ * Returns a map of field name -> visibility level for ALL non-auto fields.
245
+ */
246
+ async function promptFieldVisibility(model) {
247
+ const nonAutoFields = model.fields.filter((f) => !f.isAutoManaged);
248
+ const fieldsToRestrict = await p.multiselect({
249
+ message: 'Any fields to restrict from public access?',
250
+ options: nonAutoFields.map((f) => ({
251
+ value: f.name,
252
+ label: f.name,
253
+ hint: f.isSensitive ? 'sensitive' : undefined,
254
+ })),
255
+ initialValues: nonAutoFields.filter((f) => f.isSensitive).map((f) => f.name),
256
+ required: false,
257
+ });
258
+ if (p.isCancel(fieldsToRestrict))
259
+ return null;
260
+ const restrictedSet = new Set(fieldsToRestrict);
261
+ // For each restricted field, ask for visibility level
262
+ const restrictedLevels = new Map();
263
+ for (const fieldName of fieldsToRestrict) {
264
+ const level = await p.select({
265
+ message: `Visibility for "${fieldName}"?`,
266
+ options: [
267
+ { value: 'authenticated', label: 'authenticated \u2014 Logged-in users' },
268
+ { value: 'admin', label: 'admin \u2014 Admin only' },
269
+ ],
270
+ });
271
+ if (p.isCancel(level))
272
+ return null;
273
+ restrictedLevels.set(fieldName, level);
274
+ }
275
+ // Build full visibility map: restricted fields get their level, others get 'public'
276
+ const visibility = new Map();
277
+ for (const field of nonAutoFields) {
278
+ if (restrictedSet.has(field.name)) {
279
+ const level = restrictedLevels.get(field.name);
280
+ if (level !== undefined) {
281
+ visibility.set(field.name, level);
282
+ }
283
+ }
284
+ else {
285
+ visibility.set(field.name, 'public');
286
+ }
287
+ }
288
+ return visibility;
289
+ }
290
+ // ============================================================================
291
+ // Helpers
292
+ // ============================================================================
293
+ /**
294
+ * Build a ModelChoices object for a skipped model.
295
+ */
296
+ function buildSkipChoices(modelName) {
297
+ return {
298
+ model: modelName,
299
+ action: 'skip',
300
+ outputStrategy: 'output',
301
+ crud: {
302
+ get: false,
303
+ list: false,
304
+ create: false,
305
+ update: false,
306
+ delete: false,
307
+ },
308
+ schemaRelations: [],
309
+ includeRelations: [],
310
+ fieldVisibility: undefined,
311
+ };
312
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Sync Schema Generator
3
+ *
4
+ * Generates complete TypeScript source for Zod schema files from a
5
+ * `SchemaFilePlan`. Supports two output strategies:
6
+ *
7
+ * - `output`: Plain `z.object()` schemas with optional `WithRelationsSchema`
8
+ * - `resource`: `resourceSchema()` builder with visibility tiers
9
+ *
10
+ * Both strategies generate Create, Update, and List input schemas.
11
+ */
12
+ import type { SchemaFilePlan } from './types.js';
13
+ /**
14
+ * Generate a complete TypeScript source string for a Zod schema file.
15
+ *
16
+ * The output is a syntactically valid TypeScript module containing:
17
+ * - Imports (zod, optionally resourceSchema from @veloxts/router)
18
+ * - Base/resource schema for all non-sensitive fields
19
+ * - WithRelationsSchema (output strategy only, when relations exist)
20
+ * - CreateSchema (excludes auto-managed, sensitive, and user FK fields)
21
+ * - UpdateSchema (same fields as Create, all optional)
22
+ * - ListSchema (page + perPage with defaults)
23
+ */
24
+ export declare function generateSchemaFile(plan: SchemaFilePlan): string;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Sync Schema Generator
3
+ *
4
+ * Generates complete TypeScript source for Zod schema files from a
5
+ * `SchemaFilePlan`. Supports two output strategies:
6
+ *
7
+ * - `output`: Plain `z.object()` schemas with optional `WithRelationsSchema`
8
+ * - `resource`: `resourceSchema()` builder with visibility tiers
9
+ *
10
+ * Both strategies generate Create, Update, and List input schemas.
11
+ */
12
+ import { deriveEntityNames } from '../generators/utils/naming.js';
13
+ // ============================================================================
14
+ // Public API
15
+ // ============================================================================
16
+ /**
17
+ * Generate a complete TypeScript source string for a Zod schema file.
18
+ *
19
+ * The output is a syntactically valid TypeScript module containing:
20
+ * - Imports (zod, optionally resourceSchema from @veloxts/router)
21
+ * - Base/resource schema for all non-sensitive fields
22
+ * - WithRelationsSchema (output strategy only, when relations exist)
23
+ * - CreateSchema (excludes auto-managed, sensitive, and user FK fields)
24
+ * - UpdateSchema (same fields as Create, all optional)
25
+ * - ListSchema (page + perPage with defaults)
26
+ */
27
+ export function generateSchemaFile(plan) {
28
+ const names = deriveEntityNames(plan.model.name);
29
+ const lines = [];
30
+ // ── Imports ──────────────────────────────────────────
31
+ lines.push(generateImports(plan));
32
+ lines.push('');
33
+ // ── Base / Resource Schema ──────────────────────────
34
+ if (plan.outputStrategy === 'resource') {
35
+ lines.push(generateResourceSchema(plan, names.pascal));
36
+ }
37
+ else {
38
+ lines.push(generateBaseSchema(plan, names.pascal));
39
+ }
40
+ // ── WithRelationsSchema (output strategy only) ──────
41
+ if (plan.outputStrategy === 'output' && plan.relations.length > 0) {
42
+ lines.push('');
43
+ lines.push(generateWithRelationsSchema(plan.relations, names.pascal));
44
+ }
45
+ // ── Input Schemas ───────────────────────────────────
46
+ lines.push('');
47
+ lines.push(generateInputSchemas(plan, names.pascal, names.pascalPlural));
48
+ // Ensure trailing newline
49
+ return `${lines.join('\n')}\n`;
50
+ }
51
+ // ============================================================================
52
+ // Import Generation
53
+ // ============================================================================
54
+ function generateImports(plan) {
55
+ const imports = ["import { z } from 'zod';"];
56
+ if (plan.outputStrategy === 'resource') {
57
+ imports.push("import { resourceSchema } from '@veloxts/router';");
58
+ }
59
+ return imports.join('\n');
60
+ }
61
+ // ============================================================================
62
+ // Base Schema (output strategy)
63
+ // ============================================================================
64
+ function generateBaseSchema(plan, pascal) {
65
+ const nonSensitiveFields = plan.allFields.filter((f) => !f.isSensitive);
66
+ const fieldEntries = nonSensitiveFields.map((f) => ` ${f.name}: ${fieldToZod(f)},`);
67
+ const lines = [];
68
+ lines.push(`// ${SECTION_CHAR} Base Schema ${SECTION_PAD}`);
69
+ lines.push(`export const ${pascal}Schema = z.object({`);
70
+ lines.push(...fieldEntries);
71
+ lines.push('});');
72
+ return lines.join('\n');
73
+ }
74
+ // ============================================================================
75
+ // Resource Schema (resource strategy)
76
+ // ============================================================================
77
+ function generateResourceSchema(plan, pascal) {
78
+ const nonSensitiveFields = plan.allFields.filter((f) => !f.isSensitive);
79
+ const lines = [];
80
+ lines.push(`// ${SECTION_CHAR} Resource Schema ${SECTION_PAD}`);
81
+ lines.push(`export const ${pascal}Schema = resourceSchema()`);
82
+ for (const field of nonSensitiveFields) {
83
+ const level = getVisibilityLevel(field.name, plan.fieldVisibility);
84
+ lines.push(` .${level}('${field.name}', ${fieldToZod(field)})`);
85
+ }
86
+ lines.push(' .build();');
87
+ return lines.join('\n');
88
+ }
89
+ // ============================================================================
90
+ // WithRelationsSchema
91
+ // ============================================================================
92
+ function generateWithRelationsSchema(relations, pascal) {
93
+ const relEntries = relations.map((r) => {
94
+ if (r.kind === 'hasMany') {
95
+ return ` ${r.name}: z.array(z.object({ id: z.string() })),`;
96
+ }
97
+ return ` ${r.name}: z.object({ id: z.string() }),`;
98
+ });
99
+ const lines = [];
100
+ lines.push(`// ${SECTION_CHAR} With Relations Schema ${SECTION_PAD}`);
101
+ lines.push(`export const ${pascal}WithRelationsSchema = ${pascal}Schema.extend({`);
102
+ lines.push(...relEntries);
103
+ lines.push('});');
104
+ return lines.join('\n');
105
+ }
106
+ // ============================================================================
107
+ // Input Schemas (Create, Update, List)
108
+ // ============================================================================
109
+ function generateInputSchemas(plan, pascal, pascalPlural) {
110
+ const inputFields = plan.fields.filter((f) => !f.isUserForeignKey);
111
+ const lines = [];
112
+ lines.push(`// ${SECTION_CHAR} Input Schemas ${SECTION_PAD}`);
113
+ // ── CreateSchema ──
114
+ const createEntries = inputFields.map((f) => ` ${f.name}: ${fieldToCreateZod(f)},`);
115
+ lines.push(`export const Create${pascal}Schema = z.object({`);
116
+ lines.push(...createEntries);
117
+ lines.push('});');
118
+ lines.push('');
119
+ // ── UpdateSchema ──
120
+ const updateEntries = inputFields.map((f) => ` ${f.name}: ${fieldToUpdateZod(f)},`);
121
+ lines.push(`export const Update${pascal}Schema = z.object({`);
122
+ lines.push(...updateEntries);
123
+ lines.push('});');
124
+ lines.push('');
125
+ // ── ListSchema ──
126
+ lines.push(`export const List${pascalPlural}Schema = z.object({`);
127
+ lines.push(' page: z.number().int().positive().optional().default(1),');
128
+ lines.push(' perPage: z.number().int().min(1).max(100).optional().default(20),');
129
+ lines.push('});');
130
+ return lines.join('\n');
131
+ }
132
+ // ============================================================================
133
+ // Prisma-to-Zod Type Mapping
134
+ // ============================================================================
135
+ /** Map a field to its base Zod schema string (for output/resource schemas). */
136
+ function fieldToZod(field) {
137
+ let zod = prismaTypeToZod(field);
138
+ if (field.isOptional) {
139
+ zod += '.nullable()';
140
+ }
141
+ return zod;
142
+ }
143
+ /** Map a field to its Create input Zod schema string. */
144
+ function fieldToCreateZod(field) {
145
+ let zod = prismaTypeToZod(field);
146
+ if (!field.isOptional && field.type === 'String' && !field.isId && !isEmailField(field)) {
147
+ zod += '.min(1)';
148
+ }
149
+ if (field.isOptional) {
150
+ zod += '.nullable().optional()';
151
+ }
152
+ return zod;
153
+ }
154
+ /** Map a field to its Update input Zod schema string. */
155
+ function fieldToUpdateZod(field) {
156
+ let zod = prismaTypeToZod(field);
157
+ if (!field.isOptional && field.type === 'String' && !field.isId && !isEmailField(field)) {
158
+ zod += '.min(1)';
159
+ }
160
+ if (field.isOptional) {
161
+ zod += '.nullable().optional()';
162
+ }
163
+ else {
164
+ zod += '.optional()';
165
+ }
166
+ return zod;
167
+ }
168
+ /** Convert a Prisma type to the base Zod schema call. */
169
+ function prismaTypeToZod(field) {
170
+ switch (field.type) {
171
+ case 'String': {
172
+ if (field.isId && field.defaultValue === 'uuid()') {
173
+ return 'z.string().uuid()';
174
+ }
175
+ if (isEmailField(field)) {
176
+ return 'z.string().email()';
177
+ }
178
+ return 'z.string()';
179
+ }
180
+ case 'Int':
181
+ return 'z.number().int()';
182
+ case 'Float':
183
+ case 'Decimal':
184
+ case 'BigInt':
185
+ return 'z.number()';
186
+ case 'Boolean':
187
+ return 'z.boolean()';
188
+ case 'DateTime':
189
+ return 'z.date()';
190
+ case 'Json':
191
+ return 'z.record(z.string(), z.unknown())';
192
+ default:
193
+ return 'z.string()';
194
+ }
195
+ }
196
+ /** Check if a field should use `.email()` validation. */
197
+ function isEmailField(field) {
198
+ return field.name.toLowerCase().includes('email') && field.isUnique;
199
+ }
200
+ // ============================================================================
201
+ // Visibility Helpers
202
+ // ============================================================================
203
+ function getVisibilityLevel(fieldName, visibility) {
204
+ if (!visibility) {
205
+ return 'public';
206
+ }
207
+ return visibility.get(fieldName) ?? 'public';
208
+ }
209
+ // ============================================================================
210
+ // Section Comment Constants
211
+ // ============================================================================
212
+ const SECTION_CHAR = '\u2500\u2500';
213
+ const SECTION_PAD = '\u2500'.repeat(40);