@rvoh/psychic 3.0.2 → 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.
Files changed (35) hide show
  1. package/dist/cjs/src/bin/index.js +5 -13
  2. package/dist/cjs/src/cli/index.js +194 -46
  3. package/dist/cjs/src/devtools/helpers/launchDevServer.js +20 -45
  4. package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
  5. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
  6. package/dist/cjs/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
  7. package/dist/cjs/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
  8. package/dist/cjs/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
  9. package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  10. package/dist/cjs/src/generate/initializer/syncEnums.js +3 -3
  11. package/dist/cjs/src/generate/openapi/zustandBindings.js +27 -0
  12. package/dist/cjs/src/generate/resource.js +1 -0
  13. package/dist/esm/src/bin/index.js +5 -13
  14. package/dist/esm/src/cli/index.js +194 -46
  15. package/dist/esm/src/devtools/helpers/launchDevServer.js +20 -45
  16. package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
  17. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
  18. package/dist/esm/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
  19. package/dist/esm/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
  20. package/dist/esm/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
  21. package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  22. package/dist/esm/src/generate/initializer/syncEnums.js +3 -3
  23. package/dist/esm/src/generate/openapi/zustandBindings.js +27 -0
  24. package/dist/esm/src/generate/resource.js +1 -0
  25. package/dist/types/src/cli/index.d.ts +1 -0
  26. package/dist/types/src/generate/helpers/zustandBindings/printFinalStepsMessage.d.ts +2 -0
  27. package/dist/types/src/generate/helpers/zustandBindings/promptForOptions.d.ts +2 -0
  28. package/dist/types/src/generate/helpers/zustandBindings/writeClientConfigFile.d.ts +3 -0
  29. package/dist/types/src/generate/helpers/zustandBindings/writeInitializer.d.ts +5 -0
  30. package/dist/types/src/generate/openapi/zustandBindings.d.ts +21 -0
  31. package/dist/types/src/generate/resource.d.ts +1 -0
  32. package/package.json +13 -16
  33. /package/dist/cjs/{spec → src/devtools}/helpers/sleep.js +0 -0
  34. /package/dist/esm/{spec → src/devtools}/helpers/sleep.js +0 -0
  35. /package/dist/types/{spec → src/devtools}/helpers/sleep.d.ts +0 -0
@@ -46,27 +46,19 @@ export default class PsychicBin {
46
46
  await DreamCLI.spawn(psychicApp.psyCmd('post-sync'), {
47
47
  onStdout: message => {
48
48
  DreamCLI.logger.logContinueProgress(`[post-sync]` + ' ' + message, {
49
- logPrefixColor: 'cyan',
49
+ logPrefixColor: 'greenBright',
50
50
  });
51
51
  },
52
52
  });
53
53
  DreamCLI.logger.logEndProgress();
54
54
  }
55
55
  static async postSync() {
56
- try {
57
- await this.syncOpenapiJson();
58
- await this.runCliHooksAndUpdatePsychicTypesFileWithOutput();
59
- await this.syncOpenapiTypescriptFiles();
60
- }
61
- catch (error) {
62
- console.error(error);
63
- await CliFileWriter.revert();
64
- }
56
+ await this.syncOpenapiJson();
57
+ await this.runCliHooksAndUpdatePsychicTypesFileWithOutput();
58
+ await this.syncOpenapiTypescriptFiles();
65
59
  }
66
60
  static async syncTypes() {
67
- await DreamCLI.logger.logProgress(`syncing types/psychic.ts...`, async () => {
68
- await new ASTPsychicTypesBuilder().build();
69
- });
61
+ await new ASTPsychicTypesBuilder().build();
70
62
  }
71
63
  static openapiDiff() {
72
64
  const psychicApp = PsychicApp.getOrFail();
@@ -4,6 +4,7 @@ import generateController from '../generate/controller.js';
4
4
  import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
5
5
  import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
6
6
  import generateOpenapiReduxBindings from '../generate/openapi/reduxBindings.js';
7
+ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.js';
7
8
  import generateResource from '../generate/resource.js';
8
9
  import Watcher from '../watcher/Watcher.js';
9
10
  const INDENT = ' ';
@@ -61,10 +62,13 @@ const columnsWithTypesDescription = baseColumnsWithTypesDescription +
61
62
  `
62
63
  ${INDENT}
63
64
  ${INDENT} - belongs_to:
64
- ${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.
65
67
  ${INDENT}
66
- ${INDENT} include the fully qualified model name, e.g., if the Coach model is in src/app/models/Health/Coach:
67
- ${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)`;
68
72
  export default class PsychicCLI {
69
73
  static provide(program, { initializePsychicApp, seedDb, }) {
70
74
  DreamCLI.generateDreamCli(program, {
@@ -77,20 +81,79 @@ export default class PsychicCLI {
77
81
  program
78
82
  .command('generate:resource')
79
83
  .alias('g:resource')
80
- .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.')
81
- .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes')
82
- .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
83
- - index
84
- - create
85
- - show
86
- - update
87
- - delete`)
88
- .option('--sti-base-serializer', 'omits the serializer from the dream model, but does create the serializer so it can be extended by STI children')
89
- .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.')
90
- .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
91
- .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
92
- .argument('<path>', 'URL path from root domain. Specify nesting resource with `{}`, e.g.: `tickets/{}/comments`')
93
- .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`)
94
157
  .argument('[columnsWithTypes...]', columnsWithTypesDescription)
95
158
  .action(async (route, modelName, columnsWithTypes, options) => {
96
159
  await initializePsychicApp({
@@ -103,9 +166,18 @@ export default class PsychicCLI {
103
166
  program
104
167
  .command('generate:controller')
105
168
  .alias('g:controller')
106
- .description('Generates a controller and the inheritance chain leading to that controller, and a spec skeleton for the controller.')
107
- .argument('<controllerName>', 'the name of the controller to create, including namespaces, e.g. Posts or Api/V1/Posts')
108
- .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.`)
109
181
  .action(async (controllerName, actions) => {
110
182
  await initializePsychicApp({ bypassDreamIntegrityChecks: true, bypassDbConnectionsDuringInit: true });
111
183
  await PsychicBin.generateController(controllerName, actions);
@@ -113,9 +185,16 @@ export default class PsychicCLI {
113
185
  });
114
186
  program
115
187
  .command('setup:sync:enums')
116
- .description('Generates an initializer in your app for syncing enums to a particular path.')
117
- .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"')
118
- .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`')
119
198
  .action(async (outfile, { initializerName, }) => {
120
199
  await initializePsychicApp({
121
200
  bypassDreamIntegrityChecks: true,
@@ -126,12 +205,22 @@ export default class PsychicCLI {
126
205
  });
127
206
  program
128
207
  .command('setup:sync:openapi-redux')
129
- .description('Generates openapi redux bindings to connect one of your openapi files to one of your clients.')
130
- .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')
131
- .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')
132
- .option('--api-import <apiImport>', 'the camelCased name of the export from your api module, i.e. emptyBackendApi')
133
- .option('--output-file <outputFile>', 'the path to the file that will contain your typescript openapi redux bindings, i.e. ../client/app/myBackendApi.ts')
134
- .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')
135
224
  .action(async ({ schemaFile, apiFile, apiImport, outputFile, exportName, }) => {
136
225
  await initializePsychicApp({
137
226
  bypassDreamIntegrityChecks: true,
@@ -146,12 +235,46 @@ export default class PsychicCLI {
146
235
  });
147
236
  process.exit();
148
237
  });
238
+ program
239
+ .command('setup:sync:openapi-zustand')
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')
254
+ .action(async ({ schemaFile, outputDir, clientConfigFile, exportName, }) => {
255
+ await initializePsychicApp({
256
+ bypassDreamIntegrityChecks: true,
257
+ bypassDbConnectionsDuringInit: true,
258
+ });
259
+ await generateOpenapiZustandBindings({
260
+ exportName,
261
+ schemaFile,
262
+ outputDir,
263
+ clientConfigFile,
264
+ });
265
+ process.exit();
266
+ });
149
267
  program
150
268
  .command('setup:sync:openapi-typescript')
151
- .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
152
- .argument('<openapiFilepath>', 'the path from your backend directory to the openapi file you wish to scan, i.e. "./src/openapi/openapi.json"')
153
- .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"')
154
- .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`')
155
278
  .action(async (openapiFilepath, outfile, { initializerName, }) => {
156
279
  await initializePsychicApp({
157
280
  bypassDreamIntegrityChecks: true,
@@ -163,7 +286,7 @@ export default class PsychicCLI {
163
286
  program
164
287
  .command('inspect:controller-hierarchy')
165
288
  .alias('i:controller-hierarchy')
166
- .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.`)
167
290
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
168
291
  .action(async (controllersPath) => {
169
292
  await initializePsychicApp();
@@ -172,7 +295,12 @@ export default class PsychicCLI {
172
295
  });
173
296
  program
174
297
  .command('check:controller-hierarchy')
175
- .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`)
176
304
  .argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
177
305
  .action(async (controllersPath) => {
178
306
  await initializePsychicApp();
@@ -187,7 +315,12 @@ export default class PsychicCLI {
187
315
  });
188
316
  program
189
317
  .command('routes')
190
- .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`)
191
324
  .action(async () => {
192
325
  await initializePsychicApp();
193
326
  PsychicBin.printRoutes();
@@ -195,9 +328,16 @@ export default class PsychicCLI {
195
328
  });
196
329
  program
197
330
  .command('sync')
198
- .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/`.")
199
- .option('--ignore-errors')
200
- .option('--schema-only')
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)
201
341
  .action(async (options) => {
202
342
  await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
203
343
  await PsychicBin.sync(options);
@@ -205,15 +345,19 @@ export default class PsychicCLI {
205
345
  });
206
346
  program
207
347
  .command('watch')
208
- .description('watches your app for changes, and re-syncs any time they happen')
209
- .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')
210
354
  .action(async (dir) => {
211
355
  await initializePsychicApp({ bypassDreamIntegrityChecks: true });
212
356
  Watcher.watch(dir);
213
357
  });
214
358
  program
215
359
  .command('post-sync')
216
- .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.')
217
361
  .action(async () => {
218
362
  await initializePsychicApp();
219
363
  await PsychicBin.postSync();
@@ -221,13 +365,13 @@ export default class PsychicCLI {
221
365
  });
222
366
  program
223
367
  .command('sync:routes')
224
- .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.')
225
369
  .action(async () => {
226
370
  await PsychicBin.syncRoutes();
227
371
  });
228
372
  program
229
373
  .command('sync:openapi')
230
- .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.')
231
375
  .action(async () => {
232
376
  await initializePsychicApp();
233
377
  await PsychicBin.syncOpenapiJson();
@@ -235,8 +379,12 @@ export default class PsychicCLI {
235
379
  });
236
380
  program
237
381
  .command('diff:openapi')
238
- .description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
239
- .option('-f', '--fail-on-breaking', 'fail on spec changes that are breaking')
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)
240
388
  .action(async (options) => {
241
389
  await initializePsychicApp();
242
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
  }
@@ -30,15 +30,15 @@ import AppEnv from '../../AppEnv.js'
30
30
  export default function initialize${pascalized}(psy: PsychicApp) {
31
31
  psy.on('cli:sync', async () => {
32
32
  if (AppEnv.isDevelopmentOrTest) {
33
- DreamCLI.logger.logStartProgress(\`[${camelized}] syncing...\`)
34
- await DreamCLI.spawn('npx @rtk-query/codegen-openapi ${filePath}', {
35
- onStdout: message => {
36
- DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
37
- logPrefixColor: 'green',
38
- })
39
- },
33
+ await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
34
+ await DreamCLI.spawn('npx @rtk-query/codegen-openapi ${filePath}', {
35
+ onStdout: message => {
36
+ DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
37
+ logPrefixColor: 'green',
38
+ })
39
+ },
40
+ })
40
41
  })
41
- DreamCLI.logger.logEndProgress()
42
42
  }
43
43
  })
44
44
  }\
@@ -23,9 +23,9 @@ import AppEnv from '../AppEnv.js'
23
23
  export default (psy: PsychicApp) => {
24
24
  psy.on('cli:sync', async () => {
25
25
  if (AppEnv.isDevelopmentOrTest) {
26
- DreamCLI.logger.logStartProgress(\`[${hyphenized}] extracting types from ${openapiFilepath} to ${outfile}...\`)
27
- await DreamCLI.spawn('npx openapi-typescript ${openapiFilepath} -o ${outfile}')
28
- DreamCLI.logger.logEndProgress()
26
+ await DreamCLI.logger.logProgress(\`[${hyphenized}] extracting types from ${openapiFilepath} to ${outfile}...\`, async () => {
27
+ await DreamCLI.spawn('npx openapi-typescript ${openapiFilepath} -o ${outfile}')
28
+ })
29
29
  }
30
30
  })
31
31
  }\
@@ -0,0 +1,47 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import colorize from '../../../cli/helpers/colorize.js';
3
+ export default function printFinalStepsMessage(opts) {
4
+ const importLine = colorize(`+ import '${opts.clientConfigFile}'`, { color: 'green' });
5
+ const sdkImportLine = colorize(`+ import { getAdminCities, postAdminCities } from '${opts.outputDir}/sdk.gen'`, {
6
+ color: 'green',
7
+ });
8
+ const usageLine = colorize(` const { data, error } = await getAdminCities()`, {
9
+ color: 'green',
10
+ });
11
+ const zustandLine = colorize(` const { data } = await getAdminCities()
12
+ set({ cities: data?.results })`, { color: 'green' });
13
+ DreamCLI.logger.log(`
14
+ Finished generating @hey-api/openapi-ts bindings for your application.
15
+
16
+ First, you will need to be sure to sync, so that the typed API functions
17
+ are generated from your openapi schema:
18
+
19
+ pnpm psy sync
20
+
21
+ This will generate typed API functions and types in ${opts.outputDir}/
22
+
23
+ To use the generated API, first import the client config at your app's
24
+ entry point to configure the base URL and credentials:
25
+
26
+ ${importLine}
27
+
28
+ Then import and use the generated typed functions anywhere:
29
+
30
+ ${sdkImportLine}
31
+
32
+ // all functions are fully typed with request params and response types
33
+ ${usageLine}
34
+
35
+ To use with a Zustand store:
36
+
37
+ import { create } from 'zustand'
38
+ ${sdkImportLine}
39
+
40
+ const useCitiesStore = create((set) => ({
41
+ cities: [],
42
+ fetchCities: async () => {
43
+ ${zustandLine}
44
+ },
45
+ }))
46
+ `, { logPrefix: '' });
47
+ }