@rvoh/psychic 3.0.3 → 3.0.4

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.
@@ -6,8 +6,6 @@ import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/sy
6
6
  import generateOpenapiReduxBindings from '../generate/openapi/reduxBindings.js';
7
7
  import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.js';
8
8
  import generateResource from '../generate/resource.js';
9
- import colorize from './helpers/colorize.js';
10
- import PsychicLogos from './helpers/PsychicLogos.js';
11
9
  import Watcher from '../watcher/Watcher.js';
12
10
  const INDENT = ' ';
13
11
  const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
@@ -64,28 +62,15 @@ const columnsWithTypesDescription = baseColumnsWithTypesDescription +
64
62
  `
65
63
  ${INDENT}
66
64
  ${INDENT} - belongs_to:
67
- ${INDENT} not only adds a foreign key to the migration, but also adds a BelongsTo association to the generated model:
65
+ ${INDENT} ALWAYS use this instead of adding a raw uuid column for foreign keys. It creates the FK column, adds a database index,
66
+ ${INDENT} AND generates the @deco.BelongsTo association and typed property on the model. A raw uuid column does none of this.
68
67
  ${INDENT}
69
- ${INDENT} include the fully qualified model name, e.g., if the Coach model is in src/app/models/Health/Coach:
70
- ${INDENT} Health/Coach:belongs_to`;
68
+ ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
+ ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
+ ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
72
  export default class PsychicCLI {
72
73
  static provide(program, { initializePsychicApp, seedDb, }) {
73
- program.hook('preAction', (_thisCommand, actionCommand) => {
74
- const cmdName = actionCommand.name();
75
- switch (cmdName) {
76
- case 'post-sync':
77
- return;
78
- default:
79
- DreamCLI.logger.log(colorize(PsychicLogos.asciiLogo(), { color: 'greenBright' }), { logPrefix: '' });
80
- DreamCLI.logger.log('\n', { logPrefix: '' });
81
- DreamCLI.logger.log(colorize(' ', { color: 'green' }) +
82
- colorize(' ' + cmdName + ' ', { color: 'black', bgColor: 'bgGreen' }) +
83
- '\n', {
84
- logPrefix: '',
85
- });
86
- DreamCLI.logger.log(colorize('⭣⭣⭣', { color: 'green' }) + '\n', { logPrefix: ' ' });
87
- }
88
- });
89
74
  DreamCLI.generateDreamCli(program, {
90
75
  initializeDreamApp: initializePsychicApp,
91
76
  seedDb,
@@ -96,20 +81,79 @@ export default class PsychicCLI {
96
81
  program
97
82
  .command('generate:resource')
98
83
  .alias('g:resource')
99
- .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
100
- .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes', false)
101
- .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
102
- - index
103
- - create
104
- - show
105
- - update
106
- - delete`)
107
- .option('--sti-base-serializer', 'creates a generically typed base serializer that includes the child type in the output so consuming applications can determine shape based on type', false)
108
- .option('--owning-model <modelName>', 'the model class of the object that `associationQuery`/`createAssociation` will be performed on in the created controller and spec (e.g., "Host", "Guest", "Ticketing/Ticket"). Defaults to the current user for non-admin/internal namespaced controllers. For admin/internal namespaced controllers, this defaults to null, meaning every admin/internal user can access the model.')
109
- .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
110
- .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
111
- .argument('<path>', 'URL path from root domain. Specify nesting resource with `{}`, e.g.: `tickets/{}/comments`')
112
- .argument('<modelName>', 'the name of the model to create, e.g. Post or Settings/CommunicationPreferences')
84
+ .description(`Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.
85
+ ${INDENT}
86
+ ${INDENT}This is the preferred generator when the model will be accessible via HTTP requests (API endpoints, admin panels, internal tools). It scaffolds everything needed for a full CRUD resource. Prefer this over g:model unless the model is purely internal with no HTTP access.
87
+ ${INDENT}
88
+ ${INDENT}Examples:
89
+ ${INDENT} # Basic resource with CRUD endpoints
90
+ ${INDENT} pnpm psy g:resource v1/posts Post User:belongs_to title:citext body:text
91
+ ${INDENT}
92
+ ${INDENT} # Nested resource under a parent (use {} for nesting resource ID placeholder)
93
+ ${INDENT} pnpm psy g:resource --owning-model=Post v1/posts/\\{\\}/comments Post/Comment Post:belongs_to body:text
94
+ ${INDENT}
95
+ ${INDENT} # Singular resource (HasOne relationship from parent model, no index action, no :id in URL)
96
+ ${INDENT} pnpm psy g:resource --singular v1/profile User/Profile User:belongs_to bio:text
97
+ ${INDENT}
98
+ ${INDENT} # STI base resource
99
+ ${INDENT} pnpm psy g:resource --sti-base-serializer v1/host/rentals Rental type:enum:place_types:Apartment,House,Condo`)
100
+ .option('--singular', `Use when the parent model has-one of this resource (e.g., a User HasOne Profile, a Candidate HasOne Linkedin).
101
+ ${INDENT}Generates a singular \`r.resource\` route instead of plural \`r.resources\`, omits the \`index\` action, and removes \`:id\` from URLs since there is only one per parent.
102
+ ${INDENT}
103
+ ${INDENT}Examples:
104
+ ${INDENT} pnpm psy g:resource --singular v1/profile User/Profile User:belongs_to bio:text
105
+ ${INDENT} pnpm psy g:resource --singular --owning-model=Candidate internal/candidates/\\{\\}/linkedin Candidate/Linkedin Candidate:belongs_to url:string`, false)
106
+ .option('--only <onlyActions>', `comma separated list of resourceful endpoints to generate (omitted actions will not have controller methods, specs, or routes).
107
+ ${INDENT}
108
+ ${INDENT}Available actions: index, create, show, update, delete
109
+ ${INDENT}
110
+ ${INDENT}Examples:
111
+ ${INDENT} --only=index,create,show # create and view only (e.g., form submissions)
112
+ ${INDENT} --only=index,show,update # modify only (e.g., settings management)`)
113
+ .option('--sti-base-serializer', `Creates generically typed base serializers (default and summary) that accept a \`StiChildClass\` parameter and include the \`type\` attribute with a per-child enum constraint. This allows consuming applications to determine the response shape based on the STI type discriminator.
114
+ ${INDENT}
115
+ ${INDENT}Use this when generating the parent model of an STI hierarchy. After generating the parent, use g:sti-child for each child type.
116
+ ${INDENT}
117
+ ${INDENT}Example:
118
+ ${INDENT} # CRITICAL: the type enums must exactly match the class names of the STI children
119
+ ${INDENT} pnpm psy g:resource --sti-base-serializer v1/host/rentals Rental type:enum:place_types:Apartment,House,Condo
120
+ ${INDENT} # STI children subsequently generated using the g:sti-child generator (note the use of \`--model-name\` to generate class names that match the \`type\` column, e.g., "Apartment" instead of the "RentalApartment" default):
121
+ ${INDENT} pnpm psy g:sti-child --model-name=Apartment Rental/Apartment extends Rental
122
+ ${INDENT} pnpm psy g:sti-child --model-name=House Rental/House extends Rental
123
+ ${INDENT} pnpm psy g:sti-child --model-name=Condo Rental/Condo extends Rental`, false)
124
+ .option('--owning-model <modelName>', `The model class that owns this resource. The generated controller will use \`associationQuery\` and \`createAssociation\` on the owning model to scope queries and create records.
125
+ ${INDENT}
126
+ ${INDENT}Defaults to \`this.currentUser\` for non-admin/internal routes (e.g., \`this.currentUser.associationQuery('posts').findOrFail(this.castParam('id', 'uuid'))\`).
127
+ ${INDENT}Defaults to \`null\` for admin/internal namespaced controllers (e.g., \`Post.findOrFail(this.castParam('id', 'uuid'))\`).
128
+ ${INDENT}Supplying an owning-modle changes the the generated code in the controller to be relative to the owning model.
129
+ ${INDENT}
130
+ ${INDENT}Example:
131
+ ${INDENT} pnpm psy g:resource --owning-model=Host v1/host/places Place
132
+ ${INDENT} # results in \`await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'uuid'))\``)
133
+ .option('--connection-name <connectionName>', 'the name of the database connection to use for the model. Only needed for multi-database setups; defaults to "default"', 'default')
134
+ .option('--table-name <tableName>', `Explicit table name to use instead of the auto-generated one. Useful when model namespaces produce long or awkward table names.
135
+ ${INDENT}
136
+ ${INDENT}Example:
137
+ ${INDENT} pnpm psy g:resource --table-name=notif_prefs v1/notification-preferences Settings/NotificationPreferences User:belongs_to`)
138
+ .option('--model-name <modelName>', `Explicit model class name to use instead of the one auto-derived from the model path. Useful when the path segments don't match the desired class name.
139
+ ${INDENT}
140
+ ${INDENT}Example:
141
+ ${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
142
+ ${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
143
+ .argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
144
+ ${INDENT}
145
+ ${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.
146
+ ${INDENT}
147
+ ${INDENT}Examples:
148
+ ${INDENT} v1/posts # /v1/posts, /v1/posts/:id
149
+ ${INDENT} v1/host/places # /v1/host/places, /v1/host/places/:id
150
+ ${INDENT} v1/posts/\\{\\}/comments # /v1/posts/:postId/comments, /v1/posts/:postId/comments/:id
151
+ ${INDENT} internal/candidates/\\{\\}/linkedin # /internal/candidates/:candidateId/linkedin (with --singular)`)
152
+ .argument('<modelName>', `The fully qualified model name, using / for namespacing. This determines the model class name (may be overridden with \`--model-name\`), table name, and file path under src/app/models/.
153
+ ${INDENT}
154
+ ${INDENT}Examples:
155
+ ${INDENT} Post # src/app/models/Post.ts, table: posts
156
+ ${INDENT} Post/Comment # src/app/models/Post/Comment.ts, table: post_comments`)
113
157
  .argument('[columnsWithTypes...]', columnsWithTypesDescription)
114
158
  .action(async (route, modelName, columnsWithTypes, options) => {
115
159
  await initializePsychicApp({
@@ -122,9 +166,18 @@ export default class PsychicCLI {
122
166
  program
123
167
  .command('generate:controller')
124
168
  .alias('g:controller')
125
- .description('Generates a controller and the inheritance chain leading to that controller, and a spec skeleton for the controller.')
126
- .argument('<controllerName>', 'the name of the controller to create, including namespaces, e.g. Posts or Api/V1/Posts')
127
- .argument('[actions...]', 'the names of controller actions to create')
169
+ .description(`Generates a controller and the full inheritance chain leading to that controller, along with a spec skeleton. Use this for standalone controllers that are not tied to a model (e.g., auth, health checks, custom actions). For model-backed CRUD, prefer g:resource instead.
170
+ ${INDENT}
171
+ ${INDENT}Examples:
172
+ ${INDENT} pnpm psy g:controller Auth login logout refresh
173
+ ${INDENT} pnpm psy g:controller V1/Admin/Reports generate download
174
+ ${INDENT} pnpm psy g:controller Api/V1/Webhooks stripe sendgrid`)
175
+ .argument('<controllerName>', `The name of the controller to create, using / for namespace directories. Each segment creates a directory and a base controller in the inheritance chain.
176
+ ${INDENT}
177
+ ${INDENT}Examples:
178
+ ${INDENT} Auth # src/app/controllers/AuthController.ts
179
+ ${INDENT} V1/Admin/Reports # src/app/controllers/V1/Admin/ReportsController.ts (extends V1/Admin/V1AdminBaseController)`)
180
+ .argument('[actions...]', `Space-separated list of action method names to generate on the controller (e.g., login logout refresh). Each action gets a method stub in the controller and a describe block in the spec.`)
128
181
  .action(async (controllerName, actions) => {
129
182
  await initializePsychicApp({ bypassDreamIntegrityChecks: true, bypassDbConnectionsDuringInit: true });
130
183
  await PsychicBin.generateController(controllerName, actions);
@@ -132,9 +185,16 @@ export default class PsychicCLI {
132
185
  });
133
186
  program
134
187
  .command('setup:sync:enums')
135
- .description('Generates an initializer in your app for syncing enums to a particular path.')
136
- .argument('<outfile>', 'the path from your backend directory to the location which you want the enums copied. Should end with .ts, i.e. "../client/src/api/enums.ts"')
137
- .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-enums.ts`')
188
+ .description(`Generates an initializer that automatically exports all Dream enum types to a TypeScript file during sync. This is a one-time setup command — once the initializer exists, enums are synced automatically on every \`pnpm psy sync\`.
189
+ ${INDENT}
190
+ ${INDENT}Use this to share enum types between your backend and frontend without manual duplication.
191
+ ${INDENT}
192
+ ${INDENT}**WARNING**: This currently syncs **all** database enums to the specified front end, which may not be appropriate for all use cases. It's on our roadmap to base this on specified OpenAPI specs.
193
+ ${INDENT}
194
+ ${INDENT}Example:
195
+ ${INDENT} pnpm psy setup:sync:enums ../client/src/api/enums.ts`)
196
+ .argument('<outfile>', 'the output path (relative to backend root) where enum types will be written on each sync. Should end with .ts, e.g., "../client/src/api/enums.ts"')
197
+ .option('--initializer-filename <initializerFilename>', 'custom filename for the generated initializer in src/conf/initializers/. Defaults to `sync-enums.ts`')
138
198
  .action(async (outfile, { initializerName, }) => {
139
199
  await initializePsychicApp({
140
200
  bypassDreamIntegrityChecks: true,
@@ -145,12 +205,22 @@ export default class PsychicCLI {
145
205
  });
146
206
  program
147
207
  .command('setup:sync:openapi-redux')
148
- .description('Generates openapi redux bindings to connect one of your openapi files to one of your clients.')
149
- .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
150
- .option('--api-file <apiFile>', 'the path to the boilerplate api file that will be used to scaffold your backend endpoints together with, i.e. ../client/app/api.ts')
151
- .option('--api-import <apiImport>', 'the camelCased name of the export from your api module, i.e. emptyBackendApi')
152
- .option('--output-file <outputFile>', 'the path to the file that will contain your typescript openapi redux bindings, i.e. ../client/app/myBackendApi.ts')
153
- .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
208
+ .description(`Generates an initializer that creates typed RTK Query API bindings from your OpenAPI spec during sync. This is a one-time setup command — once configured, bindings are regenerated automatically on every \`pnpm psy sync\`.
209
+ ${INDENT}
210
+ ${INDENT}Use this for React frontends using Redux Toolkit / RTK Query. For Zustand or other state managers, use setup:sync:openapi-zustand instead.
211
+ ${INDENT}
212
+ ${INDENT}Example:
213
+ ${INDENT} pnpm psy setup:sync:openapi-redux \\
214
+ ${INDENT} --schema-file=./src/openapi/openapi.json \\
215
+ ${INDENT} --api-file=../client/app/api.ts \\
216
+ ${INDENT} --api-import=emptyBackendApi \\
217
+ ${INDENT} --output-file=../client/app/backendApi.ts \\
218
+ ${INDENT} --export-name=backendApi`)
219
+ .option('--schema-file <schemaFile>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., ./src/openapi/openapi.json')
220
+ .option('--api-file <apiFile>', 'path to the RTK Query base API file that defines the empty API with createApi(), e.g., ../client/app/api.ts')
221
+ .option('--api-import <apiImport>', 'the camelCased export name from the base API file to inject endpoints into, e.g., emptyBackendApi')
222
+ .option('--output-file <outputFile>', 'path where the generated typed API bindings will be written, e.g., ../client/app/backendApi.ts')
223
+ .option('--export-name <exportName>', 'the camelCased name for the exported enhanced API object, e.g., backendApi')
154
224
  .action(async ({ schemaFile, apiFile, apiImport, outputFile, exportName, }) => {
155
225
  await initializePsychicApp({
156
226
  bypassDreamIntegrityChecks: true,
@@ -167,11 +237,20 @@ export default class PsychicCLI {
167
237
  });
168
238
  program
169
239
  .command('setup:sync:openapi-zustand')
170
- .description('Generates typed API functions from an openapi file using @hey-api/openapi-ts, for use with Zustand or any other state manager.')
171
- .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
172
- .option('--output-dir <outputDir>', 'the directory where @hey-api/openapi-ts will generate typed API functions and types, i.e. ../client/app/api/myBackendApi')
173
- .option('--client-config-file <clientConfigFile>', 'the path to the client configuration file that configures @hey-api/client-fetch with base URL and credentials, i.e. ../client/app/api/myBackendApi/client.ts')
174
- .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
240
+ .description(`Generates an initializer that creates typed API functions from your OpenAPI spec using @hey-api/openapi-ts during sync. This is a one-time setup command — once configured, API functions are regenerated automatically on every \`pnpm psy sync\`.
241
+ ${INDENT}
242
+ ${INDENT}Use this for frontends using Zustand, Jotai, or any non-Redux state manager. For RTK Query / Redux Toolkit, use setup:sync:openapi-redux instead.
243
+ ${INDENT}
244
+ ${INDENT}Example:
245
+ ${INDENT} pnpm psy setup:sync:openapi-zustand \\
246
+ ${INDENT} --schema-file=./src/openapi/openapi.json \\
247
+ ${INDENT} --output-dir=../client/app/api/backend \\
248
+ ${INDENT} --client-config-file=../client/app/api/backend/client.ts \\
249
+ ${INDENT} --export-name=backendApi`)
250
+ .option('--schema-file <schemaFile>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., ./src/openapi/openapi.json')
251
+ .option('--output-dir <outputDir>', 'directory where @hey-api/openapi-ts will generate typed API functions, types, and schemas, e.g., ../client/app/api/backend')
252
+ .option('--client-config-file <clientConfigFile>', 'path to the @hey-api/client-fetch configuration file that sets the base URL and credentials, e.g., ../client/app/api/backend/client.ts')
253
+ .option('--export-name <exportName>', 'the camelCased name for the exported API module, e.g., backendApi')
175
254
  .action(async ({ schemaFile, outputDir, clientConfigFile, exportName, }) => {
176
255
  await initializePsychicApp({
177
256
  bypassDreamIntegrityChecks: true,
@@ -187,10 +266,15 @@ export default class PsychicCLI {
187
266
  });
188
267
  program
189
268
  .command('setup:sync:openapi-typescript')
190
- .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
191
- .argument('<openapiFilepath>', 'the path from your backend directory to the openapi file you wish to scan, i.e. "./src/openapi/openapi.json"')
192
- .argument('<outfile>', 'the path from your backend directory to the location which you want the openapi types written to. Must end with .d.ts, i.e. "./src/conf/openapi/openapi.types.d.ts"')
193
- .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-openapi-typescript.ts`')
269
+ .description(`Generates an initializer that converts your OpenAPI spec to TypeScript type definitions during sync. This is a one-time setup command once configured, types are regenerated automatically on every \`pnpm psy sync\`.
270
+ ${INDENT}
271
+ ${INDENT}Use this when you need raw TypeScript types from the OpenAPI spec without a full API client. For typed API functions, use setup:sync:openapi-zustand or setup:sync:openapi-redux instead.
272
+ ${INDENT}
273
+ ${INDENT}Example:
274
+ ${INDENT} pnpm psy setup:sync:openapi-typescript ./src/openapi/openapi.json ../client/src/api/openapi.types.d.ts`)
275
+ .argument('<openapiFilepath>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., "./src/openapi/openapi.json"')
276
+ .argument('<outfile>', 'output path for the generated TypeScript type definitions. Must end with .d.ts, e.g., "../client/src/api/openapi.types.d.ts"')
277
+ .option('--initializer-filename <initializerFilename>', 'custom filename for the generated initializer in src/conf/initializers/. Defaults to `sync-openapi-typescript.ts`')
194
278
  .action(async (openapiFilepath, outfile, { initializerName, }) => {
195
279
  await initializePsychicApp({
196
280
  bypassDreamIntegrityChecks: true,
@@ -202,7 +286,7 @@ export default class PsychicCLI {
202
286
  program
203
287
  .command('inspect:controller-hierarchy')
204
288
  .alias('i:controller-hierarchy')
205
- .description('Displays the inheritance hierarchy of all PsychicController classes in the project.')
289
+ .description(`Displays the inheritance hierarchy of all PsychicController classes in the project as a tree. Useful for understanding how controller base classes are organized and verifying that namespace grouping is correct.`)
206
290
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
207
291
  .action(async (controllersPath) => {
208
292
  await initializePsychicApp();
@@ -211,7 +295,12 @@ export default class PsychicCLI {
211
295
  });
212
296
  program
213
297
  .command('check:controller-hierarchy')
214
- .description('Checks that all controllers extend a controller in the same or parent directory. Exits with an error if any violations are found.')
298
+ .description(`Checks that all controllers extend a controller in the same or parent directory. Exits with code 1 if any violations are found.
299
+ ${INDENT}
300
+ ${INDENT}This enforces the convention that controllers inherit from a base controller in their namespace (e.g., V1/Host/PlacesController should extend V1/Host/V1HostBaseController, not a controller from a sibling namespace). Useful as a CI check.
301
+ ${INDENT}
302
+ ${INDENT}Example:
303
+ ${INDENT} pnpm psy check:controller-hierarchy`)
215
304
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
216
305
  .action(async (controllersPath) => {
217
306
  await initializePsychicApp();
@@ -226,7 +315,12 @@ export default class PsychicCLI {
226
315
  });
227
316
  program
228
317
  .command('routes')
229
- .description('Prints a list of routes defined by the application, including path arguments and the controller/action reached by the route.')
318
+ .description(`Prints all routes defined by the application, showing the HTTP method, URL path (with parameters), and the controller#action that handles each route. Useful for verifying route configuration and discovering available endpoints.
319
+ ${INDENT}
320
+ ${INDENT}Example output:
321
+ ${INDENT} GET /v1/host/places V1/Host/PlacesController#index
322
+ ${INDENT} POST /v1/host/places V1/Host/PlacesController#create
323
+ ${INDENT} GET /v1/host/places/:id V1/Host/PlacesController#show`)
230
324
  .action(async () => {
231
325
  await initializePsychicApp();
232
326
  PsychicBin.printRoutes();
@@ -234,9 +328,16 @@ export default class PsychicCLI {
234
328
  });
235
329
  program
236
330
  .command('sync')
237
- .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
238
- .option('--ignore-errors', 'ignore integrity checks and continue sync', false)
239
- .option('--schema-only', 'sync database schema types only', false)
331
+ .description(`Regenerates all auto-generated types and specs from the current state of your application. This is the most commonly run command after making changes to models, serializers, controllers, or routes. It performs:
332
+ ${INDENT}
333
+ ${INDENT} 1. Database schema types (types/db.ts, types/dream.ts)
334
+ ${INDENT} 2. OpenAPI specs from @OpenAPI decorated controller actions
335
+ ${INDENT} 3. Application types (e.g. model association and serializer names)
336
+ ${INDENT} 4. Any custom sync actions registered via \`on('cli:sync', async () => \\{\\})\` in conf/app.ts or initializers
337
+ ${INDENT}
338
+ ${INDENT}Run this after changing: associations, serializers, @OpenAPI decorators, routes, or enum types.`)
339
+ .option('--ignore-errors', 'skip integrity checks (e.g., missing migrations) and continue sync anyway. Useful when bootstrapping or debugging', false)
340
+ .option('--schema-only', 'only regenerate database schema types (types/db.ts, types/dream.ts), skipping OpenAPI, routes, and custom sync actions. Faster when you only changed the database schema', false)
240
341
  .action(async (options) => {
241
342
  await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
242
343
  await PsychicBin.sync(options);
@@ -244,15 +345,19 @@ export default class PsychicCLI {
244
345
  });
245
346
  program
246
347
  .command('watch')
247
- .description('watches your app for changes, and re-syncs any time they happen')
248
- .argument('[dir]', 'the folder you want to watch, defaults to ./src')
348
+ .description(`Watches your app source files for changes and automatically runs sync when modifications are detected. Useful during active development to keep types, OpenAPI specs, and route caches up to date without manually running \`pnpm psy sync\` after each change.
349
+ ${INDENT}
350
+ ${INDENT}Example:
351
+ ${INDENT} pnpm psy watch # watches ./src (default)
352
+ ${INDENT} pnpm psy watch ./src/app # watches only the app directory`)
353
+ .argument('[dir]', 'the directory to watch for changes. Defaults to ./src')
249
354
  .action(async (dir) => {
250
355
  await initializePsychicApp({ bypassDreamIntegrityChecks: true });
251
356
  Watcher.watch(dir);
252
357
  });
253
358
  program
254
359
  .command('post-sync')
255
- .description('an internal command that runs as the second stage of the `sync` command, since after types are rebuit, the application needs to be reloaded before autogenerating certain files, since those files will need to leverage the updated types')
360
+ .description('Internal command (do not run directly). Runs as the second stage of `sync` after types are rebuilt. The app must be reloaded between stages so that autogenerated files (e.g., OpenAPI specs, route caches) can leverage the updated types.')
256
361
  .action(async () => {
257
362
  await initializePsychicApp();
258
363
  await PsychicBin.postSync();
@@ -260,13 +365,13 @@ export default class PsychicCLI {
260
365
  });
261
366
  program
262
367
  .command('sync:routes')
263
- .description('Reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper and other things.')
368
+ .description('Regenerates the route cache file from your current route definitions. The cache powers autocomplete for the route helper and other route-aware tooling. This runs automatically as part of `pnpm psy sync` — only run it standalone if you need to update just the route cache.')
264
369
  .action(async () => {
265
370
  await PsychicBin.syncRoutes();
266
371
  });
267
372
  program
268
373
  .command('sync:openapi')
269
- .description('Syncs openapi.json file to current state of all psychic controllers within the app')
374
+ .description('Regenerates openapi.json from the current @OpenAPI decorators on all Psychic controllers. This runs automatically as part of `pnpm psy sync` — only run it standalone if you need to update just the OpenAPI spec without regenerating types or routes.')
270
375
  .action(async () => {
271
376
  await initializePsychicApp();
272
377
  await PsychicBin.syncOpenapiJson();
@@ -274,8 +379,12 @@ export default class PsychicCLI {
274
379
  });
275
380
  program
276
381
  .command('diff:openapi')
277
- .description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
278
- .option('-f, --fail-on-breaking', 'fail on spec changes that are breaking', false)
382
+ .description(`Compares the OpenAPI spec on the current branch against the main/master branch and displays a diff of changes. Useful for reviewing API contract changes in pull requests.
383
+ ${INDENT}
384
+ ${INDENT}Example:
385
+ ${INDENT} pnpm psy diff:openapi # show diff only
386
+ ${INDENT} pnpm psy diff:openapi --fail-on-breaking # exit 1 if breaking changes detected (for CI)`)
387
+ .option('-f, --fail-on-breaking', 'exit with code 1 if breaking API changes are detected (removed endpoints, changed required fields, etc.). Useful as a CI gate to prevent accidental breaking changes', false)
279
388
  .action(async (options) => {
280
389
  await initializePsychicApp();
281
390
  try {
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
- import { createServer } from 'net';
3
2
  import { debuglog } from 'node:util';
4
- import sleep from '../../../spec/helpers/sleep.js';
5
3
  import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
6
4
  import PsychicApp from '../../psychic-app/index.js';
5
+ import sleep from './sleep.js';
7
6
  const devServerProcesses = {};
8
7
  const debugEnabled = debuglog('psychic').enabled;
9
- export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000, onStdOut } = {}) {
8
+ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 20000, onStdOut } = {}) {
10
9
  if (devServerProcesses[key])
11
10
  return;
12
11
  if (debugEnabled)
@@ -34,16 +33,7 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
34
33
  console.log(txt);
35
34
  }
36
35
  });
37
- devServerProcesses[key] = proc;
38
- await waitForPort(key, port, timeout);
39
- proc.on('error', err => {
40
- throw err;
41
- });
42
- proc.stdout.on('data', data => {
43
- if (debugEnabled)
44
- PsychicApp.log(`Server output: ${data}`);
45
- });
46
- proc.stderr.on('data', data => {
36
+ proc.stderr?.on('data', data => {
47
37
  if (debugEnabled)
48
38
  PsychicApp.logWithLevel('error', `Server error: ${data}`);
49
39
  });
@@ -54,6 +44,8 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
54
44
  if (debugEnabled)
55
45
  PsychicApp.log(`Server process exited with code ${code}`);
56
46
  });
47
+ devServerProcesses[key] = proc;
48
+ await waitForHttpServer(proc, key, port, timeout);
57
49
  }
58
50
  export function stopDevServer(key) {
59
51
  const proc = devServerProcesses[key];
@@ -80,40 +72,23 @@ export function stopDevServers() {
80
72
  stopDevServer(key);
81
73
  });
82
74
  }
83
- async function isPortAvailable(port) {
84
- return new Promise(resolve => {
85
- const server = createServer()
86
- .once('error', err => {
87
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
88
- if (err.code === 'EADDRINUSE') {
89
- resolve(false);
90
- }
91
- else {
92
- resolve(true);
93
- }
94
- })
95
- .once('listening', () => {
96
- server.close();
97
- resolve(true);
98
- })
99
- .listen(port, '127.0.0.1');
100
- });
101
- }
102
- async function waitForPort(key, port, timeout = 5000) {
103
- if (await isPortAvailable(port)) {
104
- return true;
105
- }
75
+ async function waitForHttpServer(proc, key, port, timeout = 20000) {
106
76
  const startTime = Date.now();
107
- async function recursiveWaitForPort() {
108
- if (await isPortAvailable(port)) {
109
- return true;
77
+ while (Date.now() - startTime < timeout) {
78
+ if (proc.exitCode != null) {
79
+ delete devServerProcesses[key];
80
+ throw new Error(`dev server exited with code ${proc.exitCode} before becoming ready on port ${port}`);
110
81
  }
111
- if (Date.now() > startTime + timeout) {
112
- stopDevServer(key);
113
- throw new Error('waited too long for port: ' + port);
82
+ try {
83
+ const response = await fetch(`http://localhost:${port}`, { redirect: 'manual' });
84
+ if (response.status > 0)
85
+ return;
86
+ }
87
+ catch {
88
+ // server not ready yet, keep waiting
114
89
  }
115
- await sleep(50);
116
- return await recursiveWaitForPort();
90
+ await sleep(100);
117
91
  }
118
- return await recursiveWaitForPort();
92
+ stopDevServer(key);
93
+ throw new Error(`waited too long for dev server on port ${port}`);
119
94
  }
@@ -27,6 +27,7 @@ export default async function generateResource({ route, fullyQualifiedModelName,
27
27
  includeAdminSerializers: forAdmin,
28
28
  includeInternalSerializers: forInternal,
29
29
  connectionName: options.connectionName,
30
+ tableName: options.tableName,
30
31
  modelName: options.modelName,
31
32
  },
32
33
  });
@@ -6,8 +6,6 @@ import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/sy
6
6
  import generateOpenapiReduxBindings from '../generate/openapi/reduxBindings.js';
7
7
  import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.js';
8
8
  import generateResource from '../generate/resource.js';
9
- import colorize from './helpers/colorize.js';
10
- import PsychicLogos from './helpers/PsychicLogos.js';
11
9
  import Watcher from '../watcher/Watcher.js';
12
10
  const INDENT = ' ';
13
11
  const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
@@ -64,28 +62,15 @@ const columnsWithTypesDescription = baseColumnsWithTypesDescription +
64
62
  `
65
63
  ${INDENT}
66
64
  ${INDENT} - belongs_to:
67
- ${INDENT} not only adds a foreign key to the migration, but also adds a BelongsTo association to the generated model:
65
+ ${INDENT} ALWAYS use this instead of adding a raw uuid column for foreign keys. It creates the FK column, adds a database index,
66
+ ${INDENT} AND generates the @deco.BelongsTo association and typed property on the model. A raw uuid column does none of this.
68
67
  ${INDENT}
69
- ${INDENT} include the fully qualified model name, e.g., if the Coach model is in src/app/models/Health/Coach:
70
- ${INDENT} Health/Coach:belongs_to`;
68
+ ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
+ ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
+ ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
72
  export default class PsychicCLI {
72
73
  static provide(program, { initializePsychicApp, seedDb, }) {
73
- program.hook('preAction', (_thisCommand, actionCommand) => {
74
- const cmdName = actionCommand.name();
75
- switch (cmdName) {
76
- case 'post-sync':
77
- return;
78
- default:
79
- DreamCLI.logger.log(colorize(PsychicLogos.asciiLogo(), { color: 'greenBright' }), { logPrefix: '' });
80
- DreamCLI.logger.log('\n', { logPrefix: '' });
81
- DreamCLI.logger.log(colorize(' ', { color: 'green' }) +
82
- colorize(' ' + cmdName + ' ', { color: 'black', bgColor: 'bgGreen' }) +
83
- '\n', {
84
- logPrefix: '',
85
- });
86
- DreamCLI.logger.log(colorize('⭣⭣⭣', { color: 'green' }) + '\n', { logPrefix: ' ' });
87
- }
88
- });
89
74
  DreamCLI.generateDreamCli(program, {
90
75
  initializeDreamApp: initializePsychicApp,
91
76
  seedDb,
@@ -96,20 +81,79 @@ export default class PsychicCLI {
96
81
  program
97
82
  .command('generate:resource')
98
83
  .alias('g:resource')
99
- .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
100
- .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes', false)
101
- .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
102
- - index
103
- - create
104
- - show
105
- - update
106
- - delete`)
107
- .option('--sti-base-serializer', 'creates a generically typed base serializer that includes the child type in the output so consuming applications can determine shape based on type', false)
108
- .option('--owning-model <modelName>', 'the model class of the object that `associationQuery`/`createAssociation` will be performed on in the created controller and spec (e.g., "Host", "Guest", "Ticketing/Ticket"). Defaults to the current user for non-admin/internal namespaced controllers. For admin/internal namespaced controllers, this defaults to null, meaning every admin/internal user can access the model.')
109
- .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
110
- .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
111
- .argument('<path>', 'URL path from root domain. Specify nesting resource with `{}`, e.g.: `tickets/{}/comments`')
112
- .argument('<modelName>', 'the name of the model to create, e.g. Post or Settings/CommunicationPreferences')
84
+ .description(`Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.
85
+ ${INDENT}
86
+ ${INDENT}This is the preferred generator when the model will be accessible via HTTP requests (API endpoints, admin panels, internal tools). It scaffolds everything needed for a full CRUD resource. Prefer this over g:model unless the model is purely internal with no HTTP access.
87
+ ${INDENT}
88
+ ${INDENT}Examples:
89
+ ${INDENT} # Basic resource with CRUD endpoints
90
+ ${INDENT} pnpm psy g:resource v1/posts Post User:belongs_to title:citext body:text
91
+ ${INDENT}
92
+ ${INDENT} # Nested resource under a parent (use {} for nesting resource ID placeholder)
93
+ ${INDENT} pnpm psy g:resource --owning-model=Post v1/posts/\\{\\}/comments Post/Comment Post:belongs_to body:text
94
+ ${INDENT}
95
+ ${INDENT} # Singular resource (HasOne relationship from parent model, no index action, no :id in URL)
96
+ ${INDENT} pnpm psy g:resource --singular v1/profile User/Profile User:belongs_to bio:text
97
+ ${INDENT}
98
+ ${INDENT} # STI base resource
99
+ ${INDENT} pnpm psy g:resource --sti-base-serializer v1/host/rentals Rental type:enum:place_types:Apartment,House,Condo`)
100
+ .option('--singular', `Use when the parent model has-one of this resource (e.g., a User HasOne Profile, a Candidate HasOne Linkedin).
101
+ ${INDENT}Generates a singular \`r.resource\` route instead of plural \`r.resources\`, omits the \`index\` action, and removes \`:id\` from URLs since there is only one per parent.
102
+ ${INDENT}
103
+ ${INDENT}Examples:
104
+ ${INDENT} pnpm psy g:resource --singular v1/profile User/Profile User:belongs_to bio:text
105
+ ${INDENT} pnpm psy g:resource --singular --owning-model=Candidate internal/candidates/\\{\\}/linkedin Candidate/Linkedin Candidate:belongs_to url:string`, false)
106
+ .option('--only <onlyActions>', `comma separated list of resourceful endpoints to generate (omitted actions will not have controller methods, specs, or routes).
107
+ ${INDENT}
108
+ ${INDENT}Available actions: index, create, show, update, delete
109
+ ${INDENT}
110
+ ${INDENT}Examples:
111
+ ${INDENT} --only=index,create,show # create and view only (e.g., form submissions)
112
+ ${INDENT} --only=index,show,update # modify only (e.g., settings management)`)
113
+ .option('--sti-base-serializer', `Creates generically typed base serializers (default and summary) that accept a \`StiChildClass\` parameter and include the \`type\` attribute with a per-child enum constraint. This allows consuming applications to determine the response shape based on the STI type discriminator.
114
+ ${INDENT}
115
+ ${INDENT}Use this when generating the parent model of an STI hierarchy. After generating the parent, use g:sti-child for each child type.
116
+ ${INDENT}
117
+ ${INDENT}Example:
118
+ ${INDENT} # CRITICAL: the type enums must exactly match the class names of the STI children
119
+ ${INDENT} pnpm psy g:resource --sti-base-serializer v1/host/rentals Rental type:enum:place_types:Apartment,House,Condo
120
+ ${INDENT} # STI children subsequently generated using the g:sti-child generator (note the use of \`--model-name\` to generate class names that match the \`type\` column, e.g., "Apartment" instead of the "RentalApartment" default):
121
+ ${INDENT} pnpm psy g:sti-child --model-name=Apartment Rental/Apartment extends Rental
122
+ ${INDENT} pnpm psy g:sti-child --model-name=House Rental/House extends Rental
123
+ ${INDENT} pnpm psy g:sti-child --model-name=Condo Rental/Condo extends Rental`, false)
124
+ .option('--owning-model <modelName>', `The model class that owns this resource. The generated controller will use \`associationQuery\` and \`createAssociation\` on the owning model to scope queries and create records.
125
+ ${INDENT}
126
+ ${INDENT}Defaults to \`this.currentUser\` for non-admin/internal routes (e.g., \`this.currentUser.associationQuery('posts').findOrFail(this.castParam('id', 'uuid'))\`).
127
+ ${INDENT}Defaults to \`null\` for admin/internal namespaced controllers (e.g., \`Post.findOrFail(this.castParam('id', 'uuid'))\`).
128
+ ${INDENT}Supplying an owning-modle changes the the generated code in the controller to be relative to the owning model.
129
+ ${INDENT}
130
+ ${INDENT}Example:
131
+ ${INDENT} pnpm psy g:resource --owning-model=Host v1/host/places Place
132
+ ${INDENT} # results in \`await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'uuid'))\``)
133
+ .option('--connection-name <connectionName>', 'the name of the database connection to use for the model. Only needed for multi-database setups; defaults to "default"', 'default')
134
+ .option('--table-name <tableName>', `Explicit table name to use instead of the auto-generated one. Useful when model namespaces produce long or awkward table names.
135
+ ${INDENT}
136
+ ${INDENT}Example:
137
+ ${INDENT} pnpm psy g:resource --table-name=notif_prefs v1/notification-preferences Settings/NotificationPreferences User:belongs_to`)
138
+ .option('--model-name <modelName>', `Explicit model class name to use instead of the one auto-derived from the model path. Useful when the path segments don't match the desired class name.
139
+ ${INDENT}
140
+ ${INDENT}Example:
141
+ ${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
142
+ ${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
143
+ .argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
144
+ ${INDENT}
145
+ ${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.
146
+ ${INDENT}
147
+ ${INDENT}Examples:
148
+ ${INDENT} v1/posts # /v1/posts, /v1/posts/:id
149
+ ${INDENT} v1/host/places # /v1/host/places, /v1/host/places/:id
150
+ ${INDENT} v1/posts/\\{\\}/comments # /v1/posts/:postId/comments, /v1/posts/:postId/comments/:id
151
+ ${INDENT} internal/candidates/\\{\\}/linkedin # /internal/candidates/:candidateId/linkedin (with --singular)`)
152
+ .argument('<modelName>', `The fully qualified model name, using / for namespacing. This determines the model class name (may be overridden with \`--model-name\`), table name, and file path under src/app/models/.
153
+ ${INDENT}
154
+ ${INDENT}Examples:
155
+ ${INDENT} Post # src/app/models/Post.ts, table: posts
156
+ ${INDENT} Post/Comment # src/app/models/Post/Comment.ts, table: post_comments`)
113
157
  .argument('[columnsWithTypes...]', columnsWithTypesDescription)
114
158
  .action(async (route, modelName, columnsWithTypes, options) => {
115
159
  await initializePsychicApp({
@@ -122,9 +166,18 @@ export default class PsychicCLI {
122
166
  program
123
167
  .command('generate:controller')
124
168
  .alias('g:controller')
125
- .description('Generates a controller and the inheritance chain leading to that controller, and a spec skeleton for the controller.')
126
- .argument('<controllerName>', 'the name of the controller to create, including namespaces, e.g. Posts or Api/V1/Posts')
127
- .argument('[actions...]', 'the names of controller actions to create')
169
+ .description(`Generates a controller and the full inheritance chain leading to that controller, along with a spec skeleton. Use this for standalone controllers that are not tied to a model (e.g., auth, health checks, custom actions). For model-backed CRUD, prefer g:resource instead.
170
+ ${INDENT}
171
+ ${INDENT}Examples:
172
+ ${INDENT} pnpm psy g:controller Auth login logout refresh
173
+ ${INDENT} pnpm psy g:controller V1/Admin/Reports generate download
174
+ ${INDENT} pnpm psy g:controller Api/V1/Webhooks stripe sendgrid`)
175
+ .argument('<controllerName>', `The name of the controller to create, using / for namespace directories. Each segment creates a directory and a base controller in the inheritance chain.
176
+ ${INDENT}
177
+ ${INDENT}Examples:
178
+ ${INDENT} Auth # src/app/controllers/AuthController.ts
179
+ ${INDENT} V1/Admin/Reports # src/app/controllers/V1/Admin/ReportsController.ts (extends V1/Admin/V1AdminBaseController)`)
180
+ .argument('[actions...]', `Space-separated list of action method names to generate on the controller (e.g., login logout refresh). Each action gets a method stub in the controller and a describe block in the spec.`)
128
181
  .action(async (controllerName, actions) => {
129
182
  await initializePsychicApp({ bypassDreamIntegrityChecks: true, bypassDbConnectionsDuringInit: true });
130
183
  await PsychicBin.generateController(controllerName, actions);
@@ -132,9 +185,16 @@ export default class PsychicCLI {
132
185
  });
133
186
  program
134
187
  .command('setup:sync:enums')
135
- .description('Generates an initializer in your app for syncing enums to a particular path.')
136
- .argument('<outfile>', 'the path from your backend directory to the location which you want the enums copied. Should end with .ts, i.e. "../client/src/api/enums.ts"')
137
- .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-enums.ts`')
188
+ .description(`Generates an initializer that automatically exports all Dream enum types to a TypeScript file during sync. This is a one-time setup command — once the initializer exists, enums are synced automatically on every \`pnpm psy sync\`.
189
+ ${INDENT}
190
+ ${INDENT}Use this to share enum types between your backend and frontend without manual duplication.
191
+ ${INDENT}
192
+ ${INDENT}**WARNING**: This currently syncs **all** database enums to the specified front end, which may not be appropriate for all use cases. It's on our roadmap to base this on specified OpenAPI specs.
193
+ ${INDENT}
194
+ ${INDENT}Example:
195
+ ${INDENT} pnpm psy setup:sync:enums ../client/src/api/enums.ts`)
196
+ .argument('<outfile>', 'the output path (relative to backend root) where enum types will be written on each sync. Should end with .ts, e.g., "../client/src/api/enums.ts"')
197
+ .option('--initializer-filename <initializerFilename>', 'custom filename for the generated initializer in src/conf/initializers/. Defaults to `sync-enums.ts`')
138
198
  .action(async (outfile, { initializerName, }) => {
139
199
  await initializePsychicApp({
140
200
  bypassDreamIntegrityChecks: true,
@@ -145,12 +205,22 @@ export default class PsychicCLI {
145
205
  });
146
206
  program
147
207
  .command('setup:sync:openapi-redux')
148
- .description('Generates openapi redux bindings to connect one of your openapi files to one of your clients.')
149
- .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
150
- .option('--api-file <apiFile>', 'the path to the boilerplate api file that will be used to scaffold your backend endpoints together with, i.e. ../client/app/api.ts')
151
- .option('--api-import <apiImport>', 'the camelCased name of the export from your api module, i.e. emptyBackendApi')
152
- .option('--output-file <outputFile>', 'the path to the file that will contain your typescript openapi redux bindings, i.e. ../client/app/myBackendApi.ts')
153
- .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
208
+ .description(`Generates an initializer that creates typed RTK Query API bindings from your OpenAPI spec during sync. This is a one-time setup command — once configured, bindings are regenerated automatically on every \`pnpm psy sync\`.
209
+ ${INDENT}
210
+ ${INDENT}Use this for React frontends using Redux Toolkit / RTK Query. For Zustand or other state managers, use setup:sync:openapi-zustand instead.
211
+ ${INDENT}
212
+ ${INDENT}Example:
213
+ ${INDENT} pnpm psy setup:sync:openapi-redux \\
214
+ ${INDENT} --schema-file=./src/openapi/openapi.json \\
215
+ ${INDENT} --api-file=../client/app/api.ts \\
216
+ ${INDENT} --api-import=emptyBackendApi \\
217
+ ${INDENT} --output-file=../client/app/backendApi.ts \\
218
+ ${INDENT} --export-name=backendApi`)
219
+ .option('--schema-file <schemaFile>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., ./src/openapi/openapi.json')
220
+ .option('--api-file <apiFile>', 'path to the RTK Query base API file that defines the empty API with createApi(), e.g., ../client/app/api.ts')
221
+ .option('--api-import <apiImport>', 'the camelCased export name from the base API file to inject endpoints into, e.g., emptyBackendApi')
222
+ .option('--output-file <outputFile>', 'path where the generated typed API bindings will be written, e.g., ../client/app/backendApi.ts')
223
+ .option('--export-name <exportName>', 'the camelCased name for the exported enhanced API object, e.g., backendApi')
154
224
  .action(async ({ schemaFile, apiFile, apiImport, outputFile, exportName, }) => {
155
225
  await initializePsychicApp({
156
226
  bypassDreamIntegrityChecks: true,
@@ -167,11 +237,20 @@ export default class PsychicCLI {
167
237
  });
168
238
  program
169
239
  .command('setup:sync:openapi-zustand')
170
- .description('Generates typed API functions from an openapi file using @hey-api/openapi-ts, for use with Zustand or any other state manager.')
171
- .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
172
- .option('--output-dir <outputDir>', 'the directory where @hey-api/openapi-ts will generate typed API functions and types, i.e. ../client/app/api/myBackendApi')
173
- .option('--client-config-file <clientConfigFile>', 'the path to the client configuration file that configures @hey-api/client-fetch with base URL and credentials, i.e. ../client/app/api/myBackendApi/client.ts')
174
- .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
240
+ .description(`Generates an initializer that creates typed API functions from your OpenAPI spec using @hey-api/openapi-ts during sync. This is a one-time setup command — once configured, API functions are regenerated automatically on every \`pnpm psy sync\`.
241
+ ${INDENT}
242
+ ${INDENT}Use this for frontends using Zustand, Jotai, or any non-Redux state manager. For RTK Query / Redux Toolkit, use setup:sync:openapi-redux instead.
243
+ ${INDENT}
244
+ ${INDENT}Example:
245
+ ${INDENT} pnpm psy setup:sync:openapi-zustand \\
246
+ ${INDENT} --schema-file=./src/openapi/openapi.json \\
247
+ ${INDENT} --output-dir=../client/app/api/backend \\
248
+ ${INDENT} --client-config-file=../client/app/api/backend/client.ts \\
249
+ ${INDENT} --export-name=backendApi`)
250
+ .option('--schema-file <schemaFile>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., ./src/openapi/openapi.json')
251
+ .option('--output-dir <outputDir>', 'directory where @hey-api/openapi-ts will generate typed API functions, types, and schemas, e.g., ../client/app/api/backend')
252
+ .option('--client-config-file <clientConfigFile>', 'path to the @hey-api/client-fetch configuration file that sets the base URL and credentials, e.g., ../client/app/api/backend/client.ts')
253
+ .option('--export-name <exportName>', 'the camelCased name for the exported API module, e.g., backendApi')
175
254
  .action(async ({ schemaFile, outputDir, clientConfigFile, exportName, }) => {
176
255
  await initializePsychicApp({
177
256
  bypassDreamIntegrityChecks: true,
@@ -187,10 +266,15 @@ export default class PsychicCLI {
187
266
  });
188
267
  program
189
268
  .command('setup:sync:openapi-typescript')
190
- .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
191
- .argument('<openapiFilepath>', 'the path from your backend directory to the openapi file you wish to scan, i.e. "./src/openapi/openapi.json"')
192
- .argument('<outfile>', 'the path from your backend directory to the location which you want the openapi types written to. Must end with .d.ts, i.e. "./src/conf/openapi/openapi.types.d.ts"')
193
- .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-openapi-typescript.ts`')
269
+ .description(`Generates an initializer that converts your OpenAPI spec to TypeScript type definitions during sync. This is a one-time setup command once configured, types are regenerated automatically on every \`pnpm psy sync\`.
270
+ ${INDENT}
271
+ ${INDENT}Use this when you need raw TypeScript types from the OpenAPI spec without a full API client. For typed API functions, use setup:sync:openapi-zustand or setup:sync:openapi-redux instead.
272
+ ${INDENT}
273
+ ${INDENT}Example:
274
+ ${INDENT} pnpm psy setup:sync:openapi-typescript ./src/openapi/openapi.json ../client/src/api/openapi.types.d.ts`)
275
+ .argument('<openapiFilepath>', 'path to the OpenAPI JSON spec file generated by Psychic, e.g., "./src/openapi/openapi.json"')
276
+ .argument('<outfile>', 'output path for the generated TypeScript type definitions. Must end with .d.ts, e.g., "../client/src/api/openapi.types.d.ts"')
277
+ .option('--initializer-filename <initializerFilename>', 'custom filename for the generated initializer in src/conf/initializers/. Defaults to `sync-openapi-typescript.ts`')
194
278
  .action(async (openapiFilepath, outfile, { initializerName, }) => {
195
279
  await initializePsychicApp({
196
280
  bypassDreamIntegrityChecks: true,
@@ -202,7 +286,7 @@ export default class PsychicCLI {
202
286
  program
203
287
  .command('inspect:controller-hierarchy')
204
288
  .alias('i:controller-hierarchy')
205
- .description('Displays the inheritance hierarchy of all PsychicController classes in the project.')
289
+ .description(`Displays the inheritance hierarchy of all PsychicController classes in the project as a tree. Useful for understanding how controller base classes are organized and verifying that namespace grouping is correct.`)
206
290
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
207
291
  .action(async (controllersPath) => {
208
292
  await initializePsychicApp();
@@ -211,7 +295,12 @@ export default class PsychicCLI {
211
295
  });
212
296
  program
213
297
  .command('check:controller-hierarchy')
214
- .description('Checks that all controllers extend a controller in the same or parent directory. Exits with an error if any violations are found.')
298
+ .description(`Checks that all controllers extend a controller in the same or parent directory. Exits with code 1 if any violations are found.
299
+ ${INDENT}
300
+ ${INDENT}This enforces the convention that controllers inherit from a base controller in their namespace (e.g., V1/Host/PlacesController should extend V1/Host/V1HostBaseController, not a controller from a sibling namespace). Useful as a CI check.
301
+ ${INDENT}
302
+ ${INDENT}Example:
303
+ ${INDENT} pnpm psy check:controller-hierarchy`)
215
304
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
216
305
  .action(async (controllersPath) => {
217
306
  await initializePsychicApp();
@@ -226,7 +315,12 @@ export default class PsychicCLI {
226
315
  });
227
316
  program
228
317
  .command('routes')
229
- .description('Prints a list of routes defined by the application, including path arguments and the controller/action reached by the route.')
318
+ .description(`Prints all routes defined by the application, showing the HTTP method, URL path (with parameters), and the controller#action that handles each route. Useful for verifying route configuration and discovering available endpoints.
319
+ ${INDENT}
320
+ ${INDENT}Example output:
321
+ ${INDENT} GET /v1/host/places V1/Host/PlacesController#index
322
+ ${INDENT} POST /v1/host/places V1/Host/PlacesController#create
323
+ ${INDENT} GET /v1/host/places/:id V1/Host/PlacesController#show`)
230
324
  .action(async () => {
231
325
  await initializePsychicApp();
232
326
  PsychicBin.printRoutes();
@@ -234,9 +328,16 @@ export default class PsychicCLI {
234
328
  });
235
329
  program
236
330
  .command('sync')
237
- .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
238
- .option('--ignore-errors', 'ignore integrity checks and continue sync', false)
239
- .option('--schema-only', 'sync database schema types only', false)
331
+ .description(`Regenerates all auto-generated types and specs from the current state of your application. This is the most commonly run command after making changes to models, serializers, controllers, or routes. It performs:
332
+ ${INDENT}
333
+ ${INDENT} 1. Database schema types (types/db.ts, types/dream.ts)
334
+ ${INDENT} 2. OpenAPI specs from @OpenAPI decorated controller actions
335
+ ${INDENT} 3. Application types (e.g. model association and serializer names)
336
+ ${INDENT} 4. Any custom sync actions registered via \`on('cli:sync', async () => \\{\\})\` in conf/app.ts or initializers
337
+ ${INDENT}
338
+ ${INDENT}Run this after changing: associations, serializers, @OpenAPI decorators, routes, or enum types.`)
339
+ .option('--ignore-errors', 'skip integrity checks (e.g., missing migrations) and continue sync anyway. Useful when bootstrapping or debugging', false)
340
+ .option('--schema-only', 'only regenerate database schema types (types/db.ts, types/dream.ts), skipping OpenAPI, routes, and custom sync actions. Faster when you only changed the database schema', false)
240
341
  .action(async (options) => {
241
342
  await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
242
343
  await PsychicBin.sync(options);
@@ -244,15 +345,19 @@ export default class PsychicCLI {
244
345
  });
245
346
  program
246
347
  .command('watch')
247
- .description('watches your app for changes, and re-syncs any time they happen')
248
- .argument('[dir]', 'the folder you want to watch, defaults to ./src')
348
+ .description(`Watches your app source files for changes and automatically runs sync when modifications are detected. Useful during active development to keep types, OpenAPI specs, and route caches up to date without manually running \`pnpm psy sync\` after each change.
349
+ ${INDENT}
350
+ ${INDENT}Example:
351
+ ${INDENT} pnpm psy watch # watches ./src (default)
352
+ ${INDENT} pnpm psy watch ./src/app # watches only the app directory`)
353
+ .argument('[dir]', 'the directory to watch for changes. Defaults to ./src')
249
354
  .action(async (dir) => {
250
355
  await initializePsychicApp({ bypassDreamIntegrityChecks: true });
251
356
  Watcher.watch(dir);
252
357
  });
253
358
  program
254
359
  .command('post-sync')
255
- .description('an internal command that runs as the second stage of the `sync` command, since after types are rebuit, the application needs to be reloaded before autogenerating certain files, since those files will need to leverage the updated types')
360
+ .description('Internal command (do not run directly). Runs as the second stage of `sync` after types are rebuilt. The app must be reloaded between stages so that autogenerated files (e.g., OpenAPI specs, route caches) can leverage the updated types.')
256
361
  .action(async () => {
257
362
  await initializePsychicApp();
258
363
  await PsychicBin.postSync();
@@ -260,13 +365,13 @@ export default class PsychicCLI {
260
365
  });
261
366
  program
262
367
  .command('sync:routes')
263
- .description('Reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper and other things.')
368
+ .description('Regenerates the route cache file from your current route definitions. The cache powers autocomplete for the route helper and other route-aware tooling. This runs automatically as part of `pnpm psy sync` — only run it standalone if you need to update just the route cache.')
264
369
  .action(async () => {
265
370
  await PsychicBin.syncRoutes();
266
371
  });
267
372
  program
268
373
  .command('sync:openapi')
269
- .description('Syncs openapi.json file to current state of all psychic controllers within the app')
374
+ .description('Regenerates openapi.json from the current @OpenAPI decorators on all Psychic controllers. This runs automatically as part of `pnpm psy sync` — only run it standalone if you need to update just the OpenAPI spec without regenerating types or routes.')
270
375
  .action(async () => {
271
376
  await initializePsychicApp();
272
377
  await PsychicBin.syncOpenapiJson();
@@ -274,8 +379,12 @@ export default class PsychicCLI {
274
379
  });
275
380
  program
276
381
  .command('diff:openapi')
277
- .description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
278
- .option('-f, --fail-on-breaking', 'fail on spec changes that are breaking', false)
382
+ .description(`Compares the OpenAPI spec on the current branch against the main/master branch and displays a diff of changes. Useful for reviewing API contract changes in pull requests.
383
+ ${INDENT}
384
+ ${INDENT}Example:
385
+ ${INDENT} pnpm psy diff:openapi # show diff only
386
+ ${INDENT} pnpm psy diff:openapi --fail-on-breaking # exit 1 if breaking changes detected (for CI)`)
387
+ .option('-f, --fail-on-breaking', 'exit with code 1 if breaking API changes are detected (removed endpoints, changed required fields, etc.). Useful as a CI gate to prevent accidental breaking changes', false)
279
388
  .action(async (options) => {
280
389
  await initializePsychicApp();
281
390
  try {
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
- import { createServer } from 'net';
3
2
  import { debuglog } from 'node:util';
4
- import sleep from '../../../spec/helpers/sleep.js';
5
3
  import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
6
4
  import PsychicApp from '../../psychic-app/index.js';
5
+ import sleep from './sleep.js';
7
6
  const devServerProcesses = {};
8
7
  const debugEnabled = debuglog('psychic').enabled;
9
- export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000, onStdOut } = {}) {
8
+ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 20000, onStdOut } = {}) {
10
9
  if (devServerProcesses[key])
11
10
  return;
12
11
  if (debugEnabled)
@@ -34,16 +33,7 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
34
33
  console.log(txt);
35
34
  }
36
35
  });
37
- devServerProcesses[key] = proc;
38
- await waitForPort(key, port, timeout);
39
- proc.on('error', err => {
40
- throw err;
41
- });
42
- proc.stdout.on('data', data => {
43
- if (debugEnabled)
44
- PsychicApp.log(`Server output: ${data}`);
45
- });
46
- proc.stderr.on('data', data => {
36
+ proc.stderr?.on('data', data => {
47
37
  if (debugEnabled)
48
38
  PsychicApp.logWithLevel('error', `Server error: ${data}`);
49
39
  });
@@ -54,6 +44,8 @@ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', t
54
44
  if (debugEnabled)
55
45
  PsychicApp.log(`Server process exited with code ${code}`);
56
46
  });
47
+ devServerProcesses[key] = proc;
48
+ await waitForHttpServer(proc, key, port, timeout);
57
49
  }
58
50
  export function stopDevServer(key) {
59
51
  const proc = devServerProcesses[key];
@@ -80,40 +72,23 @@ export function stopDevServers() {
80
72
  stopDevServer(key);
81
73
  });
82
74
  }
83
- async function isPortAvailable(port) {
84
- return new Promise(resolve => {
85
- const server = createServer()
86
- .once('error', err => {
87
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
88
- if (err.code === 'EADDRINUSE') {
89
- resolve(false);
90
- }
91
- else {
92
- resolve(true);
93
- }
94
- })
95
- .once('listening', () => {
96
- server.close();
97
- resolve(true);
98
- })
99
- .listen(port, '127.0.0.1');
100
- });
101
- }
102
- async function waitForPort(key, port, timeout = 5000) {
103
- if (await isPortAvailable(port)) {
104
- return true;
105
- }
75
+ async function waitForHttpServer(proc, key, port, timeout = 20000) {
106
76
  const startTime = Date.now();
107
- async function recursiveWaitForPort() {
108
- if (await isPortAvailable(port)) {
109
- return true;
77
+ while (Date.now() - startTime < timeout) {
78
+ if (proc.exitCode != null) {
79
+ delete devServerProcesses[key];
80
+ throw new Error(`dev server exited with code ${proc.exitCode} before becoming ready on port ${port}`);
110
81
  }
111
- if (Date.now() > startTime + timeout) {
112
- stopDevServer(key);
113
- throw new Error('waited too long for port: ' + port);
82
+ try {
83
+ const response = await fetch(`http://localhost:${port}`, { redirect: 'manual' });
84
+ if (response.status > 0)
85
+ return;
86
+ }
87
+ catch {
88
+ // server not ready yet, keep waiting
114
89
  }
115
- await sleep(50);
116
- return await recursiveWaitForPort();
90
+ await sleep(100);
117
91
  }
118
- return await recursiveWaitForPort();
92
+ stopDevServer(key);
93
+ throw new Error(`waited too long for dev server on port ${port}`);
119
94
  }
@@ -27,6 +27,7 @@ export default async function generateResource({ route, fullyQualifiedModelName,
27
27
  includeAdminSerializers: forAdmin,
28
28
  includeInternalSerializers: forInternal,
29
29
  connectionName: options.connectionName,
30
+ tableName: options.tableName,
30
31
  modelName: options.modelName,
31
32
  },
32
33
  });
@@ -29,6 +29,7 @@ export default class PsychicCLI {
29
29
  stiBaseSerializer: boolean;
30
30
  owningModel?: string;
31
31
  connectionName: string;
32
+ tableName?: string;
32
33
  modelName?: string;
33
34
  };
34
35
  columnsWithTypes: string[];
@@ -9,6 +9,7 @@ export default function generateResource({ route, fullyQualifiedModelName, optio
9
9
  stiBaseSerializer: boolean;
10
10
  owningModel?: string;
11
11
  connectionName: string;
12
+ tableName?: string;
12
13
  modelName?: string;
13
14
  };
14
15
  columnsWithTypes: string[];
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.0.3",
5
+ "version": "3.0.4",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",