@rvoh/psychic 3.0.2 → 3.0.3

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 (26) hide show
  1. package/dist/cjs/src/bin/index.js +5 -13
  2. package/dist/cjs/src/cli/index.js +44 -5
  3. package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
  4. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
  5. package/dist/cjs/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
  6. package/dist/cjs/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
  7. package/dist/cjs/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
  8. package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  9. package/dist/cjs/src/generate/initializer/syncEnums.js +3 -3
  10. package/dist/cjs/src/generate/openapi/zustandBindings.js +27 -0
  11. package/dist/esm/src/bin/index.js +5 -13
  12. package/dist/esm/src/cli/index.js +44 -5
  13. package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
  14. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
  15. package/dist/esm/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
  16. package/dist/esm/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
  17. package/dist/esm/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
  18. package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
  19. package/dist/esm/src/generate/initializer/syncEnums.js +3 -3
  20. package/dist/esm/src/generate/openapi/zustandBindings.js +27 -0
  21. package/dist/types/src/generate/helpers/zustandBindings/printFinalStepsMessage.d.ts +2 -0
  22. package/dist/types/src/generate/helpers/zustandBindings/promptForOptions.d.ts +2 -0
  23. package/dist/types/src/generate/helpers/zustandBindings/writeClientConfigFile.d.ts +3 -0
  24. package/dist/types/src/generate/helpers/zustandBindings/writeInitializer.d.ts +5 -0
  25. package/dist/types/src/generate/openapi/zustandBindings.d.ts +21 -0
  26. package/package.json +13 -16
@@ -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,7 +4,10 @@ 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';
9
+ import colorize from './helpers/colorize.js';
10
+ import PsychicLogos from './helpers/PsychicLogos.js';
8
11
  import Watcher from '../watcher/Watcher.js';
9
12
  const INDENT = ' ';
10
13
  const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
@@ -67,6 +70,22 @@ ${INDENT} include the fully qualified model name, e.g., if the Coach mode
67
70
  ${INDENT} Health/Coach:belongs_to`;
68
71
  export default class PsychicCLI {
69
72
  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
+ });
70
89
  DreamCLI.generateDreamCli(program, {
71
90
  initializeDreamApp: initializePsychicApp,
72
91
  seedDb,
@@ -78,14 +97,14 @@ export default class PsychicCLI {
78
97
  .command('generate:resource')
79
98
  .alias('g:resource')
80
99
  .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
81
- .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes')
100
+ .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes', false)
82
101
  .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
83
102
  - index
84
103
  - create
85
104
  - show
86
105
  - update
87
106
  - 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')
107
+ .option('--sti-base-serializer', 'creates a generically typed base serializer that includes the child type in the output so consuming applications can determine shape based on type', false)
89
108
  .option('--owning-model <modelName>', 'the model class of the object that `associationQuery`/`createAssociation` will be performed on in the created controller and spec (e.g., "Host", "Guest", "Ticketing/Ticket"). Defaults to the current user for non-admin/internal namespaced controllers. For admin/internal namespaced controllers, this defaults to null, meaning every admin/internal user can access the model.')
90
109
  .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
91
110
  .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
@@ -146,6 +165,26 @@ export default class PsychicCLI {
146
165
  });
147
166
  process.exit();
148
167
  });
168
+ program
169
+ .command('setup:sync:openapi-zustand')
170
+ .description('Generates typed API functions from an openapi file using @hey-api/openapi-ts, for use with Zustand or any other state manager.')
171
+ .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
172
+ .option('--output-dir <outputDir>', 'the directory where @hey-api/openapi-ts will generate typed API functions and types, i.e. ../client/app/api/myBackendApi')
173
+ .option('--client-config-file <clientConfigFile>', 'the path to the client configuration file that configures @hey-api/client-fetch with base URL and credentials, i.e. ../client/app/api/myBackendApi/client.ts')
174
+ .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
175
+ .action(async ({ schemaFile, outputDir, clientConfigFile, exportName, }) => {
176
+ await initializePsychicApp({
177
+ bypassDreamIntegrityChecks: true,
178
+ bypassDbConnectionsDuringInit: true,
179
+ });
180
+ await generateOpenapiZustandBindings({
181
+ exportName,
182
+ schemaFile,
183
+ outputDir,
184
+ clientConfigFile,
185
+ });
186
+ process.exit();
187
+ });
149
188
  program
150
189
  .command('setup:sync:openapi-typescript')
151
190
  .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
@@ -196,8 +235,8 @@ export default class PsychicCLI {
196
235
  program
197
236
  .command('sync')
198
237
  .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
199
- .option('--ignore-errors')
200
- .option('--schema-only')
238
+ .option('--ignore-errors', 'ignore integrity checks and continue sync', false)
239
+ .option('--schema-only', 'sync database schema types only', false)
201
240
  .action(async (options) => {
202
241
  await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
203
242
  await PsychicBin.sync(options);
@@ -236,7 +275,7 @@ export default class PsychicCLI {
236
275
  program
237
276
  .command('diff:openapi')
238
277
  .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')
278
+ .option('-f, --fail-on-breaking', 'fail on spec changes that are breaking', false)
240
279
  .action(async (options) => {
241
280
  await initializePsychicApp();
242
281
  try {
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ import { camelize } from '@rvoh/dream/utils';
2
+ import cliPrompt from '../../../cli/helpers/cli-prompt.js';
3
+ import PsychicApp from '../../../psychic-app/index.js';
4
+ export default async function promptForOptions(options) {
5
+ if (!options.schemaFile) {
6
+ const defaultVal = './src/openapi/openapi.json';
7
+ const answer = await cliPrompt(`\
8
+ What would you like the schemaFile to be?
9
+
10
+ The schemaFile is the openapi file that @hey-api/openapi-ts will read to produce
11
+ all of its typed API functions and types. If not provided, it will default to
12
+
13
+ ${defaultVal}
14
+ `);
15
+ options.schemaFile = answer || defaultVal;
16
+ }
17
+ if (!options.exportName) {
18
+ const defaultVal = `${camelize(PsychicApp.getOrFail().appName)}Api`;
19
+ const answer = await cliPrompt(`\
20
+ What would you like the exportName to be?
21
+
22
+ The exportName is used to name the generated API output. It will be used to name
23
+ the initializer and config files. We recommend naming it something like the name
24
+ of your app, i.e.
25
+
26
+ ${defaultVal}
27
+ `);
28
+ options.exportName = answer || defaultVal;
29
+ }
30
+ if (!options.outputDir) {
31
+ const defaultVal = `../client/app/api/${camelize(options.exportName)}`;
32
+ const answer = await cliPrompt(`\
33
+ What would you like the outputDir to be?
34
+
35
+ The outputDir is the directory where @hey-api/openapi-ts will generate the typed
36
+ API functions and types. It will contain files like types.gen.ts and sdk.gen.ts.
37
+ If not provided, it will default to:
38
+
39
+ ${defaultVal}
40
+ `);
41
+ options.outputDir = answer || defaultVal;
42
+ }
43
+ if (!options.clientConfigFile) {
44
+ const defaultVal = `../client/app/api/${camelize(options.exportName)}/client.ts`;
45
+ const answer = await cliPrompt(`\
46
+ What would you like the path to your clientConfigFile to be?
47
+
48
+ The clientConfigFile specifies where to generate the client configuration file
49
+ that configures @hey-api/client-fetch with your base URL, credentials, and
50
+ other request options.
51
+
52
+ We expect you to provide this path with the api root in mind, so you will need
53
+ to consider how to travel to the desired filepath from within your psychic
54
+ project, i.e.
55
+
56
+ ${defaultVal}
57
+ `);
58
+ options.clientConfigFile = answer || defaultVal;
59
+ }
60
+ return options;
61
+ }
@@ -0,0 +1,43 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ export default async function writeClientConfigFile({ clientConfigFile }) {
4
+ const destDir = path.dirname(clientConfigFile);
5
+ try {
6
+ await fs.access(clientConfigFile);
7
+ return; // early return if the file already exists
8
+ }
9
+ catch {
10
+ // noop
11
+ }
12
+ try {
13
+ await fs.access(destDir);
14
+ }
15
+ catch {
16
+ await fs.mkdir(destDir, { recursive: true });
17
+ }
18
+ const contents = `\
19
+ import { client } from '@hey-api/client-fetch'
20
+
21
+ function baseUrl() {
22
+ // add custom code here for determining your application's baseUrl
23
+ // this would generally be something different, depending on if you
24
+ // are in dev/test/production environments. For dev, you might want
25
+ // http://localhost:7777, while test may be http://localhost:7778, or
26
+ // some other port, depending on how you have your spec hooks configured.
27
+ // for production, it should be the real host for your application, i.e.
28
+ // https://myapi.com
29
+
30
+ return 'http://localhost:7777'
31
+ }
32
+
33
+ client.setConfig({
34
+ baseUrl: baseUrl(),
35
+ credentials: 'include',
36
+
37
+ // you can customize headers here, for example to add auth tokens:
38
+ // headers: {
39
+ // Authorization: \`Bearer \${getToken()}\`,
40
+ // },
41
+ })`;
42
+ await fs.writeFile(clientConfigFile, contents);
43
+ }
@@ -0,0 +1,46 @@
1
+ import { camelize, pascalize } from '@rvoh/dream/utils';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import psychicPath from '../../../helpers/path/psychicPath.js';
5
+ export default async function writeInitializer({ exportName, schemaFile, outputDir, }) {
6
+ const pascalized = pascalize(exportName);
7
+ const camelized = camelize(exportName);
8
+ const destDir = path.join(psychicPath('conf'), 'initializers', 'openapi');
9
+ const initializerFilename = `${camelized}.ts`;
10
+ const initializerPath = path.join(destDir, initializerFilename);
11
+ try {
12
+ await fs.access(initializerPath);
13
+ return; // early return if the file already exists
14
+ }
15
+ catch {
16
+ // noop
17
+ }
18
+ try {
19
+ await fs.access(destDir);
20
+ }
21
+ catch {
22
+ await fs.mkdir(destDir, { recursive: true });
23
+ }
24
+ const contents = `\
25
+ import { DreamCLI } from '@rvoh/dream/system'
26
+ import { PsychicApp } from '@rvoh/psychic'
27
+ import AppEnv from '../../AppEnv.js'
28
+
29
+ export default function initialize${pascalized}(psy: PsychicApp) {
30
+ psy.on('cli:sync', async () => {
31
+ if (AppEnv.isDevelopmentOrTest) {
32
+ await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
33
+ await DreamCLI.spawn('npx @hey-api/openapi-ts -i ${schemaFile} -o ${outputDir}', {
34
+ onStdout: message => {
35
+ DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
36
+ logPrefixColor: 'green',
37
+ })
38
+ },
39
+ })
40
+ })
41
+ }
42
+ })
43
+ }\
44
+ `;
45
+ await fs.writeFile(initializerPath, contents);
46
+ }
@@ -29,9 +29,9 @@ import AppEnv from '../AppEnv.js'
29
29
  export default function ${camelized}(psy: PsychicApp) {
30
30
  psy.on('cli:sync', async () => {
31
31
  if (AppEnv.isDevelopmentOrTest) {
32
- DreamCLI.logger.logStartProgress(\`[${camelized}] syncing enums to ${outfile}...\`)
33
- await PsychicBin.syncClientEnums('${outfile}')
34
- DreamCLI.logger.logEndProgress()
32
+ await DreamCLI.logger.logProgress(\`[${camelized}] syncing enums to ${outfile}...\`, async () => {
33
+ await PsychicBin.syncClientEnums('${outfile}')
34
+ })
35
35
  }
36
36
  })
37
37
  }\
@@ -0,0 +1,27 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import PackageManager from '../../cli/helpers/PackageManager.js';
3
+ import printFinalStepsMessage from '../helpers/zustandBindings/printFinalStepsMessage.js';
4
+ import promptForOptions from '../helpers/zustandBindings/promptForOptions.js';
5
+ import writeClientConfigFile from '../helpers/zustandBindings/writeClientConfigFile.js';
6
+ import writeInitializer from '../helpers/zustandBindings/writeInitializer.js';
7
+ /**
8
+ * @internal
9
+ *
10
+ * used by the psychic CLI to generate boilerplate
11
+ * that can be used to integrate a specific openapi.json
12
+ * file with a client using @hey-api/openapi-ts.
13
+ *
14
+ * * generates the client config file if it does not exist
15
+ * * generates an initializer, which taps into the sync hooks
16
+ * to automatically run the @hey-api/openapi-ts CLI util
17
+ * * prints a helpful message, instructing devs on the final
18
+ * steps for using the generated typed API functions
19
+ * within their client application.
20
+ */
21
+ export default async function generateOpenapiZustandBindings(options = {}) {
22
+ const opts = await promptForOptions(options);
23
+ await writeClientConfigFile(opts);
24
+ await writeInitializer(opts);
25
+ await DreamCLI.spawn(PackageManager.add(['@hey-api/openapi-ts', '@hey-api/client-fetch'], { dev: true }));
26
+ printFinalStepsMessage(opts);
27
+ }
@@ -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,7 +4,10 @@ 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';
9
+ import colorize from './helpers/colorize.js';
10
+ import PsychicLogos from './helpers/PsychicLogos.js';
8
11
  import Watcher from '../watcher/Watcher.js';
9
12
  const INDENT = ' ';
10
13
  const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
@@ -67,6 +70,22 @@ ${INDENT} include the fully qualified model name, e.g., if the Coach mode
67
70
  ${INDENT} Health/Coach:belongs_to`;
68
71
  export default class PsychicCLI {
69
72
  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
+ });
70
89
  DreamCLI.generateDreamCli(program, {
71
90
  initializeDreamApp: initializePsychicApp,
72
91
  seedDb,
@@ -78,14 +97,14 @@ export default class PsychicCLI {
78
97
  .command('generate:resource')
79
98
  .alias('g:resource')
80
99
  .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
81
- .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes')
100
+ .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes', false)
82
101
  .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
83
102
  - index
84
103
  - create
85
104
  - show
86
105
  - update
87
106
  - 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')
107
+ .option('--sti-base-serializer', 'creates a generically typed base serializer that includes the child type in the output so consuming applications can determine shape based on type', false)
89
108
  .option('--owning-model <modelName>', 'the model class of the object that `associationQuery`/`createAssociation` will be performed on in the created controller and spec (e.g., "Host", "Guest", "Ticketing/Ticket"). Defaults to the current user for non-admin/internal namespaced controllers. For admin/internal namespaced controllers, this defaults to null, meaning every admin/internal user can access the model.')
90
109
  .option('--connection-name <connectionName>', 'the name of the db connection you would like to use for your model. Defaults to "default"', 'default')
91
110
  .option('--model-name <modelName>', 'explicit model class name to use instead of the auto-generated one (e.g. --model-name=Kitchen for Room/Kitchen)')
@@ -146,6 +165,26 @@ export default class PsychicCLI {
146
165
  });
147
166
  process.exit();
148
167
  });
168
+ program
169
+ .command('setup:sync:openapi-zustand')
170
+ .description('Generates typed API functions from an openapi file using @hey-api/openapi-ts, for use with Zustand or any other state manager.')
171
+ .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
172
+ .option('--output-dir <outputDir>', 'the directory where @hey-api/openapi-ts will generate typed API functions and types, i.e. ../client/app/api/myBackendApi')
173
+ .option('--client-config-file <clientConfigFile>', 'the path to the client configuration file that configures @hey-api/client-fetch with base URL and credentials, i.e. ../client/app/api/myBackendApi/client.ts')
174
+ .option('--export-name <exportName>', 'the camelCased name to use for your exported api, i.e. myBackendApi')
175
+ .action(async ({ schemaFile, outputDir, clientConfigFile, exportName, }) => {
176
+ await initializePsychicApp({
177
+ bypassDreamIntegrityChecks: true,
178
+ bypassDbConnectionsDuringInit: true,
179
+ });
180
+ await generateOpenapiZustandBindings({
181
+ exportName,
182
+ schemaFile,
183
+ outputDir,
184
+ clientConfigFile,
185
+ });
186
+ process.exit();
187
+ });
149
188
  program
150
189
  .command('setup:sync:openapi-typescript')
151
190
  .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
@@ -196,8 +235,8 @@ export default class PsychicCLI {
196
235
  program
197
236
  .command('sync')
198
237
  .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
199
- .option('--ignore-errors')
200
- .option('--schema-only')
238
+ .option('--ignore-errors', 'ignore integrity checks and continue sync', false)
239
+ .option('--schema-only', 'sync database schema types only', false)
201
240
  .action(async (options) => {
202
241
  await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
203
242
  await PsychicBin.sync(options);
@@ -236,7 +275,7 @@ export default class PsychicCLI {
236
275
  program
237
276
  .command('diff:openapi')
238
277
  .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')
278
+ .option('-f, --fail-on-breaking', 'fail on spec changes that are breaking', false)
240
279
  .action(async (options) => {
241
280
  await initializePsychicApp();
242
281
  try {
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ import { camelize } from '@rvoh/dream/utils';
2
+ import cliPrompt from '../../../cli/helpers/cli-prompt.js';
3
+ import PsychicApp from '../../../psychic-app/index.js';
4
+ export default async function promptForOptions(options) {
5
+ if (!options.schemaFile) {
6
+ const defaultVal = './src/openapi/openapi.json';
7
+ const answer = await cliPrompt(`\
8
+ What would you like the schemaFile to be?
9
+
10
+ The schemaFile is the openapi file that @hey-api/openapi-ts will read to produce
11
+ all of its typed API functions and types. If not provided, it will default to
12
+
13
+ ${defaultVal}
14
+ `);
15
+ options.schemaFile = answer || defaultVal;
16
+ }
17
+ if (!options.exportName) {
18
+ const defaultVal = `${camelize(PsychicApp.getOrFail().appName)}Api`;
19
+ const answer = await cliPrompt(`\
20
+ What would you like the exportName to be?
21
+
22
+ The exportName is used to name the generated API output. It will be used to name
23
+ the initializer and config files. We recommend naming it something like the name
24
+ of your app, i.e.
25
+
26
+ ${defaultVal}
27
+ `);
28
+ options.exportName = answer || defaultVal;
29
+ }
30
+ if (!options.outputDir) {
31
+ const defaultVal = `../client/app/api/${camelize(options.exportName)}`;
32
+ const answer = await cliPrompt(`\
33
+ What would you like the outputDir to be?
34
+
35
+ The outputDir is the directory where @hey-api/openapi-ts will generate the typed
36
+ API functions and types. It will contain files like types.gen.ts and sdk.gen.ts.
37
+ If not provided, it will default to:
38
+
39
+ ${defaultVal}
40
+ `);
41
+ options.outputDir = answer || defaultVal;
42
+ }
43
+ if (!options.clientConfigFile) {
44
+ const defaultVal = `../client/app/api/${camelize(options.exportName)}/client.ts`;
45
+ const answer = await cliPrompt(`\
46
+ What would you like the path to your clientConfigFile to be?
47
+
48
+ The clientConfigFile specifies where to generate the client configuration file
49
+ that configures @hey-api/client-fetch with your base URL, credentials, and
50
+ other request options.
51
+
52
+ We expect you to provide this path with the api root in mind, so you will need
53
+ to consider how to travel to the desired filepath from within your psychic
54
+ project, i.e.
55
+
56
+ ${defaultVal}
57
+ `);
58
+ options.clientConfigFile = answer || defaultVal;
59
+ }
60
+ return options;
61
+ }
@@ -0,0 +1,43 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ export default async function writeClientConfigFile({ clientConfigFile }) {
4
+ const destDir = path.dirname(clientConfigFile);
5
+ try {
6
+ await fs.access(clientConfigFile);
7
+ return; // early return if the file already exists
8
+ }
9
+ catch {
10
+ // noop
11
+ }
12
+ try {
13
+ await fs.access(destDir);
14
+ }
15
+ catch {
16
+ await fs.mkdir(destDir, { recursive: true });
17
+ }
18
+ const contents = `\
19
+ import { client } from '@hey-api/client-fetch'
20
+
21
+ function baseUrl() {
22
+ // add custom code here for determining your application's baseUrl
23
+ // this would generally be something different, depending on if you
24
+ // are in dev/test/production environments. For dev, you might want
25
+ // http://localhost:7777, while test may be http://localhost:7778, or
26
+ // some other port, depending on how you have your spec hooks configured.
27
+ // for production, it should be the real host for your application, i.e.
28
+ // https://myapi.com
29
+
30
+ return 'http://localhost:7777'
31
+ }
32
+
33
+ client.setConfig({
34
+ baseUrl: baseUrl(),
35
+ credentials: 'include',
36
+
37
+ // you can customize headers here, for example to add auth tokens:
38
+ // headers: {
39
+ // Authorization: \`Bearer \${getToken()}\`,
40
+ // },
41
+ })`;
42
+ await fs.writeFile(clientConfigFile, contents);
43
+ }
@@ -0,0 +1,46 @@
1
+ import { camelize, pascalize } from '@rvoh/dream/utils';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import psychicPath from '../../../helpers/path/psychicPath.js';
5
+ export default async function writeInitializer({ exportName, schemaFile, outputDir, }) {
6
+ const pascalized = pascalize(exportName);
7
+ const camelized = camelize(exportName);
8
+ const destDir = path.join(psychicPath('conf'), 'initializers', 'openapi');
9
+ const initializerFilename = `${camelized}.ts`;
10
+ const initializerPath = path.join(destDir, initializerFilename);
11
+ try {
12
+ await fs.access(initializerPath);
13
+ return; // early return if the file already exists
14
+ }
15
+ catch {
16
+ // noop
17
+ }
18
+ try {
19
+ await fs.access(destDir);
20
+ }
21
+ catch {
22
+ await fs.mkdir(destDir, { recursive: true });
23
+ }
24
+ const contents = `\
25
+ import { DreamCLI } from '@rvoh/dream/system'
26
+ import { PsychicApp } from '@rvoh/psychic'
27
+ import AppEnv from '../../AppEnv.js'
28
+
29
+ export default function initialize${pascalized}(psy: PsychicApp) {
30
+ psy.on('cli:sync', async () => {
31
+ if (AppEnv.isDevelopmentOrTest) {
32
+ await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
33
+ await DreamCLI.spawn('npx @hey-api/openapi-ts -i ${schemaFile} -o ${outputDir}', {
34
+ onStdout: message => {
35
+ DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
36
+ logPrefixColor: 'green',
37
+ })
38
+ },
39
+ })
40
+ })
41
+ }
42
+ })
43
+ }\
44
+ `;
45
+ await fs.writeFile(initializerPath, contents);
46
+ }
@@ -29,9 +29,9 @@ import AppEnv from '../AppEnv.js'
29
29
  export default function ${camelized}(psy: PsychicApp) {
30
30
  psy.on('cli:sync', async () => {
31
31
  if (AppEnv.isDevelopmentOrTest) {
32
- DreamCLI.logger.logStartProgress(\`[${camelized}] syncing enums to ${outfile}...\`)
33
- await PsychicBin.syncClientEnums('${outfile}')
34
- DreamCLI.logger.logEndProgress()
32
+ await DreamCLI.logger.logProgress(\`[${camelized}] syncing enums to ${outfile}...\`, async () => {
33
+ await PsychicBin.syncClientEnums('${outfile}')
34
+ })
35
35
  }
36
36
  })
37
37
  }\
@@ -0,0 +1,27 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import PackageManager from '../../cli/helpers/PackageManager.js';
3
+ import printFinalStepsMessage from '../helpers/zustandBindings/printFinalStepsMessage.js';
4
+ import promptForOptions from '../helpers/zustandBindings/promptForOptions.js';
5
+ import writeClientConfigFile from '../helpers/zustandBindings/writeClientConfigFile.js';
6
+ import writeInitializer from '../helpers/zustandBindings/writeInitializer.js';
7
+ /**
8
+ * @internal
9
+ *
10
+ * used by the psychic CLI to generate boilerplate
11
+ * that can be used to integrate a specific openapi.json
12
+ * file with a client using @hey-api/openapi-ts.
13
+ *
14
+ * * generates the client config file if it does not exist
15
+ * * generates an initializer, which taps into the sync hooks
16
+ * to automatically run the @hey-api/openapi-ts CLI util
17
+ * * prints a helpful message, instructing devs on the final
18
+ * steps for using the generated typed API functions
19
+ * within their client application.
20
+ */
21
+ export default async function generateOpenapiZustandBindings(options = {}) {
22
+ const opts = await promptForOptions(options);
23
+ await writeClientConfigFile(opts);
24
+ await writeInitializer(opts);
25
+ await DreamCLI.spawn(PackageManager.add(['@hey-api/openapi-ts', '@hey-api/client-fetch'], { dev: true }));
26
+ printFinalStepsMessage(opts);
27
+ }
@@ -0,0 +1,2 @@
1
+ import { OpenapiZustandBindingsOptions } from '../../openapi/zustandBindings.js';
2
+ export default function printFinalStepsMessage(opts: Required<OpenapiZustandBindingsOptions>): void;
@@ -0,0 +1,2 @@
1
+ import { OpenapiZustandBindingsOptions } from '../../openapi/zustandBindings.js';
2
+ export default function promptForOptions(options: OpenapiZustandBindingsOptions): Promise<Required<OpenapiZustandBindingsOptions>>;
@@ -0,0 +1,3 @@
1
+ export default function writeClientConfigFile({ clientConfigFile }: {
2
+ clientConfigFile: string;
3
+ }): Promise<void>;
@@ -0,0 +1,5 @@
1
+ export default function writeInitializer({ exportName, schemaFile, outputDir, }: {
2
+ exportName: string;
3
+ schemaFile: string;
4
+ outputDir: string;
5
+ }): Promise<void>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @internal
3
+ *
4
+ * used by the psychic CLI to generate boilerplate
5
+ * that can be used to integrate a specific openapi.json
6
+ * file with a client using @hey-api/openapi-ts.
7
+ *
8
+ * * generates the client config file if it does not exist
9
+ * * generates an initializer, which taps into the sync hooks
10
+ * to automatically run the @hey-api/openapi-ts CLI util
11
+ * * prints a helpful message, instructing devs on the final
12
+ * steps for using the generated typed API functions
13
+ * within their client application.
14
+ */
15
+ export default function generateOpenapiZustandBindings(options?: OpenapiZustandBindingsOptions): Promise<void>;
16
+ export interface OpenapiZustandBindingsOptions {
17
+ exportName?: string;
18
+ schemaFile?: string;
19
+ outputDir?: string;
20
+ clientConfigFile?: string;
21
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.0.2",
5
+ "version": "3.0.3",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",
@@ -76,15 +76,15 @@
76
76
  "@koa/etag": "^5.0.2",
77
77
  "@koa/router": "^15.3.0",
78
78
  "@rvoh/dream": "^2.4.0",
79
- "@types/koa__cors": "^5.0.0",
80
- "@types/koa__router": "^12.0.5",
79
+ "@types/koa": "^3.0.1",
81
80
  "@types/koa-bodyparser": "^4.3.12",
82
81
  "@types/koa-conditional-get": "^2.0.3",
83
82
  "@types/koa-etag": "^3.0.3",
84
- "@types/koa": "^3.0.1",
83
+ "@types/koa__cors": "^5.0.0",
84
+ "@types/koa__router": "^12.0.5",
85
+ "koa": "^3.1.2",
85
86
  "koa-bodyparser": "^4.4.1",
86
87
  "koa-conditional-get": "^3.0.0",
87
- "koa": "^3.1.2",
88
88
  "openapi-typescript": "^7.8.0"
89
89
  },
90
90
  "devDependencies": {
@@ -107,18 +107,18 @@
107
107
  "@types/passport-local": "^1",
108
108
  "@types/pg": "^8.11.8",
109
109
  "@types/supertest": "^6.0.3",
110
- "@typescript-eslint/parser": "^8.48.1",
110
+ "@typescript-eslint/parser": "^8.57.1",
111
111
  "@typescript/analyze-trace": "^0.10.1",
112
112
  "commander": "^14.0.3",
113
113
  "dotenv": "^16.4.5",
114
- "eslint": "^9.39.1",
114
+ "eslint": "^9.39.4",
115
115
  "jsdom": "^26.1.0",
116
116
  "koa": "^3.1.2",
117
117
  "koa-bodyparser": "^4.4.1",
118
118
  "koa-conditional-get": "^3.0.0",
119
119
  "koa-passport": "^6.0.0",
120
120
  "koa-session": "^7.0.2",
121
- "kysely": "^0.28.5",
121
+ "kysely": "^0.28.13",
122
122
  "kysely-codegen": "~0.19.0",
123
123
  "luxon-jest-matchers": "^0.1.14",
124
124
  "nodemon": "^3.1.11",
@@ -128,23 +128,20 @@
128
128
  "pg": "^8.12.0",
129
129
  "pluralize-esm": "^9.0.5",
130
130
  "prettier": "^3.3.3",
131
- "puppeteer": "^24.22.3",
132
- "supertest": "^7.1.4",
131
+ "puppeteer": "^24.39.1",
132
+ "supertest": "^7.2.2",
133
133
  "tslib": "^2.7.0",
134
134
  "tsx": "^4.21.0",
135
135
  "typedoc": "^0.28.17",
136
136
  "typescript": "^5.9.3",
137
- "typescript-eslint": "^8.48.1",
138
- "vitest": "^4.0.9",
137
+ "typescript-eslint": "^8.57.1",
138
+ "vitest": "^4.1.0",
139
139
  "winston": "^3.14.2"
140
140
  },
141
141
  "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402",
142
142
  "pnpm": {
143
143
  "overrides": {
144
- "diff": ">=8.0.3",
145
- "minimatch@3": "3.1.3",
146
- "minimatch@5": "5.1.7",
147
- "minimatch@9": "9.0.6"
144
+ "diff": ">=8.0.3"
148
145
  }
149
146
  }
150
147
  }