@remix-run/cli 0.3.4 → 0.4.0

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 (118) hide show
  1. package/README.md +105 -1
  2. package/dist/index.js +1 -1
  3. package/dist/lib/app-root.d.ts +11 -0
  4. package/dist/lib/app-root.d.ts.map +1 -0
  5. package/dist/lib/app-root.js +38 -0
  6. package/dist/lib/bootstrap-project.js +2 -2
  7. package/dist/lib/cli-context.d.ts +6 -0
  8. package/dist/lib/cli-context.d.ts.map +1 -1
  9. package/dist/lib/cli-context.js +12 -1
  10. package/dist/lib/cli.d.ts +3 -2
  11. package/dist/lib/cli.d.ts.map +1 -1
  12. package/dist/lib/cli.js +42 -14
  13. package/dist/lib/commands/completion.js +4 -4
  14. package/dist/lib/commands/db.d.ts +4 -0
  15. package/dist/lib/commands/db.d.ts.map +1 -0
  16. package/dist/lib/commands/db.js +283 -0
  17. package/dist/lib/commands/doctor.d.ts +5 -0
  18. package/dist/lib/commands/doctor.d.ts.map +1 -1
  19. package/dist/lib/commands/doctor.js +26 -251
  20. package/dist/lib/commands/help.d.ts.map +1 -1
  21. package/dist/lib/commands/help.js +17 -9
  22. package/dist/lib/commands/new.js +6 -6
  23. package/dist/lib/commands/routes.js +6 -6
  24. package/dist/lib/commands/test.d.ts +20 -1
  25. package/dist/lib/commands/test.d.ts.map +1 -1
  26. package/dist/lib/commands/test.js +353 -10
  27. package/dist/lib/commands/version.js +3 -3
  28. package/dist/lib/completion.d.ts +1 -1
  29. package/dist/lib/completion.d.ts.map +1 -1
  30. package/dist/lib/completion.js +229 -6
  31. package/dist/lib/controller-files.d.ts +0 -8
  32. package/dist/lib/controller-files.d.ts.map +1 -1
  33. package/dist/lib/controller-files.js +0 -23
  34. package/dist/lib/controller-ownership.d.ts +4 -12
  35. package/dist/lib/controller-ownership.d.ts.map +1 -1
  36. package/dist/lib/controller-ownership.js +31 -127
  37. package/dist/lib/database-command.d.ts +20 -0
  38. package/dist/lib/database-command.d.ts.map +1 -0
  39. package/dist/lib/database-command.js +7 -0
  40. package/dist/lib/doctor/controller-findings.d.ts +2 -2
  41. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  42. package/dist/lib/doctor/controller-findings.js +23 -39
  43. package/dist/lib/doctor/controllers.d.ts +1 -2
  44. package/dist/lib/doctor/controllers.d.ts.map +1 -1
  45. package/dist/lib/doctor/controllers.js +4 -7
  46. package/dist/lib/doctor/environment.js +2 -2
  47. package/dist/lib/doctor/fixes.js +1 -1
  48. package/dist/lib/doctor/project.d.ts.map +1 -1
  49. package/dist/lib/doctor/project.js +3 -212
  50. package/dist/lib/doctor/run.d.ts +9 -0
  51. package/dist/lib/doctor/run.d.ts.map +1 -0
  52. package/dist/lib/doctor/run.js +237 -0
  53. package/dist/lib/doctor/types.d.ts +2 -1
  54. package/dist/lib/doctor/types.d.ts.map +1 -1
  55. package/dist/lib/doctor/types.js +4 -1
  56. package/dist/lib/errors.d.ts +30 -0
  57. package/dist/lib/errors.d.ts.map +1 -1
  58. package/dist/lib/errors.js +55 -0
  59. package/dist/lib/help-text.js +1 -1
  60. package/dist/lib/parse-args.d.ts.map +1 -1
  61. package/dist/lib/parse-args.js +17 -4
  62. package/dist/lib/remix-config.d.ts +75 -0
  63. package/dist/lib/remix-config.d.ts.map +1 -0
  64. package/dist/lib/remix-config.js +411 -0
  65. package/dist/lib/reporter.js +1 -1
  66. package/dist/lib/route-map.js +13 -33
  67. package/package.json +13 -7
  68. package/schema/remix.json +242 -0
  69. package/src/lib/app-root.ts +46 -0
  70. package/src/lib/cli-context.ts +17 -0
  71. package/src/lib/cli.ts +41 -6
  72. package/src/lib/commands/db.ts +377 -0
  73. package/src/lib/commands/doctor.ts +27 -329
  74. package/src/lib/commands/help.ts +10 -1
  75. package/src/lib/commands/test.ts +453 -12
  76. package/src/lib/completion.ts +321 -6
  77. package/src/lib/controller-files.ts +0 -38
  78. package/src/lib/controller-ownership.ts +50 -203
  79. package/src/lib/database-command.ts +31 -0
  80. package/src/lib/doctor/controller-findings.ts +30 -47
  81. package/src/lib/doctor/controllers.ts +2 -6
  82. package/src/lib/doctor/project.ts +0 -236
  83. package/src/lib/doctor/run.ts +311 -0
  84. package/src/lib/doctor/types.ts +6 -5
  85. package/src/lib/errors.ts +65 -0
  86. package/src/lib/parse-args.ts +19 -3
  87. package/src/lib/remix-config.ts +639 -0
  88. package/src/lib/route-map.ts +11 -48
  89. package/template/.agents/skills/remix/SKILL.md +38 -27
  90. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +53 -10
  91. package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
  92. package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
  93. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +54 -8
  94. package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
  95. package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
  96. package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
  97. package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
  98. package/template/AGENTS.md +6 -3
  99. package/template/README.md +6 -3
  100. package/template/app/actions/controller.tsx +1 -1
  101. package/template/app/{ui → actions}/document.tsx +7 -5
  102. package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +1 -2
  103. package/template/app/actions/public/entry.ts +48 -0
  104. package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
  105. package/template/app/assets.ts +15 -2
  106. package/template/hmr.ts +46 -0
  107. package/template/package.json +1 -0
  108. package/template/server.ts +8 -1
  109. package/template/tsconfig.json +1 -1
  110. package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
  111. package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
  112. package/dist/lib/doctor/controller-fix-plans.js +0 -119
  113. package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
  114. package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
  115. package/dist/lib/doctor/controller-placeholders.js +0 -52
  116. package/src/lib/doctor/controller-fix-plans.ts +0 -168
  117. package/src/lib/doctor/controller-placeholders.ts +0 -70
  118. package/template/app/assets/entry.ts +0 -17
@@ -1,12 +1,6 @@
1
1
  import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
 
4
- import {
5
- getOwnerFileExtension,
6
- isActionFileName,
7
- isControllerEntryFileName,
8
- type OwnerFileExtension,
9
- } from '../controller-files.ts'
10
4
  import { CliError } from '../errors.ts'
11
5
  import { loadRouteManifestFromAppRoot, type LoadedRouteManifest } from '../route-map.ts'
12
6
  import {
@@ -61,9 +55,6 @@ export async function checkProject(projectRoot: string): Promise<ProjectDoctorRe
61
55
 
62
56
  export async function getProjectFixPlans(projectRoot: string): Promise<DoctorFixPlan[]> {
63
57
  let routesFile = path.join(projectRoot, 'app', 'routes.ts')
64
- let homeActionPath = normalizeRelativePath(
65
- path.join('app', 'actions', `controller${await inferHomeOwnerExtension(projectRoot)}`),
66
- )
67
58
 
68
59
  if (await pathExists(routesFile)) {
69
60
  let routesSource = await fs.readFile(routesFile, 'utf8')
@@ -79,18 +70,6 @@ export async function getProjectFixPlans(projectRoot: string): Promise<DoctorFix
79
70
  path: 'app/routes.ts',
80
71
  suite: 'project',
81
72
  },
82
- ...((await pathExists(path.join(projectRoot, homeActionPath)))
83
- ? []
84
- : [
85
- {
86
- code: 'missing-owner',
87
- contents: renderDefaultHomeAction(homeActionPath),
88
- kind: 'create-file',
89
- path: homeActionPath,
90
- routeName: 'home',
91
- suite: 'project',
92
- } satisfies DoctorFixPlan,
93
- ]),
94
73
  ]
95
74
  }
96
75
 
@@ -102,14 +81,6 @@ export async function getProjectFixPlans(projectRoot: string): Promise<DoctorFix
102
81
  path: 'app/routes.ts',
103
82
  suite: 'project',
104
83
  },
105
- {
106
- code: 'missing-owner',
107
- contents: renderDefaultHomeAction(homeActionPath),
108
- kind: 'create-file',
109
- path: homeActionPath,
110
- routeName: 'home',
111
- suite: 'project',
112
- },
113
84
  ]
114
85
  }
115
86
 
@@ -224,78 +195,6 @@ function hasOnlyWhitespaceAndComments(source: string): boolean {
224
195
  return true
225
196
  }
226
197
 
227
- async function inferHomeOwnerExtension(projectRoot: string): Promise<OwnerFileExtension> {
228
- let actionsDir = path.join(projectRoot, 'app', 'actions')
229
- let extensions = await collectOwnerExtensions(actionsDir)
230
-
231
- if (extensions.length > 0) {
232
- return getMostCommonExtension(extensions)
233
- }
234
-
235
- if (await pathExists(path.join(projectRoot, 'tsconfig.json'))) {
236
- return '.tsx'
237
- }
238
-
239
- return '.js'
240
- }
241
-
242
- async function collectOwnerExtensions(directory: string): Promise<OwnerFileExtension[]> {
243
- try {
244
- let entries = await fs.readdir(directory, { withFileTypes: true })
245
- let extensions: OwnerFileExtension[] = []
246
-
247
- for (let entry of entries) {
248
- let absolutePath = path.join(directory, entry.name)
249
-
250
- if (entry.isDirectory()) {
251
- extensions.push(...(await collectOwnerExtensions(absolutePath)))
252
- continue
253
- }
254
-
255
- let fileName = path.basename(absolutePath)
256
- if (!isActionFileName(fileName) && !isControllerEntryFileName(fileName)) {
257
- continue
258
- }
259
-
260
- let extension = getOwnerFileExtension(fileName)
261
- if (extension != null) {
262
- extensions.push(extension)
263
- }
264
- }
265
-
266
- return extensions
267
- } catch (error) {
268
- let nodeError = error as NodeJS.ErrnoException
269
- if (nodeError.code === 'ENOENT') {
270
- return []
271
- }
272
-
273
- throw error
274
- }
275
- }
276
-
277
- function getMostCommonExtension(extensions: OwnerFileExtension[]): OwnerFileExtension {
278
- let priority: OwnerFileExtension[] = ['.tsx', '.ts', '.jsx', '.js']
279
- let counts = new Map<OwnerFileExtension, number>()
280
-
281
- for (let extension of extensions) {
282
- counts.set(extension, (counts.get(extension) ?? 0) + 1)
283
- }
284
-
285
- let bestExtension = priority[0]
286
- let bestCount = -1
287
-
288
- for (let extension of priority) {
289
- let count = counts.get(extension) ?? 0
290
- if (count > bestCount) {
291
- bestCount = count
292
- bestExtension = extension
293
- }
294
- }
295
-
296
- return bestExtension
297
- }
298
-
299
198
  function renderDefaultRoutesFile(): string {
300
199
  return [
301
200
  `import { route } from 'remix/routes'`,
@@ -306,138 +205,3 @@ function renderDefaultRoutesFile(): string {
306
205
  '',
307
206
  ].join('\n')
308
207
  }
309
-
310
- function normalizeRelativePath(filePath: string): string {
311
- return filePath.split(path.sep).join('/')
312
- }
313
-
314
- function renderDefaultHomeAction(entryPath: string): string {
315
- let extension = getOwnerFileExtension(entryPath)
316
- if (extension === '.js') {
317
- return [
318
- `import { html } from 'remix/html-template'`,
319
- `import { createHtmlResponse } from 'remix/response/html'`,
320
- '',
321
- 'export default {',
322
- ' actions: {',
323
- ' home() {',
324
- ' let page = html`',
325
- ' <html lang="en">',
326
- ' <head>',
327
- ' <meta charset="utf-8" />',
328
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
329
- ' <title>Home</title>',
330
- ' </head>',
331
- ' <body>',
332
- ' <h1>Home</h1>',
333
- ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
334
- ' </body>',
335
- ' </html>',
336
- ' `',
337
- ' return createHtmlResponse(page)',
338
- ' },',
339
- ' },',
340
- '}',
341
- '',
342
- ].join('\n')
343
- }
344
-
345
- if (extension === '.ts') {
346
- return [
347
- `import { createController } from 'remix/router'`,
348
- `import { html } from 'remix/html-template'`,
349
- `import { createHtmlResponse } from 'remix/response/html'`,
350
- '',
351
- `import { routes } from '../routes.ts'`,
352
- '',
353
- `export default createController(routes, {`,
354
- ' actions: {',
355
- ' home() {',
356
- ' let page = html`',
357
- ' <html lang="en">',
358
- ' <head>',
359
- ' <meta charset="utf-8" />',
360
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
361
- ' <title>Home</title>',
362
- ' </head>',
363
- ' <body>',
364
- ' <h1>Home</h1>',
365
- ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
366
- ' </body>',
367
- ' </html>',
368
- ' `',
369
- ' return createHtmlResponse(page)',
370
- ' },',
371
- ' },',
372
- `})`,
373
- '',
374
- ].join('\n')
375
- }
376
-
377
- if (extension === '.jsx') {
378
- return [
379
- `import { renderToStream } from 'remix/ui/server'`,
380
- `import { createHtmlResponse } from 'remix/response/html'`,
381
- '',
382
- 'export default {',
383
- ' actions: {',
384
- ' home() {',
385
- ' let page = <HomePage />',
386
- ' return createHtmlResponse(renderToStream(page))',
387
- ' },',
388
- ' },',
389
- '}',
390
- '',
391
- 'function HomePage() {',
392
- ' return () => (',
393
- ' <html lang="en">',
394
- ' <head>',
395
- ' <meta charSet="utf-8" />',
396
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
397
- ' <title>Home</title>',
398
- ' </head>',
399
- ' <body>',
400
- ' <h1>Home</h1>',
401
- ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
402
- ' </body>',
403
- ' </html>',
404
- ' )',
405
- '}',
406
- '',
407
- ].join('\n')
408
- }
409
-
410
- return [
411
- `import { createController } from 'remix/router'`,
412
- `import { renderToStream } from 'remix/ui/server'`,
413
- `import { createHtmlResponse } from 'remix/response/html'`,
414
- '',
415
- `import { routes } from '../routes.ts'`,
416
- '',
417
- `export default createController(routes, {`,
418
- ' actions: {',
419
- ' home() {',
420
- ' let page = <HomePage />',
421
- ' return createHtmlResponse(renderToStream(page))',
422
- ' },',
423
- ' },',
424
- `})`,
425
- '',
426
- 'function HomePage() {',
427
- ' return () => (',
428
- ' <html lang="en">',
429
- ' <head>',
430
- ' <meta charSet="utf-8" />',
431
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
432
- ' <title>Home</title>',
433
- ' </head>',
434
- ' <body>',
435
- ' <h1>Home</h1>',
436
- ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
437
- ' </body>',
438
- ' </html>',
439
- ' )',
440
- '}',
441
- '',
442
- ].join('\n')
443
- }
@@ -0,0 +1,311 @@
1
+ import * as path from 'node:path'
2
+ import * as process from 'node:process'
3
+
4
+ import { checkControllerConventions } from './controllers.ts'
5
+ import { checkEnvironment, getEnvironmentFixPlans } from './environment.ts'
6
+ import { applyDoctorFixPlans } from './fixes.ts'
7
+ import { checkProject, getProjectFixPlans } from './project.ts'
8
+ import {
9
+ createDoctorSuite,
10
+ createSkippedDoctorSuite,
11
+ hasWarningFindings,
12
+ type DoctorAppliedFix,
13
+ type DoctorReport,
14
+ type DoctorSuiteName,
15
+ type DoctorSuiteResult,
16
+ } from './types.ts'
17
+ import {
18
+ createCommandReporter,
19
+ createStepProgressReporter,
20
+ type CommandReporter,
21
+ type StepProgressReporter,
22
+ } from '../reporter.ts'
23
+
24
+ const DOCTOR_SUITE_LABELS = {
25
+ actions: {
26
+ complete: 'actions',
27
+ running: 'Checking actions',
28
+ },
29
+ environment: {
30
+ complete: 'environment',
31
+ running: 'Checking environment',
32
+ },
33
+ project: {
34
+ complete: 'project',
35
+ running: 'Checking project',
36
+ },
37
+ } satisfies Record<DoctorSuiteName, string | { complete: string; running: string }>
38
+
39
+ export interface RunRemixDoctorOptions {
40
+ cwd: string
41
+ fix: boolean
42
+ json: boolean
43
+ remixVersion?: string
44
+ strict: boolean
45
+ }
46
+
47
+ export async function runRemixDoctor(options: RunRemixDoctorOptions): Promise<number> {
48
+ let reporter: CommandReporter | null = null
49
+ let progress: StepProgressReporter<DoctorSuiteName> | null = null
50
+
51
+ try {
52
+ reporter = options.json
53
+ ? null
54
+ : createCommandReporter({
55
+ remixVersion: options.remixVersion,
56
+ stderr: process.stdout,
57
+ stdout: process.stdout,
58
+ })
59
+ progress = reporter == null ? null : createDoctorProgressReporter(reporter)
60
+ if (reporter != null) {
61
+ await reporter.status.commandHeader('doctor')
62
+ }
63
+
64
+ let report = await collectDoctorReport(progress, options, reporter)
65
+
66
+ if (options.json) {
67
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
68
+ } else if (reporter != null) {
69
+ writeDoctorReport(reporter, report)
70
+ reporter.finish()
71
+ }
72
+
73
+ if ((options.fix || options.strict) && hasWarningFindings(report.findings)) {
74
+ return 1
75
+ }
76
+
77
+ return 0
78
+ } catch (error) {
79
+ progress?.writeSummaryGap()
80
+ reporter?.finish()
81
+ throw error
82
+ }
83
+ }
84
+
85
+ async function collectDoctorReport(
86
+ progress: StepProgressReporter<DoctorSuiteName> | null,
87
+ options: RunRemixDoctorOptions,
88
+ reporter: CommandReporter | null,
89
+ ): Promise<DoctorReport> {
90
+ let cwd = options.cwd
91
+ let appliedFixes: DoctorAppliedFix[] = []
92
+ let environment = await runDoctorSuite(progress, 'environment', async () => {
93
+ let result = await checkEnvironment(cwd)
94
+ let suiteAppliedFixes: DoctorAppliedFix[] = []
95
+ let finalResult = result
96
+
97
+ if (options.fix && result.projectRoot != null) {
98
+ let fixPlans = getEnvironmentFixPlans(result, options.remixVersion)
99
+ if (fixPlans.length > 0) {
100
+ suiteAppliedFixes = await applyDoctorFixPlans(result.projectRoot, fixPlans)
101
+ finalResult = await checkEnvironment(result.projectRoot)
102
+ }
103
+ }
104
+
105
+ let suite = finalResult.suite
106
+ if (suiteAppliedFixes.length > 0) {
107
+ suite = {
108
+ ...suite,
109
+ appliedFixes: suiteAppliedFixes,
110
+ }
111
+ }
112
+
113
+ return {
114
+ appliedFixes: suiteAppliedFixes,
115
+ projectRoot: finalResult.projectRoot ?? result.projectRoot,
116
+ suite,
117
+ }
118
+ })
119
+ let findings = [...environment.suite.findings]
120
+ appliedFixes.push(...(environment.appliedFixes ?? []))
121
+ let suites: DoctorSuiteResult[] = [environment.suite]
122
+ let routesFile =
123
+ environment.projectRoot == null
124
+ ? undefined
125
+ : path.join(environment.projectRoot, 'app', 'routes.ts')
126
+ writeDoctorSuiteDetails(reporter, environment.suite)
127
+ progress?.writeSummaryGap()
128
+
129
+ if (hasWarningFindings(environment.suite.findings)) {
130
+ let projectSuite = createSkippedDoctorSuite('project', 'Blocked by environment warnings.')
131
+ let actionsSuite = createSkippedDoctorSuite('actions', 'Blocked by environment warnings.')
132
+ suites.push(projectSuite, actionsSuite)
133
+ progress?.skip(projectSuite.name, projectSuite.reason)
134
+ writeDoctorSuiteDetails(reporter, projectSuite)
135
+ progress?.writeSummaryGap()
136
+ progress?.skip(actionsSuite.name, actionsSuite.reason)
137
+ writeDoctorSuiteDetails(reporter, actionsSuite)
138
+ progress?.writeSummaryGap()
139
+
140
+ return {
141
+ appRoot: environment.projectRoot,
142
+ appliedFixes,
143
+ findings,
144
+ remainingFindings: findings,
145
+ routesFile,
146
+ suites,
147
+ }
148
+ }
149
+
150
+ let project = await runDoctorSuite(progress, 'project', async () => {
151
+ let result = await checkProject(environment.projectRoot!)
152
+ let suiteAppliedFixes: DoctorAppliedFix[] = []
153
+ let finalResult = result
154
+
155
+ if (options.fix) {
156
+ let fixPlans = await getProjectFixPlans(environment.projectRoot!)
157
+ if (fixPlans.length > 0) {
158
+ suiteAppliedFixes = await applyDoctorFixPlans(environment.projectRoot!, fixPlans)
159
+ finalResult = await checkProject(environment.projectRoot!)
160
+ }
161
+ }
162
+
163
+ let suite = finalResult.suite
164
+ if (suiteAppliedFixes.length > 0) {
165
+ suite = {
166
+ ...suite,
167
+ appliedFixes: suiteAppliedFixes,
168
+ }
169
+ }
170
+
171
+ return {
172
+ appliedFixes: suiteAppliedFixes,
173
+ routeManifest: finalResult.routeManifest,
174
+ routesFile: finalResult.routesFile,
175
+ suite,
176
+ }
177
+ })
178
+ findings.push(...project.suite.findings)
179
+ appliedFixes.push(...(project.appliedFixes ?? []))
180
+ routesFile = project.routesFile
181
+ suites.push(project.suite)
182
+ writeDoctorSuiteDetails(reporter, project.suite)
183
+ progress?.writeSummaryGap()
184
+
185
+ if (hasWarningFindings(project.suite.findings)) {
186
+ let actionsSuite = createSkippedDoctorSuite('actions', 'Blocked by project warnings.')
187
+ suites.push(actionsSuite)
188
+ progress?.skip(actionsSuite.name, actionsSuite.reason)
189
+ writeDoctorSuiteDetails(reporter, actionsSuite)
190
+ progress?.writeSummaryGap()
191
+
192
+ return {
193
+ appRoot: environment.projectRoot,
194
+ appliedFixes,
195
+ findings,
196
+ remainingFindings: findings,
197
+ routesFile,
198
+ suites,
199
+ }
200
+ }
201
+
202
+ let actions = await runDoctorSuite(progress, 'actions', async () => {
203
+ let controllerResult = await checkControllerConventions(
204
+ project.routeManifest!.appRoot,
205
+ project.routeManifest!.tree,
206
+ )
207
+ return { suite: controllerResult.suite }
208
+ })
209
+
210
+ findings.push(...actions.suite.findings)
211
+ suites.push(actions.suite)
212
+ writeDoctorSuiteDetails(reporter, actions.suite)
213
+ progress?.writeSummaryGap()
214
+
215
+ let report: DoctorReport = {
216
+ appRoot: environment.projectRoot,
217
+ findings,
218
+ routesFile,
219
+ suites,
220
+ }
221
+
222
+ if (options.fix) {
223
+ report.appliedFixes = appliedFixes
224
+ report.remainingFindings = findings
225
+ }
226
+
227
+ return report
228
+ }
229
+
230
+ function formatAppliedFix(appliedFix: DoctorAppliedFix): string {
231
+ if (appliedFix.kind === 'update-file') {
232
+ return `Updated ${appliedFix.path}`
233
+ }
234
+
235
+ return `Created ${appliedFix.path}`
236
+ }
237
+
238
+ async function runDoctorSuite<
239
+ result extends { appliedFixes?: DoctorAppliedFix[]; suite: DoctorSuiteResult },
240
+ >(
241
+ progress: StepProgressReporter<DoctorSuiteName> | null,
242
+ label: DoctorSuiteName,
243
+ callback: () => Promise<result>,
244
+ ): Promise<result> {
245
+ progress?.start(label)
246
+
247
+ try {
248
+ let result = await callback()
249
+
250
+ if (result.suite.status === 'ok') {
251
+ progress?.succeed(label)
252
+ } else if (result.suite.status === 'issues') {
253
+ progress?.fail(label)
254
+ } else {
255
+ progress?.skip(label, result.suite.reason)
256
+ }
257
+
258
+ return result
259
+ } catch (error) {
260
+ progress?.fail(label)
261
+ throw error
262
+ }
263
+ }
264
+
265
+ function createDoctorProgressReporter(
266
+ reporter: CommandReporter,
267
+ ): StepProgressReporter<DoctorSuiteName> {
268
+ return createStepProgressReporter(reporter.status, DOCTOR_SUITE_LABELS)
269
+ }
270
+
271
+ function writeDoctorReport(reporter: CommandReporter, report: DoctorReport): void {
272
+ let warningCount = report.findings.filter((finding) => finding.severity === 'warn').length
273
+ let adviceCount = report.findings.length - warningCount
274
+
275
+ if ((report.appliedFixes?.length ?? 0) > 0) {
276
+ let fixCount = report.appliedFixes!.length
277
+ reporter.out.line(`Applied ${fixCount} ${fixCount === 1 ? 'fix' : 'fixes'}.`)
278
+ }
279
+
280
+ if (report.findings.length === 0) {
281
+ reporter.out.line('Doctor found no issues.')
282
+ }
283
+
284
+ reporter.out.line(`Summary: ${warningCount} warnings, ${adviceCount} advice.`)
285
+ }
286
+
287
+ function writeDoctorSuiteDetails(reporter: CommandReporter | null, suite: DoctorSuiteResult): void {
288
+ if (reporter == null) {
289
+ return
290
+ }
291
+
292
+ if (suite.findings.length === 0 && (suite.appliedFixes?.length ?? 0) === 0) {
293
+ return
294
+ }
295
+
296
+ reporter.out.withIndent(() => {
297
+ for (let finding of suite.findings) {
298
+ reporter.out.bullet(
299
+ reporter.out.label(finding.severity.toUpperCase(), finding.message, {
300
+ tone: finding.severity === 'warn' ? 'warn' : undefined,
301
+ }),
302
+ )
303
+ }
304
+
305
+ if ((suite.appliedFixes?.length ?? 0) > 0) {
306
+ reporter.out.section('Applied fixes:', () => {
307
+ reporter.out.bullets((suite.appliedFixes ?? []).map(formatAppliedFix))
308
+ })
309
+ }
310
+ })
311
+ }
@@ -18,10 +18,7 @@ export type DoctorFindingCode =
18
18
  | 'route-map-invalid-json'
19
19
  | 'route-map-loader-signal'
20
20
  | 'missing-owner'
21
- | 'duplicate-owner-file'
22
- | 'incomplete-controller'
23
- | 'orphan-controller'
24
- | 'orphan-route-directory'
21
+ | 'missing-route-directory'
25
22
 
26
23
  export type DoctorFixKind = 'create-directory' | 'create-file' | 'update-file'
27
24
 
@@ -70,6 +67,10 @@ export interface DoctorReport {
70
67
  suites: DoctorSuiteResult[]
71
68
  }
72
69
 
70
+ export function hasWarningFindings(findings: DoctorFinding[]): boolean {
71
+ return findings.some((finding) => finding.severity === 'warn')
72
+ }
73
+
73
74
  export function createDoctorSuite(
74
75
  name: DoctorSuiteName,
75
76
  findings: DoctorFinding[],
@@ -77,7 +78,7 @@ export function createDoctorSuite(
77
78
  return {
78
79
  findings,
79
80
  name,
80
- status: findings.some((finding) => finding.severity === 'warn') ? 'issues' : 'ok',
81
+ status: hasWarningFindings(findings) ? 'issues' : 'ok',
81
82
  }
82
83
  }
83
84