proteum 2.1.0 → 2.1.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.
Files changed (95) hide show
  1. package/AGENTS.md +44 -98
  2. package/README.md +143 -10
  3. package/agents/framework/AGENTS.md +146 -886
  4. package/agents/project/AGENTS.md +73 -127
  5. package/agents/project/client/AGENTS.md +22 -93
  6. package/agents/project/client/pages/AGENTS.md +24 -26
  7. package/agents/project/server/routes/AGENTS.md +10 -8
  8. package/agents/project/server/services/AGENTS.md +22 -159
  9. package/agents/project/tests/AGENTS.md +11 -8
  10. package/cli/app/config.ts +7 -20
  11. package/cli/bin.js +8 -0
  12. package/cli/commands/command.ts +243 -0
  13. package/cli/commands/commandLocalRunner.js +198 -0
  14. package/cli/commands/create.ts +5 -0
  15. package/cli/commands/deploy/web.ts +1 -2
  16. package/cli/commands/dev.ts +98 -2
  17. package/cli/commands/doctor.ts +8 -74
  18. package/cli/commands/explain.ts +8 -186
  19. package/cli/commands/init.ts +2 -94
  20. package/cli/commands/trace.ts +228 -0
  21. package/cli/compiler/artifacts/commands.ts +217 -0
  22. package/cli/compiler/artifacts/manifest.ts +35 -21
  23. package/cli/compiler/artifacts/services.ts +300 -1
  24. package/cli/compiler/client/index.ts +43 -8
  25. package/cli/compiler/common/commands.ts +175 -0
  26. package/cli/compiler/common/index.ts +1 -1
  27. package/cli/compiler/common/proteumManifest.ts +15 -114
  28. package/cli/compiler/index.ts +25 -2
  29. package/cli/compiler/server/index.ts +31 -6
  30. package/cli/index.ts +1 -4
  31. package/cli/paths.ts +16 -1
  32. package/cli/presentation/commands.ts +104 -14
  33. package/cli/presentation/devSession.ts +22 -3
  34. package/cli/presentation/proteum_logo_400x400_square_icon.txt +400 -0
  35. package/cli/runtime/commands.ts +121 -4
  36. package/cli/scaffold/index.ts +720 -0
  37. package/cli/scaffold/templates.ts +344 -0
  38. package/cli/scaffold/types.ts +26 -0
  39. package/cli/tsconfig.json +4 -1
  40. package/cli/utils/check.ts +1 -1
  41. package/client/app/component.tsx +13 -9
  42. package/client/dev/profiler/index.tsx +2511 -0
  43. package/client/dev/profiler/noop.tsx +5 -0
  44. package/client/dev/profiler/runtime.noop.ts +116 -0
  45. package/client/dev/profiler/runtime.ts +840 -0
  46. package/client/services/router/components/router.tsx +30 -2
  47. package/client/services/router/index.tsx +27 -3
  48. package/client/services/router/request/api.ts +133 -17
  49. package/commands/proteum/diagnostics.ts +11 -0
  50. package/common/dev/commands.ts +50 -0
  51. package/common/dev/diagnostics.ts +298 -0
  52. package/common/dev/profiler.ts +92 -0
  53. package/common/dev/proteumManifest.ts +135 -0
  54. package/common/dev/requestTrace.ts +115 -0
  55. package/common/env/proteumEnv.ts +284 -0
  56. package/common/router/index.ts +4 -22
  57. package/docs/dev-commands.md +93 -0
  58. package/docs/diagnostics.md +88 -0
  59. package/docs/request-tracing.md +132 -0
  60. package/eslint.js +11 -6
  61. package/package.json +3 -3
  62. package/server/app/commands.ts +35 -370
  63. package/server/app/commandsManager.ts +393 -0
  64. package/server/app/container/config.ts +11 -49
  65. package/server/app/container/console/index.ts +2 -3
  66. package/server/app/container/index.ts +5 -2
  67. package/server/app/container/trace/index.ts +364 -0
  68. package/server/app/devCommands.ts +192 -0
  69. package/server/app/devDiagnostics.ts +53 -0
  70. package/server/app/index.ts +29 -6
  71. package/server/index.ts +0 -1
  72. package/server/services/auth/index.ts +525 -61
  73. package/server/services/auth/router/index.ts +106 -7
  74. package/server/services/cron/CronTask.ts +73 -5
  75. package/server/services/cron/index.ts +34 -11
  76. package/server/services/fetch/index.ts +3 -10
  77. package/server/services/prisma/index.ts +66 -4
  78. package/server/services/router/http/index.ts +173 -6
  79. package/server/services/router/index.ts +200 -12
  80. package/server/services/router/request/api.ts +30 -1
  81. package/server/services/router/response/index.ts +83 -10
  82. package/server/services/router/response/page/document.tsx +16 -0
  83. package/server/services/router/response/page/index.tsx +27 -1
  84. package/skills/clean-project-code/SKILL.md +7 -2
  85. package/test-results/.last-run.json +4 -0
  86. package/types/aliases.d.ts +6 -0
  87. package/types/global/utils.d.ts +7 -14
  88. package/Rte.zip +0 -0
  89. package/agents/project/agents.md.zip +0 -0
  90. package/doc/TODO.md +0 -71
  91. package/doc/front/router.md +0 -27
  92. package/doc/workspace/workspace.png +0 -0
  93. package/doc/workspace/workspace2.png +0 -0
  94. package/doc/workspace/workspace_26.01.22.png +0 -0
  95. package/server/services/router/http/session.ts.old +0 -40
@@ -0,0 +1,720 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import slugify from 'slugify';
4
+ import yaml from 'yaml';
5
+ import { UsageError } from 'clipanion';
6
+
7
+ import cli from '..';
8
+ import { ensureProjectAgentSymlinks } from '../utils/agents';
9
+ import { runProcess } from '../utils/runProcess';
10
+ import {
11
+ createClientTsconfigTemplate,
12
+ createCommandTemplate,
13
+ createControllerTemplate,
14
+ createEslintConfigTemplate,
15
+ createEnvTemplate,
16
+ createGitignoreTemplate,
17
+ createIdentityTemplate,
18
+ createInitSummary,
19
+ createPackageJsonTemplate,
20
+ createPageTemplate,
21
+ createRouteTemplate,
22
+ createRouterConfigTemplate,
23
+ createServerIndexTemplate,
24
+ createServerTsconfigTemplate,
25
+ createServiceConfigTemplate,
26
+ createServiceTemplate,
27
+ } from './templates';
28
+ import type { TScaffoldFilePlan, TScaffoldInitConfig, TScaffoldKind, TScaffoldResult } from './types';
29
+
30
+ type TCreatePlan = {
31
+ files: TScaffoldFilePlan[];
32
+ nextSteps: string[];
33
+ notes: string[];
34
+ postWrite?: () => { updated: string[]; notes: string[] };
35
+ };
36
+
37
+ type TIdentityConfig = {
38
+ identifier: string;
39
+ name: string;
40
+ };
41
+
42
+ const createEmptyResult = ({ dryRun }: { dryRun: boolean }): TScaffoldResult => ({
43
+ dryRun,
44
+ created: [],
45
+ updated: [],
46
+ skipped: [],
47
+ notes: [],
48
+ nextSteps: [],
49
+ });
50
+
51
+ const isJson = () => cli.args.json === true;
52
+ const isDryRun = () => cli.args.dryRun === true;
53
+ const isForce = () => cli.args.force === true;
54
+
55
+ const ensureStringArg = (name: string) => {
56
+ const value = cli.args[name];
57
+ if (typeof value === 'string') return value.trim();
58
+ return '';
59
+ };
60
+
61
+ const ensureBooleanArg = (name: string) => cli.args[name] === true;
62
+
63
+ const toPosix = (value: string) => value.replace(/\\/g, '/');
64
+
65
+ const stripKnownExtension = (value: string) => value.replace(/\.(tsx|ts|jsx|js)$/i, '');
66
+
67
+ const splitSegments = (value: string) =>
68
+ toPosix(value)
69
+ .split('/')
70
+ .map((segment) => segment.trim())
71
+ .filter(Boolean);
72
+
73
+ const stripPrefix = (value: string, prefix: string) => {
74
+ const normalizedValue = toPosix(value);
75
+ const normalizedPrefix = toPosix(prefix);
76
+ return normalizedValue.startsWith(`${normalizedPrefix}/`) ? normalizedValue.substring(normalizedPrefix.length + 1) : normalizedValue;
77
+ };
78
+
79
+ const stripTrailingIndex = (segments: string[]) => (segments[segments.length - 1] === 'index' ? segments.slice(0, -1) : segments);
80
+
81
+ const findLastMatchingIndex = (lines: string[], matcher: RegExp) => {
82
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
83
+ if (matcher.test(lines[index])) return index;
84
+ }
85
+
86
+ return -1;
87
+ };
88
+
89
+ const toWords = (value: string) =>
90
+ value
91
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
92
+ .split(/[^A-Za-z0-9]+/)
93
+ .map((part) => part.trim())
94
+ .filter(Boolean);
95
+
96
+ const toPascal = (value: string) =>
97
+ toWords(value)
98
+ .map((part) => part.charAt(0).toUpperCase() + part.substring(1))
99
+ .join('');
100
+
101
+ const toCamel = (value: string) => {
102
+ const pascal = toPascal(value);
103
+ return pascal ? pascal.charAt(0).toLowerCase() + pascal.substring(1) : '';
104
+ };
105
+
106
+ const toSentence = (value: string) =>
107
+ toWords(value)
108
+ .map((part, index) => (index === 0 ? part.charAt(0).toUpperCase() + part.substring(1).toLowerCase() : part.toLowerCase()))
109
+ .join(' ');
110
+
111
+ const toSlug = (value: string) =>
112
+ slugify(value, {
113
+ lower: true,
114
+ strict: true,
115
+ trim: true,
116
+ });
117
+
118
+ const normalizePageSegments = (rawTarget: string) => {
119
+ const trimmed = stripKnownExtension(stripPrefix(rawTarget.trim(), 'client/pages'));
120
+ return stripTrailingIndex(splitSegments(trimmed));
121
+ };
122
+
123
+ const normalizeSourceFileSegments = (rawTarget: string, prefix: string) => {
124
+ const trimmed = stripKnownExtension(stripPrefix(rawTarget.trim(), prefix));
125
+ return splitSegments(trimmed);
126
+ };
127
+
128
+ const defaultRouteFromSegments = (segments: string[]) => {
129
+ if (segments.length === 0) return '/';
130
+ return `/${segments.map((segment) => toSlug(segment) || segment.toLowerCase()).join('/')}`;
131
+ };
132
+
133
+ const resolveRootServiceLeaf = (segments: string[]) => segments[segments.length - 1];
134
+
135
+ const readIdentityConfig = (appRoot: string): TIdentityConfig => {
136
+ const identityFilepath = path.join(appRoot, 'identity.yaml');
137
+ if (!fs.existsSync(identityFilepath)) {
138
+ throw new UsageError(`Missing identity.yaml in ${appRoot}. Run \`proteum init\` first or target a Proteum app root.`);
139
+ }
140
+
141
+ const parsed = yaml.parse(fs.readFileSync(identityFilepath, 'utf8')) as Partial<TIdentityConfig> | null;
142
+ const identifier = typeof parsed?.identifier === 'string' ? parsed.identifier.trim() : '';
143
+ const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
144
+
145
+ if (!identifier) throw new UsageError(`identity.yaml in ${appRoot} is missing a valid "identifier" field.`);
146
+
147
+ return {
148
+ identifier,
149
+ name: name || identifier,
150
+ };
151
+ };
152
+
153
+ const assertProteumAppRoot = (appRoot: string) => {
154
+ const expectedEntries = ['package.json', 'identity.yaml', 'client', 'server'];
155
+ const missing = expectedEntries.filter((entry) => !fs.existsSync(path.join(appRoot, entry)));
156
+ if (missing.length > 0) {
157
+ throw new UsageError(
158
+ `This command expects a Proteum app root. Missing: ${missing.join(', ')} in ${appRoot}.`,
159
+ );
160
+ }
161
+ };
162
+
163
+ const ensureDirectory = (filepath: string, rootDir: string, result: TScaffoldResult) => {
164
+ const directory = path.dirname(filepath);
165
+ if (directory === rootDir) return;
166
+ fs.ensureDirSync(directory);
167
+ };
168
+
169
+ const writeFilePlan = ({ rootDir, filePlan, result }: { rootDir: string; filePlan: TScaffoldFilePlan; result: TScaffoldResult }) => {
170
+ const absolutePath = path.join(rootDir, filePlan.relativePath);
171
+ const relativePath = toPosix(filePlan.relativePath);
172
+ const exists = fs.existsSync(absolutePath);
173
+
174
+ if (exists && !isForce()) {
175
+ throw new UsageError(`Refusing to overwrite existing file without --force: ${relativePath}`);
176
+ }
177
+
178
+ if (result.dryRun) {
179
+ result.created.push(relativePath);
180
+ return;
181
+ }
182
+
183
+ fs.ensureDirSync(rootDir);
184
+ ensureDirectory(absolutePath, rootDir, result);
185
+ fs.writeFileSync(absolutePath, filePlan.content, 'utf8');
186
+
187
+ if (exists) result.updated.push(relativePath);
188
+ else result.created.push(relativePath);
189
+ };
190
+
191
+ const maybeWriteFilePlans = ({ rootDir, filePlans, result }: { rootDir: string; filePlans: TScaffoldFilePlan[]; result: TScaffoldResult }) => {
192
+ for (const filePlan of filePlans) writeFilePlan({ rootDir, filePlan, result });
193
+ };
194
+
195
+ const printResult = (result: TScaffoldResult, extra: unknown = null) => {
196
+ if (isJson()) {
197
+ const payload = extra && typeof extra === 'object' ? { ...result, ...(extra as object) } : result;
198
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
199
+ return;
200
+ }
201
+
202
+ const lines: string[] = [];
203
+ const actionLabel = result.dryRun ? 'Planned files' : 'Created files';
204
+
205
+ if (result.created.length > 0) {
206
+ lines.push(`${actionLabel}:`);
207
+ lines.push(...result.created.map((entry) => `- ${entry}`));
208
+ }
209
+
210
+ if (result.updated.length > 0) {
211
+ lines.push('Updated files:');
212
+ lines.push(...result.updated.map((entry) => `- ${entry}`));
213
+ }
214
+
215
+ if (result.notes.length > 0) {
216
+ lines.push('Notes:');
217
+ lines.push(...result.notes.map((entry) => `- ${entry}`));
218
+ }
219
+
220
+ if (result.nextSteps.length > 0) {
221
+ lines.push('Next steps:');
222
+ lines.push(...result.nextSteps.map((entry) => `- ${entry}`));
223
+ }
224
+
225
+ process.stdout.write(`${lines.join('\n')}\n`);
226
+ };
227
+
228
+ const shouldUseLocalCoreDependency = () => !toPosix(cli.paths.core.root).includes('/node_modules/');
229
+
230
+ const resolveProteumDependency = () => {
231
+ const override = ensureStringArg('proteumVersion');
232
+ if (override) return override;
233
+ if (shouldUseLocalCoreDependency()) return `file:${cli.paths.core.root}`;
234
+ return `^${String(cli.packageJson.version || '')}`;
235
+ };
236
+
237
+ const createPagePlan = (target: string): TCreatePlan => {
238
+ const pageSegments = normalizePageSegments(target);
239
+ const routePath = ensureStringArg('route') || defaultRouteFromSegments(pageSegments);
240
+ const relativePath =
241
+ pageSegments.length === 0 ? path.join('client', 'pages', 'index.tsx') : path.join('client', 'pages', ...pageSegments, 'index.tsx');
242
+ const heading = toSentence(pageSegments[pageSegments.length - 1] || 'Home');
243
+
244
+ return {
245
+ files: [
246
+ {
247
+ relativePath,
248
+ content: createPageTemplate({
249
+ routePath,
250
+ heading,
251
+ message: 'This page was generated by Proteum create.',
252
+ }),
253
+ },
254
+ ],
255
+ notes: ['Review the generated route path and SSR setup values before committing.'],
256
+ nextSteps: ['Run `npx proteum refresh`.', 'Run `npx proteum explain --routes` to verify discovery.'],
257
+ };
258
+ };
259
+
260
+ const createControllerPlan = ({ appIdentifier, target }: { appIdentifier: string; target: string }): TCreatePlan => {
261
+ const segments = normalizeSourceFileSegments(target, 'server/controllers');
262
+ if (segments.length === 0) throw new UsageError('Create controller requires a target path, for example `Founder/projects`.');
263
+
264
+ const relativePath = path.join('server', 'controllers', ...segments) + '.ts';
265
+ const className = `${segments.map(toPascal).join('')}Controller`;
266
+ const methodName = ensureStringArg('method') || 'run';
267
+
268
+ return {
269
+ files: [
270
+ {
271
+ relativePath,
272
+ content: createControllerTemplate({
273
+ appIdentifier,
274
+ className,
275
+ methodName,
276
+ }),
277
+ },
278
+ ],
279
+ notes: ['Wire the generated controller method into a real service call before exposing it to production traffic.'],
280
+ nextSteps: ['Run `npx proteum refresh`.', 'Run `npx proteum explain --controllers` to verify discovery.'],
281
+ };
282
+ };
283
+
284
+ const createCommandPlan = ({ target }: { target: string }): TCreatePlan => {
285
+ const segments = normalizeSourceFileSegments(target, 'commands');
286
+ if (segments.length === 0) throw new UsageError('Create command requires a target path, for example `diagnostics`.');
287
+
288
+ const relativePath = path.join('commands', ...segments) + '.ts';
289
+ const className = `${segments.map(toPascal).join('')}Commands`;
290
+ const methodName = ensureStringArg('method') || 'run';
291
+
292
+ return {
293
+ files: [
294
+ {
295
+ relativePath,
296
+ content: createCommandTemplate({
297
+ className,
298
+ methodName,
299
+ }),
300
+ },
301
+ ],
302
+ notes: ['Commands are dev-only internal entrypoints and should not replace normal controllers or routes.'],
303
+ nextSteps: ['Run `npx proteum refresh`.', 'Run `npx proteum explain --commands` to verify discovery.'],
304
+ };
305
+ };
306
+
307
+ const createRoutePlan = ({ target }: { target: string }): TCreatePlan => {
308
+ const segments = normalizeSourceFileSegments(target, 'server/routes');
309
+ if (segments.length === 0) throw new UsageError('Create route requires a target path, for example `webhooks/stripe`.');
310
+
311
+ const httpMethod = (ensureStringArg('httpMethod') || 'get').toLowerCase();
312
+ const routePath = ensureStringArg('route') || defaultRouteFromSegments(segments);
313
+ const relativePath = path.join('server', 'routes', ...segments) + '.ts';
314
+
315
+ return {
316
+ files: [
317
+ {
318
+ relativePath,
319
+ content: createRouteTemplate({
320
+ httpMethod,
321
+ routePath,
322
+ }),
323
+ },
324
+ ],
325
+ notes: ['Prefer controllers for normal app APIs; use manual routes only for explicit HTTP semantics.'],
326
+ nextSteps: ['Run `npx proteum refresh`.', 'Run `npx proteum explain --routes` to verify discovery.'],
327
+ };
328
+ };
329
+
330
+ const insertImportLine = ({
331
+ content,
332
+ importLine,
333
+ matcher,
334
+ fallbackMatcher,
335
+ }: {
336
+ content: string;
337
+ importLine: string;
338
+ matcher: RegExp;
339
+ fallbackMatcher: RegExp;
340
+ }) => {
341
+ if (content.includes(importLine)) return content;
342
+
343
+ const lines = content.split('\n');
344
+ const preferredIndex = findLastMatchingIndex(lines, matcher);
345
+ const fallbackIndex = findLastMatchingIndex(lines, fallbackMatcher);
346
+ const classIndex = lines.findIndex((line) => line.includes('export default class '));
347
+ const insertIndex = preferredIndex >= 0 ? preferredIndex + 1 : fallbackIndex >= 0 ? fallbackIndex + 1 : Math.max(classIndex, 0);
348
+
349
+ lines.splice(insertIndex, 0, importLine);
350
+ return lines.join('\n');
351
+ };
352
+
353
+ const insertClassProperty = ({
354
+ content,
355
+ propertyLine,
356
+ }: {
357
+ content: string;
358
+ propertyLine: string;
359
+ }) => {
360
+ if (content.includes(propertyLine.trim())) return content;
361
+
362
+ const lines = content.split('\n');
363
+ const classIndex = lines.findIndex((line) => line.includes('export default class ') && line.includes('extends Application'));
364
+ if (classIndex < 0) throw new UsageError('Could not locate the app Application class in server/index.ts.');
365
+
366
+ const closingIndex = lines.length - 1 - [...lines].reverse().findIndex((line) => line.trim() === '}');
367
+ const candidateIndex = (() => {
368
+ for (let index = closingIndex - 1; index > classIndex; index -= 1) {
369
+ if (/^\s+public .*= new .*;\s*$/.test(lines[index])) return index + 1;
370
+ }
371
+ for (let index = classIndex + 1; index < closingIndex; index += 1) {
372
+ if (/^\s+public .*[;!]\s*$/.test(lines[index])) return index + 1;
373
+ }
374
+ return classIndex + 1;
375
+ })();
376
+
377
+ lines.splice(candidateIndex, 0, propertyLine);
378
+ return lines.join('\n');
379
+ };
380
+
381
+ const registerRootService = ({
382
+ appRoot,
383
+ servicePath,
384
+ serviceImportName,
385
+ configFileBase,
386
+ configNamespace,
387
+ configExportName,
388
+ propertyName,
389
+ }: {
390
+ appRoot: string;
391
+ servicePath: string;
392
+ serviceImportName: string;
393
+ configFileBase: string;
394
+ configNamespace: string;
395
+ configExportName: string;
396
+ propertyName: string;
397
+ }) => {
398
+ const serverIndexFilepath = path.join(appRoot, 'server', 'index.ts');
399
+ if (!fs.existsSync(serverIndexFilepath)) {
400
+ return {
401
+ updated: [] as string[],
402
+ notes: ['Could not auto-register the new service because server/index.ts is missing.'],
403
+ };
404
+ }
405
+
406
+ const serviceImportLine = `import ${serviceImportName} from ${JSON.stringify(`@/server/services/${toPosix(servicePath)}`)};`;
407
+ const configImportLine = `import * as ${configNamespace} from ${JSON.stringify(`@/server/config/${configFileBase}`)};`;
408
+ const propertyLine = ` public ${propertyName} = new ${serviceImportName}(this, ${configNamespace}.${configExportName}, this);`;
409
+
410
+ let content = fs.readFileSync(serverIndexFilepath, 'utf8');
411
+ const initialContent = content;
412
+
413
+ content = insertImportLine({
414
+ content,
415
+ importLine: serviceImportLine,
416
+ matcher: /^import .* from ['"]@\/server\/services\//,
417
+ fallbackMatcher: /^import .* from ['"]@server\//,
418
+ });
419
+ content = insertImportLine({
420
+ content,
421
+ importLine: configImportLine,
422
+ matcher: /^import \* as .* from ['"]@\/server\/config\//,
423
+ fallbackMatcher: /^import .* from ['"]@\/server\/services\//,
424
+ });
425
+ content = insertClassProperty({ content, propertyLine });
426
+
427
+ if (content === initialContent) {
428
+ return {
429
+ updated: [] as string[],
430
+ notes: ['The new service already appears to be registered in server/index.ts.'],
431
+ };
432
+ }
433
+
434
+ if (!isDryRun()) fs.writeFileSync(serverIndexFilepath, content, 'utf8');
435
+
436
+ return {
437
+ updated: ['server/index.ts'],
438
+ notes: ['Auto-registered the new root service in server/index.ts.'],
439
+ };
440
+ };
441
+
442
+ const createServicePlan = ({
443
+ appIdentifier,
444
+ appRoot,
445
+ target,
446
+ }: {
447
+ appIdentifier: string;
448
+ appRoot: string;
449
+ target: string;
450
+ }): TCreatePlan => {
451
+ const segments = normalizeSourceFileSegments(target, 'server/services');
452
+ if (segments.length === 0) throw new UsageError('Create service requires a target path, for example `Analytics` or `Conversion/Plans`.');
453
+
454
+ const leaf = resolveRootServiceLeaf(segments);
455
+ const serviceImportName = toPascal(leaf);
456
+ const className = `${serviceImportName}Service`;
457
+ const configFileBase = toCamel(leaf) || leaf.toLowerCase();
458
+ const configNamespace = `${configFileBase}Config`;
459
+ const configExportName = `${configFileBase}Config`;
460
+ const relativeServiceDir = path.join('server', 'services', ...segments);
461
+ const relativeServiceFilepath = path.join(relativeServiceDir, 'index.ts');
462
+ const relativeServiceConfigFilepath = path.join(relativeServiceDir, 'service.json');
463
+ const relativeConfigFilepath = path.join('server', 'config', `${configFileBase}.ts`);
464
+ const propertyName = serviceImportName;
465
+
466
+ const serviceJson = JSON.stringify(
467
+ {
468
+ id: `${appIdentifier}/${serviceImportName}`,
469
+ name: `${appIdentifier}${serviceImportName}`,
470
+ parent: 'app',
471
+ dependences: [],
472
+ },
473
+ null,
474
+ 4,
475
+ ) + '\n';
476
+
477
+ return {
478
+ files: [
479
+ {
480
+ relativePath: relativeServiceFilepath,
481
+ content: createServiceTemplate({
482
+ appIdentifier,
483
+ className,
484
+ }),
485
+ },
486
+ {
487
+ relativePath: relativeServiceConfigFilepath,
488
+ content: serviceJson,
489
+ },
490
+ {
491
+ relativePath: relativeConfigFilepath,
492
+ content: createServiceConfigTemplate({
493
+ configExportName,
494
+ serviceImportPath: `@/server/services/${toPosix(segments.join('/'))}`,
495
+ serviceImportName,
496
+ }),
497
+ },
498
+ ],
499
+ notes: ['Root services must be explicitly registered in server/index.ts and use typed config from server/config/*.ts.'],
500
+ nextSteps: ['Run `npx proteum refresh`.', 'Run `npx proteum explain --services` to verify discovery.'],
501
+ postWrite: () =>
502
+ registerRootService({
503
+ appRoot,
504
+ servicePath: segments.join('/'),
505
+ serviceImportName,
506
+ configFileBase,
507
+ configNamespace,
508
+ configExportName,
509
+ propertyName,
510
+ }),
511
+ };
512
+ };
513
+
514
+ const buildCreatePlan = ({ appRoot, appIdentifier, kind, target }: { appRoot: string; appIdentifier: string; kind: TScaffoldKind; target: string }) => {
515
+ switch (kind) {
516
+ case 'page':
517
+ return createPagePlan(target);
518
+ case 'controller':
519
+ return createControllerPlan({ appIdentifier, target });
520
+ case 'command':
521
+ return createCommandPlan({ target });
522
+ case 'route':
523
+ return createRoutePlan({ target });
524
+ case 'service':
525
+ return createServicePlan({ appIdentifier, appRoot, target });
526
+ default:
527
+ throw new UsageError(`Unsupported scaffold kind: ${kind}.`);
528
+ }
529
+ };
530
+
531
+ export const runCreateScaffold = async () => {
532
+ const appRoot = cli.paths.appRoot;
533
+ assertProteumAppRoot(appRoot);
534
+
535
+ const rawKind = ensureStringArg('kind');
536
+ const rawTarget = ensureStringArg('target');
537
+ const allowedKinds: TScaffoldKind[] = ['page', 'controller', 'command', 'route', 'service'];
538
+
539
+ if (!allowedKinds.includes(rawKind as TScaffoldKind)) {
540
+ throw new UsageError(`Unknown scaffold kind "${rawKind}". Allowed values: ${allowedKinds.join(', ')}.`);
541
+ }
542
+
543
+ if (!rawTarget) throw new UsageError('Create requires a target path, for example `proteum create page landing/faq`.');
544
+
545
+ const { identifier } = readIdentityConfig(appRoot);
546
+ const plan = buildCreatePlan({
547
+ appRoot,
548
+ appIdentifier: identifier,
549
+ kind: rawKind as TScaffoldKind,
550
+ target: rawTarget,
551
+ });
552
+ const result = createEmptyResult({ dryRun: isDryRun() });
553
+
554
+ maybeWriteFilePlans({ rootDir: appRoot, filePlans: plan.files, result });
555
+
556
+ if (plan.postWrite) {
557
+ const postWriteResult = plan.postWrite();
558
+ result.updated.push(...postWriteResult.updated);
559
+ result.notes.push(...postWriteResult.notes);
560
+ }
561
+
562
+ result.notes.push(...plan.notes);
563
+ result.nextSteps.push(...plan.nextSteps);
564
+ printResult(result);
565
+ };
566
+
567
+ const toDefaultIdentifier = (name: string) => {
568
+ const identifier = toPascal(name);
569
+ return identifier || 'ProteumApp';
570
+ };
571
+
572
+ const toDefaultDirectory = (name: string) => {
573
+ const slug = toSlug(name);
574
+ return slug || 'proteum-app';
575
+ };
576
+
577
+ const resolveInitConfig = async (): Promise<TScaffoldInitConfig> => {
578
+ let directory = ensureStringArg('directory');
579
+ let name = ensureStringArg('name');
580
+ let description = ensureStringArg('description');
581
+ let identifier = ensureStringArg('identifier');
582
+ const rawPort = ensureStringArg('port');
583
+ let port = rawPort ? Number(rawPort) : 3000;
584
+
585
+ if (!directory && name) directory = toDefaultDirectory(name);
586
+ if (!name && directory) name = toSentence(directory.replace(/[-_]/g, ' '));
587
+ if (!name) name = 'Proteum App';
588
+ if (!directory) directory = toDefaultDirectory(name);
589
+ if (!description) description = `${name} built with Proteum.`;
590
+ if (!identifier) identifier = toDefaultIdentifier(name);
591
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
592
+ throw new UsageError(`Invalid --port "${rawPort}". Expected an integer between 1 and 65535.`);
593
+ }
594
+
595
+ const url = ensureStringArg('url') || `http://localhost:${port}`;
596
+
597
+ return {
598
+ directory,
599
+ name,
600
+ identifier,
601
+ description,
602
+ port,
603
+ url,
604
+ install: ensureBooleanArg('install'),
605
+ proteumDependency: resolveProteumDependency(),
606
+ };
607
+ };
608
+
609
+ const assertInitTarget = ({ appRoot }: { appRoot: string }) => {
610
+ if (!fs.existsSync(appRoot)) return;
611
+ const entries = fs.readdirSync(appRoot);
612
+ if (entries.length === 0) return;
613
+ if (isForce()) return;
614
+
615
+ throw new UsageError(
616
+ `Refusing to scaffold into non-empty directory without --force: ${appRoot}`,
617
+ );
618
+ };
619
+
620
+ const createInitFilePlans = (config: TScaffoldInitConfig): TScaffoldFilePlan[] => [
621
+ {
622
+ relativePath: 'package.json',
623
+ content: createPackageJsonTemplate({
624
+ packageName: toSlug(config.name) || 'proteum-app',
625
+ appDescription: config.description,
626
+ proteumDependency: config.proteumDependency,
627
+ preactDependency: String(cli.packageJson.dependencies?.preact || '^10.27.1'),
628
+ }),
629
+ },
630
+ {
631
+ relativePath: 'identity.yaml',
632
+ content: createIdentityTemplate({
633
+ appName: config.name,
634
+ appIdentifier: config.identifier,
635
+ appDescription: config.description,
636
+ }),
637
+ },
638
+ {
639
+ relativePath: '.env',
640
+ content: createEnvTemplate({
641
+ port: config.port,
642
+ url: config.url,
643
+ }),
644
+ },
645
+ {
646
+ relativePath: '.gitignore',
647
+ content: createGitignoreTemplate(),
648
+ },
649
+ {
650
+ relativePath: 'eslint.config.mjs',
651
+ content: createEslintConfigTemplate(),
652
+ },
653
+ {
654
+ relativePath: path.join('client', 'tsconfig.json'),
655
+ content: createClientTsconfigTemplate(),
656
+ },
657
+ {
658
+ relativePath: path.join('server', 'tsconfig.json'),
659
+ content: createServerTsconfigTemplate(),
660
+ },
661
+ {
662
+ relativePath: path.join('server', 'config', 'app.ts'),
663
+ content: createRouterConfigTemplate(),
664
+ },
665
+ {
666
+ relativePath: path.join('server', 'index.ts'),
667
+ content: createServerIndexTemplate({
668
+ appIdentifier: config.identifier,
669
+ }),
670
+ },
671
+ {
672
+ relativePath: path.join('client', 'pages', 'index.tsx'),
673
+ content: createPageTemplate({
674
+ routePath: '/',
675
+ heading: config.name,
676
+ message: 'Proteum init generated this page. Replace it with your real entrypoint.',
677
+ }),
678
+ },
679
+ ];
680
+
681
+ export const runInitScaffold = async () => {
682
+ const config = await resolveInitConfig();
683
+ const appRoot = path.resolve(cli.args.workdir as string, config.directory);
684
+ assertInitTarget({ appRoot });
685
+
686
+ const result = createEmptyResult({ dryRun: isDryRun() });
687
+ const filePlans = createInitFilePlans(config);
688
+
689
+ maybeWriteFilePlans({ rootDir: appRoot, filePlans, result });
690
+
691
+ if (!result.dryRun) {
692
+ fs.ensureDirSync(path.join(appRoot, 'client'));
693
+ fs.ensureDirSync(path.join(appRoot, 'server'));
694
+ ensureProjectAgentSymlinks({ appRoot, coreRoot: cli.paths.core.root });
695
+ }
696
+
697
+ if (config.install) {
698
+ if (result.dryRun) result.notes.push('Install was requested, but dry-run mode does not execute npm install.');
699
+ else {
700
+ await runProcess('npm', ['install'], { cwd: appRoot });
701
+ result.notes.push('Installed app dependencies with npm install.');
702
+ }
703
+ }
704
+
705
+ result.notes.push(
706
+ shouldUseLocalCoreDependency()
707
+ ? 'This scaffold targets the current local Proteum checkout through a file: dependency.'
708
+ : `This scaffold targets Proteum ${config.proteumDependency}.`,
709
+ );
710
+ result.nextSteps.push(
711
+ result.dryRun
712
+ ? `Rerun \`proteum init ${JSON.stringify(config.directory)} --name ${JSON.stringify(config.name)}\` without \`--dry-run\` when you want to write the scaffold.`
713
+ : config.install
714
+ ? 'Run `npm run dev` in the new app directory.'
715
+ : 'Run `npm install`, then `npm run dev` in the new app directory.',
716
+ );
717
+ result.nextSteps.push('Use `proteum create page|controller|command|route|service ...` to add app artifacts.');
718
+
719
+ printResult(result, createInitSummary(result, config));
720
+ };