@remix-run/cli 0.3.3 → 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 (121) 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.d.ts.map +1 -1
  48. package/dist/lib/doctor/fixes.js +46 -7
  49. package/dist/lib/doctor/project.d.ts.map +1 -1
  50. package/dist/lib/doctor/project.js +3 -212
  51. package/dist/lib/doctor/run.d.ts +9 -0
  52. package/dist/lib/doctor/run.d.ts.map +1 -0
  53. package/dist/lib/doctor/run.js +237 -0
  54. package/dist/lib/doctor/types.d.ts +2 -1
  55. package/dist/lib/doctor/types.d.ts.map +1 -1
  56. package/dist/lib/doctor/types.js +4 -1
  57. package/dist/lib/errors.d.ts +30 -0
  58. package/dist/lib/errors.d.ts.map +1 -1
  59. package/dist/lib/errors.js +55 -0
  60. package/dist/lib/help-text.js +1 -1
  61. package/dist/lib/parse-args.d.ts.map +1 -1
  62. package/dist/lib/parse-args.js +17 -4
  63. package/dist/lib/remix-config.d.ts +75 -0
  64. package/dist/lib/remix-config.d.ts.map +1 -0
  65. package/dist/lib/remix-config.js +411 -0
  66. package/dist/lib/reporter.js +1 -1
  67. package/dist/lib/route-map.js +13 -33
  68. package/package.json +12 -6
  69. package/schema/remix.json +242 -0
  70. package/src/lib/app-root.ts +46 -0
  71. package/src/lib/cli-context.ts +17 -0
  72. package/src/lib/cli.ts +41 -6
  73. package/src/lib/commands/db.ts +377 -0
  74. package/src/lib/commands/doctor.ts +27 -329
  75. package/src/lib/commands/help.ts +10 -1
  76. package/src/lib/commands/test.ts +453 -12
  77. package/src/lib/completion.ts +321 -6
  78. package/src/lib/controller-files.ts +0 -38
  79. package/src/lib/controller-ownership.ts +50 -203
  80. package/src/lib/database-command.ts +31 -0
  81. package/src/lib/doctor/controller-findings.ts +30 -47
  82. package/src/lib/doctor/controllers.ts +2 -6
  83. package/src/lib/doctor/fixes.ts +55 -6
  84. package/src/lib/doctor/project.ts +0 -236
  85. package/src/lib/doctor/run.ts +311 -0
  86. package/src/lib/doctor/types.ts +6 -5
  87. package/src/lib/errors.ts +65 -0
  88. package/src/lib/parse-args.ts +19 -3
  89. package/src/lib/remix-config.ts +639 -0
  90. package/src/lib/route-map.ts +11 -48
  91. package/template/.agents/skills/remix/SKILL.md +39 -28
  92. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +53 -10
  93. package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
  94. package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
  95. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +54 -8
  96. package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
  97. package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
  98. package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
  99. package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
  100. package/template/AGENTS.md +6 -3
  101. package/template/README.md +6 -3
  102. package/template/app/actions/controller.tsx +1 -1
  103. package/template/app/{ui → actions}/document.tsx +7 -5
  104. package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +2 -3
  105. package/template/app/actions/public/entry.ts +48 -0
  106. package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
  107. package/template/app/assets.ts +19 -8
  108. package/template/app/middleware/render.tsx +29 -1
  109. package/template/hmr.ts +46 -0
  110. package/template/package.json +4 -3
  111. package/template/server.ts +8 -1
  112. package/template/tsconfig.json +1 -1
  113. package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
  114. package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
  115. package/dist/lib/doctor/controller-fix-plans.js +0 -119
  116. package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
  117. package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
  118. package/dist/lib/doctor/controller-placeholders.js +0 -52
  119. package/src/lib/doctor/controller-fix-plans.ts +0 -168
  120. package/src/lib/doctor/controller-placeholders.ts +0 -70
  121. package/template/app/assets/entry.ts +0 -8
@@ -0,0 +1,639 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+ import {
4
+ findNodeAtLocation,
5
+ getNodeValue,
6
+ parseTree,
7
+ printParseErrorCode,
8
+ type Node as JsonNode,
9
+ type ParseError,
10
+ } from 'jsonc-parser'
11
+ import type { RemixTestPool } from '@remix-run/test/cli'
12
+
13
+ import { invalidRemixConfig, remixConfigNotFound } from './errors.ts'
14
+
15
+ const reporters = ['spec', 'files', 'tap', 'dot'] as const
16
+ const testPools: readonly RemixTestPool[] = ['forks', 'threads']
17
+ const testTypes = ['server', 'browser', 'e2e'] as const
18
+
19
+ type Reporter = (typeof reporters)[number]
20
+ type TestPool = RemixTestPool
21
+ type TestType = (typeof testTypes)[number]
22
+ type JsonPath = Array<number | string>
23
+
24
+ export interface RemixConfig {
25
+ db?: RemixDbCommandConfig
26
+ doctor?: RemixDoctorCommandConfig
27
+ test?: RemixTestCommandConfig
28
+ }
29
+
30
+ export type RemixDbString = string | { env: string; default?: string }
31
+
32
+ export type RemixDbAdapterConfig =
33
+ | {
34
+ type: 'sqlite'
35
+ filename: RemixDbString
36
+ foreignKeys?: boolean
37
+ busyTimeout?: number
38
+ }
39
+ | {
40
+ type: 'postgres'
41
+ connectionString: RemixDbString
42
+ maintenanceDatabase?: string
43
+ template?: string
44
+ }
45
+ | {
46
+ type: 'mysql'
47
+ uri: RemixDbString
48
+ characterSet?: string
49
+ collation?: string
50
+ }
51
+
52
+ export interface RemixDbCommandConfig {
53
+ adapter: RemixDbAdapterConfig
54
+ migrations?: {
55
+ directory: string
56
+ journalTable?: string
57
+ }
58
+ seed?: string
59
+ }
60
+
61
+ export interface RemixDoctorCommandConfig {
62
+ strict?: boolean
63
+ }
64
+
65
+ export interface RemixTestCommandConfig {
66
+ browserFiles?: string[]
67
+ concurrency?: number
68
+ coverage?: {
69
+ branches?: number
70
+ dir?: string
71
+ enabled?: boolean
72
+ exclude?: string[]
73
+ functions?: number
74
+ include?: string[]
75
+ lines?: number
76
+ statements?: number
77
+ }
78
+ e2eFiles?: string[]
79
+ exclude?: string[]
80
+ files?: string[]
81
+ only?: string[]
82
+ playwright?: {
83
+ configFile?: string
84
+ echo?: boolean
85
+ open?: boolean
86
+ projects?: string[]
87
+ }
88
+ pool?: TestPool
89
+ quiet?: boolean
90
+ reporter?: Reporter
91
+ setup?: string
92
+ type?: TestType[]
93
+ watch?: boolean
94
+ }
95
+
96
+ interface ConfigSource {
97
+ filePath: string
98
+ root: JsonNode | undefined
99
+ text: string
100
+ }
101
+
102
+ export async function loadRemixConfig(
103
+ cwd: string,
104
+ configPath: string | undefined,
105
+ ): Promise<RemixConfig> {
106
+ let filePath = path.resolve(cwd, configPath ?? 'remix.json')
107
+ let text: string
108
+
109
+ try {
110
+ text = await fs.readFile(filePath, 'utf8')
111
+ } catch (error) {
112
+ if (isNodeError(error) && error.code === 'ENOENT') {
113
+ if (configPath === undefined) return {}
114
+ throw remixConfigNotFound(filePath)
115
+ }
116
+
117
+ throw error
118
+ }
119
+
120
+ // Editors saving "UTF-8 with BOM" prefix the file with U+FEFF, which
121
+ // jsonc-parser reports as an InvalidSymbol error.
122
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
123
+
124
+ let parseErrors: ParseError[] = []
125
+ let root = parseTree(text, parseErrors, {
126
+ allowTrailingComma: true,
127
+ disallowComments: false,
128
+ })
129
+ let value: unknown = root === undefined ? undefined : getNodeValue(root)
130
+ let source = { filePath, root, text }
131
+
132
+ // An empty or comments-only file has no JSON value at all; treat it like a
133
+ // missing file rather than a malformed one.
134
+ if (
135
+ root === undefined &&
136
+ parseErrors.every((error) => printParseErrorCode(error.error) === 'ValueExpected')
137
+ ) {
138
+ return {}
139
+ }
140
+
141
+ if (parseErrors.length > 0) {
142
+ let error = parseErrors[0]!
143
+ throwConfigError(source, [], printParseErrorCode(error.error), error.offset)
144
+ }
145
+
146
+ return parseConfig(value, source, path.dirname(filePath), cwd)
147
+ }
148
+
149
+ function parseConfig(
150
+ value: unknown,
151
+ source: ConfigSource,
152
+ configDir: string,
153
+ cwd: string,
154
+ ): RemixConfig {
155
+ let object = requireObject(value, source, [])
156
+ requireKnownProperties(object, ['$schema', 'db', 'doctor', 'test'], source, [])
157
+
158
+ if (object.$schema !== undefined) {
159
+ requireString(object.$schema, source, ['$schema'])
160
+ }
161
+
162
+ let config: RemixConfig = {}
163
+
164
+ if (object.db !== undefined) {
165
+ config.db = parseDbConfig(object.db, source, configDir)
166
+ }
167
+
168
+ if (object.doctor !== undefined) {
169
+ config.doctor = parseDoctorConfig(object.doctor, source)
170
+ }
171
+
172
+ if (object.test !== undefined) {
173
+ config.test = parseTestConfig(object.test, source, configDir, cwd)
174
+ }
175
+
176
+ return config
177
+ }
178
+
179
+ function parseDbConfig(
180
+ value: unknown,
181
+ source: ConfigSource,
182
+ configDir: string,
183
+ ): RemixDbCommandConfig {
184
+ let objectPath = ['db']
185
+ let object = requireObject(value, source, objectPath)
186
+ requireKnownProperties(object, ['adapter', 'migrations', 'seed'], source, objectPath)
187
+
188
+ if (object.adapter === undefined) {
189
+ throwConfigError(source, [...objectPath, 'adapter'], 'Expected an adapter configuration')
190
+ }
191
+
192
+ let config: RemixDbCommandConfig = {
193
+ adapter: parseDbAdapterConfig(object.adapter, source),
194
+ }
195
+
196
+ if (object.migrations !== undefined) {
197
+ let migrationsPath = [...objectPath, 'migrations']
198
+ let migrations = requireObject(object.migrations, source, migrationsPath)
199
+ requireKnownProperties(migrations, ['directory', 'journalTable'], source, migrationsPath)
200
+ let directory = requireString(migrations.directory, source, [...migrationsPath, 'directory'])
201
+ let journalTable = optionalString(migrations.journalTable, source, [
202
+ ...migrationsPath,
203
+ 'journalTable',
204
+ ])
205
+ config.migrations = {
206
+ directory: path.resolve(configDir, directory),
207
+ journalTable,
208
+ }
209
+ }
210
+
211
+ if (object.seed !== undefined) {
212
+ let seed = requireString(object.seed, source, [...objectPath, 'seed'])
213
+ config.seed = path.resolve(configDir, seed)
214
+ }
215
+
216
+ return config
217
+ }
218
+
219
+ function parseDbAdapterConfig(value: unknown, source: ConfigSource): RemixDbAdapterConfig {
220
+ let objectPath = ['db', 'adapter']
221
+ let object = requireObject(value, source, objectPath)
222
+ let type = requireEnum(object.type, ['sqlite', 'postgres', 'mysql'], source, [
223
+ ...objectPath,
224
+ 'type',
225
+ ])
226
+
227
+ if (type === 'sqlite') {
228
+ requireKnownProperties(
229
+ object,
230
+ ['busyTimeout', 'filename', 'foreignKeys', 'type'],
231
+ source,
232
+ objectPath,
233
+ )
234
+ let busyTimeout = optionalNumber(object.busyTimeout, source, [...objectPath, 'busyTimeout'])
235
+ if (busyTimeout !== undefined && busyTimeout < 0) {
236
+ throwConfigError(source, [...objectPath, 'busyTimeout'], 'Expected a non-negative number')
237
+ }
238
+ return {
239
+ type,
240
+ filename: parseDbString(object.filename, source, [...objectPath, 'filename']),
241
+ foreignKeys: optionalBoolean(object.foreignKeys, source, [...objectPath, 'foreignKeys']),
242
+ busyTimeout,
243
+ }
244
+ }
245
+
246
+ if (type === 'postgres') {
247
+ requireKnownProperties(
248
+ object,
249
+ ['connectionString', 'maintenanceDatabase', 'template', 'type'],
250
+ source,
251
+ objectPath,
252
+ )
253
+ return {
254
+ type,
255
+ connectionString: parseDbString(object.connectionString, source, [
256
+ ...objectPath,
257
+ 'connectionString',
258
+ ]),
259
+ maintenanceDatabase: optionalString(object.maintenanceDatabase, source, [
260
+ ...objectPath,
261
+ 'maintenanceDatabase',
262
+ ]),
263
+ template: optionalString(object.template, source, [...objectPath, 'template']),
264
+ }
265
+ }
266
+
267
+ requireKnownProperties(object, ['characterSet', 'collation', 'type', 'uri'], source, objectPath)
268
+ return {
269
+ type,
270
+ uri: parseDbString(object.uri, source, [...objectPath, 'uri']),
271
+ characterSet: optionalString(object.characterSet, source, [...objectPath, 'characterSet']),
272
+ collation: optionalString(object.collation, source, [...objectPath, 'collation']),
273
+ }
274
+ }
275
+
276
+ function parseDbString(
277
+ value: unknown,
278
+ source: ConfigSource,
279
+ propertyPath: JsonPath,
280
+ ): RemixDbString {
281
+ if (typeof value === 'string') return value
282
+
283
+ let object = requireObject(value, source, propertyPath)
284
+ requireKnownProperties(object, ['default', 'env'], source, propertyPath)
285
+ let env = requireString(object.env, source, [...propertyPath, 'env'])
286
+ let defaultValue = optionalString(object.default, source, [...propertyPath, 'default'])
287
+ return defaultValue === undefined ? { env } : { env, default: defaultValue }
288
+ }
289
+
290
+ function parseDoctorConfig(value: unknown, source: ConfigSource): RemixDoctorCommandConfig {
291
+ let objectPath = ['doctor']
292
+ let object = requireObject(value, source, objectPath)
293
+ requireKnownProperties(object, ['strict'], source, objectPath)
294
+
295
+ let config: RemixDoctorCommandConfig = {}
296
+ let strict = optionalBoolean(object.strict, source, [...objectPath, 'strict'])
297
+ if (strict !== undefined) config.strict = strict
298
+ return config
299
+ }
300
+
301
+ function parseTestConfig(
302
+ value: unknown,
303
+ source: ConfigSource,
304
+ configDir: string,
305
+ cwd: string,
306
+ ): RemixTestCommandConfig {
307
+ let objectPath = ['test']
308
+ let object = requireObject(value, source, objectPath)
309
+ requireKnownProperties(
310
+ object,
311
+ [
312
+ 'browserFiles',
313
+ 'concurrency',
314
+ 'coverage',
315
+ 'e2eFiles',
316
+ 'exclude',
317
+ 'files',
318
+ 'only',
319
+ 'playwright',
320
+ 'pool',
321
+ 'quiet',
322
+ 'reporter',
323
+ 'setup',
324
+ 'type',
325
+ 'watch',
326
+ ],
327
+ source,
328
+ objectPath,
329
+ )
330
+
331
+ let config: RemixTestCommandConfig = {}
332
+ let browserFiles = optionalStringArray(object.browserFiles, source, [
333
+ ...objectPath,
334
+ 'browserFiles',
335
+ ])
336
+ let e2eFiles = optionalStringArray(object.e2eFiles, source, [...objectPath, 'e2eFiles'])
337
+ let exclude = optionalStringArray(object.exclude, source, [...objectPath, 'exclude'])
338
+ let files = optionalStringArray(object.files, source, [...objectPath, 'files'])
339
+ let only = optionalStringArray(object.only, source, [...objectPath, 'only'])
340
+ let concurrency = optionalNumber(object.concurrency, source, [...objectPath, 'concurrency'])
341
+ let setup = optionalString(object.setup, source, [...objectPath, 'setup'])
342
+
343
+ if (browserFiles !== undefined) config.browserFiles = resolveGlobs(browserFiles, configDir, cwd)
344
+ if (e2eFiles !== undefined) config.e2eFiles = resolveGlobs(e2eFiles, configDir, cwd)
345
+ if (exclude !== undefined) config.exclude = resolveGlobs(exclude, configDir, cwd)
346
+ if (files !== undefined) config.files = resolveGlobs(files, configDir, cwd)
347
+ if (only !== undefined) {
348
+ for (let [index, pattern] of only.entries()) {
349
+ validateOnlyPattern(pattern, source, [...objectPath, 'only', index])
350
+ }
351
+ config.only = only
352
+ }
353
+
354
+ if (concurrency !== undefined) {
355
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
356
+ throwConfigError(source, [...objectPath, 'concurrency'], 'Expected a positive integer')
357
+ }
358
+ config.concurrency = concurrency
359
+ }
360
+
361
+ let pool = optionalEnum(object.pool, testPools, source, [...objectPath, 'pool'])
362
+ if (pool !== undefined) config.pool = pool
363
+
364
+ let quiet = optionalBoolean(object.quiet, source, [...objectPath, 'quiet'])
365
+ if (quiet !== undefined) config.quiet = quiet
366
+
367
+ let reporter = optionalEnum(object.reporter, reporters, source, [...objectPath, 'reporter'])
368
+ if (reporter !== undefined) config.reporter = reporter
369
+
370
+ if (setup !== undefined) config.setup = path.resolve(configDir, setup)
371
+
372
+ let type = optionalEnumArray(object.type, testTypes, source, [...objectPath, 'type'])
373
+ if (type !== undefined) config.type = type
374
+
375
+ let watch = optionalBoolean(object.watch, source, [...objectPath, 'watch'])
376
+ if (watch !== undefined) config.watch = watch
377
+
378
+ if (object.coverage !== undefined) {
379
+ config.coverage = parseCoverageConfig(object.coverage, source, configDir, cwd)
380
+ }
381
+
382
+ if (object.playwright !== undefined) {
383
+ config.playwright = parsePlaywrightConfig(object.playwright, source, configDir)
384
+ }
385
+
386
+ return config
387
+ }
388
+
389
+ function parseCoverageConfig(
390
+ value: unknown,
391
+ source: ConfigSource,
392
+ configDir: string,
393
+ cwd: string,
394
+ ): NonNullable<RemixTestCommandConfig['coverage']> {
395
+ let objectPath = ['test', 'coverage']
396
+ let object = requireObject(value, source, objectPath)
397
+ requireKnownProperties(
398
+ object,
399
+ ['branches', 'dir', 'enabled', 'exclude', 'functions', 'include', 'lines', 'statements'],
400
+ source,
401
+ objectPath,
402
+ )
403
+
404
+ let config: NonNullable<RemixTestCommandConfig['coverage']> = {}
405
+ let branches = optionalNumber(object.branches, source, [...objectPath, 'branches'])
406
+ let dir = optionalString(object.dir, source, [...objectPath, 'dir'])
407
+ let enabled = optionalBoolean(object.enabled, source, [...objectPath, 'enabled'])
408
+ let exclude = optionalStringArray(object.exclude, source, [...objectPath, 'exclude'])
409
+ let functions = optionalNumber(object.functions, source, [...objectPath, 'functions'])
410
+ let include = optionalStringArray(object.include, source, [...objectPath, 'include'])
411
+ let lines = optionalNumber(object.lines, source, [...objectPath, 'lines'])
412
+ let statements = optionalNumber(object.statements, source, [...objectPath, 'statements'])
413
+
414
+ if (branches !== undefined) config.branches = branches
415
+ if (dir !== undefined) config.dir = path.resolve(configDir, dir)
416
+ if (enabled !== undefined) config.enabled = enabled
417
+ if (exclude !== undefined) config.exclude = resolveGlobs(exclude, configDir, cwd)
418
+ if (functions !== undefined) config.functions = functions
419
+ if (include !== undefined) config.include = resolveGlobs(include, configDir, cwd)
420
+ if (lines !== undefined) config.lines = lines
421
+ if (statements !== undefined) config.statements = statements
422
+
423
+ return config
424
+ }
425
+
426
+ function parsePlaywrightConfig(
427
+ value: unknown,
428
+ source: ConfigSource,
429
+ configDir: string,
430
+ ): NonNullable<RemixTestCommandConfig['playwright']> {
431
+ let objectPath = ['test', 'playwright']
432
+ let object = requireObject(value, source, objectPath)
433
+ requireKnownProperties(object, ['configFile', 'echo', 'open', 'projects'], source, objectPath)
434
+
435
+ let config: NonNullable<RemixTestCommandConfig['playwright']> = {}
436
+ let configFile = optionalString(object.configFile, source, [...objectPath, 'configFile'])
437
+ let echo = optionalBoolean(object.echo, source, [...objectPath, 'echo'])
438
+ let open = optionalBoolean(object.open, source, [...objectPath, 'open'])
439
+ let projects = optionalStringArray(object.projects, source, [...objectPath, 'projects'])
440
+
441
+ if (configFile !== undefined) config.configFile = path.resolve(configDir, configFile)
442
+ if (echo !== undefined) config.echo = echo
443
+ if (open !== undefined) config.open = open
444
+ if (projects !== undefined) config.projects = projects
445
+
446
+ return config
447
+ }
448
+
449
+ function requireObject(
450
+ value: unknown,
451
+ source: ConfigSource,
452
+ propertyPath: JsonPath,
453
+ ): Record<string, unknown> {
454
+ if (!isRecord(value)) {
455
+ throwConfigError(source, propertyPath, 'Expected an object')
456
+ }
457
+
458
+ return value
459
+ }
460
+
461
+ function requireKnownProperties(
462
+ object: Record<string, unknown>,
463
+ knownProperties: readonly string[],
464
+ source: ConfigSource,
465
+ propertyPath: JsonPath,
466
+ ): void {
467
+ for (let property of Object.keys(object)) {
468
+ if (!knownProperties.includes(property)) {
469
+ throwConfigError(source, [...propertyPath, property], `Unknown property "${property}"`)
470
+ }
471
+ }
472
+ }
473
+
474
+ function optionalBoolean(
475
+ value: unknown,
476
+ source: ConfigSource,
477
+ propertyPath: JsonPath,
478
+ ): boolean | undefined {
479
+ if (value === undefined) return undefined
480
+ if (typeof value !== 'boolean') throwConfigError(source, propertyPath, 'Expected a boolean')
481
+ return value
482
+ }
483
+
484
+ function optionalNumber(
485
+ value: unknown,
486
+ source: ConfigSource,
487
+ propertyPath: JsonPath,
488
+ ): number | undefined {
489
+ if (value === undefined) return undefined
490
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
491
+ throwConfigError(source, propertyPath, 'Expected a number')
492
+ }
493
+ return value
494
+ }
495
+
496
+ function optionalString(
497
+ value: unknown,
498
+ source: ConfigSource,
499
+ propertyPath: JsonPath,
500
+ ): string | undefined {
501
+ if (value === undefined) return undefined
502
+ return requireString(value, source, propertyPath)
503
+ }
504
+
505
+ function requireString(value: unknown, source: ConfigSource, propertyPath: JsonPath): string {
506
+ if (typeof value !== 'string') throwConfigError(source, propertyPath, 'Expected a string')
507
+ return value
508
+ }
509
+
510
+ function optionalStringArray(
511
+ value: unknown,
512
+ source: ConfigSource,
513
+ propertyPath: JsonPath,
514
+ ): string[] | undefined {
515
+ if (value === undefined) return undefined
516
+ if (!Array.isArray(value)) throwConfigError(source, propertyPath, 'Expected an array of strings')
517
+
518
+ return value.map((item, index) => requireString(item, source, [...propertyPath, index]))
519
+ }
520
+
521
+ function optionalEnum<const value extends string>(
522
+ input: unknown,
523
+ values: readonly value[],
524
+ source: ConfigSource,
525
+ propertyPath: JsonPath,
526
+ ): value | undefined {
527
+ if (input === undefined) return undefined
528
+ return requireEnum(input, values, source, propertyPath)
529
+ }
530
+
531
+ function optionalEnumArray<const value extends string>(
532
+ input: unknown,
533
+ values: readonly value[],
534
+ source: ConfigSource,
535
+ propertyPath: JsonPath,
536
+ ): value[] | undefined {
537
+ if (input === undefined) return undefined
538
+ if (!Array.isArray(input)) throwConfigError(source, propertyPath, 'Expected an array')
539
+
540
+ return input.map((item, index) => requireEnum(item, values, source, [...propertyPath, index]))
541
+ }
542
+
543
+ function requireEnum<const value extends string>(
544
+ input: unknown,
545
+ values: readonly value[],
546
+ source: ConfigSource,
547
+ propertyPath: JsonPath,
548
+ ): value {
549
+ let string = requireString(input, source, propertyPath)
550
+ let matched = values.find((value) => value === string)
551
+ if (matched === undefined) {
552
+ throwConfigError(source, propertyPath, `Expected one of: ${values.join(', ')}`)
553
+ }
554
+ return matched
555
+ }
556
+
557
+ function resolveGlobs(values: string[], configDir: string, cwd: string): string[] {
558
+ return values.map((value) => toPosix(path.relative(cwd, path.resolve(configDir, value))))
559
+ }
560
+
561
+ function toPosix(value: string): string {
562
+ return path.sep === '/' ? value : value.replaceAll(path.sep, '/')
563
+ }
564
+
565
+ function validateOnlyPattern(pattern: string, source: ConfigSource, propertyPath: JsonPath): void {
566
+ let regex = parseRegexLiteral(pattern) ?? { flags: 'i', source: pattern }
567
+
568
+ try {
569
+ new RegExp(regex.source, regex.flags)
570
+ } catch (error) {
571
+ let reason = error instanceof Error ? error.message : String(error)
572
+ throwConfigError(
573
+ source,
574
+ propertyPath,
575
+ `Expected a valid JavaScript regular expression: ${reason}`,
576
+ )
577
+ }
578
+ }
579
+
580
+ function parseRegexLiteral(pattern: string): { flags: string; source: string } | undefined {
581
+ if (!pattern.startsWith('/') || pattern.length < 2) return undefined
582
+
583
+ // The closing delimiter is the last unescaped slash; escape state must be
584
+ // tracked left-to-right since it depends on the preceding backslashes.
585
+ let closingIndex = -1
586
+ let escaped = false
587
+ for (let index = 1; index < pattern.length; index++) {
588
+ if (escaped) {
589
+ escaped = false
590
+ } else if (pattern[index] === '\\') {
591
+ escaped = true
592
+ } else if (pattern[index] === '/') {
593
+ closingIndex = index
594
+ }
595
+ }
596
+
597
+ if (closingIndex === -1) return undefined
598
+
599
+ return {
600
+ flags: pattern.slice(closingIndex + 1),
601
+ source: pattern.slice(1, closingIndex),
602
+ }
603
+ }
604
+
605
+ function throwConfigError(
606
+ source: ConfigSource,
607
+ propertyPath: JsonPath,
608
+ details: string,
609
+ offset?: number,
610
+ ): never {
611
+ let node = source.root == null ? undefined : findNodeAtLocation(source.root, propertyPath)
612
+ let location = getLineAndColumn(source.text, offset ?? node?.offset ?? 0)
613
+ let label = propertyPath.length === 0 ? '' : ` at ${propertyPath.join('.')}`
614
+ throw invalidRemixConfig(source.filePath, `${details}${label}`, location.line, location.column)
615
+ }
616
+
617
+ function getLineAndColumn(text: string, offset: number): { column: number; line: number } {
618
+ let line = 1
619
+ let column = 1
620
+
621
+ for (let index = 0; index < offset; index++) {
622
+ if (text[index] === '\n') {
623
+ line++
624
+ column = 1
625
+ } else {
626
+ column++
627
+ }
628
+ }
629
+
630
+ return { column, line }
631
+ }
632
+
633
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
634
+ return error instanceof Error && 'code' in error
635
+ }
636
+
637
+ function isRecord(value: unknown): value is Record<string, unknown> {
638
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
639
+ }