@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.
- package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +341 -0
- package/dist/cjs/src/bin/index.js +12 -9
- package/dist/cjs/src/cli/helpers/ASTBuilder.js +175 -0
- package/dist/cjs/src/cli/helpers/ASTPsychicTypesBuilder.js +59 -0
- package/dist/cjs/src/cli/index.js +22 -1
- package/dist/cjs/src/controller/index.js +1 -1
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +1 -1
- package/dist/cjs/src/error/psychic-app/init-missing-package-manager.js +1 -1
- package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
- package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/cjs/src/openapi-renderer/app.js +1 -1
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +341 -0
- package/dist/esm/src/bin/index.js +12 -9
- package/dist/esm/src/cli/helpers/ASTBuilder.js +175 -0
- package/dist/esm/src/cli/helpers/ASTPsychicTypesBuilder.js +59 -0
- package/dist/esm/src/cli/index.js +22 -1
- package/dist/esm/src/controller/index.js +1 -1
- package/dist/esm/src/devtools/helpers/launchDevServer.js +1 -1
- package/dist/esm/src/error/psychic-app/init-missing-package-manager.js +1 -1
- package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
- package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/esm/src/openapi-renderer/app.js +1 -1
- package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +138 -0
- package/dist/types/src/bin/index.d.ts +3 -1
- package/dist/types/src/cli/helpers/ASTBuilder.d.ts +89 -0
- package/dist/types/src/cli/helpers/ASTPsychicTypesBuilder.d.ts +28 -0
- package/dist/types/src/controller/index.d.ts +1 -1
- package/dist/types/src/psychic-app/index.d.ts +28 -0
- package/package.json +19 -16
- package/dist/cjs/src/cli/helpers/TypesBuilder.js +0 -23
- package/dist/esm/src/cli/helpers/TypesBuilder.js +0 -23
- 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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 `
|
|
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 = '
|
|
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)
|