@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.
- package/dist/cjs/src/cli/index.js +178 -69
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +20 -45
- package/dist/cjs/src/generate/resource.js +1 -0
- package/dist/esm/src/cli/index.js +178 -69
- package/dist/esm/src/devtools/helpers/launchDevServer.js +20 -45
- package/dist/esm/src/generate/resource.js +1 -0
- package/dist/types/src/cli/index.d.ts +1 -0
- package/dist/types/src/generate/resource.d.ts +1 -0
- package/package.json +1 -1
- /package/dist/cjs/{spec → src/devtools}/helpers/sleep.js +0 -0
- /package/dist/esm/{spec → src/devtools}/helpers/sleep.js +0 -0
- /package/dist/types/{spec → src/devtools}/helpers/sleep.d.ts +0 -0
|
@@ -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}
|
|
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}
|
|
70
|
-
${INDENT}
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
248
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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(
|
|
278
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
116
|
-
return await recursiveWaitForPort();
|
|
90
|
+
await sleep(100);
|
|
117
91
|
}
|
|
118
|
-
|
|
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}
|
|
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}
|
|
70
|
-
${INDENT}
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
248
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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(
|
|
278
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
116
|
-
return await recursiveWaitForPort();
|
|
90
|
+
await sleep(100);
|
|
117
91
|
}
|
|
118
|
-
|
|
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
|
});
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|