@compilr-dev/factory 0.1.3 → 0.1.4

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.
@@ -33,7 +33,7 @@ You have access to these tools:
33
33
  Before building the model, understand the project:
34
34
 
35
35
  1. Check for existing context:
36
- - Use \`project_document_get_by_type\` to look for PRD, architecture, and design docs
36
+ - Use \`project_document_get\` to look for PRD, architecture, and design docs
37
37
  - Read any existing Application Model with \`app_model_get\`
38
38
 
39
39
  2. If NO model exists, gather requirements from the user:
@@ -9,8 +9,9 @@ import { createListToolkitsTool } from './list-toolkits-tool.js';
9
9
  import { defaultRegistry } from './registry.js';
10
10
  export function createFactoryTools(config) {
11
11
  const registry = config.registry ?? defaultRegistry;
12
+ const validToolkitIds = registry.list().map((t) => t.id);
12
13
  return [
13
- ...createModelTools(config),
14
+ ...createModelTools({ ...config, validToolkitIds }),
14
15
  createScaffoldTool({ ...config, registry }),
15
16
  createListToolkitsTool(registry),
16
17
  ];
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export type { ApplicationModel, Entity, Field, FieldType, View, Relationship, RelationshipType, Identity, Layout, ShellType, Features, Theme, TechStack, Meta, } from './model/types.js';
7
7
  export { validateModel } from './model/schema.js';
8
- export type { ValidationError, ValidationResult } from './model/schema.js';
8
+ export type { ValidationError, ValidationResult, ValidateModelOptions } from './model/schema.js';
9
9
  export { createDefaultModel, createDefaultEntity, createDefaultField } from './model/defaults.js';
10
10
  export { toPascalCase, toCamelCase, toKebabCase, toPlural, isPascalCase, isCamelCase, isValidHexColor, } from './model/naming.js';
11
11
  export type { FactoryFile, FactoryResult, FactoryToolkit } from './toolkits/types.js';
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * addEntity, updateEntity, removeEntity, renameEntity, reorderEntities
5
5
  */
6
- import { isPascalCase } from './naming.js';
6
+ import { isPascalCase, toCamelCase } from './naming.js';
7
7
  function findEntity(model, name) {
8
8
  const entity = model.entities.find((e) => e.name === name);
9
9
  if (!entity) {
@@ -72,15 +72,22 @@ export function renameEntity(model, op) {
72
72
  if (entity.name === op.entity) {
73
73
  entity = { ...entity, name: op.newName };
74
74
  }
75
- // Update relationship targets
75
+ // Update relationship targets and cascade fieldName
76
76
  const hasTargetRef = entity.relationships.some((r) => r.target === op.entity);
77
77
  if (hasTargetRef) {
78
+ const oldDefaultFieldName = toCamelCase(op.entity) + 'Id';
79
+ const newDefaultFieldName = toCamelCase(op.newName) + 'Id';
78
80
  entity = {
79
81
  ...entity,
80
82
  relationships: entity.relationships.map((r) => {
81
83
  if (r.target !== op.entity)
82
84
  return r;
83
- return { ...r, target: op.newName };
85
+ const updated = { ...r, target: op.newName };
86
+ // Cascade fieldName if it matches the old default pattern (or is undefined/implicit)
87
+ if (r.fieldName === undefined || r.fieldName === oldDefaultFieldName) {
88
+ updated.fieldName = newDefaultFieldName;
89
+ }
90
+ return updated;
84
91
  }),
85
92
  };
86
93
  }
@@ -12,4 +12,7 @@ export interface ValidationResult {
12
12
  readonly valid: boolean;
13
13
  readonly errors: readonly ValidationError[];
14
14
  }
15
- export declare function validateModel(model: ApplicationModel): ValidationResult;
15
+ export interface ValidateModelOptions {
16
+ readonly validToolkitIds?: readonly string[];
17
+ }
18
+ export declare function validateModel(model: ApplicationModel, options?: ValidateModelOptions): ValidationResult;
@@ -206,7 +206,7 @@ function validateTheme(model) {
206
206
  }
207
207
  return errors;
208
208
  }
209
- function validateTechStack(model) {
209
+ function validateTechStack(model, validToolkitIds) {
210
210
  const errors = [];
211
211
  if (model.techStack.toolkit.trim().length === 0) {
212
212
  errors.push({
@@ -214,6 +214,12 @@ function validateTechStack(model) {
214
214
  message: 'Toolkit is required',
215
215
  });
216
216
  }
217
+ else if (validToolkitIds && !validToolkitIds.includes(model.techStack.toolkit)) {
218
+ errors.push({
219
+ path: 'techStack.toolkit',
220
+ message: `Unknown toolkit "${model.techStack.toolkit}". Valid toolkits: ${validToolkitIds.join(', ')}`,
221
+ });
222
+ }
217
223
  return errors;
218
224
  }
219
225
  function validateMeta(model) {
@@ -235,10 +241,7 @@ function validateMeta(model) {
235
241
  }
236
242
  return errors;
237
243
  }
238
- // =============================================================================
239
- // Public API
240
- // =============================================================================
241
- export function validateModel(model) {
244
+ export function validateModel(model, options) {
242
245
  const errors = [];
243
246
  errors.push(...validateIdentity(model));
244
247
  // Entity uniqueness
@@ -260,7 +263,7 @@ export function validateModel(model) {
260
263
  errors.push(...validateLayout(model));
261
264
  errors.push(...validateFeatures(model));
262
265
  errors.push(...validateTheme(model));
263
- errors.push(...validateTechStack(model));
266
+ errors.push(...validateTechStack(model, options?.validToolkitIds));
264
267
  errors.push(...validateMeta(model));
265
268
  return {
266
269
  valid: errors.length === 0,
@@ -10,5 +10,6 @@ import type { PlatformContext } from '@compilr-dev/sdk';
10
10
  export interface ModelToolsConfig {
11
11
  readonly context: PlatformContext;
12
12
  readonly cwd?: string;
13
+ readonly validToolkitIds?: readonly string[];
13
14
  }
14
15
  export declare function createModelTools(config: ModelToolsConfig): Tool<never>[];
@@ -222,7 +222,9 @@ EXAMPLES:
222
222
  // Apply
223
223
  const updated = applyOperation(model, operation);
224
224
  // Validate result
225
- const validation = validateModel(updated);
225
+ const validation = validateModel(updated, {
226
+ validToolkitIds: config.validToolkitIds,
227
+ });
226
228
  if (!validation.valid) {
227
229
  const msgs = validation.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
228
230
  return createErrorResult(`Operation would produce invalid model: ${msgs}`);
@@ -422,7 +424,7 @@ function createAppModelValidateTool(config) {
422
424
  errors: [{ path: '', message: 'No Application Model found' }],
423
425
  });
424
426
  }
425
- const result = validateModel(model);
427
+ const result = validateModel(model, { validToolkitIds: config.validToolkitIds });
426
428
  return createSuccessResult({
427
429
  valid: result.valid,
428
430
  errors: result.errors,
@@ -7,16 +7,21 @@ import { toKebabCase, toCamelCase } from '../../model/naming.js';
7
7
  export function generateDashboard(model) {
8
8
  if (!model.features.dashboard)
9
9
  return [];
10
+ const typeImports = model.entities.map((e) => e.name).join(', ');
11
+ // State: counts + recent items
12
+ const stateLines = model.entities
13
+ .map((e) => {
14
+ const camel = toCamelCase(e.name);
15
+ return ` const [${camel}Count, set${e.name}Count] = useState(0);\n const [recent${e.name}s, setRecent${e.name}s] = useState<${e.name}[]>([]);`;
16
+ })
17
+ .join('\n');
18
+ // Fetch: counts + recent items
10
19
  const fetchCalls = model.entities
11
20
  .map((e) => {
12
21
  const apiUrl = '/api/' + toKebabCase(e.pluralName).toLowerCase();
13
- return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => set${e.name}Count(d.length)),`;
22
+ return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => { set${e.name}Count(d.length); setRecent${e.name}s(d.slice(-5).reverse()); }),`;
14
23
  })
15
24
  .join('\n');
16
- const stateLines = model.entities
17
- .map((e) => ` const [${toCamelCase(e.name)}Count, set${e.name}Count] = useState(0);`)
18
- .join('\n');
19
- const typeImports = model.entities.map((e) => e.name).join(', ');
20
25
  const cards = model.entities
21
26
  .map((e) => {
22
27
  const path = '/' + toKebabCase(e.pluralName).toLowerCase();
@@ -27,6 +32,21 @@ export function generateDashboard(model) {
27
32
  </Link>`;
28
33
  })
29
34
  .join('\n');
35
+ // Find first string field per entity for display name
36
+ const recentRows = model.entities
37
+ .map((e) => {
38
+ const firstStringField = e.fields.find((f) => f.type === 'string');
39
+ const displayExpr = firstStringField ? `item.${firstStringField.name}` : `String(item.id)`;
40
+ const path = '/' + toKebabCase(e.pluralName).toLowerCase();
41
+ return ` {recent${e.name}s.map((item) => (
42
+ <tr key={item.id}>
43
+ <td className="px-4 py-2 text-sm">${e.icon} ${e.name}</td>
44
+ <td className="px-4 py-2 text-sm"><Link to={\`${path}/\${String(item.id)}\`} className="text-primary hover:underline">{${displayExpr}}</Link></td>
45
+ <td className="px-4 py-2 text-sm text-gray-500">{String(item.id)}</td>
46
+ </tr>
47
+ ))}`;
48
+ })
49
+ .join('\n');
30
50
  return [
31
51
  {
32
52
  path: 'src/pages/Dashboard.tsx',
@@ -51,6 +71,22 @@ ${fetchCalls}
51
71
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${String(Math.min(model.entities.length, 4))}">
52
72
  ${cards}
53
73
  </div>
74
+
75
+ <h2 className="mb-4 mt-8 text-xl font-bold text-gray-900 dark:text-white">Recent Activity</h2>
76
+ <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
77
+ <table className="w-full text-left">
78
+ <thead className="bg-gray-50 dark:bg-gray-800">
79
+ <tr>
80
+ <th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Type</th>
81
+ <th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Name</th>
82
+ <th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">ID</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
86
+ ${recentRows}
87
+ </tbody>
88
+ </table>
89
+ </div>
54
90
  </div>
55
91
  );
56
92
  }
@@ -7,6 +7,7 @@ import { toKebabCase } from '../../model/naming.js';
7
7
  export function generateRouter(model) {
8
8
  const imports = [];
9
9
  const routes = [];
10
+ const topLevelRoutes = [];
10
11
  // Layout import
11
12
  imports.push("import Layout from './components/layout/Layout';");
12
13
  // Dashboard
@@ -28,6 +29,24 @@ export function generateRouter(model) {
28
29
  routes.push(` { path: '${routeBase}/:id', element: <${entity.name}Detail /> },`);
29
30
  routes.push(` { path: '${routeBase}/:id/edit', element: <${entity.name}Form /> },`);
30
31
  }
32
+ // Settings route (inside Layout)
33
+ if (model.features.settings) {
34
+ imports.push("import Settings from './pages/Settings';");
35
+ routes.push(" { path: 'settings', element: <Settings /> },");
36
+ }
37
+ // Profile route (inside Layout)
38
+ if (model.features.userProfiles) {
39
+ imports.push("import Profile from './pages/Profile';");
40
+ routes.push(" { path: 'profile', element: <Profile /> },");
41
+ }
42
+ // Auth routes (top-level, outside Layout)
43
+ if (model.features.auth) {
44
+ imports.push("import Login from './pages/Login';");
45
+ imports.push("import Register from './pages/Register';");
46
+ topLevelRoutes.push(" { path: '/login', element: <Login /> },");
47
+ topLevelRoutes.push(" { path: '/register', element: <Register /> },");
48
+ }
49
+ const topLevelSection = topLevelRoutes.length > 0 ? '\n' + topLevelRoutes.join('\n') : '';
31
50
  return [
32
51
  {
33
52
  path: 'src/router.tsx',
@@ -41,7 +60,7 @@ export const router = createBrowserRouter([
41
60
  children: [
42
61
  ${routes.join('\n')}
43
62
  ],
44
- },
63
+ },${topLevelSection}
45
64
  ]);
46
65
  `,
47
66
  },
@@ -5,7 +5,22 @@
5
5
  */
6
6
  import { toKebabCase } from '../../model/naming.js';
7
7
  export function generateShellFiles(model) {
8
- return [generateApp(model), generateLayout(model), generateSidebar(model), generateHeader(model)];
8
+ const files = [
9
+ generateApp(model),
10
+ generateLayout(model),
11
+ generateSidebar(model),
12
+ generateHeader(model),
13
+ ];
14
+ if (model.features.settings) {
15
+ files.push(generateSettingsPage());
16
+ }
17
+ if (model.features.auth) {
18
+ files.push(...generateAuthPages());
19
+ }
20
+ if (model.features.userProfiles) {
21
+ files.push(generateProfilePage());
22
+ }
23
+ return files;
9
24
  }
10
25
  function generateApp(_model) {
11
26
  return {
@@ -63,15 +78,21 @@ export default function Layout() {
63
78
  };
64
79
  }
65
80
  function generateSidebar(model) {
66
- const navItems = model.entities
67
- .map((e) => {
81
+ const items = [];
82
+ if (model.features.dashboard) {
83
+ items.push(" { label: '\u{1F4CA} Dashboard', path: '/' },");
84
+ }
85
+ for (const e of model.entities) {
68
86
  const path = '/' + toKebabCase(e.pluralName).toLowerCase();
69
- return ` { label: '${e.icon} ${e.pluralName}', path: '${path}' },`;
70
- })
71
- .join('\n');
87
+ items.push(` { label: '${e.icon} ${e.pluralName}', path: '${path}' },`);
88
+ }
89
+ if (model.features.settings) {
90
+ items.push(" { label: '\u2699\uFE0F Settings', path: '/settings' },");
91
+ }
92
+ const navItems = items.join('\n');
72
93
  return {
73
94
  path: 'src/components/layout/Sidebar.tsx',
74
- content: `import { useState } from 'react';
95
+ content: `import { useState, useEffect } from 'react';
75
96
  import { Link, useLocation } from 'react-router-dom';
76
97
 
77
98
  const navItems = [
@@ -79,9 +100,15 @@ ${navItems}
79
100
  ];
80
101
 
81
102
  export default function Sidebar() {
82
- const [collapsed, setCollapsed] = useState(false);
103
+ const [collapsed, setCollapsed] = useState(window.innerWidth < 1024);
83
104
  const location = useLocation();
84
105
 
106
+ useEffect(() => {
107
+ const onResize = () => setCollapsed(window.innerWidth < 1024);
108
+ window.addEventListener('resize', onResize);
109
+ return () => window.removeEventListener('resize', onResize);
110
+ }, []);
111
+
85
112
  return (
86
113
  <aside
87
114
  className={\`\${collapsed ? 'w-16' : 'w-64'} flex flex-col border-r border-gray-200 bg-white transition-all dark:border-gray-700 dark:bg-gray-800\`}
@@ -121,6 +148,7 @@ export default function Sidebar() {
121
148
  };
122
149
  }
123
150
  function generateHeader(model) {
151
+ const hasSidebar = model.layout.shell === 'sidebar-header';
124
152
  const darkModeToggle = model.features.darkMode
125
153
  ? `
126
154
  <button
@@ -130,6 +158,10 @@ function generateHeader(model) {
130
158
  🌓
131
159
  </button>`
132
160
  : '';
161
+ const appNameSection = !hasSidebar
162
+ ? `
163
+ <span className="text-lg font-bold text-gray-900 dark:text-white">${model.identity.name}</span>`
164
+ : '';
133
165
  return {
134
166
  path: 'src/components/layout/Header.tsx',
135
167
  content: `import { useLocation } from 'react-router-dom';
@@ -141,7 +173,9 @@ export default function Header() {
141
173
 
142
174
  return (
143
175
  <header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800">
144
- <span className="text-sm text-gray-500 dark:text-gray-400">{breadcrumb}</span>
176
+ <div className="flex items-center gap-4">${appNameSection}
177
+ <span className="text-sm text-gray-500 dark:text-gray-400">{breadcrumb}</span>
178
+ </div>
145
179
  <div className="flex items-center gap-2">${darkModeToggle}
146
180
  </div>
147
181
  </header>
@@ -150,3 +184,105 @@ export default function Header() {
150
184
  `,
151
185
  };
152
186
  }
187
+ function generateSettingsPage() {
188
+ return {
189
+ path: 'src/pages/Settings.tsx',
190
+ content: `export default function Settings() {
191
+ return (
192
+ <div>
193
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Settings</h1>
194
+ <p className="text-gray-500 dark:text-gray-400">
195
+ Application settings will be configured here.
196
+ </p>
197
+ </div>
198
+ );
199
+ }
200
+ `,
201
+ };
202
+ }
203
+ function generateAuthPages() {
204
+ return [
205
+ {
206
+ path: 'src/pages/Login.tsx',
207
+ content: `import { Link } from 'react-router-dom';
208
+
209
+ export default function Login() {
210
+ return (
211
+ <div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
212
+ <div className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
213
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Login</h1>
214
+ <form className="space-y-4">
215
+ <div>
216
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
217
+ <input type="email" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
218
+ </div>
219
+ <div>
220
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
221
+ <input type="password" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
222
+ </div>
223
+ <button type="submit" className="w-full rounded bg-primary px-4 py-2 text-white hover:bg-primary/90">
224
+ Sign In
225
+ </button>
226
+ </form>
227
+ <p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
228
+ Don&apos;t have an account? <Link to="/register" className="text-primary hover:underline">Register</Link>
229
+ </p>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+ `,
235
+ },
236
+ {
237
+ path: 'src/pages/Register.tsx',
238
+ content: `import { Link } from 'react-router-dom';
239
+
240
+ export default function Register() {
241
+ return (
242
+ <div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
243
+ <div className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
244
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Register</h1>
245
+ <form className="space-y-4">
246
+ <div>
247
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
248
+ <input type="text" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
249
+ </div>
250
+ <div>
251
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
252
+ <input type="email" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
253
+ </div>
254
+ <div>
255
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
256
+ <input type="password" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
257
+ </div>
258
+ <button type="submit" className="w-full rounded bg-primary px-4 py-2 text-white hover:bg-primary/90">
259
+ Create Account
260
+ </button>
261
+ </form>
262
+ <p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
263
+ Already have an account? <Link to="/login" className="text-primary hover:underline">Login</Link>
264
+ </p>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
269
+ `,
270
+ },
271
+ ];
272
+ }
273
+ function generateProfilePage() {
274
+ return {
275
+ path: 'src/pages/Profile.tsx',
276
+ content: `export default function Profile() {
277
+ return (
278
+ <div>
279
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Profile</h1>
280
+ <p className="text-gray-500 dark:text-gray-400">
281
+ User profile information will be displayed here.
282
+ </p>
283
+ </div>
284
+ );
285
+ }
286
+ `,
287
+ };
288
+ }
@@ -32,12 +32,26 @@ VITE_API_URL=http://localhost:3001
32
32
  };
33
33
  }
34
34
  function generateReadme(model) {
35
+ let entitiesSection = '';
36
+ if (model.entities.length > 0) {
37
+ const entityLines = model.entities
38
+ .map((e) => {
39
+ const relCount = e.relationships.length;
40
+ const parts = [`${String(e.fields.length)} fields`];
41
+ if (relCount > 0) {
42
+ parts.push(`${String(relCount)} relationship${relCount > 1 ? 's' : ''}`);
43
+ }
44
+ return `- **${e.name}** (${e.icon}) — ${parts.join(', ')}`;
45
+ })
46
+ .join('\n');
47
+ entitiesSection = `\n## Entities\n\n${entityLines}\n`;
48
+ }
35
49
  return {
36
50
  path: 'README.md',
37
51
  content: `# ${model.identity.name}
38
52
 
39
53
  ${model.identity.description}
40
-
54
+ ${entitiesSection}
41
55
  ## Getting Started
42
56
 
43
57
  \`\`\`bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/factory",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "AI-driven application scaffolder for the compilr-dev ecosystem",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",