proteum 2.5.4 → 2.5.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,767 @@
1
+ import fs from 'fs-extra';
2
+ import net from 'net';
3
+ import path from 'path';
4
+ import dotenv from 'dotenv';
5
+
6
+ import type { TProteumMcpNextAction } from '../../common/dev/mcpPayloads';
7
+ import type { TProteumManifest } from '../../common/dev/proteumManifest';
8
+ import { quoteShellPath, readProteumAppRootSummary } from '../utils/appRoots';
9
+ import { inspectDevPort } from './ports';
10
+
11
+ type TPreflightState = 'blocked' | 'ready' | 'warning';
12
+ type TCheckStatus = 'blocked' | 'ok' | 'skipped' | 'warning';
13
+
14
+ export type TFreshCopyPreflight = {
15
+ state: TPreflightState;
16
+ blockers: string[];
17
+ warnings: string[];
18
+ roots: {
19
+ appRoot: string;
20
+ installRoot: string;
21
+ packageManager: 'npm' | 'pnpm' | 'yarn' | 'unknown';
22
+ };
23
+ env: {
24
+ app: {
25
+ filepath: string;
26
+ present: boolean;
27
+ exampleFilepath?: string;
28
+ keys: string[];
29
+ };
30
+ root?: {
31
+ filepath: string;
32
+ present: boolean;
33
+ required: boolean;
34
+ exampleFilepath?: string;
35
+ keys: string[];
36
+ };
37
+ requiredKeys: {
38
+ missing: string[];
39
+ provided: string[];
40
+ total: number;
41
+ };
42
+ };
43
+ dependencies: {
44
+ installCommand: string;
45
+ lockfile?: string;
46
+ nodeModulesPresent: boolean;
47
+ packageJsonPresent: boolean;
48
+ status: TCheckStatus;
49
+ };
50
+ generated: {
51
+ manifestPresent: boolean;
52
+ manifestReadable: boolean;
53
+ status: TCheckStatus;
54
+ };
55
+ database: {
56
+ configFilepath?: string;
57
+ datasourceProvider?: string;
58
+ detected: boolean;
59
+ generatedClientPresent?: boolean;
60
+ localTcp?: {
61
+ checked: boolean;
62
+ host?: string;
63
+ port?: number;
64
+ reachable?: boolean;
65
+ reason?: string;
66
+ };
67
+ requiredEnvKey?: string;
68
+ schemaFilepath?: string;
69
+ status: TCheckStatus;
70
+ url?: {
71
+ database?: string;
72
+ host?: string;
73
+ port?: string;
74
+ protocol?: string;
75
+ redacted: string;
76
+ usernamePresent: boolean;
77
+ };
78
+ };
79
+ connectedProjects: Array<{
80
+ namespace: string;
81
+ sourceKind?: string;
82
+ sourceValue?: string;
83
+ status: TCheckStatus;
84
+ urlInternal?: string;
85
+ }>;
86
+ };
87
+
88
+ export type TFreshCopyPreflightResult = {
89
+ nextActions: TProteumMcpNextAction[];
90
+ readiness: TFreshCopyPreflight;
91
+ };
92
+
93
+ type TBuildFreshCopyPreflightArgs = {
94
+ appRoot: string;
95
+ baseRoot?: string;
96
+ manifest?: TProteumManifest;
97
+ };
98
+
99
+ type TPackageManager = TFreshCopyPreflight['roots']['packageManager'];
100
+
101
+ const envFileName = '.env';
102
+ const envExampleFilenames = ['.env.example', '.env.local.example', '.env.development.example'];
103
+ const baseRequiredEnvKeys = ['ENV_NAME', 'ENV_PROFILE', 'PORT', 'URL', 'URL_INTERNAL'];
104
+ const databaseUrlEnvKey = 'DATABASE_URL';
105
+
106
+ const statusBlocked = 'blocked' as const;
107
+ const statusOk = 'ok' as const;
108
+ const statusSkipped = 'skipped' as const;
109
+ const statusWarning = 'warning' as const;
110
+
111
+ const normalizePath = (value: string) => path.normalize(path.resolve(value));
112
+
113
+ const pathEntryExists = (filepath: string) => {
114
+ try {
115
+ fs.lstatSync(filepath);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ };
121
+
122
+ const isDirectory = (filepath: string) => {
123
+ try {
124
+ return fs.statSync(filepath).isDirectory();
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+
130
+ const readJsonFile = (filepath: string) => {
131
+ try {
132
+ return fs.readJSONSync(filepath) as Record<string, unknown>;
133
+ } catch {
134
+ return {};
135
+ }
136
+ };
137
+
138
+ const findNearestFile = (startPath: string, filenames: string[]) => {
139
+ let currentPath = normalizePath(startPath);
140
+
141
+ while (true) {
142
+ for (const filename of filenames) {
143
+ const candidate = path.join(currentPath, filename);
144
+ if (pathEntryExists(candidate)) return candidate;
145
+ }
146
+
147
+ const parentPath = path.dirname(currentPath);
148
+ if (parentPath === currentPath) return undefined;
149
+ currentPath = parentPath;
150
+ }
151
+ };
152
+
153
+ const findVisibleDirectory = (startPath: string, directoryName: string) => {
154
+ let currentPath = normalizePath(startPath);
155
+
156
+ while (true) {
157
+ const candidate = path.join(currentPath, directoryName);
158
+ if (isDirectory(candidate)) return candidate;
159
+
160
+ const parentPath = path.dirname(currentPath);
161
+ if (parentPath === currentPath) return undefined;
162
+ currentPath = parentPath;
163
+ }
164
+ };
165
+
166
+ const findPackageJsonRoot = (startPath: string) => {
167
+ let currentPath = normalizePath(startPath);
168
+
169
+ while (true) {
170
+ if (pathEntryExists(path.join(currentPath, 'package.json'))) return currentPath;
171
+
172
+ const parentPath = path.dirname(currentPath);
173
+ if (parentPath === currentPath) return normalizePath(startPath);
174
+ currentPath = parentPath;
175
+ }
176
+ };
177
+
178
+ const resolvePackageManager = (lockfile?: string, packageRoot?: string): TPackageManager => {
179
+ if (lockfile?.endsWith('package-lock.json')) return 'npm';
180
+ if (lockfile?.endsWith('pnpm-lock.yaml')) return 'pnpm';
181
+ if (lockfile?.endsWith('yarn.lock')) return 'yarn';
182
+
183
+ const packageJson = packageRoot ? readJsonFile(path.join(packageRoot, 'package.json')) : {};
184
+ const packageManager = typeof packageJson.packageManager === 'string' ? packageJson.packageManager : '';
185
+ if (packageManager.startsWith('pnpm@')) return 'pnpm';
186
+ if (packageManager.startsWith('yarn@')) return 'yarn';
187
+ if (packageManager.startsWith('npm@')) return 'npm';
188
+
189
+ return 'unknown';
190
+ };
191
+
192
+ const resolveInstallRoot = (appRoot: string) => {
193
+ const lockfile = findNearestFile(appRoot, ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
194
+ const packageRoot = lockfile ? path.dirname(lockfile) : findPackageJsonRoot(appRoot);
195
+ const packageManager = resolvePackageManager(lockfile, packageRoot);
196
+
197
+ return {
198
+ installRoot: packageRoot,
199
+ lockfile,
200
+ packageManager,
201
+ };
202
+ };
203
+
204
+ const createScopedCommand = ({
205
+ baseRoot,
206
+ command,
207
+ cwd,
208
+ }: {
209
+ baseRoot?: string;
210
+ command: string;
211
+ cwd: string;
212
+ }) => {
213
+ const normalizedCwd = normalizePath(cwd);
214
+ const normalizedBaseRoot = baseRoot ? normalizePath(baseRoot) : normalizedCwd;
215
+ if (normalizedCwd === normalizedBaseRoot) return command;
216
+
217
+ const relative = path.relative(normalizedBaseRoot, normalizedCwd);
218
+ const cdTarget = relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : normalizedCwd;
219
+ return `cd ${quoteShellPath(cdTarget)} && ${command}`;
220
+ };
221
+
222
+ const createInstallCommand = (packageManager: TPackageManager) => {
223
+ if (packageManager === 'pnpm') return 'pnpm install';
224
+ if (packageManager === 'yarn') return 'yarn install';
225
+ return 'npm install';
226
+ };
227
+
228
+ const createRefreshCommand = (cwd: string, baseRoot?: string) =>
229
+ createScopedCommand({ baseRoot, command: 'npx proteum refresh', cwd });
230
+
231
+ const createRuntimeStatusCommand = (cwd: string, baseRoot?: string) =>
232
+ createScopedCommand({ baseRoot, command: 'npx proteum runtime status', cwd });
233
+
234
+ const createStartDevCommand = (cwd: string, port: number | undefined, baseRoot?: string) =>
235
+ createScopedCommand({
236
+ baseRoot,
237
+ command: `npx proteum dev --session-file var/run/proteum/dev/agents/<task>.json --port ${port || '<free-port>'}`,
238
+ cwd,
239
+ });
240
+
241
+ const createCopyEnvCommand = (cwd: string, exampleFilepath: string, targetFilepath: string, baseRoot?: string) =>
242
+ createScopedCommand({
243
+ baseRoot,
244
+ command: `cp ${quoteShellPath(path.relative(cwd, exampleFilepath) || path.basename(exampleFilepath))} ${quoteShellPath(
245
+ path.relative(cwd, targetFilepath) || path.basename(targetFilepath),
246
+ )}`,
247
+ cwd,
248
+ });
249
+
250
+ const findEnvExample = (root: string) =>
251
+ envExampleFilenames.map((filename) => path.join(root, filename)).find((filepath) => pathEntryExists(filepath));
252
+
253
+ const readEnvFile = (filepath: string) => {
254
+ if (!pathEntryExists(filepath)) return {};
255
+
256
+ try {
257
+ return dotenv.parse(fs.readFileSync(filepath));
258
+ } catch {
259
+ return {};
260
+ }
261
+ };
262
+
263
+ const hasWorkspaceRootTooling = (workspaceRoot: string) => {
264
+ if (pathEntryExists(path.join(workspaceRoot, 'prisma.config.ts'))) return true;
265
+
266
+ const packageJson = readJsonFile(path.join(workspaceRoot, 'package.json'));
267
+ return Array.isArray(packageJson.workspaces);
268
+ };
269
+
270
+ const resolveWorkspaceRootEnv = (appRoot: string, installRoot: string) => {
271
+ const normalizedAppRoot = normalizePath(appRoot);
272
+ const normalizedInstallRoot = normalizePath(installRoot);
273
+ if (normalizedAppRoot === normalizedInstallRoot) return undefined;
274
+ if (!hasWorkspaceRootTooling(normalizedInstallRoot)) return undefined;
275
+
276
+ return path.join(normalizedInstallRoot, envFileName);
277
+ };
278
+
279
+ const mergeEnvValues = (...sources: Array<Record<string, string> | undefined>) => {
280
+ const output: Record<string, string> = {};
281
+ for (const source of sources) {
282
+ if (!source) continue;
283
+ for (const [key, value] of Object.entries(source)) output[key] = value;
284
+ }
285
+ return output;
286
+ };
287
+
288
+ const unique = <TValue>(values: TValue[]) => [...new Set(values)];
289
+
290
+ const resolveRequiredEnvKeys = ({ databaseDetected, manifest }: { databaseDetected: boolean; manifest?: TProteumManifest }) =>
291
+ unique([
292
+ ...(manifest ? manifest.env.requiredVariables.map((variable) => variable.key) : baseRequiredEnvKeys),
293
+ ...(databaseDetected ? [databaseUrlEnvKey] : []),
294
+ ]);
295
+
296
+ const findSchemaFromPrismaConfig = (configFilepath: string) => {
297
+ try {
298
+ const content = fs.readFileSync(configFilepath, 'utf8');
299
+ const match = content.match(/\bschema\s*:\s*['"`]([^'"`]+)['"`]/);
300
+ const configuredSchema = match?.[1]?.trim();
301
+ if (!configuredSchema) return undefined;
302
+
303
+ const resolved = path.resolve(path.dirname(configFilepath), configuredSchema);
304
+ if (pathEntryExists(resolved) && isDirectory(resolved)) return path.join(resolved, 'schema.prisma');
305
+ return resolved;
306
+ } catch {
307
+ return undefined;
308
+ }
309
+ };
310
+
311
+ const findPrismaSchema = (appRoot: string, installRoot: string) => {
312
+ const configFilepath = findNearestFile(appRoot, ['prisma.config.ts']);
313
+ const configSchema = configFilepath ? findSchemaFromPrismaConfig(configFilepath) : undefined;
314
+ if (configSchema && pathEntryExists(configSchema)) return { configFilepath, schemaFilepath: configSchema };
315
+
316
+ const candidates = unique([
317
+ path.join(appRoot, 'prisma', 'schema.prisma'),
318
+ path.join(appRoot, 'schema.prisma'),
319
+ path.join(installRoot, 'prisma', 'schema.prisma'),
320
+ path.join(installRoot, 'packages', 'db', 'prisma', 'schema.prisma'),
321
+ ]);
322
+ const schemaFilepath = candidates.find((candidate) => pathEntryExists(candidate));
323
+
324
+ return { configFilepath, schemaFilepath };
325
+ };
326
+
327
+ const readPrismaSchemaInfo = (schemaFilepath: string | undefined) => {
328
+ if (!schemaFilepath || !pathEntryExists(schemaFilepath)) return {};
329
+
330
+ try {
331
+ const content = fs.readFileSync(schemaFilepath, 'utf8');
332
+ const provider = content.match(/\bprovider\s*=\s*["']([^"']+)["']/)?.[1];
333
+ const output = content.match(/\boutput\s*=\s*["']([^"']+)["']/)?.[1];
334
+ return { output, provider };
335
+ } catch {
336
+ return {};
337
+ }
338
+ };
339
+
340
+ const resolveGeneratedClientPath = ({
341
+ appRoot,
342
+ installRoot,
343
+ output,
344
+ schemaFilepath,
345
+ }: {
346
+ appRoot: string;
347
+ installRoot: string;
348
+ output?: string;
349
+ schemaFilepath?: string;
350
+ }) => {
351
+ if (output && schemaFilepath) return path.resolve(path.dirname(schemaFilepath), output);
352
+
353
+ const candidates = [
354
+ path.join(appRoot, 'var', 'prisma'),
355
+ path.join(installRoot, 'node_modules', '.prisma', 'client'),
356
+ path.join(appRoot, 'node_modules', '.prisma', 'client'),
357
+ ];
358
+
359
+ return candidates.find((candidate) => pathEntryExists(candidate));
360
+ };
361
+
362
+ const resolvePrismaCommand = ({
363
+ appRoot,
364
+ command,
365
+ configFilepath,
366
+ schemaFilepath,
367
+ }: {
368
+ appRoot: string;
369
+ command: 'generate' | 'migrate status';
370
+ configFilepath?: string;
371
+ schemaFilepath?: string;
372
+ }) => {
373
+ const cwd = configFilepath ? path.dirname(configFilepath) : appRoot;
374
+ if (configFilepath) return { command: `npx prisma ${command} --config ./prisma.config.ts`, cwd };
375
+ if (!schemaFilepath) return { command: `npx prisma ${command}`, cwd };
376
+
377
+ return {
378
+ command: `npx prisma ${command} --schema ${quoteShellPath(path.relative(cwd, schemaFilepath))}`,
379
+ cwd,
380
+ };
381
+ };
382
+
383
+ const redactDatabaseUrl = (databaseUrl: string) => {
384
+ const parsed = new URL(databaseUrl);
385
+ const database = parsed.pathname.replace(/^\/+/, '') || undefined;
386
+ const host = parsed.hostname || undefined;
387
+ const port = parsed.port || undefined;
388
+ const auth = parsed.username ? '<user>@' : '';
389
+ const hostPort = `${host || '<host>'}${port ? `:${port}` : ''}`;
390
+
391
+ return {
392
+ database,
393
+ host,
394
+ port,
395
+ protocol: parsed.protocol.replace(/:$/, ''),
396
+ redacted: `${parsed.protocol}//${auth}${hostPort}${database ? `/${database}` : ''}`,
397
+ usernamePresent: Boolean(parsed.username),
398
+ };
399
+ };
400
+
401
+ const resolveDatabasePort = (url: URL) => {
402
+ if (url.port) return Number(url.port);
403
+ if (url.protocol === 'postgres:' || url.protocol === 'postgresql:') return 5432;
404
+ if (url.protocol === 'mysql:' || url.protocol === 'mariadb:') return 3306;
405
+ return undefined;
406
+ };
407
+
408
+ const isSupportedDatabaseProtocol = (protocol: string) =>
409
+ protocol === 'mysql:' || protocol === 'mariadb:' || protocol === 'postgres:' || protocol === 'postgresql:';
410
+
411
+ const isLocalDatabaseHost = (host: string) => ['localhost', '127.0.0.1', '::1'].includes(host);
412
+
413
+ const probeLocalTcp = async (host: string, port: number, timeoutMs = 350) =>
414
+ await new Promise<boolean>((resolve) => {
415
+ const socket = net.createConnection({ host, port });
416
+ let settled = false;
417
+ const finish = (value: boolean) => {
418
+ if (settled) return;
419
+ settled = true;
420
+ socket.destroy();
421
+ resolve(value);
422
+ };
423
+
424
+ socket.setTimeout(timeoutMs);
425
+ socket.once('connect', () => finish(true));
426
+ socket.once('timeout', () => finish(false));
427
+ socket.once('error', () => finish(false));
428
+ });
429
+
430
+ const resolveDatabaseReadiness = async ({
431
+ appRoot,
432
+ baseRoot,
433
+ envValues,
434
+ installRoot,
435
+ }: {
436
+ appRoot: string;
437
+ baseRoot?: string;
438
+ envValues: Record<string, string>;
439
+ installRoot: string;
440
+ }) => {
441
+ const { configFilepath, schemaFilepath } = findPrismaSchema(appRoot, installRoot);
442
+ const schemaPresent = Boolean(schemaFilepath);
443
+ const schemaInfo = readPrismaSchemaInfo(schemaFilepath);
444
+ const generatedClientPath = resolveGeneratedClientPath({
445
+ appRoot,
446
+ installRoot,
447
+ output: schemaInfo.output,
448
+ schemaFilepath,
449
+ });
450
+ const generatedClientPresent = schemaPresent ? Boolean(generatedClientPath && pathEntryExists(generatedClientPath)) : undefined;
451
+ const databaseUrl = envValues[databaseUrlEnvKey]?.trim();
452
+ const blockers: string[] = [];
453
+ const warnings: string[] = [];
454
+ const nextActions: TProteumMcpNextAction[] = [];
455
+ let url: TFreshCopyPreflight['database']['url'];
456
+ let localTcp: TFreshCopyPreflight['database']['localTcp'];
457
+
458
+ if (!schemaPresent) {
459
+ return {
460
+ blockers,
461
+ database: {
462
+ configFilepath,
463
+ detected: false,
464
+ status: statusSkipped,
465
+ } satisfies TFreshCopyPreflight['database'],
466
+ nextActions,
467
+ warnings,
468
+ };
469
+ }
470
+
471
+ if (!databaseUrl) {
472
+ blockers.push('DATABASE_URL is required for the detected Prisma schema.');
473
+ } else {
474
+ try {
475
+ const parsed = new URL(databaseUrl);
476
+ url = redactDatabaseUrl(databaseUrl);
477
+
478
+ if (!isSupportedDatabaseProtocol(parsed.protocol)) {
479
+ blockers.push(`DATABASE_URL uses unsupported protocol ${parsed.protocol}`);
480
+ } else if (!parsed.pathname.replace(/^\/+/, '')) {
481
+ blockers.push('DATABASE_URL must include a database name.');
482
+ } else if (parsed.hostname && isLocalDatabaseHost(parsed.hostname)) {
483
+ const port = resolveDatabasePort(parsed);
484
+ if (port) {
485
+ const reachable = await probeLocalTcp(parsed.hostname, port);
486
+ localTcp = { checked: true, host: parsed.hostname, port, reachable };
487
+ if (!reachable) blockers.push(`Local database ${parsed.hostname}:${port} is not reachable.`);
488
+ }
489
+ } else {
490
+ localTcp = {
491
+ checked: false,
492
+ host: parsed.hostname || undefined,
493
+ port: resolveDatabasePort(parsed),
494
+ reason: 'Remote database hosts are not probed by workflow_start.',
495
+ };
496
+ }
497
+ } catch (error) {
498
+ blockers.push(error instanceof Error ? `DATABASE_URL is invalid: ${error.message}` : 'DATABASE_URL is invalid.');
499
+ }
500
+ }
501
+
502
+ if (!generatedClientPresent) {
503
+ blockers.push('Prisma generated client artifacts are missing.');
504
+ const generate = resolvePrismaCommand({ appRoot, command: 'generate', configFilepath, schemaFilepath });
505
+ nextActions.push({
506
+ label: 'Generate Prisma Client',
507
+ command: createScopedCommand({ baseRoot, command: generate.command, cwd: generate.cwd }),
508
+ reason: 'Prisma schema is present but generated client artifacts were not found.',
509
+ });
510
+ }
511
+
512
+ if (databaseUrl && blockers.length === 0) {
513
+ const migrateStatus = resolvePrismaCommand({ appRoot, command: 'migrate status', configFilepath, schemaFilepath });
514
+ nextActions.push({
515
+ label: 'Check Prisma Migrations',
516
+ command: createScopedCommand({ baseRoot, command: migrateStatus.command, cwd: migrateStatus.cwd }),
517
+ reason: 'Verify database migration state with a read-only Prisma status command before starting dev.',
518
+ });
519
+ }
520
+
521
+ return {
522
+ blockers,
523
+ database: {
524
+ configFilepath,
525
+ datasourceProvider: schemaInfo.provider,
526
+ detected: true,
527
+ generatedClientPresent,
528
+ localTcp,
529
+ requiredEnvKey: databaseUrlEnvKey,
530
+ schemaFilepath,
531
+ status: blockers.length > 0 ? statusBlocked : warnings.length > 0 ? statusWarning : statusOk,
532
+ url,
533
+ } satisfies TFreshCopyPreflight['database'],
534
+ nextActions,
535
+ warnings,
536
+ };
537
+ };
538
+
539
+ const resolveConnectedProjectActions = async ({
540
+ appRoot,
541
+ baseRoot,
542
+ manifest,
543
+ }: {
544
+ appRoot: string;
545
+ baseRoot?: string;
546
+ manifest?: TProteumManifest;
547
+ }) => {
548
+ const blockers: string[] = [];
549
+ const connectedProjects: TFreshCopyPreflight['connectedProjects'] = [];
550
+ const nextActions: TProteumMcpNextAction[] = [];
551
+
552
+ for (const project of manifest?.connectedProjects || []) {
553
+ const status = project.sourceKind === 'file' && project.sourceValue && !pathEntryExists(project.sourceValue) ? statusBlocked : statusOk;
554
+ connectedProjects.push({
555
+ namespace: project.namespace,
556
+ sourceKind: project.sourceKind,
557
+ sourceValue: project.sourceValue,
558
+ status,
559
+ urlInternal: project.urlInternal,
560
+ });
561
+
562
+ if (status === statusBlocked) {
563
+ blockers.push(`Connected project ${project.namespace} source is missing.`);
564
+ continue;
565
+ }
566
+
567
+ if (project.sourceKind !== 'file' || !project.sourceValue) continue;
568
+
569
+ const producerRoot = normalizePath(project.sourceValue);
570
+ const producerSummary = readProteumAppRootSummary(producerRoot, baseRoot);
571
+ const producerPort = producerSummary.manifest
572
+ ? await inspectDevPort({ appRoot: producerRoot, port: producerSummary.manifest.routerPort })
573
+ : undefined;
574
+ const startPort =
575
+ producerPort && !producerPort.canStartOnConfiguredPort ? producerPort.recommendedPort : producerSummary.manifest?.routerPort;
576
+
577
+ nextActions.push({
578
+ label: `Workflow Start ${project.namespace}`,
579
+ tool: 'workflow_start',
580
+ toolArgs: { cwd: producerRoot, task: `prepare connected producer ${project.namespace}` },
581
+ reason: 'Run the same fresh-copy preflight against the connected producer app before the consumer starts.',
582
+ });
583
+
584
+ if (producerSummary.hasManifest) {
585
+ nextActions.push({
586
+ label: `Start ${project.namespace}`,
587
+ command: createStartDevCommand(producerRoot, startPort, baseRoot),
588
+ reason: 'Start the local connected producer before validating the consumer app.',
589
+ });
590
+ } else {
591
+ nextActions.push({
592
+ label: `Refresh ${project.namespace}`,
593
+ command: createRefreshCommand(producerRoot, baseRoot),
594
+ reason: 'Generate the connected producer manifest before starting it.',
595
+ });
596
+ }
597
+ }
598
+
599
+ return { blockers, connectedProjects, nextActions };
600
+ };
601
+
602
+ const dedupeNextActions = (actions: TProteumMcpNextAction[]) => {
603
+ const seen = new Set<string>();
604
+ const output: TProteumMcpNextAction[] = [];
605
+
606
+ for (const action of actions) {
607
+ const key = JSON.stringify({
608
+ command: action.command,
609
+ label: action.label,
610
+ tool: action.tool,
611
+ toolArgs: action.toolArgs,
612
+ });
613
+ if (seen.has(key)) continue;
614
+ seen.add(key);
615
+ output.push(action);
616
+ }
617
+
618
+ return output;
619
+ };
620
+
621
+ export const buildFreshCopyPreflight = async ({
622
+ appRoot,
623
+ baseRoot,
624
+ manifest,
625
+ }: TBuildFreshCopyPreflightArgs): Promise<TFreshCopyPreflightResult> => {
626
+ const normalizedAppRoot = normalizePath(appRoot);
627
+ const { installRoot, lockfile, packageManager } = resolveInstallRoot(normalizedAppRoot);
628
+ const appEnvFilepath = path.join(normalizedAppRoot, envFileName);
629
+ const rootEnvFilepath = resolveWorkspaceRootEnv(normalizedAppRoot, installRoot);
630
+ const appEnv = readEnvFile(appEnvFilepath);
631
+ const rootEnv = rootEnvFilepath ? readEnvFile(rootEnvFilepath) : undefined;
632
+ const envValues = mergeEnvValues(rootEnv, appEnv);
633
+ const databaseReadiness = await resolveDatabaseReadiness({
634
+ appRoot: normalizedAppRoot,
635
+ baseRoot,
636
+ envValues,
637
+ installRoot,
638
+ });
639
+ const requiredEnvKeys = resolveRequiredEnvKeys({ databaseDetected: databaseReadiness.database.detected, manifest });
640
+ const missingRequiredEnvKeys = requiredEnvKeys.filter((key) => !envValues[key]?.trim());
641
+ const providedRequiredEnvKeys = requiredEnvKeys.filter((key) => envValues[key]?.trim());
642
+ const blockers: string[] = [];
643
+ const warnings: string[] = [];
644
+ const nextActions: TProteumMcpNextAction[] = [];
645
+ const appEnvExample = findEnvExample(normalizedAppRoot);
646
+ const rootEnvExample = rootEnvFilepath ? findEnvExample(path.dirname(rootEnvFilepath)) : undefined;
647
+ const appEnvPresent = pathEntryExists(appEnvFilepath);
648
+ const rootEnvPresent = rootEnvFilepath ? pathEntryExists(rootEnvFilepath) : false;
649
+ const packageJsonPresent = pathEntryExists(path.join(normalizedAppRoot, 'package.json'));
650
+ const nodeModulesPresent = findVisibleDirectory(normalizedAppRoot, 'node_modules') !== undefined;
651
+ const manifestFilepath = path.join(normalizedAppRoot, '.proteum', 'manifest.json');
652
+ const manifestPresent = pathEntryExists(manifestFilepath);
653
+ const manifestReadable = Boolean(manifest);
654
+ const connected = await resolveConnectedProjectActions({ appRoot: normalizedAppRoot, baseRoot, manifest });
655
+
656
+ if (!appEnvPresent) {
657
+ blockers.push('App .env is missing.');
658
+ nextActions.push({
659
+ label: appEnvExample ? 'Copy App Env Example' : 'Create App Env',
660
+ ...(appEnvExample ? { command: createCopyEnvCommand(normalizedAppRoot, appEnvExample, appEnvFilepath, baseRoot) } : {}),
661
+ reason: appEnvExample
662
+ ? 'Create the app .env from the tracked example, then fill any secret values.'
663
+ : `Create ${appEnvFilepath} with the required app runtime variables.`,
664
+ });
665
+ }
666
+
667
+ if (rootEnvFilepath && !rootEnvPresent) {
668
+ blockers.push('Workspace root .env is missing.');
669
+ nextActions.push({
670
+ label: rootEnvExample ? 'Copy Root Env Example' : 'Create Root Env',
671
+ ...(rootEnvExample
672
+ ? { command: createCopyEnvCommand(path.dirname(rootEnvFilepath), rootEnvExample, rootEnvFilepath, baseRoot) }
673
+ : {}),
674
+ reason: rootEnvExample
675
+ ? 'Create the workspace root .env from the tracked example, then fill any secret values.'
676
+ : `Create ${rootEnvFilepath} for workspace-root tooling such as Prisma or package scripts.`,
677
+ });
678
+ }
679
+
680
+ if (missingRequiredEnvKeys.length > 0) {
681
+ blockers.push(`Missing required env keys: ${missingRequiredEnvKeys.join(', ')}.`);
682
+ nextActions.push({
683
+ label: 'Fill Env Values',
684
+ reason: `Add values for ${missingRequiredEnvKeys.join(', ')} without committing secrets.`,
685
+ });
686
+ }
687
+
688
+ if (!packageJsonPresent) {
689
+ blockers.push('package.json is missing.');
690
+ }
691
+
692
+ if (!nodeModulesPresent) {
693
+ blockers.push('node_modules is missing.');
694
+ nextActions.push({
695
+ label: 'Install Dependencies',
696
+ command: createScopedCommand({ baseRoot, command: createInstallCommand(packageManager), cwd: installRoot }),
697
+ reason: 'Install dependencies at the detected package root before refresh, Prisma, or dev commands.',
698
+ });
699
+ }
700
+
701
+ if (!manifestPresent || !manifestReadable) {
702
+ blockers.push(!manifestPresent ? '.proteum/manifest.json is missing.' : '.proteum/manifest.json is not readable.');
703
+ nextActions.push({
704
+ label: 'Refresh Manifest',
705
+ command: createRefreshCommand(normalizedAppRoot, baseRoot),
706
+ reason: 'Generate Proteum manifest and generated runtime artifacts before owner, route, or dev reads.',
707
+ });
708
+ }
709
+
710
+ blockers.push(...databaseReadiness.blockers, ...connected.blockers);
711
+ warnings.push(...databaseReadiness.warnings);
712
+ nextActions.push(...databaseReadiness.nextActions, ...connected.nextActions);
713
+
714
+ const state: TPreflightState = blockers.length > 0 ? 'blocked' : warnings.length > 0 ? 'warning' : 'ready';
715
+
716
+ return {
717
+ nextActions: dedupeNextActions(nextActions),
718
+ readiness: {
719
+ state,
720
+ blockers,
721
+ warnings,
722
+ roots: {
723
+ appRoot: normalizedAppRoot,
724
+ installRoot,
725
+ packageManager,
726
+ },
727
+ env: {
728
+ app: {
729
+ filepath: appEnvFilepath,
730
+ present: appEnvPresent,
731
+ exampleFilepath: appEnvExample,
732
+ keys: Object.keys(appEnv).sort(),
733
+ },
734
+ ...(rootEnvFilepath
735
+ ? {
736
+ root: {
737
+ filepath: rootEnvFilepath,
738
+ present: rootEnvPresent,
739
+ required: true,
740
+ exampleFilepath: rootEnvExample,
741
+ keys: Object.keys(rootEnv || {}).sort(),
742
+ },
743
+ }
744
+ : {}),
745
+ requiredKeys: {
746
+ missing: missingRequiredEnvKeys,
747
+ provided: providedRequiredEnvKeys,
748
+ total: requiredEnvKeys.length,
749
+ },
750
+ },
751
+ dependencies: {
752
+ installCommand: createInstallCommand(packageManager),
753
+ lockfile,
754
+ nodeModulesPresent,
755
+ packageJsonPresent,
756
+ status: !packageJsonPresent || !nodeModulesPresent ? statusBlocked : statusOk,
757
+ },
758
+ generated: {
759
+ manifestPresent,
760
+ manifestReadable,
761
+ status: manifestPresent && manifestReadable ? statusOk : statusBlocked,
762
+ },
763
+ database: databaseReadiness.database,
764
+ connectedProjects: connected.connectedProjects,
765
+ },
766
+ };
767
+ };