@contractkit/plugin-bruno 1.2.0 → 1.4.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/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +15 -14
- package/CHANGELOG.md +26 -0
- package/README.md +7 -5
- package/coverage/clover.xml +395 -307
- package/coverage/coverage-final.json +3 -2
- package/coverage/index.html +19 -19
- package/coverage/src/codegen-bruno.ts.html +765 -177
- package/coverage/src/index.html +34 -19
- package/coverage/src/index.ts.html +658 -0
- package/coverage/tests/helpers.ts.html +16 -16
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-bruno.d.ts +41 -3
- package/dist/codegen-bruno.d.ts.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +216 -85
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-bruno.ts +236 -40
- package/src/index.ts +89 -51
- package/tests/codegen-bruno.test.ts +164 -24
package/src/codegen-bruno.ts
CHANGED
|
@@ -8,8 +8,21 @@ import type {
|
|
|
8
8
|
ContractRootNode,
|
|
9
9
|
ModelNode,
|
|
10
10
|
FieldNode,
|
|
11
|
+
IncrementalManifest,
|
|
12
|
+
IncrementalUnit,
|
|
13
|
+
IncrementalResult as IncrementalResultBase,
|
|
14
|
+
} from '@contractkit/core';
|
|
15
|
+
import {
|
|
16
|
+
resolveSecurity,
|
|
17
|
+
resolveModifiers,
|
|
18
|
+
SECURITY_NONE,
|
|
19
|
+
collectTransitiveModelRefs,
|
|
20
|
+
runIncrementalCodegen,
|
|
21
|
+
parseIncrementalManifest,
|
|
22
|
+
emptyIncrementalManifest,
|
|
23
|
+
hashFingerprint,
|
|
24
|
+
INCREMENTAL_MANIFEST_VERSION,
|
|
11
25
|
} from '@contractkit/core';
|
|
12
|
-
import { resolveSecurity, resolveModifiers, SECURITY_NONE } from '@contractkit/core';
|
|
13
26
|
import { basename } from 'path';
|
|
14
27
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
15
28
|
|
|
@@ -22,6 +35,13 @@ export interface OpenCollectionFile {
|
|
|
22
35
|
/** Manifest filename — tracks which files this plugin previously generated so subsequent runs can clean up only those, leaving any user-added files alone. */
|
|
23
36
|
export const MANIFEST_FILENAME = '.contractkit-bruno-manifest.json';
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Bumped whenever the codegen output shape changes in a way that should bust
|
|
40
|
+
* every per-op fingerprint. Mixed into the per-op fingerprint so a plugin
|
|
41
|
+
* upgrade forces full regeneration even when source `.ck` files are unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export const BRUNO_CODEGEN_VERSION = '1';
|
|
44
|
+
|
|
25
45
|
/** Subset of a security scheme sufficient for Bruno auth generation (non-HMAC). */
|
|
26
46
|
export interface BrunoSecurityScheme {
|
|
27
47
|
type: string; // "http" | "apiKey" | "oauth2" | "openIdConnect"
|
|
@@ -68,28 +88,76 @@ export interface OpenCollectionOptions {
|
|
|
68
88
|
environments?: Record<string, Record<string, unknown>>;
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
/** Per-operation bookkeeping computed up front: where the file lives, what to call it, and the YAML inputs needed to render or fingerprint it. */
|
|
92
|
+
interface OpEntry {
|
|
93
|
+
/** Stable identifier across runs — `<file>::<METHOD> <path>`. */
|
|
94
|
+
opKey: string;
|
|
95
|
+
/** Output path relative to the collection root (e.g. `users/get-user.yml`). */
|
|
96
|
+
relativePath: string;
|
|
97
|
+
/** Display name used inside the YAML `info:` block (alphabetized within its folder). */
|
|
98
|
+
requestName: string;
|
|
99
|
+
/** 1-based sequence within the request's containing folder, drives Bruno's UI ordering. */
|
|
100
|
+
seq: number;
|
|
101
|
+
route: OpRouteNode;
|
|
102
|
+
op: OpOperationNode;
|
|
103
|
+
root: OpRootNode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @deprecated Use {@link IncrementalManifest} from `@contractkit/core`. Re-exported here for backwards compatibility.
|
|
108
|
+
*/
|
|
109
|
+
export type BrunoManifest = IncrementalManifest;
|
|
110
|
+
|
|
111
|
+
/** Result of {@link generateOpenCollectionIncremental}. Renamed `skippedOpCount` for Bruno-specific clarity but otherwise the shared {@link IncrementalResultBase}. */
|
|
112
|
+
export interface IncrementalResult extends Omit<IncrementalResultBase, 'skippedUnitCount'> {
|
|
113
|
+
/** Number of ops whose codegen was skipped because their fingerprint matched. */
|
|
114
|
+
skippedOpCount: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
71
117
|
/**
|
|
72
118
|
* Generates an OpenCollection (https://spec.opencollection.com/) API collection
|
|
73
119
|
* from a set of operation roots. Produces opencollection.yml, an environment
|
|
74
120
|
* file, and one .yml request file per operation.
|
|
121
|
+
*
|
|
122
|
+
* This is the full-regeneration entry point — every file is rebuilt from scratch.
|
|
123
|
+
* For cache-aware incremental builds, use {@link generateOpenCollectionIncremental}.
|
|
75
124
|
*/
|
|
76
125
|
export function generateOpenCollection(roots: OpRootNode[], options: OpenCollectionOptions): OpenCollectionFile[] {
|
|
77
|
-
const
|
|
126
|
+
const result = generateOpenCollectionIncremental(roots, options, emptyManifest());
|
|
127
|
+
return result.filesToWrite;
|
|
128
|
+
}
|
|
78
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Cache-aware variant of {@link generateOpenCollection}. Skips re-rendering YAML
|
|
132
|
+
* for any op whose fingerprint matches the entry in `prevManifest`. The caller is
|
|
133
|
+
* responsible for emitting `filesToWrite`, deleting `deletedPaths`, and persisting
|
|
134
|
+
* `manifest` so the next run can match against it.
|
|
135
|
+
*
|
|
136
|
+
* Global files (collection root, env files, folder.yml) are always regenerated —
|
|
137
|
+
* they're cheap and depend on options the manifest doesn't fingerprint.
|
|
138
|
+
*/
|
|
139
|
+
export function generateOpenCollectionIncremental(
|
|
140
|
+
roots: OpRootNode[],
|
|
141
|
+
options: OpenCollectionOptions,
|
|
142
|
+
prevManifest: IncrementalManifest,
|
|
143
|
+
fileExists: (relativePath: string) => boolean = () => true,
|
|
144
|
+
): IncrementalResult {
|
|
79
145
|
const modelMap = buildModelMap(options.contractRoots ?? []);
|
|
80
146
|
const authOpts = options.auth;
|
|
81
147
|
const defaultScheme = authOpts?.defaultScheme ? authOpts.schemes?.[authOpts.defaultScheme] : undefined;
|
|
82
148
|
const randomExamples = options.randomExamples ?? false;
|
|
83
149
|
const includeInternal = options.includeInternal ?? true;
|
|
84
150
|
|
|
85
|
-
files
|
|
151
|
+
// ── Global files (collection root + env files + folder.yml per area/subarea) ──
|
|
152
|
+
const globalFiles: OpenCollectionFile[] = [];
|
|
153
|
+
globalFiles.push({
|
|
154
|
+
relativePath: 'opencollection.yml',
|
|
155
|
+
content: generateCollectionRoot(options.collectionName, defaultScheme),
|
|
156
|
+
});
|
|
86
157
|
for (const envFile of generateEnvFiles(options.environments, defaultScheme)) {
|
|
87
|
-
|
|
158
|
+
globalFiles.push(envFile);
|
|
88
159
|
}
|
|
89
|
-
// Manifest is appended at the end so it lists every generated path including itself.
|
|
90
160
|
|
|
91
|
-
// Roots are sorted by their top-level folder display name (then by subarea) so the
|
|
92
|
-
// emitted `seq:` numbers — which drive Bruno's UI ordering — line up alphabetically.
|
|
93
161
|
const sortedRoots = [...roots].sort((a, b) => {
|
|
94
162
|
const aArea = a.meta['area'] ?? deriveFolderName(a.file);
|
|
95
163
|
const bArea = b.meta['area'] ?? deriveFolderName(b.file);
|
|
@@ -98,12 +166,12 @@ export function generateOpenCollection(roots: OpRootNode[], options: OpenCollect
|
|
|
98
166
|
return (a.meta['subarea'] ?? '').localeCompare(b.meta['subarea'] ?? '');
|
|
99
167
|
});
|
|
100
168
|
|
|
169
|
+
const units: IncrementalUnit[] = [];
|
|
101
170
|
for (let rootIdx = 0; rootIdx < sortedRoots.length; rootIdx++) {
|
|
102
171
|
const root = sortedRoots[rootIdx]!;
|
|
103
172
|
const folder = root.meta['area'] ? slugifyName(root.meta['area']) : deriveFolderName(root.file);
|
|
104
173
|
const displayName = (root.meta['area'] ?? folder).charAt(0).toUpperCase() + (root.meta['area'] ?? folder).slice(1);
|
|
105
|
-
|
|
106
|
-
files.push({ relativePath: `${folder}/folder.yml`, content: generateFolderFile(displayName, rootIdx + 1) });
|
|
174
|
+
globalFiles.push({ relativePath: `${folder}/folder.yml`, content: generateFolderFile(displayName, rootIdx + 1) });
|
|
107
175
|
|
|
108
176
|
const subarea = root.meta['subarea'];
|
|
109
177
|
const subareaSlug = subarea ? slugifyName(subarea) : undefined;
|
|
@@ -111,52 +179,180 @@ export function generateOpenCollection(roots: OpRootNode[], options: OpenCollect
|
|
|
111
179
|
|
|
112
180
|
if (subareaSlug) {
|
|
113
181
|
const subareaDisplayName = subarea!.charAt(0).toUpperCase() + subarea!.slice(1);
|
|
114
|
-
|
|
182
|
+
globalFiles.push({ relativePath: `${requestDir}/folder.yml`, content: generateFolderFile(subareaDisplayName, 1) });
|
|
115
183
|
}
|
|
116
184
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
185
|
+
for (const entry of buildOpEntries(root, requestDir, includeInternal)) {
|
|
186
|
+
units.push({
|
|
187
|
+
key: entry.opKey,
|
|
188
|
+
fingerprint: computeOpFingerprint(entry, modelMap, defaultScheme, randomExamples),
|
|
189
|
+
render: () => [renderOpFile(entry, modelMap, defaultScheme, randomExamples)],
|
|
190
|
+
});
|
|
124
191
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = runIncrementalCodegen({
|
|
195
|
+
codegenVersion: BRUNO_CODEGEN_VERSION,
|
|
196
|
+
manifestFilename: MANIFEST_FILENAME,
|
|
197
|
+
prevManifest,
|
|
198
|
+
globalFiles,
|
|
199
|
+
units,
|
|
200
|
+
fileExists,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
filesToWrite: result.filesToWrite,
|
|
205
|
+
manifest: result.manifest,
|
|
206
|
+
deletedPaths: result.deletedPaths,
|
|
207
|
+
skippedOpCount: result.skippedUnitCount,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Build the ordered, alphabetized op entries for a single root. seq numbers are assigned in alphabetical order so the Bruno UI shows requests sorted by display name. */
|
|
212
|
+
function buildOpEntries(root: OpRootNode, requestDir: string, includeInternal: boolean): OpEntry[] {
|
|
213
|
+
const requests: Array<{ route: OpRouteNode; op: OpOperationNode; requestName: string }> = [];
|
|
214
|
+
for (const route of root.routes) {
|
|
215
|
+
for (const op of route.operations) {
|
|
216
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
217
|
+
requests.push({ route, op, requestName: op.name ?? route.path });
|
|
137
218
|
}
|
|
138
219
|
}
|
|
220
|
+
requests.sort((a, b) => a.requestName.localeCompare(b.requestName));
|
|
221
|
+
|
|
222
|
+
const entries: OpEntry[] = [];
|
|
223
|
+
let seq = 1;
|
|
224
|
+
for (const { route, op, requestName } of requests) {
|
|
225
|
+
const fileName = op.name ? `${slugifyName(op.name)}.yml` : `${op.method}-${sanitizePath(route.path)}.yml`;
|
|
226
|
+
entries.push({
|
|
227
|
+
opKey: `${root.file}::${op.method.toUpperCase()} ${route.path}`,
|
|
228
|
+
relativePath: `${requestDir}/${fileName}`,
|
|
229
|
+
requestName,
|
|
230
|
+
seq,
|
|
231
|
+
route,
|
|
232
|
+
op,
|
|
233
|
+
root,
|
|
234
|
+
});
|
|
235
|
+
seq++;
|
|
236
|
+
}
|
|
237
|
+
return entries;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Render a single op's request file (including any plugin-extension YAML override). */
|
|
241
|
+
function renderOpFile(
|
|
242
|
+
entry: OpEntry,
|
|
243
|
+
modelMap: Map<string, ModelNode>,
|
|
244
|
+
defaultScheme: BrunoSecurityScheme | undefined,
|
|
245
|
+
randomExamples: boolean,
|
|
246
|
+
): OpenCollectionFile {
|
|
247
|
+
let content = generateRequestFile(entry.route, entry.op, entry.requestName, entry.seq, modelMap, entry.root, defaultScheme, randomExamples);
|
|
248
|
+
const brunoExt = entry.op.pluginExtensions?.['bruno'];
|
|
249
|
+
const pluginOverride = brunoExt && typeof brunoExt === 'object' && !Array.isArray(brunoExt) ? brunoExt['template'] : undefined;
|
|
250
|
+
if (typeof pluginOverride === 'string') {
|
|
251
|
+
content = mergePluginFile(content, pluginOverride);
|
|
252
|
+
}
|
|
253
|
+
return { relativePath: entry.relativePath, content };
|
|
254
|
+
}
|
|
139
255
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
256
|
+
/** Compute a fingerprint covering every input that affects this op's rendered file. Stable across runs given identical inputs. */
|
|
257
|
+
function computeOpFingerprint(
|
|
258
|
+
entry: OpEntry,
|
|
259
|
+
modelMap: Map<string, ModelNode>,
|
|
260
|
+
defaultScheme: BrunoSecurityScheme | undefined,
|
|
261
|
+
randomExamples: boolean,
|
|
262
|
+
): string {
|
|
263
|
+
const referencedModels = collectTransitiveModelRefs(collectOpTypeNodes(entry.route, entry.op), modelMap);
|
|
264
|
+
const modelSnapshot: Record<string, unknown> = {};
|
|
265
|
+
for (const name of [...referencedModels].sort()) {
|
|
266
|
+
const m = modelMap.get(name);
|
|
267
|
+
if (m) modelSnapshot[name] = m;
|
|
268
|
+
}
|
|
269
|
+
// Only include the route fields this op actually depends on. We deliberately exclude
|
|
270
|
+
// `route.operations` so a change to a sibling op doesn't invalidate this op's cache.
|
|
271
|
+
const routeShape = {
|
|
272
|
+
path: entry.route.path,
|
|
273
|
+
params: entry.route.params ?? null,
|
|
274
|
+
modifiers: entry.route.modifiers ?? null,
|
|
275
|
+
security: entry.route.security ?? null,
|
|
276
|
+
description: entry.route.description ?? null,
|
|
277
|
+
};
|
|
278
|
+
return hashFingerprint({
|
|
279
|
+
v: BRUNO_CODEGEN_VERSION,
|
|
280
|
+
opKey: entry.opKey,
|
|
281
|
+
relativePath: entry.relativePath,
|
|
282
|
+
requestName: entry.requestName,
|
|
283
|
+
seq: entry.seq,
|
|
284
|
+
route: routeShape,
|
|
285
|
+
op: entry.op,
|
|
286
|
+
rootMeta: entry.root.meta,
|
|
287
|
+
rootFile: entry.root.file,
|
|
288
|
+
defaultScheme: defaultScheme ?? null,
|
|
289
|
+
randomExamples,
|
|
290
|
+
models: modelSnapshot,
|
|
144
291
|
});
|
|
292
|
+
}
|
|
145
293
|
|
|
146
|
-
|
|
294
|
+
/** Collect every ContractTypeNode that contributes to this op's rendered output. Used as the seed set for transitive model collection in {@link computeOpFingerprint}. */
|
|
295
|
+
function collectOpTypeNodes(route: OpRouteNode, op: OpOperationNode): ContractTypeNode[] {
|
|
296
|
+
const out: ContractTypeNode[] = [];
|
|
297
|
+
pushFromParamSource(route.params, out);
|
|
298
|
+
pushFromParamSource(op.query, out);
|
|
299
|
+
pushFromParamSource(op.headers, out);
|
|
300
|
+
if (op.request) {
|
|
301
|
+
for (const body of op.request.bodies) out.push(body.bodyType);
|
|
302
|
+
}
|
|
303
|
+
for (const resp of op.responses) {
|
|
304
|
+
if (resp.bodyType) out.push(resp.bodyType);
|
|
305
|
+
if (resp.headers) {
|
|
306
|
+
for (const h of resp.headers) out.push(h.type);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function pushFromParamSource(src: ParamSource | undefined, out: ContractTypeNode[]): void {
|
|
313
|
+
if (!src) return;
|
|
314
|
+
if (src.kind === 'params') {
|
|
315
|
+
for (const n of src.nodes) out.push(n.type);
|
|
316
|
+
} else if (src.kind === 'ref') {
|
|
317
|
+
out.push({ kind: 'ref', name: src.name } as ContractTypeNode);
|
|
318
|
+
} else if (src.kind === 'type') {
|
|
319
|
+
out.push(src.node);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Empty-state manifest, used when none has been written yet (or when the cache is being bypassed). */
|
|
324
|
+
export function emptyManifest(): IncrementalManifest {
|
|
325
|
+
return emptyIncrementalManifest(BRUNO_CODEGEN_VERSION);
|
|
147
326
|
}
|
|
148
327
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Parse a previously-written manifest. Accepts both v1 (`{ files: string[] }`) and v2
|
|
330
|
+
* (full {@link IncrementalManifest}) shapes. v1 manifests are returned with an empty
|
|
331
|
+
* `units` map so the next run treats every op as a cache miss while still cleaning up
|
|
332
|
+
* the tracked file list.
|
|
333
|
+
*
|
|
334
|
+
* Returns an empty manifest on any parse error so a stale/garbled file never blocks
|
|
335
|
+
* regeneration.
|
|
336
|
+
*/
|
|
337
|
+
export function parseManifest(content: string): IncrementalManifest {
|
|
338
|
+
const parsed = parseIncrementalManifest(content);
|
|
339
|
+
// The shared parser handles v2. If v2 parsing produced an empty manifest, fall
|
|
340
|
+
// through to a v1 shape ({ files: [...] }) and migrate it for cleanup purposes.
|
|
341
|
+
if (parsed.files.length > 0 || Object.keys(parsed.units).length > 0) return parsed;
|
|
151
342
|
try {
|
|
152
|
-
const
|
|
153
|
-
if (Array.isArray(
|
|
154
|
-
return
|
|
343
|
+
const raw = JSON.parse(content);
|
|
344
|
+
if (Array.isArray(raw?.files) && raw.files.every((f: unknown) => typeof f === 'string')) {
|
|
345
|
+
return {
|
|
346
|
+
version: INCREMENTAL_MANIFEST_VERSION,
|
|
347
|
+
codegenVersion: '',
|
|
348
|
+
files: raw.files as string[],
|
|
349
|
+
units: {},
|
|
350
|
+
};
|
|
155
351
|
}
|
|
156
352
|
} catch {
|
|
157
353
|
// fall through
|
|
158
354
|
}
|
|
159
|
-
return
|
|
355
|
+
return emptyManifest();
|
|
160
356
|
}
|
|
161
357
|
|
|
162
358
|
// ─── Plugin file merge ─────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolve, basename, dirname } from 'node:path';
|
|
2
2
|
import { existsSync, readFileSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { generateOpenCollectionIncremental, MANIFEST_FILENAME, parseManifest, emptyManifest } from './codegen-bruno.js';
|
|
4
4
|
import type { BrunoSecurityScheme } from './codegen-bruno.js';
|
|
5
|
-
import type { ContractKitPlugin } from '@contractkit/core';
|
|
5
|
+
import type { ContractKitPlugin, PluginContext, PluginValue, OpRootNode, ContractRootNode, IncrementalManifest } from '@contractkit/core';
|
|
6
6
|
|
|
7
7
|
/** Configuration accepted by the Bruno plugin, both via `contractkit.config.json` and `createBrunoPlugin`. */
|
|
8
8
|
export interface BrunoPluginConfig {
|
|
@@ -38,28 +38,39 @@ export interface BrunoPluginOptions extends BrunoPluginConfig {
|
|
|
38
38
|
|
|
39
39
|
// ─── Default export: loaded via plugins array, reads config from ctx.options ─
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Validates a `plugins.bruno` extension entry on an operation. The expected shape is
|
|
43
|
+
* `{ template?: string }`, where `template` is a YAML fragment to deep-merge into the
|
|
44
|
+
* generated request file (typically a `file://...` URL whose contents have already
|
|
45
|
+
* been loaded by the CLI resolver).
|
|
46
|
+
*/
|
|
47
|
+
export function validateBrunoExtension(value: PluginValue): { errors?: string[] } | void {
|
|
48
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
49
|
+
return { errors: [`expected an object, got ${describe(value)}`] };
|
|
50
|
+
}
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
for (const [key, val] of Object.entries(value)) {
|
|
53
|
+
if (key === 'template') {
|
|
54
|
+
if (typeof val !== 'string') errors.push(`'template' must be a string, got ${describe(val)}`);
|
|
55
|
+
} else {
|
|
56
|
+
errors.push(`unknown field '${key}' (allowed: template)`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return errors.length ? { errors } : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function describe(value: PluginValue): string {
|
|
63
|
+
if (value === null) return 'null';
|
|
64
|
+
if (Array.isArray(value)) return 'array';
|
|
65
|
+
return typeof value;
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
const plugin: ContractKitPlugin = {
|
|
42
69
|
name: 'bruno',
|
|
43
|
-
|
|
70
|
+
validateExtension: validateBrunoExtension,
|
|
44
71
|
async generateTargets({ opRoots, contractRoots }, ctx) {
|
|
45
72
|
const { auth, ...config } = ctx.options as BrunoPluginOptions;
|
|
46
|
-
|
|
47
|
-
const outDir = resolve(base, config.output ?? 'bruno-collection');
|
|
48
|
-
const collectionName = config.collectionName ?? basename(ctx.rootDir);
|
|
49
|
-
|
|
50
|
-
cleanupTrackedFiles(outDir);
|
|
51
|
-
|
|
52
|
-
const files = generateOpenCollection(opRoots, {
|
|
53
|
-
collectionName,
|
|
54
|
-
contractRoots,
|
|
55
|
-
auth,
|
|
56
|
-
randomExamples: config.randomExamples ?? true,
|
|
57
|
-
includeInternal: config.includeInternal,
|
|
58
|
-
environments: config.environments,
|
|
59
|
-
});
|
|
60
|
-
for (const { relativePath, content } of files) {
|
|
61
|
-
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
62
|
-
}
|
|
73
|
+
await runBrunoCodegen(opRoots, contractRoots, ctx, config, auth);
|
|
63
74
|
},
|
|
64
75
|
};
|
|
65
76
|
|
|
@@ -84,57 +95,83 @@ export function createBrunoPlugin(
|
|
|
84
95
|
): ContractKitPlugin {
|
|
85
96
|
return {
|
|
86
97
|
name: 'bruno',
|
|
87
|
-
|
|
98
|
+
validateExtension: validateBrunoExtension,
|
|
88
99
|
async generateTargets({ opRoots, contractRoots }, ctx) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
cleanupTrackedFiles(outDir);
|
|
94
|
-
|
|
95
|
-
const files = generateOpenCollection(opRoots, {
|
|
96
|
-
collectionName,
|
|
97
|
-
contractRoots,
|
|
98
|
-
auth,
|
|
99
|
-
randomExamples: config.randomExamples ?? true,
|
|
100
|
-
includeInternal: config.includeInternal,
|
|
101
|
-
environments: config.environments,
|
|
102
|
-
});
|
|
103
|
-
for (const { relativePath, content } of files) {
|
|
104
|
-
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
105
|
-
}
|
|
100
|
+
// The factory captures rootDir at creation time; ctx.rootDir may differ when the
|
|
101
|
+
// plugin is loaded via a config file, so respect the explicit one passed in here.
|
|
102
|
+
await runBrunoCodegen(opRoots, contractRoots, { ...ctx, rootDir }, config, auth);
|
|
106
103
|
},
|
|
107
104
|
};
|
|
108
105
|
}
|
|
109
106
|
|
|
110
107
|
/**
|
|
111
|
-
*
|
|
112
|
-
* the user added (custom .bru files, scripts, secrets, etc.) untouched.
|
|
108
|
+
* Shared orchestration used by both the default export and {@link createBrunoPlugin}.
|
|
113
109
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
110
|
+
* Reads the prior manifest, runs the cache-aware codegen, deletes any files that
|
|
111
|
+
* are no longer produced, and emits the changed files plus the new manifest. When
|
|
112
|
+
* `ctx.cacheEnabled` is `false` (e.g. `--force`) the prior manifest is ignored so
|
|
113
|
+
* every op regenerates.
|
|
116
114
|
*/
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
async function runBrunoCodegen(
|
|
116
|
+
opRoots: OpRootNode[],
|
|
117
|
+
contractRoots: ContractRootNode[],
|
|
118
|
+
ctx: PluginContext,
|
|
119
|
+
config: BrunoPluginConfig,
|
|
120
|
+
auth: BrunoPluginOptions['auth'],
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const base = config.baseDir ? resolve(ctx.rootDir, config.baseDir) : ctx.rootDir;
|
|
123
|
+
const outDir = resolve(base, config.output ?? 'bruno-collection');
|
|
124
|
+
const collectionName = config.collectionName ?? basename(ctx.rootDir);
|
|
125
|
+
|
|
126
|
+
const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(outDir) : emptyManifest();
|
|
127
|
+
|
|
128
|
+
const result = generateOpenCollectionIncremental(
|
|
129
|
+
opRoots,
|
|
130
|
+
{
|
|
131
|
+
collectionName,
|
|
132
|
+
contractRoots,
|
|
133
|
+
auth,
|
|
134
|
+
randomExamples: config.randomExamples ?? true,
|
|
135
|
+
includeInternal: config.includeInternal,
|
|
136
|
+
environments: config.environments,
|
|
137
|
+
},
|
|
138
|
+
prevManifest,
|
|
139
|
+
relPath => existsSync(resolve(outDir, relPath)),
|
|
140
|
+
);
|
|
120
141
|
|
|
121
|
-
|
|
142
|
+
deleteStalePaths(outDir, result.deletedPaths);
|
|
143
|
+
|
|
144
|
+
for (const { relativePath, content } of result.filesToWrite) {
|
|
145
|
+
ctx.emitFile(resolve(outDir, relativePath), content);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Read the previous run's manifest. Returns an empty manifest when the file is missing or unreadable so the next run safely starts from scratch. */
|
|
150
|
+
function readManifest(outDir: string): IncrementalManifest {
|
|
151
|
+
const manifestPath = resolve(outDir, MANIFEST_FILENAME);
|
|
152
|
+
if (!existsSync(manifestPath)) return emptyManifest();
|
|
122
153
|
try {
|
|
123
|
-
|
|
154
|
+
return parseManifest(readFileSync(manifestPath, 'utf-8'));
|
|
124
155
|
} catch {
|
|
125
|
-
return;
|
|
156
|
+
return emptyManifest();
|
|
126
157
|
}
|
|
158
|
+
}
|
|
127
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Delete files that the previous run tracked but the current run doesn't produce, then
|
|
162
|
+
* walk back up each affected directory and remove it if it's now empty (stopping at outDir).
|
|
163
|
+
* Anything user-added survives because the manifest only ever lists plugin-generated paths.
|
|
164
|
+
*/
|
|
165
|
+
function deleteStalePaths(outDir: string, relPaths: string[]): void {
|
|
166
|
+
if (relPaths.length === 0) return;
|
|
128
167
|
const removedDirs = new Set<string>();
|
|
129
|
-
for (const rel of
|
|
168
|
+
for (const rel of relPaths) {
|
|
130
169
|
const abs = resolve(outDir, rel);
|
|
131
170
|
if (existsSync(abs)) {
|
|
132
171
|
rmSync(abs, { force: true });
|
|
133
172
|
removedDirs.add(dirname(abs));
|
|
134
173
|
}
|
|
135
174
|
}
|
|
136
|
-
|
|
137
|
-
// Walk up from each affected directory and remove it if empty, stopping at outDir.
|
|
138
175
|
for (const dir of removedDirs) {
|
|
139
176
|
let current = dir;
|
|
140
177
|
while (current.startsWith(outDir) && current !== outDir) {
|
|
@@ -151,3 +188,4 @@ function cleanupTrackedFiles(outDir: string): void {
|
|
|
151
188
|
}
|
|
152
189
|
}
|
|
153
190
|
}
|
|
191
|
+
|