proteum 2.2.2-1 → 2.2.3

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.
@@ -259,6 +259,63 @@ class CheckCommand extends ProteumCommand {
259
259
  }
260
260
  }
261
261
 
262
+ class E2eCommand extends ProteumCommand {
263
+ public static paths = [['e2e']];
264
+
265
+ public static usage = buildUsage('e2e');
266
+
267
+ public cwd = Option.String('--cwd', { description: 'Run Playwright against another Proteum app root.' });
268
+ public port = Option.String('--port', { description: 'Set E2E_BASE_URL from a local router port.' });
269
+ public url = Option.String('--url', { description: 'Set E2E_BASE_URL from an explicit base URL.' });
270
+ public sessionEmail = Option.String('--session-email', {
271
+ description: 'Mint a dev session before Playwright starts and pass it as E2E_AUTH_TOKEN.',
272
+ });
273
+ public sessionRole = Option.String('--session-role', { description: 'Require the dev session user to have this role.' });
274
+ public env = Option.Array('--env', [], { description: 'Pass an environment value to Playwright as KEY=value.' });
275
+ public envFile = Option.Array('--env-file', [], { description: 'Load environment values from a dotenv file before Playwright starts.' });
276
+ public config = Option.String('--config', { description: 'Playwright config file.' });
277
+ public debug = Option.Boolean('--debug', false, { description: 'Run Playwright in debug mode.' });
278
+ public grep = Option.String('--grep', { description: 'Playwright grep filter.' });
279
+ public headed = Option.Boolean('--headed', false, { description: 'Run browsers in headed mode.' });
280
+ public list = Option.Boolean('--list', false, { description: 'List Playwright tests without running them.' });
281
+ public project = Option.Array('--project', [], { description: 'Playwright project name. Can be repeated.' });
282
+ public reporter = Option.String('--reporter', { description: 'Playwright reporter.' });
283
+ public retries = Option.String('--retries', { description: 'Playwright retry count.' });
284
+ public timeout = Option.String('--timeout', { description: 'Playwright per-test timeout.' });
285
+ public ui = Option.Boolean('--ui', false, { description: 'Run Playwright in UI mode.' });
286
+ public workers = Option.String('--workers', { description: 'Playwright worker count.' });
287
+ public specs = Option.Rest();
288
+
289
+ public async execute() {
290
+ const playwrightArgs = [
291
+ ...(this.config ? ['--config', this.config] : []),
292
+ ...(this.debug ? ['--debug'] : []),
293
+ ...(this.grep ? ['--grep', this.grep] : []),
294
+ ...(this.headed ? ['--headed'] : []),
295
+ ...(this.list ? ['--list'] : []),
296
+ ...this.project.flatMap((project) => ['--project', project]),
297
+ ...(this.reporter ? ['--reporter', this.reporter] : []),
298
+ ...(this.retries ? ['--retries', this.retries] : []),
299
+ ...(this.timeout ? ['--timeout', this.timeout] : []),
300
+ ...(this.ui ? ['--ui'] : []),
301
+ ...(this.workers ? ['--workers', this.workers] : []),
302
+ ...this.specs,
303
+ ];
304
+
305
+ this.setCliArgs({
306
+ env: this.env,
307
+ envFile: this.envFile,
308
+ playwrightArgs,
309
+ port: this.port ?? '',
310
+ sessionEmail: this.sessionEmail ?? '',
311
+ sessionRole: this.sessionRole ?? '',
312
+ url: this.url ?? '',
313
+ workdir: this.cwd ?? '',
314
+ });
315
+ return await runCommandModule(() => import('../commands/e2e'));
316
+ }
317
+ }
318
+
262
319
  class ConnectCommand extends ProteumCommand {
263
320
  public static paths = [['connect']];
264
321
 
@@ -608,6 +665,7 @@ export const registeredCommands = {
608
665
  typecheck: TypecheckCommand,
609
666
  lint: LintCommand,
610
667
  check: CheckCommand,
668
+ e2e: E2eCommand,
611
669
  connect: ConnectCommand,
612
670
  doctor: DoctorCommand,
613
671
  explain: ExplainCommand,
@@ -640,6 +698,7 @@ export const createCli = (version: string) => {
640
698
  clipanion.register(TypecheckCommand);
641
699
  clipanion.register(LintCommand);
642
700
  clipanion.register(CheckCommand);
701
+ clipanion.register(E2eCommand);
643
702
  clipanion.register(ConnectCommand);
644
703
  clipanion.register(DoctorCommand);
645
704
  clipanion.register(ExplainCommand);
@@ -727,7 +727,7 @@ export const runInitScaffold = async () => {
727
727
  ? 'Run `npm run dev` in the new app directory.'
728
728
  : 'Run `npm install`, then `npm run dev` in the new app directory.',
729
729
  );
730
- result.nextSteps.push('Run `proteum configure agents` when you want Proteum-managed instruction symlinks.');
730
+ result.nextSteps.push('Run `proteum configure agents` when you want Proteum-managed instruction stubs.');
731
731
  result.nextSteps.push('Use `proteum create page|controller|command|route|service ...` to add app artifacts.');
732
732
 
733
733
  printResult(result, createInitSummary(result, config));
@@ -12,7 +12,7 @@ import { logVerbose } from '../runtime/verbose';
12
12
  ----------------------------------*/
13
13
 
14
14
  type TProjectInstructionArgs = { coreRoot: string };
15
- type TConfigureProjectAgentSymlinksArgs = {
15
+ type TConfigureProjectAgentInstructionsArgs = {
16
16
  appRoot: string;
17
17
  coreRoot: string;
18
18
  dryRun?: boolean;
@@ -20,9 +20,14 @@ type TConfigureProjectAgentSymlinksArgs = {
20
20
  overwriteBlockedPaths?: string[];
21
21
  };
22
22
 
23
- type TAgentLinkDefinition = { projectPath: string; sourcePath: string; ensureParentDir?: boolean };
23
+ type TAgentInstructionDefinition = {
24
+ includeWorktreeBootstrap?: boolean;
25
+ projectPath: string;
26
+ sourcePath: string;
27
+ ensureParentDir?: boolean;
28
+ };
24
29
 
25
- type TEnsureSymlinksResult = {
30
+ type TEnsureInstructionStubsResult = {
26
31
  blocked: string[];
27
32
  created: string[];
28
33
  overwritten: string[];
@@ -30,7 +35,7 @@ type TEnsureSymlinksResult = {
30
35
  updated: string[];
31
36
  };
32
37
 
33
- export type TConfigureProjectAgentSymlinksResult = {
38
+ export type TConfigureProjectAgentInstructionsResult = {
34
39
  appRoot: string;
35
40
  blocked: string[];
36
41
  created: string[];
@@ -51,7 +56,24 @@ export type TProjectAgentFileInspection = {
51
56
  - CONSTANTS
52
57
  ----------------------------------*/
53
58
 
54
- const sharedAppAgentLinkDefinitions: TAgentLinkDefinition[] = [
59
+ const managedInstructionStubHeader = '# Proteum Managed Instructions';
60
+ const managedInstructionStubIntro = 'This file is managed by `proteum configure agents`.';
61
+ const newProteumWorktreeBootstrapBlock = [
62
+ '## New Proteum Worktree Bootstrap',
63
+ '',
64
+ 'When starting work in a newly created Proteum worktree, immediately copy the `.env` file from the main worktree, change it to use a different free port by updating `PORT`, `URL`, and `URL_INTERNAL`, and run:',
65
+ '',
66
+ '```',
67
+ 'npx proteum configure agents',
68
+ 'npm i',
69
+ 'npx prisma generate',
70
+ 'npx proteum refresh',
71
+ '```',
72
+ '',
73
+ 'Run it before reading project-specific AGENTS files or making code changes. If the command is interactive, answer according to the current repository layout, then continue by reading the generated AGENTS.md files.',
74
+ ];
75
+
76
+ const sharedAppAgentInstructionDefinitions: TAgentInstructionDefinition[] = [
55
77
  { projectPath: 'CODING_STYLE.md', sourcePath: 'CODING_STYLE.md' },
56
78
  { projectPath: 'diagnostics.md', sourcePath: 'diagnostics.md' },
57
79
  { projectPath: 'optimizations.md', sourcePath: 'optimizations.md' },
@@ -65,34 +87,36 @@ const sharedAppAgentLinkDefinitions: TAgentLinkDefinition[] = [
65
87
  { projectPath: path.join('tests', 'e2e', 'AGENTS.md'), sourcePath: path.join('tests', 'AGENTS.md') },
66
88
  ];
67
89
 
68
- const standaloneAppAgentLinkDefinitions: TAgentLinkDefinition[] = [
69
- { projectPath: 'AGENTS.md', sourcePath: 'AGENTS.md' },
70
- ...sharedAppAgentLinkDefinitions,
90
+ const standaloneAppAgentInstructionDefinitions: TAgentInstructionDefinition[] = [
91
+ { projectPath: 'AGENTS.md', sourcePath: 'AGENTS.md', includeWorktreeBootstrap: true },
92
+ ...sharedAppAgentInstructionDefinitions,
71
93
  ];
72
94
 
73
- const monorepoAppAgentLinkDefinitions: TAgentLinkDefinition[] = [
95
+ const monorepoAppAgentInstructionDefinitions: TAgentInstructionDefinition[] = [
74
96
  { projectPath: 'AGENTS.md', sourcePath: path.join('app-root', 'AGENTS.md') },
75
- ...sharedAppAgentLinkDefinitions,
97
+ ...sharedAppAgentInstructionDefinitions,
76
98
  ];
77
99
 
78
- const monorepoRootAgentLinkDefinitions: TAgentLinkDefinition[] = [
79
- { projectPath: 'AGENTS.md', sourcePath: path.join('root', 'AGENTS.md') },
100
+ const monorepoRootAgentInstructionDefinitions: TAgentInstructionDefinition[] = [
101
+ { projectPath: 'AGENTS.md', sourcePath: path.join('root', 'AGENTS.md'), includeWorktreeBootstrap: true },
80
102
  ];
81
103
 
82
- const projectInstructionGitignoreBlockStart = '# Proteum-managed instruction symlinks';
83
- const projectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction symlinks';
104
+ const legacyProjectInstructionGitignoreBlockStart = '# Proteum-managed instruction symlinks';
105
+ const legacyProjectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction symlinks';
106
+ const projectInstructionGitignoreBlockStart = '# Proteum-managed instruction files';
107
+ const projectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction files';
84
108
 
85
109
  /*----------------------------------
86
110
  - PUBLIC API
87
111
  ----------------------------------*/
88
112
 
89
- export function configureProjectAgentSymlinks({
113
+ export function configureProjectAgentInstructions({
90
114
  appRoot,
91
115
  coreRoot,
92
116
  dryRun = false,
93
117
  monorepoRoot,
94
118
  overwriteBlockedPaths = [],
95
- }: TConfigureProjectAgentSymlinksArgs): TConfigureProjectAgentSymlinksResult {
119
+ }: TConfigureProjectAgentInstructionsArgs): TConfigureProjectAgentInstructionsResult {
96
120
  const normalizedAppRoot = path.resolve(appRoot);
97
121
  const normalizedMonorepoRoot = monorepoRoot ? path.resolve(monorepoRoot) : undefined;
98
122
  const normalizedOverwriteBlockedPaths = new Set(
@@ -100,7 +124,7 @@ export function configureProjectAgentSymlinks({
100
124
  );
101
125
  const mode =
102
126
  normalizedMonorepoRoot && normalizedMonorepoRoot !== normalizedAppRoot ? ('monorepo' as const) : ('standalone' as const);
103
- const result: TConfigureProjectAgentSymlinksResult = {
127
+ const result: TConfigureProjectAgentInstructionsResult = {
104
128
  appRoot: normalizedAppRoot,
105
129
  blocked: [],
106
130
  created: [],
@@ -114,50 +138,66 @@ export function configureProjectAgentSymlinks({
114
138
  if (mode === 'monorepo' && normalizedMonorepoRoot) {
115
139
  result.monorepoRoot = normalizedMonorepoRoot;
116
140
 
117
- const rootLinks = getRootAgentLinkDefinitions({ coreRoot });
118
- const rootSymlinks = ensureSymlinks(normalizedMonorepoRoot, rootLinks, '[agents]', path.join(coreRoot, 'agents', 'project'), {
119
- dryRun,
120
- overwriteBlockedPaths: normalizedOverwriteBlockedPaths,
121
- });
122
- mergeSymlinkResults(result, rootSymlinks, normalizedMonorepoRoot);
123
-
124
- if (!dryRun && ensureInstructionGitignoreEntries({ rootDir: normalizedMonorepoRoot, linkDefinitions: rootLinks }))
141
+ const rootInstructions = getRootAgentInstructionDefinitions({ coreRoot });
142
+ const rootStubs = ensureInstructionStubs(
143
+ normalizedMonorepoRoot,
144
+ rootInstructions,
145
+ '[agents]',
146
+ path.join(coreRoot, 'agents', 'project'),
147
+ {
148
+ dryRun,
149
+ overwriteBlockedPaths: normalizedOverwriteBlockedPaths,
150
+ },
151
+ );
152
+ mergeInstructionResults(result, rootStubs, normalizedMonorepoRoot);
153
+
154
+ if (!dryRun && ensureInstructionGitignoreEntries({ rootDir: normalizedMonorepoRoot, instructionDefinitions: rootInstructions }))
125
155
  result.updatedGitignores.push(path.join(normalizedMonorepoRoot, '.gitignore'));
126
156
  }
127
157
 
128
- const appLinks = getAppAgentLinkDefinitions({ coreRoot, mode });
129
- const appSymlinks = ensureSymlinks(normalizedAppRoot, appLinks, '[agents]', path.join(coreRoot, 'agents', 'project'), {
130
- dryRun,
131
- overwriteBlockedPaths: normalizedOverwriteBlockedPaths,
132
- });
133
- mergeSymlinkResults(result, appSymlinks, normalizedAppRoot);
158
+ const appInstructions = getAppAgentInstructionDefinitions({ coreRoot, mode });
159
+ const appStubs = ensureInstructionStubs(
160
+ normalizedAppRoot,
161
+ appInstructions,
162
+ '[agents]',
163
+ path.join(coreRoot, 'agents', 'project'),
164
+ {
165
+ dryRun,
166
+ overwriteBlockedPaths: normalizedOverwriteBlockedPaths,
167
+ },
168
+ );
169
+ mergeInstructionResults(result, appStubs, normalizedAppRoot);
134
170
 
135
- if (!dryRun && ensureInstructionGitignoreEntries({ rootDir: normalizedAppRoot, linkDefinitions: appLinks }))
171
+ if (!dryRun && ensureInstructionGitignoreEntries({ rootDir: normalizedAppRoot, instructionDefinitions: appInstructions }))
136
172
  result.updatedGitignores.push(path.join(normalizedAppRoot, '.gitignore'));
137
173
 
138
174
  return result;
139
175
  }
140
176
 
177
+ export const configureProjectAgentSymlinks = configureProjectAgentInstructions;
178
+
141
179
  export function getProjectInstructionGitignoreEntries({ coreRoot }: TProjectInstructionArgs) {
142
180
  return Array.from(
143
181
  new Set(
144
- getAppAgentLinkDefinitions({ coreRoot, mode: 'standalone' }).map((linkDefinition) =>
145
- `/${normalizeProjectPathForGitignore(linkDefinition.projectPath)}`,
182
+ getAppAgentInstructionDefinitions({ coreRoot, mode: 'standalone' }).map((instructionDefinition) =>
183
+ `/${normalizeProjectPathForGitignore(instructionDefinition.projectPath)}`,
146
184
  ),
147
185
  ),
148
186
  );
149
187
  }
150
188
 
151
189
  export function renderProjectInstructionGitignoreBlock({ coreRoot }: TProjectInstructionArgs) {
152
- return renderInstructionGitignoreBlock({ linkDefinitions: getAppAgentLinkDefinitions({ coreRoot, mode: 'standalone' }) });
190
+ return renderInstructionGitignoreBlock({
191
+ instructionDefinitions: getAppAgentInstructionDefinitions({ coreRoot, mode: 'standalone' }),
192
+ });
153
193
  }
154
194
 
155
195
  export function inspectProjectAgentFiles({ appRoot }: { appRoot: string }): TProjectAgentFileInspection {
156
196
  const normalizedAppRoot = path.resolve(appRoot);
157
197
  const expectedAgentPaths = Array.from(
158
198
  new Set(
159
- standaloneAppAgentLinkDefinitions
160
- .map((linkDefinition) => linkDefinition.projectPath)
199
+ standaloneAppAgentInstructionDefinitions
200
+ .map((instructionDefinition) => instructionDefinition.projectPath)
161
201
  .filter((projectPath) => projectPath.endsWith('AGENTS.md')),
162
202
  ),
163
203
  );
@@ -187,42 +227,47 @@ export function inspectProjectAgentFiles({ appRoot }: { appRoot: string }): TPro
187
227
  - HELPERS
188
228
  ----------------------------------*/
189
229
 
190
- function getAppAgentLinkDefinitions({
230
+ function getAppAgentInstructionDefinitions({
191
231
  coreRoot,
192
232
  mode,
193
233
  }: TProjectInstructionArgs & { mode: 'monorepo' | 'standalone' }) {
194
234
  const agentSourceRoot = path.join(coreRoot, 'agents', 'project');
195
- const sourceDefinitions = mode === 'monorepo' ? monorepoAppAgentLinkDefinitions : standaloneAppAgentLinkDefinitions;
235
+ const sourceDefinitions =
236
+ mode === 'monorepo' ? monorepoAppAgentInstructionDefinitions : standaloneAppAgentInstructionDefinitions;
196
237
 
197
- return resolveAgentLinkDefinitions({
238
+ return resolveAgentInstructionDefinitions({
198
239
  agentSourceRoot,
199
- linkDefinitions: sourceDefinitions,
240
+ instructionDefinitions: sourceDefinitions,
200
241
  });
201
242
  }
202
243
 
203
- function getRootAgentLinkDefinitions({ coreRoot }: TProjectInstructionArgs) {
204
- return resolveAgentLinkDefinitions({
244
+ function getRootAgentInstructionDefinitions({ coreRoot }: TProjectInstructionArgs) {
245
+ return resolveAgentInstructionDefinitions({
205
246
  agentSourceRoot: path.join(coreRoot, 'agents', 'project'),
206
- linkDefinitions: monorepoRootAgentLinkDefinitions,
247
+ instructionDefinitions: monorepoRootAgentInstructionDefinitions,
207
248
  });
208
249
  }
209
250
 
210
- function resolveAgentLinkDefinitions({
251
+ function resolveAgentInstructionDefinitions({
211
252
  agentSourceRoot,
212
- linkDefinitions,
253
+ instructionDefinitions,
213
254
  }: {
214
255
  agentSourceRoot: string;
215
- linkDefinitions: TAgentLinkDefinition[];
256
+ instructionDefinitions: TAgentInstructionDefinition[];
216
257
  }) {
217
- return linkDefinitions.map((linkDefinition) => ({
218
- ...linkDefinition,
219
- sourcePath: path.join(agentSourceRoot, linkDefinition.sourcePath),
258
+ return instructionDefinitions.map((instructionDefinition) => ({
259
+ ...instructionDefinition,
260
+ sourcePath: path.join(agentSourceRoot, instructionDefinition.sourcePath),
220
261
  }));
221
262
  }
222
263
 
223
- function renderInstructionGitignoreBlock({ linkDefinitions }: { linkDefinitions: TAgentLinkDefinition[] }) {
264
+ function renderInstructionGitignoreBlock({ instructionDefinitions }: { instructionDefinitions: TAgentInstructionDefinition[] }) {
224
265
  const entries = Array.from(
225
- new Set(linkDefinitions.map((linkDefinition) => `/${normalizeProjectPathForGitignore(linkDefinition.projectPath)}`)),
266
+ new Set(
267
+ instructionDefinitions.map(
268
+ (instructionDefinition) => `/${normalizeProjectPathForGitignore(instructionDefinition.projectPath)}`,
269
+ ),
270
+ ),
226
271
  );
227
272
 
228
273
  return [projectInstructionGitignoreBlockStart, ...entries, projectInstructionGitignoreBlockEnd].join('\n');
@@ -230,15 +275,17 @@ function renderInstructionGitignoreBlock({ linkDefinitions }: { linkDefinitions:
230
275
 
231
276
  function ensureInstructionGitignoreEntries({
232
277
  rootDir,
233
- linkDefinitions,
278
+ instructionDefinitions,
234
279
  }: {
235
280
  rootDir: string;
236
- linkDefinitions: TAgentLinkDefinition[];
281
+ instructionDefinitions: TAgentInstructionDefinition[];
237
282
  }) {
238
283
  const gitignoreFilepath = path.join(rootDir, '.gitignore');
239
284
  if (!pathEntryExists(gitignoreFilepath)) return false;
240
285
 
241
- const managedEntries = new Set(linkDefinitions.map((linkDefinition) => normalizeGitignoreEntry(linkDefinition.projectPath)));
286
+ const managedEntries = new Set(
287
+ instructionDefinitions.map((instructionDefinition) => normalizeGitignoreEntry(instructionDefinition.projectPath)),
288
+ );
242
289
  const lines = fs.readFileSync(gitignoreFilepath, 'utf8').split(/\r?\n/);
243
290
  const filteredLines: string[] = [];
244
291
  let insideManagedBlock = false;
@@ -246,12 +293,12 @@ function ensureInstructionGitignoreEntries({
246
293
  for (const line of lines) {
247
294
  const trimmedLine = line.trim();
248
295
 
249
- if (trimmedLine === projectInstructionGitignoreBlockStart) {
296
+ if (trimmedLine === projectInstructionGitignoreBlockStart || trimmedLine === legacyProjectInstructionGitignoreBlockStart) {
250
297
  insideManagedBlock = true;
251
298
  continue;
252
299
  }
253
300
 
254
- if (trimmedLine === projectInstructionGitignoreBlockEnd) {
301
+ if (trimmedLine === projectInstructionGitignoreBlockEnd || trimmedLine === legacyProjectInstructionGitignoreBlockEnd) {
255
302
  insideManagedBlock = false;
256
303
  continue;
257
304
  }
@@ -263,7 +310,7 @@ function ensureInstructionGitignoreEntries({
263
310
  }
264
311
 
265
312
  const baseContent = trimTrailingBlankLines(filteredLines).join('\n');
266
- const managedBlock = renderInstructionGitignoreBlock({ linkDefinitions });
313
+ const managedBlock = renderInstructionGitignoreBlock({ instructionDefinitions });
267
314
  const nextContent = baseContent ? `${baseContent}\n\n${managedBlock}\n` : `${managedBlock}\n`;
268
315
 
269
316
  if (nextContent === fs.readFileSync(gitignoreFilepath, 'utf8')) return false;
@@ -274,9 +321,9 @@ function ensureInstructionGitignoreEntries({
274
321
  return true;
275
322
  }
276
323
 
277
- function ensureSymlinks(
324
+ function ensureInstructionStubs(
278
325
  rootDir: string,
279
- linkDefinitions: TAgentLinkDefinition[],
326
+ instructionDefinitions: TAgentInstructionDefinition[],
280
327
  logPrefix: string,
281
328
  managedSourceRoot: string,
282
329
  {
@@ -286,8 +333,8 @@ function ensureSymlinks(
286
333
  dryRun: boolean;
287
334
  overwriteBlockedPaths: Set<string>;
288
335
  },
289
- ): TEnsureSymlinksResult {
290
- const result: TEnsureSymlinksResult = {
336
+ ): TEnsureInstructionStubsResult {
337
+ const result: TEnsureInstructionStubsResult = {
291
338
  blocked: [],
292
339
  created: [],
293
340
  overwritten: [],
@@ -295,24 +342,31 @@ function ensureSymlinks(
295
342
  updated: [],
296
343
  };
297
344
 
298
- for (const linkDefinition of linkDefinitions) {
299
- const projectFilepath = path.join(rootDir, linkDefinition.projectPath);
345
+ for (const instructionDefinition of instructionDefinitions) {
346
+ const projectFilepath = path.join(rootDir, instructionDefinition.projectPath);
300
347
  const projectParentDir = path.dirname(projectFilepath);
301
348
  const relativeProjectPath = path.relative(rootDir, projectFilepath) || '.';
302
349
 
303
- if (linkDefinition.ensureParentDir) fs.ensureDirSync(projectParentDir);
350
+ if (instructionDefinition.ensureParentDir) fs.ensureDirSync(projectParentDir);
304
351
  else if (!fs.existsSync(projectParentDir)) {
305
352
  result.skipped.push(relativeProjectPath);
306
353
  continue;
307
354
  }
308
355
 
309
- const sourceFilepath = linkDefinition.sourcePath;
356
+ const sourceFilepath = instructionDefinition.sourcePath;
310
357
  if (!fs.existsSync(sourceFilepath)) throw new Error(`Missing project instruction asset: ${sourceFilepath}`);
311
358
 
359
+ const stubContent = renderInstructionStub({
360
+ includeWorktreeBootstrap: instructionDefinition.includeWorktreeBootstrap === true,
361
+ projectFilepath,
362
+ sourceFilepath,
363
+ });
364
+
312
365
  const existingState = inspectExistingPath({
313
366
  managedSourceRoot,
314
367
  projectFilepath,
315
368
  sourceFilepath,
369
+ stubContent,
316
370
  });
317
371
 
318
372
  if (existingState.kind === 'match') {
@@ -326,57 +380,94 @@ function ensureSymlinks(
326
380
  continue;
327
381
  }
328
382
 
329
- const symlinkTarget = path.relative(projectParentDir, sourceFilepath);
330
-
331
383
  if (existingState.kind === 'managed-different') {
332
384
  if (!dryRun) {
333
- fs.unlinkSync(projectFilepath);
334
- fs.symlinkSync(symlinkTarget, projectFilepath);
385
+ fs.removeSync(projectFilepath);
386
+ fs.writeFileSync(projectFilepath, stubContent);
335
387
  }
336
388
  result.updated.push(relativeProjectPath);
337
- logVerbose(`${logPrefix} Updated ${relativeProjectPath} -> ${symlinkTarget}`);
389
+ logVerbose(`${logPrefix} Updated ${relativeProjectPath}`);
338
390
  continue;
339
391
  }
340
392
 
341
393
  if (existingState.kind === 'blocked') {
342
394
  if (!dryRun) {
343
395
  fs.removeSync(projectFilepath);
344
- fs.symlinkSync(symlinkTarget, projectFilepath);
396
+ fs.writeFileSync(projectFilepath, stubContent);
345
397
  }
346
398
  result.overwritten.push(relativeProjectPath);
347
- logVerbose(`${logPrefix} Replaced ${relativeProjectPath} -> ${symlinkTarget}`);
399
+ logVerbose(`${logPrefix} Replaced ${relativeProjectPath}`);
348
400
  continue;
349
401
  }
350
402
 
351
- if (!dryRun) fs.symlinkSync(symlinkTarget, projectFilepath);
403
+ if (!dryRun) fs.writeFileSync(projectFilepath, stubContent);
352
404
  result.created.push(relativeProjectPath);
353
- logVerbose(`${logPrefix} Created ${relativeProjectPath} -> ${symlinkTarget}`);
405
+ logVerbose(`${logPrefix} Created ${relativeProjectPath}`);
354
406
  }
355
407
 
356
408
  return result;
357
409
  }
358
410
 
411
+ function renderInstructionStub({
412
+ includeWorktreeBootstrap,
413
+ projectFilepath,
414
+ sourceFilepath,
415
+ }: {
416
+ includeWorktreeBootstrap: boolean;
417
+ projectFilepath: string;
418
+ sourceFilepath: string;
419
+ }) {
420
+ const sourcePath = normalizeProjectPathForGitignore(path.relative(path.dirname(projectFilepath), sourceFilepath));
421
+ const lines = [
422
+ ...(includeWorktreeBootstrap ? [...newProteumWorktreeBootstrapBlock, ''] : []),
423
+ managedInstructionStubHeader,
424
+ '',
425
+ managedInstructionStubIntro,
426
+ '',
427
+ 'Before reading or applying instructions from this file, read and follow the canonical Proteum instruction file at:',
428
+ '',
429
+ `\`${sourcePath}\``,
430
+ '',
431
+ 'Resolve that path relative to this file. Treat the canonical file as if its full contents were written here.',
432
+ '',
433
+ 'If the canonical file cannot be read, stop and run `npx proteum configure agents` before continuing.',
434
+ '',
435
+ ];
436
+
437
+ return lines.join('\n');
438
+ }
439
+
359
440
  function inspectExistingPath({
360
441
  managedSourceRoot,
361
442
  projectFilepath,
362
443
  sourceFilepath,
444
+ stubContent,
363
445
  }: {
364
446
  managedSourceRoot: string;
365
447
  projectFilepath: string;
366
448
  sourceFilepath: string;
449
+ stubContent: string;
367
450
  }) {
368
451
  if (!pathEntryExists(projectFilepath)) return { kind: 'missing' as const };
369
452
 
370
453
  const stats = fs.lstatSync(projectFilepath);
371
- if (!stats.isSymbolicLink()) return { kind: 'blocked' as const };
454
+ if (!stats.isSymbolicLink()) {
455
+ if (!stats.isFile()) return { kind: 'blocked' as const };
456
+
457
+ const existingContent = fs.readFileSync(projectFilepath, 'utf8');
458
+ if (existingContent === stubContent) return { kind: 'match' as const };
459
+ if (isManagedInstructionStub(existingContent)) return { kind: 'managed-different' as const };
460
+
461
+ return { kind: 'blocked' as const };
462
+ }
372
463
 
373
464
  const existingTarget = resolveSymlinkTarget(projectFilepath);
374
465
  const normalizedExistingTarget = normalizeAbsolutePath(existingTarget);
375
466
  const normalizedSourceFilepath = normalizeAbsolutePath(sourceFilepath);
376
467
  const normalizedManagedSourceRoot = normalizeAbsolutePath(managedSourceRoot);
377
468
 
378
- if (normalizedExistingTarget === normalizedSourceFilepath) return { kind: 'match' as const };
379
469
  if (
470
+ normalizedExistingTarget === normalizedSourceFilepath ||
380
471
  normalizedExistingTarget === normalizedManagedSourceRoot ||
381
472
  normalizedExistingTarget.startsWith(`${normalizedManagedSourceRoot}/`)
382
473
  )
@@ -385,15 +476,19 @@ function inspectExistingPath({
385
476
  return { kind: 'blocked' as const };
386
477
  }
387
478
 
479
+ function isManagedInstructionStub(content: string) {
480
+ return content.includes(`${managedInstructionStubHeader}\n\n${managedInstructionStubIntro}\n`);
481
+ }
482
+
388
483
  function resolveSymlinkTarget(projectFilepath: string) {
389
484
  const projectParentDir = path.dirname(projectFilepath);
390
485
  const rawTarget = fs.readlinkSync(projectFilepath);
391
486
  return path.resolve(projectParentDir, rawTarget);
392
487
  }
393
488
 
394
- function mergeSymlinkResults(
395
- result: TConfigureProjectAgentSymlinksResult,
396
- next: TEnsureSymlinksResult,
489
+ function mergeInstructionResults(
490
+ result: TConfigureProjectAgentInstructionsResult,
491
+ next: TEnsureInstructionStubsResult,
397
492
  rootDir: string,
398
493
  ) {
399
494
  result.created.push(...next.created.map((entry) => formatResultPath(rootDir, entry)));
@@ -2,10 +2,11 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  import cli from '..';
5
- import Compiler from '../compiler';
6
5
  import { runProcess } from './runProcess';
7
6
 
8
- const tsconfigPaths = ['client/tsconfig.json', 'server/tsconfig.json', 'commands/tsconfig.json'];
7
+ const appConfigPaths = ['identity.config.ts', 'proteum.config.ts'];
8
+ const appTsconfigPaths = ['client/tsconfig.json', 'server/tsconfig.json', 'commands/tsconfig.json'];
9
+ const frameworkTsconfigPaths = ['client/app.tsconfig.json', 'server/app.tsconfig.json', 'cli/tsconfig.json'];
9
10
  const eslintConfigPaths = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs'];
10
11
 
11
12
  const resolveInstalledBinary = (packageName: string, binName: string) => cli.paths.resolveBinary(packageName, binName);
@@ -15,6 +16,28 @@ const resolveExistingAppPaths = (paths: string[]) =>
15
16
  .map((relativePath) => ({ relativePath, absolutePath: path.join(cli.paths.appRoot, relativePath) }))
16
17
  .filter(({ absolutePath }) => fs.existsSync(absolutePath));
17
18
 
19
+ const isFrameworkCheckout = () => {
20
+ let packageJson: { name?: unknown } | undefined;
21
+
22
+ try {
23
+ packageJson = JSON.parse(fs.readFileSync(path.join(cli.paths.appRoot, 'package.json'), 'utf8'));
24
+ } catch {}
25
+
26
+ return packageJson?.name === 'proteum' && fs.existsSync(path.join(cli.paths.appRoot, 'cli', 'bin.js'));
27
+ };
28
+
29
+ const resolveTypecheckProjects = () => {
30
+ const appProjects = resolveExistingAppPaths(appTsconfigPaths);
31
+ if (appProjects.length > 0) return appProjects;
32
+
33
+ if (isFrameworkCheckout()) return resolveExistingAppPaths(frameworkTsconfigPaths);
34
+
35
+ return [];
36
+ };
37
+
38
+ export const hasAppConfig = () =>
39
+ appConfigPaths.every((relativePath) => fs.existsSync(path.join(cli.paths.appRoot, relativePath)));
40
+
18
41
  const getTypecheckEnv = () => {
19
42
  const existingNodeOptions = process.env.NODE_OPTIONS ?? '';
20
43
 
@@ -26,16 +49,21 @@ const getTypecheckEnv = () => {
26
49
  };
27
50
 
28
51
  export const refreshGeneratedTypings = async () => {
52
+ const { default: Compiler } = await import('../compiler');
29
53
  const compiler = new Compiler('dev');
30
54
 
31
55
  await compiler.refreshGeneratedTypings();
32
56
  };
33
57
 
34
58
  export const runAppTypecheck = async () => {
35
- const existingProjects = resolveExistingAppPaths(tsconfigPaths);
59
+ const existingProjects = resolveTypecheckProjects();
36
60
 
37
61
  if (existingProjects.length === 0)
38
- throw new Error(`No TypeScript app projects found. Expected one of: ${tsconfigPaths.join(', ')}.`);
62
+ throw new Error(
63
+ `No TypeScript projects found. Expected one of: ${[...appTsconfigPaths, ...frameworkTsconfigPaths].join(
64
+ ', ',
65
+ )}.`,
66
+ );
39
67
 
40
68
  const tsc = resolveInstalledBinary('typescript', 'tsc');
41
69