@rvoh/psychic 3.0.1 → 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.
- package/dist/cjs/src/bin/helpers/printControllerHierarchy.js +168 -0
- package/dist/cjs/src/bin/index.js +12 -13
- package/dist/cjs/src/cli/index.js +69 -5
- package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
- package/dist/cjs/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
- package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
- package/dist/cjs/src/generate/initializer/syncEnums.js +3 -3
- package/dist/cjs/src/generate/openapi/zustandBindings.js +27 -0
- package/dist/esm/src/bin/helpers/printControllerHierarchy.js +168 -0
- package/dist/esm/src/bin/index.js +12 -13
- package/dist/esm/src/cli/index.js +69 -5
- package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +8 -8
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +3 -3
- package/dist/esm/src/generate/helpers/zustandBindings/printFinalStepsMessage.js +47 -0
- package/dist/esm/src/generate/helpers/zustandBindings/promptForOptions.js +61 -0
- package/dist/esm/src/generate/helpers/zustandBindings/writeClientConfigFile.js +43 -0
- package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +46 -0
- package/dist/esm/src/generate/initializer/syncEnums.js +3 -3
- package/dist/esm/src/generate/openapi/zustandBindings.js +27 -0
- package/dist/types/src/bin/helpers/printControllerHierarchy.d.ts +35 -0
- package/dist/types/src/bin/index.d.ts +2 -0
- package/dist/types/src/generate/helpers/zustandBindings/printFinalStepsMessage.d.ts +2 -0
- package/dist/types/src/generate/helpers/zustandBindings/promptForOptions.d.ts +2 -0
- package/dist/types/src/generate/helpers/zustandBindings/writeClientConfigFile.d.ts +3 -0
- package/dist/types/src/generate/helpers/zustandBindings/writeInitializer.d.ts +5 -0
- package/dist/types/src/generate/openapi/zustandBindings.d.ts +21 -0
- package/package.json +13 -16
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import colors from 'yoctocolors';
|
|
2
|
+
import PsychicController from '../../controller/index.js';
|
|
3
|
+
import PsychicApp from '../../psychic-app/index.js';
|
|
4
|
+
const ROOT_CHILD_INDENT = 5;
|
|
5
|
+
const MIN_DASHES = 2;
|
|
6
|
+
export default function printControllerHierarchy(controllersPath) {
|
|
7
|
+
const lines = controllerHierarchyLines(controllersPath);
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
console.log(line);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function resolveControllerClasses(controllersPath) {
|
|
13
|
+
const psychicApp = PsychicApp.getOrFail();
|
|
14
|
+
const resolvedPath = controllersPath ?? psychicApp.paths.controllers;
|
|
15
|
+
const allControllers = psychicApp.controllers;
|
|
16
|
+
const controllerClasses = Object.values(allControllers).filter(ctrl => {
|
|
17
|
+
return ctrl.globalName.startsWith(globalPrefixFromPath(resolvedPath, psychicApp.paths.controllers));
|
|
18
|
+
});
|
|
19
|
+
return { controllerClasses, resolvedPath };
|
|
20
|
+
}
|
|
21
|
+
export function controllerHierarchyLines(controllersPath) {
|
|
22
|
+
const { controllerClasses, resolvedPath } = resolveControllerClasses(controllersPath);
|
|
23
|
+
const childrenMap = buildChildrenMap(controllerClasses);
|
|
24
|
+
const roots = controllerClasses.filter(ctrl => !controllerClasses.some(other => other !== ctrl && ctrl.prototype instanceof other));
|
|
25
|
+
if (roots.length === 0) {
|
|
26
|
+
return ['No controllers found.'];
|
|
27
|
+
}
|
|
28
|
+
const lines = [];
|
|
29
|
+
for (const root of roots) {
|
|
30
|
+
collectTreeLines(root, childrenMap, { depth: 0, displayColumn: 0, strippedPrefix: '', baseIndent: 0 }, lines, resolvedPath);
|
|
31
|
+
}
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
export function controllerHierarchyViolations(controllersPath) {
|
|
35
|
+
const { controllerClasses, resolvedPath } = resolveControllerClasses(controllersPath);
|
|
36
|
+
const violations = [];
|
|
37
|
+
for (const ctrl of controllerClasses) {
|
|
38
|
+
const parentClass = Object.getPrototypeOf(ctrl);
|
|
39
|
+
if (parentClass === PsychicController)
|
|
40
|
+
continue;
|
|
41
|
+
const violation = hierarchyViolation(ctrl.globalName, parentClass.globalName, resolvedPath);
|
|
42
|
+
if (violation) {
|
|
43
|
+
violations.push(violation);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return violations;
|
|
47
|
+
}
|
|
48
|
+
export function buildChildrenMap(controllerClasses) {
|
|
49
|
+
const childrenMap = new Map();
|
|
50
|
+
for (const ctrl of controllerClasses) {
|
|
51
|
+
const parent = Object.getPrototypeOf(ctrl);
|
|
52
|
+
if (!childrenMap.has(parent)) {
|
|
53
|
+
childrenMap.set(parent, []);
|
|
54
|
+
}
|
|
55
|
+
childrenMap.get(parent).push(ctrl);
|
|
56
|
+
}
|
|
57
|
+
return childrenMap;
|
|
58
|
+
}
|
|
59
|
+
export function controllerTreeLine(displayedName, depth, displayColumn, baseIndent) {
|
|
60
|
+
if (depth === 0) {
|
|
61
|
+
return colors.cyan(displayedName);
|
|
62
|
+
}
|
|
63
|
+
const numDashes = Math.max(MIN_DASHES, displayColumn - baseIndent - 2);
|
|
64
|
+
const indentation = ' '.repeat(baseIndent);
|
|
65
|
+
const dashes = '─'.repeat(numDashes);
|
|
66
|
+
return `${indentation}└${dashes} ${colors.cyan(displayedName)}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Computes the actual column where the name starts on screen,
|
|
70
|
+
* accounting for MIN_DASHES potentially pushing the name further right.
|
|
71
|
+
*/
|
|
72
|
+
export function actualDisplayColumn(displayColumn, depth, baseIndent) {
|
|
73
|
+
if (depth === 0)
|
|
74
|
+
return displayColumn;
|
|
75
|
+
const numDashes = Math.max(MIN_DASHES, displayColumn - baseIndent - 2);
|
|
76
|
+
return baseIndent + numDashes + 2;
|
|
77
|
+
}
|
|
78
|
+
export function sharedDirPrefix(a, b) {
|
|
79
|
+
const aParts = a.split('/');
|
|
80
|
+
const bParts = b.split('/');
|
|
81
|
+
let shared = '';
|
|
82
|
+
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
|
|
83
|
+
if (aParts[i] === bParts[i]) {
|
|
84
|
+
shared += aParts[i] + '/';
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return shared;
|
|
91
|
+
}
|
|
92
|
+
export function globalPrefixFromPath(path, defaultControllersPath) {
|
|
93
|
+
if (path === defaultControllersPath)
|
|
94
|
+
return 'controllers/';
|
|
95
|
+
const normalized = path.replace(/\/$/, '');
|
|
96
|
+
const defaultNormalized = defaultControllersPath.replace(/\/$/, '');
|
|
97
|
+
if (normalized.startsWith(defaultNormalized + '/')) {
|
|
98
|
+
const suffix = normalized.slice(defaultNormalized.length + 1);
|
|
99
|
+
return `controllers/${suffix}/`;
|
|
100
|
+
}
|
|
101
|
+
return 'controllers/';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns the directory portion of a globalName.
|
|
105
|
+
* e.g. "controllers/Admin/AuthedController" -> "controllers/Admin/"
|
|
106
|
+
* "controllers/ApplicationController" -> "controllers/"
|
|
107
|
+
*/
|
|
108
|
+
export function globalNameDir(globalName) {
|
|
109
|
+
const lastSlash = globalName.lastIndexOf('/');
|
|
110
|
+
if (lastSlash === -1)
|
|
111
|
+
return '';
|
|
112
|
+
return globalName.slice(0, lastSlash + 1);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Returns the parent directory of a directory path.
|
|
116
|
+
* e.g. "controllers/Admin/" -> "controllers/"
|
|
117
|
+
* "controllers/" -> ""
|
|
118
|
+
*/
|
|
119
|
+
export function parentOfDir(dir) {
|
|
120
|
+
const withoutTrailing = dir.slice(0, -1);
|
|
121
|
+
const lastSlash = withoutTrailing.lastIndexOf('/');
|
|
122
|
+
if (lastSlash === -1)
|
|
123
|
+
return '';
|
|
124
|
+
return withoutTrailing.slice(0, lastSlash + 1);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks whether a child controller violates the hierarchy convention.
|
|
128
|
+
* A controller should extend a controller in the same directory or the parent directory.
|
|
129
|
+
* Returns a warning message string if violated, or null if OK.
|
|
130
|
+
*/
|
|
131
|
+
export function hierarchyViolation(childGlobalName, parentGlobalName, controllersPath) {
|
|
132
|
+
const childDir = globalNameDir(childGlobalName);
|
|
133
|
+
const parentControllerDir = globalNameDir(parentGlobalName);
|
|
134
|
+
if (childDir === parentControllerDir || parentOfDir(childDir) === parentControllerDir) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const childFilePath = controllersPath + '/' + childGlobalName.replace(/^controllers\//, '') + '.ts';
|
|
138
|
+
return `[hierarchy violation: ${childFilePath} should extend a BaseController at its same level]`;
|
|
139
|
+
}
|
|
140
|
+
function collectTreeLines(controller, childrenMap, { depth, displayColumn, strippedPrefix, baseIndent, }, lines, controllersPath) {
|
|
141
|
+
const displayedName = controller.globalName.slice(strippedPrefix.length);
|
|
142
|
+
lines.push(controllerTreeLine(displayedName, depth, displayColumn, baseIndent));
|
|
143
|
+
// The actual column where the name appears on screen (may differ from displayColumn
|
|
144
|
+
// when MIN_DASHES pushes the name further right)
|
|
145
|
+
const effectiveDisplayColumn = actualDisplayColumn(displayColumn, depth, baseIndent);
|
|
146
|
+
if (depth > 0) {
|
|
147
|
+
const parentClass = Object.getPrototypeOf(controller);
|
|
148
|
+
if (parentClass !== PsychicController) {
|
|
149
|
+
const violation = hierarchyViolation(controller.globalName, parentClass.globalName, controllersPath);
|
|
150
|
+
if (violation) {
|
|
151
|
+
const warningIndent = ' '.repeat(effectiveDisplayColumn);
|
|
152
|
+
lines.push(warningIndent + colors.yellow(violation));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const childBaseIndent = depth === 0 ? ROOT_CHILD_INDENT : effectiveDisplayColumn - 1;
|
|
157
|
+
const children = childrenMap.get(controller) ?? [];
|
|
158
|
+
for (const child of children) {
|
|
159
|
+
const shared = sharedDirPrefix(controller.globalName, child.globalName);
|
|
160
|
+
const childDisplayColumn = effectiveDisplayColumn + shared.length - strippedPrefix.length;
|
|
161
|
+
collectTreeLines(child, childrenMap, {
|
|
162
|
+
depth: depth + 1,
|
|
163
|
+
displayColumn: childDisplayColumn,
|
|
164
|
+
strippedPrefix: shared,
|
|
165
|
+
baseIndent: childBaseIndent,
|
|
166
|
+
}, lines, controllersPath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -10,6 +10,7 @@ import PsychicApp from '../psychic-app/index.js';
|
|
|
10
10
|
import enumsFileStr from './helpers/enumsFileStr.js';
|
|
11
11
|
import generateRouteTypes from './helpers/generateRouteTypes.js';
|
|
12
12
|
import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
|
|
13
|
+
import printControllerHierarchy, { controllerHierarchyViolations, } from './helpers/printControllerHierarchy.js';
|
|
13
14
|
import printRoutes from './helpers/printRoutes.js';
|
|
14
15
|
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
15
16
|
export default class PsychicBin {
|
|
@@ -26,6 +27,12 @@ export default class PsychicBin {
|
|
|
26
27
|
static printRoutes() {
|
|
27
28
|
printRoutes();
|
|
28
29
|
}
|
|
30
|
+
static printControllerHierarchy(controllersPath) {
|
|
31
|
+
printControllerHierarchy(controllersPath);
|
|
32
|
+
}
|
|
33
|
+
static controllerHierarchyViolations(controllersPath) {
|
|
34
|
+
return controllerHierarchyViolations(controllersPath);
|
|
35
|
+
}
|
|
29
36
|
static async sync({ bypassDreamSync = false, schemaOnly = false, } = {}) {
|
|
30
37
|
if (!bypassDreamSync)
|
|
31
38
|
await DreamBin.sync(() => { }, { schemaOnly });
|
|
@@ -39,27 +46,19 @@ export default class PsychicBin {
|
|
|
39
46
|
await DreamCLI.spawn(psychicApp.psyCmd('post-sync'), {
|
|
40
47
|
onStdout: message => {
|
|
41
48
|
DreamCLI.logger.logContinueProgress(`[post-sync]` + ' ' + message, {
|
|
42
|
-
logPrefixColor: '
|
|
49
|
+
logPrefixColor: 'greenBright',
|
|
43
50
|
});
|
|
44
51
|
},
|
|
45
52
|
});
|
|
46
53
|
DreamCLI.logger.logEndProgress();
|
|
47
54
|
}
|
|
48
55
|
static async postSync() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await this.syncOpenapiTypescriptFiles();
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
console.error(error);
|
|
56
|
-
await CliFileWriter.revert();
|
|
57
|
-
}
|
|
56
|
+
await this.syncOpenapiJson();
|
|
57
|
+
await this.runCliHooksAndUpdatePsychicTypesFileWithOutput();
|
|
58
|
+
await this.syncOpenapiTypescriptFiles();
|
|
58
59
|
}
|
|
59
60
|
static async syncTypes() {
|
|
60
|
-
await
|
|
61
|
-
await new ASTPsychicTypesBuilder().build();
|
|
62
|
-
});
|
|
61
|
+
await new ASTPsychicTypesBuilder().build();
|
|
63
62
|
}
|
|
64
63
|
static openapiDiff() {
|
|
65
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', '
|
|
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.')
|
|
@@ -160,6 +199,31 @@ export default class PsychicCLI {
|
|
|
160
199
|
await generateSyncOpenapiTypescriptInitializer(openapiFilepath, outfile, initializerName);
|
|
161
200
|
process.exit();
|
|
162
201
|
});
|
|
202
|
+
program
|
|
203
|
+
.command('inspect:controller-hierarchy')
|
|
204
|
+
.alias('i:controller-hierarchy')
|
|
205
|
+
.description('Displays the inheritance hierarchy of all PsychicController classes in the project.')
|
|
206
|
+
.argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
|
|
207
|
+
.action(async (controllersPath) => {
|
|
208
|
+
await initializePsychicApp();
|
|
209
|
+
PsychicBin.printControllerHierarchy(controllersPath);
|
|
210
|
+
process.exit();
|
|
211
|
+
});
|
|
212
|
+
program
|
|
213
|
+
.command('check:controller-hierarchy')
|
|
214
|
+
.description('Checks that all controllers extend a controller in the same or parent directory. Exits with an error if any violations are found.')
|
|
215
|
+
.argument('[path]', 'the controllers directory to scan (defaults to the configured controllers path)')
|
|
216
|
+
.action(async (controllersPath) => {
|
|
217
|
+
await initializePsychicApp();
|
|
218
|
+
const violations = PsychicBin.controllerHierarchyViolations(controllersPath);
|
|
219
|
+
if (violations.length > 0) {
|
|
220
|
+
for (const violation of violations) {
|
|
221
|
+
console.error(violation);
|
|
222
|
+
}
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
process.exit();
|
|
226
|
+
});
|
|
163
227
|
program
|
|
164
228
|
.command('routes')
|
|
165
229
|
.description('Prints a list of routes defined by the application, including path arguments and the controller/action reached by the route.')
|
|
@@ -171,8 +235,8 @@ export default class PsychicCLI {
|
|
|
171
235
|
program
|
|
172
236
|
.command('sync')
|
|
173
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/`.")
|
|
174
|
-
.option('--ignore-errors')
|
|
175
|
-
.option('--schema-only')
|
|
238
|
+
.option('--ignore-errors', 'ignore integrity checks and continue sync', false)
|
|
239
|
+
.option('--schema-only', 'sync database schema types only', false)
|
|
176
240
|
.action(async (options) => {
|
|
177
241
|
await initializePsychicApp({ bypassDreamIntegrityChecks: options.ignoreErrors || options.schemaOnly });
|
|
178
242
|
await PsychicBin.sync(options);
|
|
@@ -211,7 +275,7 @@ export default class PsychicCLI {
|
|
|
211
275
|
program
|
|
212
276
|
.command('diff:openapi')
|
|
213
277
|
.description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
|
|
214
|
-
.option('-f
|
|
278
|
+
.option('-f, --fail-on-breaking', 'fail on spec changes that are breaking', false)
|
|
215
279
|
.action(async (options) => {
|
|
216
280
|
await initializePsychicApp();
|
|
217
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.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
}
|