@remix-run/cli 0.5.0 → 0.7.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 (55) hide show
  1. package/README.md +52 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/lib/cli-context.d.ts.map +1 -1
  6. package/dist/lib/cli-context.js +5 -2
  7. package/dist/lib/cli.d.ts +1 -1
  8. package/dist/lib/cli.js +5 -1
  9. package/dist/lib/commands/assets.d.ts +4 -0
  10. package/dist/lib/commands/assets.d.ts.map +1 -0
  11. package/dist/lib/commands/assets.js +90 -0
  12. package/dist/lib/commands/db.d.ts.map +1 -1
  13. package/dist/lib/commands/db.js +63 -5
  14. package/dist/lib/commands/help.d.ts.map +1 -1
  15. package/dist/lib/commands/help.js +7 -0
  16. package/dist/lib/completion.d.ts.map +1 -1
  17. package/dist/lib/completion.js +25 -1
  18. package/dist/lib/database-command.d.ts +5 -1
  19. package/dist/lib/database-command.d.ts.map +1 -1
  20. package/dist/lib/database-command.js +1 -0
  21. package/dist/lib/errors.d.ts +6 -0
  22. package/dist/lib/errors.d.ts.map +1 -1
  23. package/dist/lib/errors.js +10 -0
  24. package/dist/lib/remix-config.d.ts +22 -0
  25. package/dist/lib/remix-config.d.ts.map +1 -1
  26. package/dist/lib/remix-config.js +80 -4
  27. package/package.json +6 -5
  28. package/schema/remix.json +49 -1
  29. package/src/index.ts +10 -0
  30. package/src/lib/cli-context.ts +10 -2
  31. package/src/lib/cli.ts +6 -1
  32. package/src/lib/commands/assets.ts +109 -0
  33. package/src/lib/commands/db.ts +75 -5
  34. package/src/lib/commands/help.ts +8 -0
  35. package/src/lib/completion.ts +41 -1
  36. package/src/lib/database-command.ts +6 -1
  37. package/src/lib/errors.ts +11 -0
  38. package/src/lib/remix-config.ts +128 -4
  39. package/template/.agents/skills/remix/SKILL.md +7 -5
  40. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +22 -20
  41. package/template/.agents/skills/remix/references/auth-and-sessions.md +1 -15
  42. package/template/.agents/skills/remix/references/component-model.md +18 -16
  43. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +75 -52
  44. package/template/.agents/skills/remix/references/middleware-and-server.md +5 -2
  45. package/template/.agents/skills/remix/references/mixins-styling-events.md +1 -1
  46. package/template/.agents/skills/remix/references/routing-and-controllers.md +1 -1
  47. package/template/.agents/skills/remix/references/testing-patterns.md +1 -1
  48. package/template/AGENTS.md +2 -3
  49. package/template/README.md +2 -3
  50. package/template/app/actions/controller.tsx +2 -4
  51. package/template/app/actions/document.tsx +7 -4
  52. package/template/app/actions/public/entry.ts +15 -29
  53. package/template/app/assets.ts +7 -8
  54. package/template/app/router.ts +5 -3
  55. package/template/app/middleware/render.tsx +0 -78
@@ -4,6 +4,11 @@ export const CLI_ERROR_DEFINITIONS = {
4
4
  title: 'Could not determine an app name',
5
5
  fix: 'Pass --app-name or choose a target directory name that can become an app name.',
6
6
  },
7
+ assetsConfigRequired: {
8
+ code: 'RMX_ASSETS_CONFIG_REQUIRED',
9
+ title: 'Asset configuration is required',
10
+ fix: 'Add an assets configuration to remix.json.',
11
+ },
7
12
  dbConfigRequired: {
8
13
  code: 'RMX_DB_CONFIG_REQUIRED',
9
14
  title: 'Database configuration is required',
@@ -165,6 +170,11 @@ export function appNameUnavailable(targetDir) {
165
170
  message: 'Could not determine an app name from the target directory.',
166
171
  });
167
172
  }
173
+ export function assetsConfigRequired() {
174
+ return createCliError(CLI_ERROR_DEFINITIONS.assetsConfigRequired, {
175
+ message: 'Asset configuration is missing from remix.json.',
176
+ });
177
+ }
168
178
  export function dbConfigRequired(filePath) {
169
179
  return createCliError(CLI_ERROR_DEFINITIONS.dbConfigRequired, {
170
180
  context: { filePath },
@@ -1,14 +1,28 @@
1
+ import type { AssetServerOptions } from '@remix-run/assets';
1
2
  import type { RemixTestPool } from '@remix-run/test/cli';
2
3
  declare const reporters: readonly ['spec', 'files', 'tap', 'dot'];
3
4
  declare const testTypes: readonly ['server', 'browser', 'e2e'];
4
5
  type Reporter = (typeof reporters)[number];
5
6
  type TestPool = RemixTestPool;
6
7
  type TestType = (typeof testTypes)[number];
8
+ /** Validated configuration loaded from a Remix project config file. */
7
9
  export interface RemixConfig {
10
+ /** Shared asset mapping and browser access configuration. */
11
+ assets?: RemixAssetsConfig;
12
+ /** Database command configuration. */
8
13
  db?: RemixDbCommandConfig;
14
+ /** Project health-check configuration. */
9
15
  doctor?: RemixDoctorCommandConfig;
16
+ /** Test runner configuration. */
10
17
  test?: RemixTestCommandConfig;
11
18
  }
19
+ /** JSON-compatible asset server configuration loaded from `remix.json`. */
20
+ export interface RemixAssetsConfig extends Pick<AssetServerOptions, 'allowFiles' | 'allowPackages' | 'basePath' | 'denyFiles' | 'mounts'> {
21
+ /** Leaf file asset configuration. */
22
+ files?: Pick<NonNullable<AssetServerOptions['files']>, 'extensions'>;
23
+ /** Absolute root directory used to resolve asset file paths. */
24
+ rootDir: string;
25
+ }
12
26
  export type RemixDbString = string | {
13
27
  env: string;
14
28
  default?: string;
@@ -70,6 +84,14 @@ export interface RemixTestCommandConfig {
70
84
  type?: TestType[];
71
85
  watch?: boolean;
72
86
  }
87
+ /**
88
+ * Loads the nearest Remix project configuration or an explicitly selected config file.
89
+ *
90
+ * @param from A config file or directory from which to search upward for `remix.json`. Defaults to
91
+ * `process.cwd()`.
92
+ * @returns The validated Remix project configuration, or an empty object when no config is found.
93
+ */
94
+ export declare function loadConfig(from?: string | URL): Promise<RemixConfig>;
73
95
  export declare function loadRemixConfig(cwd: string, configPath: string | undefined): Promise<RemixConfig>;
74
96
  export {};
75
97
  //# sourceMappingURL=remix-config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"remix-config.d.ts","sourceRoot":"","sources":["../../src/lib/remix-config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"remix-config.d.ts","sourceRoot":"","sources":["../../src/lib/remix-config.ts"],"names":[],"mappings":"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"}
@@ -1,10 +1,40 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import * as process from 'node:process';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { findNodeAtLocation, getNodeValue, parseTree, printParseErrorCode, } from 'jsonc-parser';
6
+ import { findAppRoot } from './app-root.js';
4
7
  import { invalidRemixConfig, remixConfigNotFound } from './errors.js';
5
8
  const reporters = ['spec', 'files', 'tap', 'dot'];
6
9
  const testPools = ['forks', 'threads'];
7
10
  const testTypes = ['server', 'browser', 'e2e'];
11
+ /**
12
+ * Loads the nearest Remix project configuration or an explicitly selected config file.
13
+ *
14
+ * @param from A config file or directory from which to search upward for `remix.json`. Defaults to
15
+ * `process.cwd()`.
16
+ * @returns The validated Remix project configuration, or an empty object when no config is found.
17
+ */
18
+ export async function loadConfig(from = process.cwd()) {
19
+ let fromPath = path.resolve(from instanceof URL ? fileURLToPath(from) : from);
20
+ let stat;
21
+ try {
22
+ stat = await fs.stat(fromPath);
23
+ }
24
+ catch (error) {
25
+ if (isNodeError(error) && error.code === 'ENOENT')
26
+ throw remixConfigNotFound(fromPath);
27
+ throw error;
28
+ }
29
+ if (stat.isFile()) {
30
+ return loadRemixConfig(path.dirname(fromPath), path.basename(fromPath));
31
+ }
32
+ if (!stat.isDirectory()) {
33
+ throw new TypeError(`Expected a Remix config file or directory: ${fromPath}`);
34
+ }
35
+ let configDir = await findAppRoot(fromPath, 'remix.json');
36
+ return configDir === null ? {} : loadRemixConfig(configDir, undefined);
37
+ }
8
38
  export async function loadRemixConfig(cwd, configPath) {
9
39
  let filePath = path.resolve(cwd, configPath ?? 'remix.json');
10
40
  let text;
@@ -44,11 +74,14 @@ export async function loadRemixConfig(cwd, configPath) {
44
74
  }
45
75
  function parseConfig(value, source, configDir, cwd) {
46
76
  let object = requireObject(value, source, []);
47
- requireKnownProperties(object, ['$schema', 'db', 'doctor', 'test'], source, []);
77
+ requireKnownProperties(object, ['$schema', 'assets', 'db', 'doctor', 'test'], source, []);
48
78
  if (object.$schema !== undefined) {
49
79
  requireString(object.$schema, source, ['$schema']);
50
80
  }
51
81
  let config = {};
82
+ if (object.assets !== undefined) {
83
+ config.assets = parseAssetsConfig(object.assets, source, configDir);
84
+ }
52
85
  if (object.db !== undefined) {
53
86
  config.db = parseDbConfig(object.db, source, configDir);
54
87
  }
@@ -60,6 +93,39 @@ function parseConfig(value, source, configDir, cwd) {
60
93
  }
61
94
  return config;
62
95
  }
96
+ function parseAssetsConfig(value, source, configDir) {
97
+ let objectPath = ['assets'];
98
+ let object = requireObject(value, source, objectPath);
99
+ requireKnownProperties(object, ['allowFiles', 'allowPackages', 'basePath', 'denyFiles', 'files', 'mounts', 'rootDir'], source, objectPath);
100
+ let config = {
101
+ allowFiles: requireStringArray(object.allowFiles, source, [...objectPath, 'allowFiles']),
102
+ basePath: requireString(object.basePath, source, [...objectPath, 'basePath']),
103
+ rootDir: path.resolve(configDir, optionalString(object.rootDir, source, [...objectPath, 'rootDir']) ?? '.'),
104
+ };
105
+ let allowPackages = optionalStringArray(object.allowPackages, source, [
106
+ ...objectPath,
107
+ 'allowPackages',
108
+ ]);
109
+ let denyFiles = optionalStringArray(object.denyFiles, source, [...objectPath, 'denyFiles']);
110
+ let mounts = object.mounts === undefined
111
+ ? undefined
112
+ : requireStringRecord(object.mounts, source, [...objectPath, 'mounts']);
113
+ if (allowPackages !== undefined)
114
+ config.allowPackages = allowPackages;
115
+ if (denyFiles !== undefined)
116
+ config.denyFiles = denyFiles;
117
+ if (mounts !== undefined)
118
+ config.mounts = mounts;
119
+ if (object.files !== undefined) {
120
+ let filesPath = [...objectPath, 'files'];
121
+ let files = requireObject(object.files, source, filesPath);
122
+ requireKnownProperties(files, ['extensions'], source, filesPath);
123
+ config.files = {
124
+ extensions: requireStringArray(files.extensions, source, [...filesPath, 'extensions']),
125
+ };
126
+ }
127
+ return config;
128
+ }
63
129
  function parseDbConfig(value, source, configDir) {
64
130
  let objectPath = ['db'];
65
131
  let object = requireObject(value, source, objectPath);
@@ -315,13 +381,23 @@ function requireString(value, source, propertyPath) {
315
381
  throwConfigError(source, propertyPath, 'Expected a string');
316
382
  return value;
317
383
  }
318
- function optionalStringArray(value, source, propertyPath) {
319
- if (value === undefined)
320
- return undefined;
384
+ function requireStringArray(value, source, propertyPath) {
321
385
  if (!Array.isArray(value))
322
386
  throwConfigError(source, propertyPath, 'Expected an array of strings');
323
387
  return value.map((item, index) => requireString(item, source, [...propertyPath, index]));
324
388
  }
389
+ function requireStringRecord(value, source, propertyPath) {
390
+ let object = requireObject(value, source, propertyPath);
391
+ return Object.fromEntries(Object.entries(object).map(([key, item]) => [
392
+ key,
393
+ requireString(item, source, [...propertyPath, key]),
394
+ ]));
395
+ }
396
+ function optionalStringArray(value, source, propertyPath) {
397
+ if (value === undefined)
398
+ return undefined;
399
+ return requireStringArray(value, source, propertyPath);
400
+ }
325
401
  function optionalEnum(input, values, source, propertyPath) {
326
402
  if (input === undefined)
327
403
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Command-line interface for Remix",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
@@ -33,10 +33,11 @@
33
33
  "dependencies": {
34
34
  "jsonc-parser": "^3.3.1",
35
35
  "semver": "^7.7.4",
36
- "@remix-run/data-table": "^0.4.0",
37
- "@remix-run/data-table-postgres": "^0.5.0",
38
- "@remix-run/data-table-sqlite": "^0.6.0",
39
- "@remix-run/data-table-mysql": "^0.5.0",
36
+ "@remix-run/assets": "^0.7.0",
37
+ "@remix-run/data-table": "^0.5.1",
38
+ "@remix-run/data-table-postgres": "^0.5.2",
39
+ "@remix-run/data-table-mysql": "^0.5.2",
40
+ "@remix-run/data-table-sqlite": "^0.6.2",
40
41
  "@remix-run/terminal": "^0.1.1",
41
42
  "@remix-run/test": "^0.6.0"
42
43
  },
package/schema/remix.json CHANGED
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://remix.run/schemas/remix.json",
4
3
  "title": "Remix CLI configuration",
5
4
  "description": "Configuration for the Remix command-line interface.",
6
5
  "type": "object",
@@ -10,6 +9,9 @@
10
9
  "type": "string",
11
10
  "description": "JSON Schema used by editors to validate this file."
12
11
  },
12
+ "assets": {
13
+ "$ref": "#/$defs/assets"
14
+ },
13
15
  "db": {
14
16
  "$ref": "#/$defs/db"
15
17
  },
@@ -21,6 +23,52 @@
21
23
  }
22
24
  },
23
25
  "$defs": {
26
+ "assets": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["allowFiles", "basePath"],
30
+ "properties": {
31
+ "allowFiles": {
32
+ "$ref": "#/$defs/stringArray",
33
+ "description": "File paths and glob patterns that may be served."
34
+ },
35
+ "allowPackages": {
36
+ "$ref": "#/$defs/stringArray",
37
+ "description": "Package names whose files and dependencies may be served."
38
+ },
39
+ "basePath": {
40
+ "type": "string",
41
+ "description": "Public URL prefix for served assets."
42
+ },
43
+ "denyFiles": {
44
+ "$ref": "#/$defs/stringArray",
45
+ "description": "File paths and glob patterns that must not be served."
46
+ },
47
+ "mounts": {
48
+ "type": "object",
49
+ "description": "Directories keyed by public URL paths relative to basePath.",
50
+ "additionalProperties": { "type": "string" }
51
+ },
52
+ "files": {
53
+ "$ref": "#/$defs/assetsFiles"
54
+ },
55
+ "rootDir": {
56
+ "type": "string",
57
+ "description": "Root directory for asset file paths, relative to this config file."
58
+ }
59
+ }
60
+ },
61
+ "assetsFiles": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "required": ["extensions"],
65
+ "properties": {
66
+ "extensions": {
67
+ "$ref": "#/$defs/stringArray",
68
+ "description": "Additional file extensions served without script or style compilation."
69
+ }
70
+ }
71
+ },
24
72
  "db": {
25
73
  "type": "object",
26
74
  "additionalProperties": false,
package/src/index.ts CHANGED
@@ -1 +1,11 @@
1
1
  export { runRemix, type RunRemixOptions } from './lib/cli.ts'
2
+ export {
3
+ loadConfig,
4
+ type RemixAssetsConfig,
5
+ type RemixConfig,
6
+ type RemixDbAdapterConfig,
7
+ type RemixDbCommandConfig,
8
+ type RemixDbString,
9
+ type RemixDoctorCommandConfig,
10
+ type RemixTestCommandConfig,
11
+ } from './lib/remix-config.ts'
@@ -2,7 +2,11 @@ import * as path from 'node:path'
2
2
  import * as process from 'node:process'
3
3
 
4
4
  import { resolveDefaultRemixVersion } from './remix-version.ts'
5
- import { loadRemixConfig, type RemixConfig } from './remix-config.ts'
5
+ import {
6
+ loadConfig as loadProjectConfig,
7
+ loadRemixConfig,
8
+ type RemixConfig,
9
+ } from './remix-config.ts'
6
10
 
7
11
  interface ResolveCliContextOptions {
8
12
  configPath?: string
@@ -26,7 +30,11 @@ export async function resolveCliContext(
26
30
  let remixVersion = normalizeRemixVersion(options.remixVersion)
27
31
 
28
32
  let configPromise: Promise<RemixConfig> | undefined
29
- let loadConfig = () => (configPromise ??= loadRemixConfig(cwd, options.configPath))
33
+ let loadConfig = () =>
34
+ (configPromise ??=
35
+ options.configPath === undefined
36
+ ? loadProjectConfig(cwd)
37
+ : loadRemixConfig(cwd, options.configPath))
30
38
 
31
39
  // Validate an explicitly selected config file up front so every command
32
40
  // fails fast on a bad --config path. The default remix.json is loaded
package/src/lib/cli.ts CHANGED
@@ -23,7 +23,7 @@ export interface RunRemixOptions {
23
23
 
24
24
  /**
25
25
  * Entry point for the `remix` CLI. Parses `argv`, dispatches to the matching
26
- * subcommand (`new`, `db`, `doctor`, `routes`, `test`, `version`,
26
+ * subcommand (`new`, `assets`, `db`, `doctor`, `routes`, `test`, `version`,
27
27
  * `completion`, `help`), and resolves with the exit code the process should
28
28
  * use.
29
29
  *
@@ -103,6 +103,11 @@ async function runCommand(command: string, argv: string[], context: CliContext):
103
103
  return runCompletionCommand(argv)
104
104
  }
105
105
 
106
+ if (command === 'assets') {
107
+ let { runAssetsCommand } = await import('./commands/assets.ts')
108
+ return runAssetsCommand(argv, context)
109
+ }
110
+
106
111
  if (command === 'doctor') {
107
112
  let { runDoctorCommand } = await import('./commands/doctor.ts')
108
113
  return runDoctorCommand(argv, context)
@@ -0,0 +1,109 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import * as process from 'node:process'
4
+ import { createAssetServer, type AssetDetails } from '@remix-run/assets'
5
+
6
+ import type { CliContext } from '../cli-context.ts'
7
+ import {
8
+ assetsConfigRequired,
9
+ invalidOptionValue,
10
+ renderCliError,
11
+ toCliError,
12
+ unknownCommand,
13
+ } from '../errors.ts'
14
+ import { formatHelpText } from '../help-text.ts'
15
+ import { parseArgs } from '../parse-args.ts'
16
+
17
+ export async function runAssetsCommand(argv: string[], context: CliContext): Promise<number> {
18
+ if (argv.includes('-h') || argv.includes('--help')) {
19
+ process.stdout.write(getAssetsCommandHelpText())
20
+ return 0
21
+ }
22
+
23
+ try {
24
+ let invocation = parseAssetsCommandArgs(argv)
25
+ let config = await context.loadConfig()
26
+ if (config.assets === undefined) throw assetsConfigRequired()
27
+
28
+ let assetServer = createAssetServer({ ...config.assets, watch: false })
29
+ try {
30
+ let rootDir = fs.realpathSync(config.assets.rootDir)
31
+
32
+ if (invocation.command === 'list') {
33
+ process.stdout.write(formatAssetList(await assetServer.getAssets(), rootDir))
34
+ } else {
35
+ process.stdout.write(
36
+ formatAssetDetails(await assetServer.getAssetDetails(invocation.input), rootDir),
37
+ )
38
+ }
39
+ } finally {
40
+ await assetServer.close()
41
+ }
42
+
43
+ return 0
44
+ } catch (error) {
45
+ process.stderr.write(
46
+ renderCliError(toCliError(error), { helpText: getAssetsCommandHelpText(process.stderr) }),
47
+ )
48
+ return 1
49
+ }
50
+ }
51
+
52
+ export function getAssetsCommandHelpText(target: NodeJS.WriteStream = process.stdout): string {
53
+ return formatHelpText(
54
+ {
55
+ description: 'List or inspect browser-reachable assets.',
56
+ commands: [{ description: 'Inspect one asset URL or file', label: 'inspect <url-or-file>' }],
57
+ examples: [
58
+ 'remix assets',
59
+ 'remix assets inspect /assets/app/actions/public/entry.ts',
60
+ 'remix assets inspect app/actions/public/entry.ts',
61
+ ],
62
+ usage: ['remix assets', 'remix assets inspect <url-or-file>'],
63
+ },
64
+ target,
65
+ )
66
+ }
67
+
68
+ type AssetsCommandInvocation = { command: 'list' } | { command: 'inspect'; input: string }
69
+
70
+ function parseAssetsCommandArgs(argv: string[]): AssetsCommandInvocation {
71
+ let parsed = parseArgs(argv, {}, { maxPositionals: 2 })
72
+ let [command, input] = parsed.positionals
73
+
74
+ if (command === undefined) return { command: 'list' }
75
+ if (command !== 'inspect') throw unknownCommand(`assets ${command}`)
76
+ if (input === undefined) {
77
+ throw invalidOptionValue('`remix assets inspect` requires a URL or file path.')
78
+ }
79
+ return { command, input }
80
+ }
81
+
82
+ function formatAssetList(assets: readonly AssetDetails[], rootDir: string): string {
83
+ if (assets.length === 0) return 'No assets.\n'
84
+ return `${assets
85
+ .map((asset) => `${asset.url} -> ${formatFilePath(asset.filePath, rootDir)}`)
86
+ .join('\n')}\n`
87
+ }
88
+
89
+ function formatAssetDetails(details: AssetDetails, rootDir: string): string {
90
+ let lines = [`Status: ${details.status}`]
91
+ if (details.url !== undefined) lines.push(`URL: ${details.url}`)
92
+ if (details.filePath !== undefined) {
93
+ lines.push(`File: ${formatFilePath(details.filePath, rootDir)}`)
94
+ }
95
+ if (details.access?.deniedBy !== undefined) {
96
+ lines.push(`Denied by: ${details.access.deniedBy}`)
97
+ }
98
+ return `${lines.join('\n')}\n`
99
+ }
100
+
101
+ function formatFilePath(filePath: string | undefined, rootDir: string): string {
102
+ if (filePath === undefined) return ''
103
+ let relativePath = path.relative(rootDir, filePath)
104
+ return relativePath === '' ||
105
+ relativePath.startsWith(`..${path.sep}`) ||
106
+ path.isAbsolute(relativePath)
107
+ ? filePath
108
+ : relativePath.split(path.sep).join('/')
109
+ }
@@ -25,7 +25,6 @@ import {
25
25
  import { formatHelpText } from '../help-text.ts'
26
26
  import { parseArgs } from '../parse-args.ts'
27
27
  import {
28
- loadRemixConfig,
29
28
  type RemixDbAdapterConfig,
30
29
  type RemixDbCommandConfig,
31
30
  type RemixDbString,
@@ -63,6 +62,8 @@ export function getDbCommandHelpText(target: NodeJS.WriteStream = process.stdout
63
62
  'remix db wipe --force',
64
63
  'remix db migrate',
65
64
  'remix db migrate --to 20260715123000_add_users',
65
+ 'remix db rollback',
66
+ 'remix db rollback --step 2 --dry-run',
66
67
  'remix db status',
67
68
  'remix db seed',
68
69
  'remix db reset --force',
@@ -72,24 +73,35 @@ export function getDbCommandHelpText(target: NodeJS.WriteStream = process.stdout
72
73
  description: 'Read the database connection from an environment variable',
73
74
  label: '--connection-env <name>',
74
75
  },
76
+ {
77
+ description: 'Report what would be reverted without running it (rollback only)',
78
+ label: '--dry-run',
79
+ },
75
80
  { description: 'Confirm a destructive command (wipe and reset only)', label: '--force' },
76
81
  {
77
- description: 'Use a migration journal table (migrate, status, and reset only)',
82
+ description: 'Use a migration journal table (migrate, rollback, status, and reset only)',
78
83
  label: '--journal-table <name>',
79
84
  },
80
85
  {
81
- description: 'Load migrations from a directory (migrate, status, and reset only)',
86
+ description:
87
+ 'Load migrations from a directory (migrate, rollback, status, and reset only)',
82
88
  label: '--migrations <path>',
83
89
  },
84
90
  { description: 'Run a SQL seed file (seed and reset only)', label: '--seed <path>' },
85
91
  {
86
- description: 'Stop after applying the specified migration (migrate only)',
92
+ description: 'Revert this many migrations, newest first (rollback only)',
93
+ label: '--step <count>',
94
+ },
95
+ {
96
+ description:
97
+ 'Stop after applying the specified migration, or revert back through it (migrate and rollback only)',
87
98
  label: '--to <migration>',
88
99
  },
89
100
  ],
90
101
  usage: [
91
102
  'remix db wipe --force [options]',
92
103
  'remix db migrate [--to <migration>] [options]',
104
+ 'remix db rollback [--step <count> | --to <migration>] [--dry-run] [options]',
93
105
  'remix db status [options]',
94
106
  'remix db seed [options]',
95
107
  'remix db reset --force [options]',
@@ -120,6 +132,28 @@ function parseDbCommandArgs(argv: string[]): DatabaseCommandInvocation {
120
132
  return { command, ...parsed.options }
121
133
  }
122
134
 
135
+ if (command === 'rollback') {
136
+ let parsed = parseArgs(
137
+ commandArgv,
138
+ {
139
+ connectionEnv: connectionOption,
140
+ dryRun: { flag: '--dry-run', type: 'boolean' },
141
+ journalTable: journalOption,
142
+ migrations: migrationsOption,
143
+ step: { flag: '--step', type: 'string' },
144
+ to: { flag: '--to', type: 'string' },
145
+ },
146
+ { maxPositionals: 0 },
147
+ )
148
+
149
+ if (parsed.options.step !== undefined && parsed.options.to !== undefined) {
150
+ throw invalidOptionValue('Options --step and --to are mutually exclusive')
151
+ }
152
+
153
+ let { step: rawStep, ...options } = parsed.options
154
+ return { command, ...options, step: parseStepOption(rawStep) }
155
+ }
156
+
123
157
  if (command === 'reset') {
124
158
  let parsed = parseArgs(
125
159
  commandArgv,
@@ -171,6 +205,17 @@ function parseDbCommandArgs(argv: string[]): DatabaseCommandInvocation {
171
205
  return { command, ...parsed.options }
172
206
  }
173
207
 
208
+ function parseStepOption(value: string | undefined): number | undefined {
209
+ if (value === undefined) return undefined
210
+
211
+ let step = Number(value)
212
+ if (!Number.isInteger(step) || step < 1) {
213
+ throw invalidOptionValue(`Option --step expects a positive integer, received "${value}"`)
214
+ }
215
+
216
+ return step
217
+ }
218
+
174
219
  async function resolveDbConfig(
175
220
  context: CliContext,
176
221
  ): Promise<{ config: RemixDbCommandConfig; configDir: string }> {
@@ -184,7 +229,7 @@ async function resolveDbConfig(
184
229
  let configDir = await findAppRoot(context.cwd, 'remix.json')
185
230
  if (configDir === null) throw remixConfigNotFound(path.join(context.cwd, 'remix.json'))
186
231
  configPath = path.join(configDir, 'remix.json')
187
- config = await loadRemixConfig(configDir, undefined)
232
+ config = await context.loadConfig()
188
233
  }
189
234
 
190
235
  if (config.db === undefined) throw dbConfigRequired(configPath)
@@ -206,6 +251,7 @@ function resolveDatabaseCommandPlan(
206
251
  if (
207
252
  (invocation.command === 'migrate' ||
208
253
  invocation.command === 'reset' ||
254
+ invocation.command === 'rollback' ||
209
255
  invocation.command === 'status') &&
210
256
  migrations === undefined
211
257
  ) {
@@ -221,9 +267,11 @@ function resolveDatabaseCommandPlan(
221
267
  return {
222
268
  adapter,
223
269
  command: invocation.command,
270
+ dryRun: invocation.dryRun,
224
271
  journalTable,
225
272
  migrations,
226
273
  seed,
274
+ step: invocation.step,
227
275
  to: invocation.to,
228
276
  }
229
277
  }
@@ -305,6 +353,28 @@ async function executeDatabaseCommand(plan: DatabaseCommandPlan, db: Database):
305
353
  })
306
354
  }
307
355
 
356
+ if (plan.command === 'rollback') {
357
+ if (plan.to !== undefined) {
358
+ return runRemixDb({
359
+ command: plan.command,
360
+ db,
361
+ migrations,
362
+ to: plan.to,
363
+ dryRun: plan.dryRun,
364
+ journalTable: plan.journalTable,
365
+ })
366
+ }
367
+
368
+ return runRemixDb({
369
+ command: plan.command,
370
+ db,
371
+ migrations,
372
+ step: plan.step,
373
+ dryRun: plan.dryRun,
374
+ journalTable: plan.journalTable,
375
+ })
376
+ }
377
+
308
378
  if (plan.command === 'reset') {
309
379
  let seed = plan.seed === undefined ? undefined : await loadSeed(plan.seed)
310
380
  return runRemixDb({
@@ -24,6 +24,7 @@ export function getCliHelpText(target: NodeJS.WriteStream = process.stdout): str
24
24
  return formatHelpText(
25
25
  {
26
26
  commands: [
27
+ { description: 'List or inspect browser-reachable assets', label: 'assets [command]' },
27
28
  { description: 'Print shell completion scripts for Remix', label: 'completion' },
28
29
  { description: 'Show help for Remix commands', label: 'help [command]' },
29
30
  { description: 'Create a new Remix project', label: 'new <name>' },
@@ -34,6 +35,7 @@ export function getCliHelpText(target: NodeJS.WriteStream = process.stdout): str
34
35
  { description: 'Show the current Remix version', label: 'version' },
35
36
  ],
36
37
  examples: [
38
+ 'remix assets',
37
39
  'remix completion bash',
38
40
  'remix help',
39
41
  'remix help completion',
@@ -64,6 +66,7 @@ export function getHelpCommandHelpText(target: NodeJS.WriteStream = process.stdo
64
66
  description: 'Show help for Remix commands.',
65
67
  examples: [
66
68
  'remix help',
69
+ 'remix help assets',
67
70
  'remix help completion',
68
71
  'remix help db',
69
72
  'remix help doctor',
@@ -94,6 +97,11 @@ async function getCommandHelpText(argv: string[]): Promise<string> {
94
97
  return getNewCommandHelpText()
95
98
  }
96
99
 
100
+ if (command === 'assets' && rest.length === 0) {
101
+ let { getAssetsCommandHelpText } = await import('./assets.ts')
102
+ return getAssetsCommandHelpText()
103
+ }
104
+
97
105
  if (command === 'completion' && rest.length === 0) {
98
106
  let { getCompletionCommandHelpText } = await import('./completion.ts')
99
107
  return getCompletionCommandHelpText()