@rvoh/psychic 2.0.4 → 2.2.0

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 (34) hide show
  1. package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +341 -0
  2. package/dist/cjs/src/bin/index.js +12 -9
  3. package/dist/cjs/src/cli/helpers/ASTBuilder.js +175 -0
  4. package/dist/cjs/src/cli/helpers/ASTPsychicTypesBuilder.js +59 -0
  5. package/dist/cjs/src/cli/index.js +22 -1
  6. package/dist/cjs/src/controller/index.js +1 -1
  7. package/dist/cjs/src/devtools/helpers/launchDevServer.js +1 -1
  8. package/dist/cjs/src/error/psychic-app/init-missing-package-manager.js +1 -1
  9. package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
  10. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
  11. package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  12. package/dist/cjs/src/openapi-renderer/app.js +1 -1
  13. package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +341 -0
  14. package/dist/esm/src/bin/index.js +12 -9
  15. package/dist/esm/src/cli/helpers/ASTBuilder.js +175 -0
  16. package/dist/esm/src/cli/helpers/ASTPsychicTypesBuilder.js +59 -0
  17. package/dist/esm/src/cli/index.js +22 -1
  18. package/dist/esm/src/controller/index.js +1 -1
  19. package/dist/esm/src/devtools/helpers/launchDevServer.js +1 -1
  20. package/dist/esm/src/error/psychic-app/init-missing-package-manager.js +1 -1
  21. package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
  22. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
  23. package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  24. package/dist/esm/src/openapi-renderer/app.js +1 -1
  25. package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +138 -0
  26. package/dist/types/src/bin/index.d.ts +3 -1
  27. package/dist/types/src/cli/helpers/ASTBuilder.d.ts +89 -0
  28. package/dist/types/src/cli/helpers/ASTPsychicTypesBuilder.d.ts +28 -0
  29. package/dist/types/src/controller/index.d.ts +1 -1
  30. package/dist/types/src/psychic-app/index.d.ts +28 -0
  31. package/package.json +19 -16
  32. package/dist/cjs/src/cli/helpers/TypesBuilder.js +0 -23
  33. package/dist/esm/src/cli/helpers/TypesBuilder.js +0 -23
  34. package/dist/types/src/cli/helpers/TypesBuilder.d.ts +0 -7
@@ -0,0 +1,341 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import * as cp from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import colorize from '../../cli/helpers/colorize.js';
6
+ /**
7
+ * Class-based OpenAPI specification diff tool
8
+ *
9
+ * Compares current OpenAPI specs against the head branch using oasdiff
10
+ *
11
+ * Example usages:
12
+ *
13
+ * Instance-based usage
14
+ * const diffTool = new OpenApiSpecDiff()
15
+ * diffTool.compare(openapiConfigs)
16
+ *
17
+ * Factory method usage
18
+ * const diffTool = OpenApiSpecDiff.create()
19
+ * diffTool.compare(openapiConfigs)
20
+ *
21
+ * Static method usage (backward compatibility)
22
+ * OpenApiSpecDiff.compare(openapiConfigs)
23
+ */
24
+ export class OpenApiSpecDiff {
25
+ /**
26
+ * The configuration for the oasdiff command
27
+ */
28
+ oasdiffConfig;
29
+ /**
30
+ * Compares a list of OpenAPI specifications between the current branch and the head branch.
31
+ *
32
+ * Uses `oasdiff` under the hood to detect breaking and non-breaking changes for each file,
33
+ * helping you review and validate API updates with confidence before merging.
34
+ *
35
+ * This tool only runs for configurations where `checkDiffs` is enabled.
36
+ *
37
+ * @param openapiConfigs - An array of tuples containing the OpenAPI file name and its configuration.
38
+ * @example
39
+ * ```ts
40
+ * const openapiConfigs: [string, PsychicOpenapiConfig][] = [
41
+ * ['openapi', { outputFilepath: 'openapi.json' }],
42
+ * ]
43
+ * OpenApiSpecDiff.compare(openapiConfigs)
44
+ * ```
45
+ */
46
+ compare(openapiConfigs) {
47
+ const results = [];
48
+ this.oasdiffConfig = this.getOasDiffConfig();
49
+ const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
50
+ DreamCLI.logger.logStartProgress(comparing, { logPrefixColor: 'cyanBright' });
51
+ DreamCLI.logger.logContinueProgress(`\n`, { logPrefixColor: 'cyanBright' });
52
+ for (const [openapiName, config] of openapiConfigs) {
53
+ const result = this.compareConfig(openapiName, config);
54
+ results.push(result);
55
+ }
56
+ this.processResults(results);
57
+ }
58
+ /**
59
+ * Checks if oasdiff is installed locally
60
+ */
61
+ hasOasDiffInstalled() {
62
+ try {
63
+ cp.execSync('oasdiff --version', { stdio: 'ignore' });
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Fetch the head branch of a remote using git remote show origin
72
+ * default to main if not set
73
+ */
74
+ getHeadBranch() {
75
+ let head = '';
76
+ const output = cp.execSync('git remote show origin', { encoding: 'utf-8' });
77
+ const lines = output.split('\n');
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ if (trimmed.startsWith('HEAD branch:')) {
81
+ head = trimmed.replace('HEAD branch:', '').trim() || 'main';
82
+ }
83
+ }
84
+ return head;
85
+ }
86
+ /**
87
+ * Validates that oasdiff is installed and builds the oasdiff config
88
+ */
89
+ getOasDiffConfig() {
90
+ const headBranch = this.getHeadBranch();
91
+ if (this.hasOasDiffInstalled()) {
92
+ DreamCLI.logger.logContinueProgress('🎉 oasdiff package found\n');
93
+ return {
94
+ command: 'oasdiff',
95
+ baseArgs: [],
96
+ headBranch,
97
+ };
98
+ }
99
+ throw new Error(`⚠️ oasdiff not found.
100
+
101
+ Install it via the instructions here:
102
+ https://github.com/tufin/oasdiff
103
+ `);
104
+ }
105
+ /**
106
+ * Runs oasdiff command and returns the output
107
+ */
108
+ runOasDiffCommand(subcommand, mainPath, currentPath, flags) {
109
+ if (!this.oasdiffConfig) {
110
+ throw new Error('OasDiff config not initialized');
111
+ }
112
+ const args = [...this.oasdiffConfig.baseArgs, subcommand, mainPath, currentPath];
113
+ if (flags && flags.length > 0) {
114
+ args.push(...flags.map(flag => `--${flag}`));
115
+ }
116
+ try {
117
+ const output = cp.execFileSync(this.oasdiffConfig.command, args, {
118
+ shell: true,
119
+ encoding: 'utf8',
120
+ cwd: process.cwd(),
121
+ stdio: 'pipe',
122
+ });
123
+ return output.trim();
124
+ }
125
+ catch (error) {
126
+ const errorOutput = error instanceof Error ? error.message : String(error);
127
+ return errorOutput;
128
+ }
129
+ }
130
+ /**
131
+ * Compares two OpenAPI files using oasdiff
132
+ */
133
+ compareSpecs(mainFilePath, currentFilePath) {
134
+ if (!this.oasdiffConfig) {
135
+ throw new Error('OasDiff config not initialized');
136
+ }
137
+ const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
138
+ const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
139
+ const breaking = breakingChanges && !breakingChanges.includes('Command failed')
140
+ ? breakingChanges.split('\n').filter(line => line.trim())
141
+ : [];
142
+ const changelog = changelogChanges && !changelogChanges.includes('Command failed')
143
+ ? changelogChanges.split('\n').filter(line => line.trim())
144
+ : [];
145
+ const failedToCompare = breakingChanges.includes('Command failed')
146
+ ? breakingChanges
147
+ : changelogChanges.includes('Command failed')
148
+ ? changelogChanges
149
+ : '';
150
+ return {
151
+ breaking,
152
+ changelog,
153
+ error: failedToCompare,
154
+ };
155
+ }
156
+ /**
157
+ * Creates a temporary file path for the head branch content
158
+ */
159
+ createTempFilePath(filePath) {
160
+ const tempFileName = `temp_main_${path.basename(filePath)}`;
161
+ return path.join(path.dirname(filePath), tempFileName);
162
+ }
163
+ /**
164
+ * Retrieves head branch content for a file
165
+ */
166
+ getHeadBranchContent(filePath) {
167
+ if (!this.oasdiffConfig) {
168
+ throw new Error('OasDiff config not initialized');
169
+ }
170
+ const branchRef = process.env.CI === '1' ? `origin/${this.oasdiffConfig.headBranch}` : this.oasdiffConfig.headBranch;
171
+ return cp.execSync(`git show ${branchRef}:${filePath}`, {
172
+ encoding: 'utf8',
173
+ cwd: process.cwd(),
174
+ });
175
+ }
176
+ /**
177
+ * Compares a single OpenAPI file against head branch
178
+ */
179
+ compareConfig(openapiName, config) {
180
+ const result = {
181
+ file: openapiName,
182
+ hasChanges: false,
183
+ breaking: [],
184
+ changelog: [],
185
+ };
186
+ const currentFilePath = config.outputFilepath;
187
+ if (!fs.existsSync(currentFilePath)) {
188
+ result.error = `File ${config.outputFilepath} does not exist in current branch`;
189
+ return result;
190
+ }
191
+ const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
192
+ const mainContent = this.getHeadBranchContent(config.outputFilepath);
193
+ fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
194
+ fs.writeFileSync(tempMainFilePath, mainContent);
195
+ try {
196
+ const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
197
+ result.breaking = breaking;
198
+ result.changelog = changelog;
199
+ result.hasChanges = breaking.length > 0 || changelog.length > 0;
200
+ result.error = error ?? '';
201
+ }
202
+ catch (error) {
203
+ result.error = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
204
+ }
205
+ finally {
206
+ if (fs.existsSync(tempMainFilePath)) {
207
+ fs.unlinkSync(tempMainFilePath);
208
+ }
209
+ }
210
+ return result;
211
+ }
212
+ /**
213
+ * Process and display the comparison results
214
+ */
215
+ processResults(results) {
216
+ let hasAnyChanges = false;
217
+ let hasBreakingChanges = false;
218
+ for (const result of results) {
219
+ if (result.error) {
220
+ this.logError(result);
221
+ }
222
+ else if (result.hasChanges) {
223
+ this.logChanges(result);
224
+ hasAnyChanges = true;
225
+ if (result.breaking.length > 0) {
226
+ this.logBreakingChanges(result);
227
+ hasBreakingChanges = true;
228
+ }
229
+ if (result.changelog.length > 0) {
230
+ this.logChangelog(result);
231
+ }
232
+ }
233
+ else {
234
+ this.logNoChanges(result);
235
+ }
236
+ }
237
+ this.logSummary(hasAnyChanges, hasBreakingChanges);
238
+ }
239
+ /**
240
+ * Log error for a comparison result
241
+ */
242
+ logError(result) {
243
+ const file = colorize(`❌ ${result.file}`, { color: 'whiteBright' });
244
+ const error = colorize(`${result.error}`, { color: 'redBright' });
245
+ DreamCLI.logger.logContinueProgress(`${file}: ${error}`, { logPrefixColor: 'redBright' });
246
+ }
247
+ /**
248
+ * Log changes for a comparison result
249
+ */
250
+ logChanges(result) {
251
+ const file = colorize(`${result.file}`, { color: 'whiteBright' });
252
+ const changes = colorize('HAS CHANGES', { color: 'yellowBright' });
253
+ DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'yellowBright' });
254
+ }
255
+ /**
256
+ * Log breaking changes for a comparison result
257
+ */
258
+ logBreakingChanges(result) {
259
+ DreamCLI.logger.logContinueProgress(` ${colorize(`🚨 BREAKING CHANGES:`, { color: 'redBright' })}`, {
260
+ logPrefixColor: 'redBright',
261
+ });
262
+ result.breaking.forEach(change => {
263
+ DreamCLI.logger.logContinueProgress(` ${colorize(`• ${change}`, { color: 'redBright' })}`, {
264
+ logPrefixColor: 'redBright',
265
+ });
266
+ });
267
+ }
268
+ /**
269
+ * Log changelog for a comparison result
270
+ */
271
+ logChangelog(result) {
272
+ DreamCLI.logger.logContinueProgress(` ${colorize(`📋 CHANGELOG:`, { color: 'blueBright' })}`, {
273
+ logPrefixColor: 'blueBright',
274
+ });
275
+ const changelogLines = result.changelog;
276
+ changelogLines.forEach(line => {
277
+ DreamCLI.logger.logContinueProgress(` ${colorize(line, { color: 'whiteBright' })}`, {
278
+ logPrefixBgColor: 'bgWhite',
279
+ logPrefixColor: 'white',
280
+ });
281
+ });
282
+ }
283
+ /**
284
+ * Log no changes for a comparison result
285
+ */
286
+ logNoChanges(result) {
287
+ const file = colorize(`${result.file}`, { color: 'whiteBright' });
288
+ const changes = colorize('No changes', { color: 'greenBright' });
289
+ DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'greenBright' });
290
+ }
291
+ /**
292
+ * Log final summary and handle exit conditions
293
+ */
294
+ logSummary(hasAnyChanges, hasBreakingChanges) {
295
+ DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
296
+ logPrefixColor: 'gray',
297
+ });
298
+ if (hasBreakingChanges) {
299
+ DreamCLI.logger.logContinueProgress(`${colorize(`🚨 CRITICAL:`, { color: 'redBright' })} ${colorize(`Breaking changes detected in current branch compared to ${this.oasdiffConfig?.headBranch}! Review before merging.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
300
+ DreamCLI.logger.logContinueProgress(`${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
301
+ logPrefixColor: 'gray',
302
+ });
303
+ DreamCLI.logger.logContinueProgress('\n'.repeat(5), {
304
+ logPrefixColor: 'gray',
305
+ });
306
+ throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
307
+ }
308
+ else if (hasAnyChanges) {
309
+ const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
310
+ DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
311
+ }
312
+ else {
313
+ const summary = colorize(`📊 Summary: All OpenAPI files in current branch are identical to ${this.oasdiffConfig?.headBranch} branch`, { color: 'green' });
314
+ DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'green' });
315
+ }
316
+ }
317
+ /**
318
+ * Static factory method for convenience
319
+ */
320
+ static create() {
321
+ return new OpenApiSpecDiff();
322
+ }
323
+ /**
324
+ * Static method to maintain compatibility with functional approach
325
+ */
326
+ static compare(openapiConfigs) {
327
+ const instance = OpenApiSpecDiff.create();
328
+ instance.compare(openapiConfigs);
329
+ }
330
+ }
331
+ export class BreakingChangesDetectedInOpenApiSpecError extends Error {
332
+ oasdiffConfig;
333
+ constructor(oasdiffConfig) {
334
+ super();
335
+ this.oasdiffConfig = oasdiffConfig;
336
+ this.name = 'BreakingChangesDetectedInOpenApiSpecError';
337
+ }
338
+ get message() {
339
+ return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
340
+ }
341
+ }
@@ -1,7 +1,7 @@
1
1
  import { CliFileWriter, DreamBin, DreamCLI } from '@rvoh/dream/system';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
- import TypesBuilder from '../cli/helpers/TypesBuilder.js';
4
+ import ASTPsychicTypesBuilder from '../cli/helpers/ASTPsychicTypesBuilder.js';
5
5
  import generateController from '../generate/controller.js';
6
6
  import generateResource from '../generate/resource.js';
7
7
  import isObject from '../helpers/isObject.js';
@@ -9,7 +9,9 @@ import OpenapiAppRenderer from '../openapi-renderer/app.js';
9
9
  import PsychicApp from '../psychic-app/index.js';
10
10
  import enumsFileStr from './helpers/enumsFileStr.js';
11
11
  import generateRouteTypes from './helpers/generateRouteTypes.js';
12
+ import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
12
13
  import printRoutes from './helpers/printRoutes.js';
14
+ export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
13
15
  export default class PsychicBin {
14
16
  static async generateController(controllerName, actions) {
15
17
  await generateController({
@@ -54,11 +56,15 @@ export default class PsychicBin {
54
56
  await CliFileWriter.revert();
55
57
  }
56
58
  }
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
- static async syncTypes(customTypes = undefined) {
59
- DreamCLI.logger.logStartProgress(`syncing types/psychic.ts...`);
60
- await TypesBuilder.sync(customTypes);
61
- DreamCLI.logger.logEndProgress();
59
+ static async syncTypes() {
60
+ await DreamCLI.logger.logProgress(`syncing types/psychic.ts...`, async () => {
61
+ await new ASTPsychicTypesBuilder().build();
62
+ });
63
+ }
64
+ static openapiDiff() {
65
+ const psychicApp = PsychicApp.getOrFail();
66
+ const openapiConfigsWithCheckDiffs = Object.entries(psychicApp.openapi).filter(([, config]) => config.checkDiffs);
67
+ OpenApiSpecDiff.compare(openapiConfigsWithCheckDiffs);
62
68
  }
63
69
  static async syncOpenapiTypescriptFiles() {
64
70
  DreamCLI.logger.logStartProgress(`syncing openapi types...`);
@@ -117,8 +123,5 @@ export default class PsychicBin {
117
123
  output = { ...output, ...res };
118
124
  }
119
125
  }
120
- if (Object.keys(output).length) {
121
- await PsychicBin.syncTypes(output);
122
- }
123
126
  }
124
127
  }
@@ -0,0 +1,175 @@
1
+ import { DreamApp } from '@rvoh/dream';
2
+ import * as path from 'node:path';
3
+ import ts from 'typescript';
4
+ const f = ts.factory;
5
+ /**
6
+ * @internal
7
+ *
8
+ * This is a base class, which is inherited by the ASTSchemaBuilder,
9
+ * the ASTKyselyCodegenEnhancer, and the ASTGlobalSchemaBuilder,
10
+ * each of which is responsible for building up the output of the various
11
+ * type files consumed by dream internally.
12
+ *
13
+ * This base class is just a container for common methods used by all
14
+ * classes.
15
+ */
16
+ export default class ASTBuilder {
17
+ /**
18
+ * @internal
19
+ *
20
+ * builds a new line, useful for injecting new lines into AST statements
21
+ */
22
+ newLine() {
23
+ return f.createIdentifier('\n');
24
+ }
25
+ /**
26
+ * @internal
27
+ *
28
+ * given an interface declaration, it will extrace the relevant property statement
29
+ * by the given property name.
30
+ */
31
+ getPropertyFromInterface(interfaceNode, propertyName) {
32
+ for (const member of interfaceNode.members) {
33
+ if (ts.isPropertySignature(member)) {
34
+ if (ts.isIdentifier(member.name) && member.name.text === propertyName) {
35
+ return member;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * @internal
43
+ *
44
+ * returns an array of string type literals which were extracted from
45
+ * either a type or type union, depending on what is provided
46
+ * for the typeAlias. this allows you to safely and easily collect
47
+ * an array of types given an alias
48
+ */
49
+ extractStringLiteralTypeNodesFromTypeOrUnion(typeAlias) {
50
+ const literals = [];
51
+ if (ts.isUnionTypeNode(typeAlias.type)) {
52
+ typeAlias.type.types.forEach(typeNode => {
53
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
54
+ literals.push(typeNode);
55
+ }
56
+ });
57
+ }
58
+ else if (ts.isLiteralTypeNode(typeAlias.type) && ts.isStringLiteral(typeAlias.type.literal)) {
59
+ literals.push(typeAlias.type);
60
+ }
61
+ return literals;
62
+ }
63
+ /**
64
+ * @internal
65
+ *
66
+ * returns an array of type literals which were extracted from
67
+ * either a type or type union, depending on what is provided
68
+ * for the typeAlias. this allows you to safely and easily collect
69
+ * an array of types given an alias
70
+ */
71
+ extractTypeNodesFromTypeOrUnion(typeAlias) {
72
+ const literals = [];
73
+ if (typeAlias.type && ts.isUnionTypeNode(typeAlias.type)) {
74
+ typeAlias.type.types.forEach(typeNode => {
75
+ literals.push(typeNode);
76
+ });
77
+ }
78
+ else if (typeAlias.type) {
79
+ literals.push(typeAlias.type);
80
+ }
81
+ return literals;
82
+ }
83
+ /**
84
+ * @internal
85
+ *
86
+ * returns the provided node iff
87
+ * a.) the node is an exported type alias
88
+ * b.) the exported name matches the provided name (or else there was no name provided)
89
+ *
90
+ * otherwise, returns null
91
+ */
92
+ exportedTypeAliasOrNull(node, exportName) {
93
+ if (ts.isTypeAliasDeclaration(node) &&
94
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
95
+ (!exportName ? true : node.name.text === exportName))
96
+ return node;
97
+ return null;
98
+ }
99
+ /**
100
+ * @internal
101
+ *
102
+ * returns the provided node iff
103
+ * a.) the node is an exported interface
104
+ * b.) the exported name matches the provided name (or else there was no name provided)
105
+ *
106
+ * otherwise, returns null
107
+ */
108
+ exportedInterfaceOrNull(node, exportName) {
109
+ if (ts.isInterfaceDeclaration(node) &&
110
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
111
+ (!exportName ? true : node.name.text === exportName))
112
+ return node;
113
+ return null;
114
+ }
115
+ /**
116
+ * @internal
117
+ *
118
+ * returns the path to the dream.globals.ts file
119
+ */
120
+ psychicTypesPath() {
121
+ const dreamApp = DreamApp.getOrFail();
122
+ return path.join(dreamApp.projectRoot, dreamApp.paths.types, 'psychic.ts');
123
+ }
124
+ /**
125
+ * @internal
126
+ *
127
+ * safely runs prettier against the provided output. If prettier
128
+ * is not installed, then the original output is returned
129
+ */
130
+ async prettier(output) {
131
+ try {
132
+ // dynamically, safely bring in prettier.
133
+ // ini the event that it fails, we will return the
134
+ // original output, unformatted, since prettier
135
+ // is technically not a real dependency of dream,
136
+ // though psychic and dream apps are provisioned
137
+ // with prettier by default, so this should usually work
138
+ const prettier = (await import('prettier')).default;
139
+ const results = await prettier.format(output, {
140
+ parser: 'typescript',
141
+ semi: false,
142
+ singleQuote: true,
143
+ tabWidth: 2,
144
+ lineWidth: 80,
145
+ });
146
+ return typeof results === 'string' ? results : output;
147
+ }
148
+ catch {
149
+ // intentional noop, we don't want to raise if prettier
150
+ // fails, since it is possible for the end user to not
151
+ // want to use prettier, and it is not a required peer
152
+ // dependency of dream
153
+ return output;
154
+ }
155
+ }
156
+ /**
157
+ * @internal
158
+ *
159
+ * given a type node, it will send back the first found generic
160
+ * provided to that type.
161
+ */
162
+ getFirstGenericType(node) {
163
+ if (ts.isTypeReferenceNode(node)) {
164
+ if (node.typeArguments && node.typeArguments.length > 0) {
165
+ return node.typeArguments[0];
166
+ }
167
+ }
168
+ else if (ts.isCallExpression(node)) {
169
+ if (node.typeArguments && node.typeArguments.length > 0) {
170
+ return node.typeArguments[0];
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ }
@@ -0,0 +1,59 @@
1
+ import { CliFileWriter, DreamCLI } from '@rvoh/dream/system';
2
+ import ts from 'typescript';
3
+ import PsychicApp from '../../psychic-app/index.js';
4
+ import ASTBuilder from './ASTBuilder.js';
5
+ const f = ts.factory;
6
+ /**
7
+ * Responsible for building dream globals, which can be found at
8
+ * types/dream.globals.ts.
9
+ *
10
+ * This class leverages internal AST building mechanisms built into
11
+ * typescript to manually build up object literals and interfaces
12
+ * for our app to consume.
13
+ */
14
+ export default class ASTPsychicTypesBuilder extends ASTBuilder {
15
+ async build() {
16
+ const logger = DreamCLI.logger;
17
+ const sourceFile = ts.createSourceFile('', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
18
+ await logger.logProgress('[psychic] building psychic types', async () => {
19
+ const output = await this.prettier(this.printStatements(this.buildPsychicTypes(), sourceFile));
20
+ await CliFileWriter.write(this.psychicTypesPath(), output);
21
+ });
22
+ }
23
+ /**
24
+ * @internal
25
+ *
26
+ * builds up the `export const psychicTypes = ...` statement within the types/psychic.ts
27
+ * file. It does this by leveraging low-level AST utils built into typescript
28
+ * to manually build up an object literal, cast it as a const, and write it to
29
+ * an exported variable.
30
+ */
31
+ buildPsychicTypes() {
32
+ const psychicApp = PsychicApp.getOrFail();
33
+ const psychicTypesObjectLiteral = f.createObjectLiteralExpression([
34
+ f.createPropertyAssignment(f.createIdentifier('openapiNames'), f.createArrayLiteralExpression(Object.keys(psychicApp.openapi).map(key => f.createStringLiteral(key)))),
35
+ ], true);
36
+ // add "as const" to the end of the schema object we
37
+ // have built before returning it
38
+ const constAssertion = f.createAsExpression(psychicTypesObjectLiteral, f.createKeywordTypeNode(ts.SyntaxKind.ConstKeyword));
39
+ const psychicTypesObjectLiteralConst = f.createVariableStatement(undefined, f.createVariableDeclarationList([
40
+ f.createVariableDeclaration(f.createIdentifier('psychicTypes'), undefined, undefined, constAssertion),
41
+ ], ts.NodeFlags.Const));
42
+ const defaultExportIdentifier = f.createIdentifier('psychicTypes');
43
+ const exportDefaultStatement = f.createExportDefault(defaultExportIdentifier);
44
+ return [psychicTypesObjectLiteralConst, this.newLine(), exportDefaultStatement];
45
+ }
46
+ /**
47
+ * @internal
48
+ *
49
+ * writes the compiled statements to string.
50
+ *
51
+ */
52
+ printStatements(statements, sourceFile) {
53
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, omitTrailingSemicolon: true });
54
+ const result = printer.printList(ts.ListFormat.SourceFileStatements, f.createNodeArray(statements), sourceFile);
55
+ // TODO: add autogenerate disclaimer
56
+ return `\
57
+ ${result}`;
58
+ }
59
+ }
@@ -1,5 +1,5 @@
1
1
  import { DreamCLI } from '@rvoh/dream/system';
2
- import PsychicBin from '../bin/index.js';
2
+ import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
3
3
  import generateController from '../generate/controller.js';
4
4
  import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
5
5
  import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
@@ -196,6 +196,27 @@ export default class PsychicCLI {
196
196
  await PsychicBin.syncOpenapiJson();
197
197
  process.exit();
198
198
  });
199
+ program
200
+ .command('diff:openapi')
201
+ .description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
202
+ .option('-f', '--fail-on-breaking', 'fail on spec changes that are breaking')
203
+ .action(async (options) => {
204
+ await initializePsychicApp();
205
+ try {
206
+ PsychicBin.openapiDiff();
207
+ }
208
+ catch (error) {
209
+ if (error instanceof BreakingChangesDetectedInOpenApiSpecError) {
210
+ if (options.failOnBreaking) {
211
+ console.error(error.message);
212
+ process.exit(1);
213
+ }
214
+ }
215
+ else {
216
+ throw error;
217
+ }
218
+ }
219
+ });
199
220
  }
200
221
  /**
201
222
  * @internal
@@ -131,7 +131,7 @@ export default class PsychicController {
131
131
  /**
132
132
  * @internal
133
133
  *
134
- * Used for displaying routes when running `yarn psy routes`
134
+ * Used for displaying routes when running `pnpm psy routes`
135
135
  * cli command
136
136
  */
137
137
  static get disaplayName() {
@@ -6,7 +6,7 @@ import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
6
6
  import PsychicApp from '../../psychic-app/index.js';
7
7
  const devServerProcesses = {};
8
8
  const debugEnabled = debuglog('psychic').enabled;
9
- export async function launchDevServer(key, { port = 3000, cmd = 'yarn client', timeout = 5000 } = {}) {
9
+ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000 } = {}) {
10
10
  if (devServerProcesses[key])
11
11
  return;
12
12
  if (debugEnabled)
@@ -10,7 +10,7 @@ within conf/app.ts, you must have a call to "#set('packageManager', '<YOUR_CHOSE
10
10
 
11
11
  // conf/app.ts
12
12
  export default async (psy: PsychicApp) => {
13
- psy.set('packageManager', 'yarn')
13
+ psy.set('packageManager', 'pnpm')
14
14
  }
15
15
  `;
16
16
  }