@readme/cli 0.1.0 → 0.3.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/bin/git-format.js +80 -0
- package/bin/readme.js +1 -1
- package/package.json +54 -26
- package/src/bootstrap.js +9 -13
- package/src/cli.js +17 -11
- package/src/commands/components.js +35 -18
- package/src/commands/dev.js +12 -8
- package/src/commands/eyes.js +12 -8
- package/src/commands/import.js +2200 -2049
- package/src/commands/lint.js +33 -29
- package/src/commands/oas-bundle.js +6 -2
- package/src/commands/oas-sync.js +271 -161
- package/src/commands/oas-validate.js +10 -7
- package/src/commands/pretty.js +188 -165
- package/src/commands/setup.js +24 -26
- package/src/commands/versions.js +6 -2
- package/src/dev/app/[...slug]/page.js +1 -0
- package/src/dev/app/layout.js +18 -11
- package/src/dev/app/page.js +1 -0
- package/src/dev/lib/docs.js +27 -19
- package/src/dev/next.config.mjs +2 -2
- package/src/gha.js +7 -7
- package/src/prompts/index.js +16 -6
- package/src/utils/containment.js +87 -0
- package/src/utils/eyes.js +143 -51
- package/src/utils/git.js +3 -3
- package/src/utils/lint.js +39 -38
- package/src/utils/llms.js +94 -91
- package/src/utils/markdown.js +256 -0
- package/src/utils/marketplace.js +12 -5
- package/src/utils/multi-spinner.js +5 -7
- package/src/utils/promise-pool.js +7 -5
- package/src/utils/reporter.js +88 -94
- package/src/utils/setup-templates.js +12 -6
- package/src/utils/styles.js +2 -1
- package/src/utils/tamagotchi.js +257 -91
- package/src/utils/tips.js +18 -9
- package/src/utils/url-segs.js +46 -41
- package/src/validators/components.js +55 -63
- package/src/validators/content.js +16 -14
- package/src/validators/duplicates.js +6 -2
- package/src/validators/frontmatter.js +21 -29
- package/src/validators/links.js +4 -8
- package/src/validators/oas-reference.js +25 -18
- package/src/validators/oas-schema.js +2 -0
- package/src/validators/ordering.js +5 -5
- package/src/validators/recipes.js +4 -12
- package/vendor/git-format/CLAUDE.md +352 -0
- package/vendor/git-format/frontmatter.schema.json +199 -0
- package/src/commands/import-oas.test.js +0 -277
- package/src/commands/import.classify.test.js +0 -172
- package/src/commands/import.landing.test.js +0 -217
- package/src/commands/import.slug-planner.test.js +0 -86
- package/src/commands/import.staging.test.js +0 -230
- package/src/commands/import.test.js +0 -382
- package/src/utils/llms.test.js +0 -302
- package/src/utils/url-segs.test.js +0 -239
package/src/commands/oas-sync.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import matter from 'gray-matter';
|
|
5
|
-
import * as styles from '../utils/styles.js';
|
|
6
|
-
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
import { makeContainment, PathEscapeError } from '../utils/containment.js';
|
|
7
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
8
|
+
import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
|
|
9
|
+
import * as styles from '../utils/styles.js';
|
|
10
10
|
|
|
11
11
|
export const command = 'oas:sync';
|
|
12
12
|
export const order = 2;
|
|
@@ -15,25 +15,31 @@ export const description = 'Sync reference pages with OpenAPI specs';
|
|
|
15
15
|
|
|
16
16
|
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
17
17
|
|
|
18
|
+
// js-yaml 5's CORE_SCHEMA omits YAML merge keys (`<<: *anchor`). OpenAPI specs commonly
|
|
19
|
+
// reuse path items via merges — keep that working when reading YAML specs.
|
|
20
|
+
const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Find OAS files at the root of reference/ (JSON or YAML).
|
|
21
24
|
*/
|
|
22
25
|
export function findOasFiles(refDir) {
|
|
23
|
-
const entries = fs.readdirSync(refDir);
|
|
26
|
+
const entries = fs.readdirSync(refDir, { withFileTypes: true });
|
|
24
27
|
const oasFiles = [];
|
|
25
28
|
|
|
26
29
|
for (const entry of entries) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
// `stat` follows links. A spec committed as a symlink would be read from
|
|
31
|
+
// outside the repo; skip it. `syncOas` refuses the link itself, including
|
|
32
|
+
// when it is the only spec, so this skip cannot turn into a silent success.
|
|
33
|
+
if (entry.isSymbolicLink() || !entry.isFile()) continue;
|
|
34
|
+
if (!/\.(json|yaml|yml)$/i.test(entry.name)) continue;
|
|
35
|
+
const filePath = path.join(refDir, entry.name);
|
|
30
36
|
|
|
31
37
|
try {
|
|
32
38
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
33
|
-
const parsed = entry.endsWith('.json') ? JSON.parse(raw) :
|
|
39
|
+
const parsed = entry.name.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
|
|
34
40
|
|
|
35
41
|
if (parsed && (parsed.openapi || parsed.swagger)) {
|
|
36
|
-
oasFiles.push({ filename: entry, spec: parsed });
|
|
42
|
+
oasFiles.push({ filename: entry.name, spec: parsed });
|
|
37
43
|
}
|
|
38
44
|
} catch {
|
|
39
45
|
// Skip files that can't be parsed.
|
|
@@ -139,7 +145,7 @@ function resolveXInternal(operation, spec) {
|
|
|
139
145
|
* `webhook POST newPet`) for lint to warn about.
|
|
140
146
|
*/
|
|
141
147
|
export function findIgnoredInternalExtensions(spec) {
|
|
142
|
-
const hasInternal =
|
|
148
|
+
const hasInternal = obj => {
|
|
143
149
|
const xReadme = obj?.['x-readme'];
|
|
144
150
|
return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme;
|
|
145
151
|
};
|
|
@@ -147,7 +153,10 @@ export function findIgnoredInternalExtensions(spec) {
|
|
|
147
153
|
const locations = [];
|
|
148
154
|
if (hasInternal(spec)) locations.push('root');
|
|
149
155
|
|
|
150
|
-
for (const [entries, isWebhook] of [
|
|
156
|
+
for (const [entries, isWebhook] of [
|
|
157
|
+
[spec?.paths, false],
|
|
158
|
+
[spec?.webhooks, true],
|
|
159
|
+
]) {
|
|
151
160
|
for (const [name, rawItem] of Object.entries(entries || {})) {
|
|
152
161
|
for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) {
|
|
153
162
|
if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue;
|
|
@@ -199,7 +208,7 @@ export function extractOperations(spec) {
|
|
|
199
208
|
summary: operation.summary || null,
|
|
200
209
|
description: operation.description || null,
|
|
201
210
|
// The platform groups by the first *non-empty* tag.
|
|
202
|
-
tag: (Array.isArray(operation.tags) && operation.tags.find(
|
|
211
|
+
tag: (Array.isArray(operation.tags) && operation.tags.find(t => t)) || null,
|
|
203
212
|
path: pathStr,
|
|
204
213
|
isWebhook,
|
|
205
214
|
xInternal: resolveXInternal(operation, spec),
|
|
@@ -222,25 +231,23 @@ export function collectExistingPages(refDir) {
|
|
|
222
231
|
const pages = [];
|
|
223
232
|
|
|
224
233
|
function walk(dir) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
234
|
+
// Dirent, not `stat`: `stat` follows symlinks, so a directory link
|
|
235
|
+
// committed under reference/ would be walked and its pages later unlinked.
|
|
236
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
237
|
+
if (entry.isSymbolicLink()) continue;
|
|
238
|
+
const full = path.join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory()) {
|
|
229
240
|
walk(full);
|
|
230
|
-
} else if (entry.endsWith('.md')) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
} catch {
|
|
243
|
-
// Skip unparseable files.
|
|
241
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
242
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
243
|
+
const { data } = parseFrontmatter(content);
|
|
244
|
+
if (data.api && data.api.file && data.api.operationId) {
|
|
245
|
+
pages.push({
|
|
246
|
+
filePath: full,
|
|
247
|
+
relativePath: path.relative(refDir, full),
|
|
248
|
+
data,
|
|
249
|
+
content,
|
|
250
|
+
});
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
253
|
}
|
|
@@ -259,36 +266,55 @@ function yamlSafeSlug(slug) {
|
|
|
259
266
|
function parseOrderYaml(content) {
|
|
260
267
|
return content
|
|
261
268
|
.split('\n')
|
|
262
|
-
.map(
|
|
263
|
-
.filter(
|
|
264
|
-
.map(
|
|
269
|
+
.map(line => line.trim())
|
|
270
|
+
.filter(line => line.startsWith('- '))
|
|
271
|
+
.map(line =>
|
|
272
|
+
line
|
|
273
|
+
.slice(2)
|
|
274
|
+
.trim()
|
|
275
|
+
.replace(/^["'](.+)["']$/, '$1'),
|
|
276
|
+
);
|
|
265
277
|
}
|
|
266
278
|
|
|
267
|
-
function writeOrderYaml(filePath, slugs) {
|
|
268
|
-
|
|
279
|
+
function writeOrderYaml(filePath, slugs, assertContained) {
|
|
280
|
+
assertContained(filePath);
|
|
281
|
+
const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
|
|
269
282
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
270
283
|
fs.writeFileSync(filePath, content);
|
|
271
284
|
}
|
|
272
285
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
} else {
|
|
282
|
-
writeOrderYaml(orderPath, [slug]);
|
|
286
|
+
// `assertContained` allows a path that does not exist yet, so lstat first to
|
|
287
|
+
// tell an absent order file from one that must be refused before it is read.
|
|
288
|
+
function readOrderIfExists(orderPath, assertContained) {
|
|
289
|
+
try {
|
|
290
|
+
fs.lstatSync(orderPath);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (err.code === 'ENOENT') return null;
|
|
293
|
+
throw err;
|
|
283
294
|
}
|
|
295
|
+
assertContained(orderPath);
|
|
296
|
+
return fs.readFileSync(orderPath, 'utf-8');
|
|
284
297
|
}
|
|
285
298
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
299
|
+
function addToOrder(orderPath, slug, assertContained) {
|
|
300
|
+
const content = readOrderIfExists(orderPath, assertContained);
|
|
301
|
+
if (content === null) {
|
|
302
|
+
writeOrderYaml(orderPath, [slug], assertContained);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const slugs = parseOrderYaml(content);
|
|
306
|
+
if (!slugs.includes(slug)) {
|
|
307
|
+
slugs.push(slug);
|
|
308
|
+
writeOrderYaml(orderPath, slugs, assertContained);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function removeFromOrder(orderPath, slug, assertContained) {
|
|
313
|
+
const content = readOrderIfExists(orderPath, assertContained);
|
|
314
|
+
if (content === null) return;
|
|
315
|
+
const slugs = parseOrderYaml(content).filter(s => s !== slug);
|
|
290
316
|
if (slugs.length > 0) {
|
|
291
|
-
writeOrderYaml(orderPath, slugs);
|
|
317
|
+
writeOrderYaml(orderPath, slugs, assertContained);
|
|
292
318
|
} else {
|
|
293
319
|
fs.unlinkSync(orderPath);
|
|
294
320
|
}
|
|
@@ -304,21 +330,33 @@ function safeSegment(value, fallback) {
|
|
|
304
330
|
return !segment || segment === '.' || segment === '..' ? fallback : segment;
|
|
305
331
|
}
|
|
306
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Lexical "is `target` inside `baseDir`?" for placement decisions about
|
|
335
|
+
* pages the walk already found without following links. Not a write guard —
|
|
336
|
+
* writes go through `assertContained`, which refuses symlinks. `path.relative`
|
|
337
|
+
* alone treats a symlink under reference/ as inside the repo.
|
|
338
|
+
*/
|
|
307
339
|
function isWithin(baseDir, target) {
|
|
308
340
|
const rel = path.relative(baseDir, target);
|
|
309
|
-
return (
|
|
310
|
-
rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
|
|
311
|
-
);
|
|
341
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
312
342
|
}
|
|
313
343
|
|
|
314
344
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* so trim it to match.
|
|
345
|
+
* Throw if any symlink exists under `refDir`. Run before the first mutation:
|
|
346
|
+
* a link discovered halfway through the sync would otherwise leave the
|
|
347
|
+
* in-repo half of the run applied. Does not follow links.
|
|
319
348
|
*/
|
|
320
|
-
function
|
|
321
|
-
|
|
349
|
+
function refuseSymlinks(refDir) {
|
|
350
|
+
const walk = dir => {
|
|
351
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
352
|
+
const full = path.join(dir, entry.name);
|
|
353
|
+
if (entry.isSymbolicLink()) {
|
|
354
|
+
throw new PathEscapeError(full, 'symbolic links inside the reference directory are not followed');
|
|
355
|
+
}
|
|
356
|
+
if (entry.isDirectory()) walk(full);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
walk(refDir);
|
|
322
360
|
}
|
|
323
361
|
|
|
324
362
|
function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
|
|
@@ -339,7 +377,7 @@ function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false
|
|
|
339
377
|
hidden,
|
|
340
378
|
};
|
|
341
379
|
|
|
342
|
-
return
|
|
380
|
+
return toFrontmatter(frontmatter);
|
|
343
381
|
}
|
|
344
382
|
|
|
345
383
|
/**
|
|
@@ -354,7 +392,7 @@ function buildTagIndexContent(title, description) {
|
|
|
354
392
|
// As with operation pages, upload always stamps hidden: false on new pages.
|
|
355
393
|
frontmatter.hidden = false;
|
|
356
394
|
|
|
357
|
-
return
|
|
395
|
+
return toFrontmatter(frontmatter);
|
|
358
396
|
}
|
|
359
397
|
|
|
360
398
|
/**
|
|
@@ -402,13 +440,16 @@ function operationGroup(op) {
|
|
|
402
440
|
*/
|
|
403
441
|
function existingFoldersBySlug(apiDir) {
|
|
404
442
|
const bySlug = new Map();
|
|
405
|
-
let entries;
|
|
406
443
|
try {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
444
|
+
// Caller has already refused a symlink at apiDir. lstat only distinguishes
|
|
445
|
+
// "not created yet" from a real directory `readdir` can list.
|
|
446
|
+
fs.lstatSync(apiDir);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (err.code === 'ENOENT') return bySlug; // apiDir doesn't exist yet — nothing to reuse.
|
|
449
|
+
throw err;
|
|
410
450
|
}
|
|
411
|
-
|
|
451
|
+
|
|
452
|
+
for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) {
|
|
412
453
|
if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name);
|
|
413
454
|
}
|
|
414
455
|
return bySlug;
|
|
@@ -432,20 +473,16 @@ function collectReferenceSlugs(refDir) {
|
|
|
432
473
|
const counts = new Map();
|
|
433
474
|
|
|
434
475
|
function walk(dir) {
|
|
435
|
-
for (const entry of fs.readdirSync(dir)) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch {
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
if (stat.isDirectory()) {
|
|
476
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
477
|
+
if (entry.isSymbolicLink()) continue;
|
|
478
|
+
|
|
479
|
+
const full = path.join(dir, entry.name);
|
|
480
|
+
if (entry.isDirectory()) {
|
|
444
481
|
walk(full);
|
|
445
|
-
} else if (entry.endsWith('.md')) {
|
|
482
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
446
483
|
// A folder's index.md contributes the folder name as a slug; any other
|
|
447
484
|
// page contributes its own filename.
|
|
448
|
-
const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md');
|
|
485
|
+
const slug = entry.name === 'index.md' ? path.basename(dir) : path.basename(entry.name, '.md');
|
|
449
486
|
takeSlug(counts, slug);
|
|
450
487
|
}
|
|
451
488
|
}
|
|
@@ -493,18 +530,19 @@ function reserveSlug(takenSlugs, base) {
|
|
|
493
530
|
/**
|
|
494
531
|
* Rewrite a page's frontmatter in place, keeping its body. `mutate` receives
|
|
495
532
|
* a copy of the parsed frontmatter and edits it. Returns true if the file
|
|
496
|
-
* changed.
|
|
497
|
-
* so mutating the returned `data` would poison later parses of that content.
|
|
533
|
+
* changed.
|
|
498
534
|
*/
|
|
499
|
-
function updateFrontmatter(filePath, mutate) {
|
|
535
|
+
function updateFrontmatter(filePath, mutate, assertContained) {
|
|
536
|
+
assertContained(filePath);
|
|
500
537
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
501
|
-
const parsed =
|
|
538
|
+
const parsed = parseFrontmatter(content);
|
|
539
|
+
// Refuse to rewrite from empty `data` when YAML failed to parse.
|
|
540
|
+
if (frontmatterWriteError(parsed)) return false;
|
|
502
541
|
const data = structuredClone(parsed.data);
|
|
503
542
|
mutate(data);
|
|
504
|
-
const next = parsed.content.trim()
|
|
505
|
-
? matter.stringify(parsed.content, data)
|
|
506
|
-
: stringifyFrontmatter(data);
|
|
543
|
+
const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
|
|
507
544
|
if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
|
|
545
|
+
assertContained(filePath);
|
|
508
546
|
fs.writeFileSync(filePath, next);
|
|
509
547
|
return true;
|
|
510
548
|
}
|
|
@@ -527,7 +565,7 @@ export function applyOASOrder(currentOrder, orderedSlugs) {
|
|
|
527
565
|
const order = [...new Set(currentOrder)];
|
|
528
566
|
if (!order.length) return desired;
|
|
529
567
|
|
|
530
|
-
const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(
|
|
568
|
+
const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(i => i > -1);
|
|
531
569
|
if (!slots.length) return [...order, ...desired];
|
|
532
570
|
|
|
533
571
|
const remaining = [...desired];
|
|
@@ -561,21 +599,17 @@ const INDEX_FILES = ['index.md', 'index.mdx', 'index.html'];
|
|
|
561
599
|
* deleting it. A stale page can be removed by hand; a deleted edit is gone.
|
|
562
600
|
*/
|
|
563
601
|
function isGeneratedTagIndex(indexPath, specDescriptions) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
parsed = matter(fs.readFileSync(indexPath, 'utf-8'));
|
|
567
|
-
} catch {
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
570
|
-
const { data, content } = parsed;
|
|
571
|
-
if (content.trim()) return false;
|
|
602
|
+
const { data, content, error } = parseFrontmatter(fs.readFileSync(indexPath, 'utf-8'));
|
|
603
|
+
if (error || content.trim()) return false;
|
|
572
604
|
if (typeof data.title !== 'string' || !('hidden' in data)) return false;
|
|
573
|
-
if (Object.keys(data).some(
|
|
605
|
+
if (Object.keys(data).some(k => !['title', 'hidden', 'excerpt'].includes(k))) return false;
|
|
574
606
|
if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false;
|
|
575
607
|
|
|
576
608
|
const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath)));
|
|
577
609
|
const titleSlug = slugifyFolder(data.title);
|
|
578
|
-
return
|
|
610
|
+
return (
|
|
611
|
+
dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug)
|
|
612
|
+
);
|
|
579
613
|
}
|
|
580
614
|
|
|
581
615
|
/**
|
|
@@ -585,23 +619,32 @@ function isGeneratedTagIndex(indexPath, specDescriptions) {
|
|
|
585
619
|
* by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the
|
|
586
620
|
* parent `_order.yaml` entry still applies).
|
|
587
621
|
*/
|
|
588
|
-
function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) {
|
|
589
|
-
|
|
622
|
+
function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained }) {
|
|
623
|
+
assertContained(dir);
|
|
590
624
|
try {
|
|
591
|
-
|
|
592
|
-
} catch {
|
|
593
|
-
return;
|
|
625
|
+
fs.lstatSync(dir);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
if (err.code === 'ENOENT') return;
|
|
628
|
+
throw err;
|
|
594
629
|
}
|
|
595
|
-
|
|
630
|
+
|
|
631
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
632
|
+
for (const entry of entries) {
|
|
633
|
+
assertContained(path.join(dir, entry.name));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const names = entries.map(e => e.name);
|
|
637
|
+
const others = names.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
|
|
596
638
|
if (others.length) return;
|
|
597
639
|
|
|
598
640
|
const parentDir = path.dirname(dir);
|
|
599
641
|
const slug = path.basename(dir);
|
|
600
|
-
const indexFile =
|
|
642
|
+
const indexFile = names.find(e => INDEX_FILES.includes(e));
|
|
601
643
|
|
|
602
644
|
if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
|
|
645
|
+
assertContained(dir);
|
|
603
646
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
604
|
-
removeFromOrder(path.join(parentDir, '_order.yaml'), slug);
|
|
647
|
+
removeFromOrder(path.join(parentDir, '_order.yaml'), slug, assertContained);
|
|
605
648
|
if (indexFile) {
|
|
606
649
|
releaseSlug(takenSlugs, slug);
|
|
607
650
|
changes.deleted.push(path.relative(refDir, path.join(dir, indexFile)));
|
|
@@ -611,8 +654,11 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
611
654
|
|
|
612
655
|
const from = path.join(dir, indexFile);
|
|
613
656
|
const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`);
|
|
657
|
+
assertContained(from);
|
|
658
|
+
assertContained(to);
|
|
614
659
|
if (fs.existsSync(to)) return;
|
|
615
660
|
fs.renameSync(from, to);
|
|
661
|
+
assertContained(dir);
|
|
616
662
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
617
663
|
changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) });
|
|
618
664
|
}
|
|
@@ -623,12 +669,9 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
623
669
|
* `takenSlugs` is the reference-wide set of slugs already in use; it is read and
|
|
624
670
|
* mutated so slugs stay unique across every spec processed in one sync run.
|
|
625
671
|
*/
|
|
626
|
-
function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
672
|
+
function syncOneOas(refDir, oasFilename, spec, takenSlugs, assertContained) {
|
|
627
673
|
const specOps = extractOperations(spec);
|
|
628
|
-
const infoTitle = safeSegment(
|
|
629
|
-
spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
|
|
630
|
-
'api',
|
|
631
|
-
);
|
|
674
|
+
const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
|
|
632
675
|
|
|
633
676
|
// Hyphen vs. space in a group's folder name is not a meaningful difference
|
|
634
677
|
// — "shipping-labels" and "shipping labels" are the same folder to a
|
|
@@ -637,18 +680,17 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
637
680
|
// folder for this API as an O(1) lookup, rather than walking apiDir again
|
|
638
681
|
// for each distinct tag/group.
|
|
639
682
|
const apiDir = path.join(refDir, infoTitle);
|
|
683
|
+
|
|
684
|
+
// Before any read of the target or any delete. A title directory committed
|
|
685
|
+
// as a symlink would otherwise be readdir'd (and later written) through.
|
|
686
|
+
assertContained(apiDir);
|
|
640
687
|
const foldersBySlug = existingFoldersBySlug(apiDir);
|
|
641
688
|
|
|
642
|
-
const existingPages = collectExistingPages(refDir).filter(
|
|
643
|
-
(p) => p.data.api.file === oasFilename,
|
|
644
|
-
);
|
|
689
|
+
const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
|
|
645
690
|
|
|
646
691
|
const pagesByOpId = new Map();
|
|
647
692
|
for (const page of existingPages) {
|
|
648
|
-
pagesByOpId.set(
|
|
649
|
-
operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
|
|
650
|
-
page,
|
|
651
|
-
);
|
|
693
|
+
pagesByOpId.set(operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }), page);
|
|
652
694
|
}
|
|
653
695
|
|
|
654
696
|
const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] };
|
|
@@ -661,9 +703,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
661
703
|
// Tag descriptions from the spec's top-level `tags` array, used for the
|
|
662
704
|
// per-tag category landing page (index.md).
|
|
663
705
|
const tagDescriptions = new Map(
|
|
664
|
-
(Array.isArray(spec.tags) ? spec.tags : [])
|
|
665
|
-
.filter((t) => t && t.name)
|
|
666
|
-
.map((t) => [t.name, t.description || null]),
|
|
706
|
+
(Array.isArray(spec.tags) ? spec.tags : []).filter(t => t && t.name).map(t => [t.name, t.description || null]),
|
|
667
707
|
);
|
|
668
708
|
// Every description the spec supplies, regardless of which tag: what an
|
|
669
709
|
// emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`).
|
|
@@ -672,6 +712,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
672
712
|
// Deletes: pages referencing operations that no longer exist.
|
|
673
713
|
for (const [opId, page] of pagesByOpId) {
|
|
674
714
|
if (!specOps.has(opId)) {
|
|
715
|
+
assertContained(page.filePath);
|
|
675
716
|
fs.unlinkSync(page.filePath);
|
|
676
717
|
|
|
677
718
|
const pageDir = path.dirname(page.filePath);
|
|
@@ -686,7 +727,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
686
727
|
const isIndexPage = path.basename(page.filePath) === 'index.md';
|
|
687
728
|
const pageSlug = path.basename(page.filePath, '.md');
|
|
688
729
|
const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
|
|
689
|
-
removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
|
|
730
|
+
removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug, assertContained);
|
|
690
731
|
releaseSlug(takenSlugs, referenceSlug);
|
|
691
732
|
|
|
692
733
|
changes.deleted.push(page.relativePath);
|
|
@@ -714,25 +755,49 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
714
755
|
// listed in `tags`) keeps its natural encounter order, appended after every
|
|
715
756
|
// declared tag.
|
|
716
757
|
const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
|
|
717
|
-
.filter(
|
|
718
|
-
.map(
|
|
758
|
+
.filter(t => t && t.name)
|
|
759
|
+
.map(t => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
|
|
719
760
|
const orderedFolders = [
|
|
720
|
-
...declaredOrder.filter(
|
|
721
|
-
...[...groupsByFolder.keys()].filter(
|
|
761
|
+
...declaredOrder.filter(folder => groupsByFolder.has(folder)),
|
|
762
|
+
...[...groupsByFolder.keys()].filter(folder => !declaredOrder.includes(folder)),
|
|
722
763
|
];
|
|
723
764
|
|
|
765
|
+
// Groups that will hold at least one of this spec's pages after this run:
|
|
766
|
+
// a new page is created in its folder, an existing page already lives
|
|
767
|
+
// anywhere under it (a direct child, or nested in a hand-made subfolder),
|
|
768
|
+
// or `apply-tag-changes` will move one in (see the move pass below). A group
|
|
769
|
+
// whose pages were all moved somewhere else (another category, or a folder
|
|
770
|
+
// spelled differently) gets no category page — creating one would leave an
|
|
771
|
+
// empty index.md with no children.
|
|
772
|
+
const populatedFolders = new Set();
|
|
773
|
+
for (const [key, op] of specOps) {
|
|
774
|
+
const { folder } = operationGroup(op);
|
|
775
|
+
const page = pagesByOpId.get(key);
|
|
776
|
+
const tagDir = path.join(apiDir, foldersBySlug.get(folder) || folder);
|
|
777
|
+
if (
|
|
778
|
+
!page ||
|
|
779
|
+
isWithin(tagDir, page.filePath) ||
|
|
780
|
+
(applyTagChanges && isWithin(apiDir, page.filePath) && path.basename(page.filePath) !== 'index.md')
|
|
781
|
+
) {
|
|
782
|
+
populatedFolders.add(folder);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
724
786
|
const groupDirs = new Map();
|
|
725
787
|
const createdIndexes = new Set();
|
|
726
788
|
for (const folder of orderedFolders) {
|
|
727
789
|
const { title, description } = groupsByFolder.get(folder);
|
|
728
790
|
const actualFolder = foldersBySlug.get(folder) || folder;
|
|
729
791
|
const pageDir = path.join(refDir, infoTitle, actualFolder);
|
|
730
|
-
|
|
731
|
-
groupDirs.set(folder, pageDir);
|
|
792
|
+
assertContained(pageDir);
|
|
732
793
|
|
|
733
794
|
const indexPath = path.join(pageDir, 'index.md');
|
|
795
|
+
if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
|
|
796
|
+
groupDirs.set(folder, pageDir);
|
|
797
|
+
|
|
734
798
|
if (!fs.existsSync(indexPath)) {
|
|
735
799
|
// Never overwrite an existing index.md — it may be a hand-written category.
|
|
800
|
+
assertContained(indexPath);
|
|
736
801
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
737
802
|
fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
|
|
738
803
|
changes.added.push(path.relative(refDir, indexPath));
|
|
@@ -745,15 +810,19 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
745
810
|
// With `apply-tag-changes`, the spec owns the category page's title and
|
|
746
811
|
// excerpt too (a tag with no description clears the excerpt). Its body
|
|
747
812
|
// and any other frontmatter are the user's and are kept.
|
|
748
|
-
const updated = updateFrontmatter(
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
813
|
+
const updated = updateFrontmatter(
|
|
814
|
+
indexPath,
|
|
815
|
+
data => {
|
|
816
|
+
data.title = title;
|
|
817
|
+
if (description) data.excerpt = description;
|
|
818
|
+
else delete data.excerpt;
|
|
819
|
+
},
|
|
820
|
+
assertContained,
|
|
821
|
+
);
|
|
753
822
|
if (updated) changes.updated.push(path.relative(refDir, indexPath));
|
|
754
823
|
}
|
|
755
|
-
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
|
|
756
|
-
addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
|
|
824
|
+
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder, assertContained);
|
|
825
|
+
addToOrder(path.join(refDir, '_order.yaml'), infoTitle, assertContained);
|
|
757
826
|
}
|
|
758
827
|
|
|
759
828
|
// Where each of this spec's operations ends up this run, by operationKey.
|
|
@@ -781,6 +850,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
781
850
|
path.dirname(filePath) !== targetDir
|
|
782
851
|
) {
|
|
783
852
|
const target = path.join(targetDir, path.basename(filePath));
|
|
853
|
+
assertContained(filePath);
|
|
854
|
+
assertContained(target);
|
|
784
855
|
if (fs.existsSync(target)) {
|
|
785
856
|
changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId });
|
|
786
857
|
} else {
|
|
@@ -788,8 +859,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
788
859
|
const slug = path.basename(filePath, '.md');
|
|
789
860
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
790
861
|
fs.renameSync(filePath, target);
|
|
791
|
-
removeFromOrder(path.join(fromDir, '_order.yaml'), slug);
|
|
792
|
-
addToOrder(path.join(targetDir, '_order.yaml'), slug);
|
|
862
|
+
removeFromOrder(path.join(fromDir, '_order.yaml'), slug, assertContained);
|
|
863
|
+
addToOrder(path.join(targetDir, '_order.yaml'), slug, assertContained);
|
|
793
864
|
vacatedDirs.add(fromDir);
|
|
794
865
|
changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) });
|
|
795
866
|
filePath = target;
|
|
@@ -801,9 +872,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
801
872
|
// its own `hidden` — removing the extension never unhides a page.
|
|
802
873
|
if (op.xInternal.present) {
|
|
803
874
|
const hidden = Boolean(op.xInternal.value);
|
|
804
|
-
const updated = updateFrontmatter(
|
|
805
|
-
|
|
806
|
-
|
|
875
|
+
const updated = updateFrontmatter(
|
|
876
|
+
filePath,
|
|
877
|
+
data => {
|
|
878
|
+
data.hidden = hidden;
|
|
879
|
+
},
|
|
880
|
+
assertContained,
|
|
881
|
+
);
|
|
807
882
|
if (updated) changes.updated.push(path.relative(refDir, filePath));
|
|
808
883
|
}
|
|
809
884
|
|
|
@@ -824,9 +899,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
824
899
|
const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
|
|
825
900
|
const pagePath = path.join(pageDir, `${slug}.md`);
|
|
826
901
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
|
|
902
|
+
// A spec-crafted name that still escapes reference/, or a symlink anywhere
|
|
903
|
+
// on the way there, fails the sync instead of being skipped quietly.
|
|
904
|
+
// reserveSlug already prevents slug collisions; an existing real file is
|
|
905
|
+
// the only skip left.
|
|
906
|
+
assertContained(pagePath);
|
|
907
|
+
if (fs.existsSync(pagePath)) {
|
|
830
908
|
changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
|
|
831
909
|
continue;
|
|
832
910
|
}
|
|
@@ -840,7 +918,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
840
918
|
});
|
|
841
919
|
fs.writeFileSync(pagePath, content);
|
|
842
920
|
|
|
843
|
-
addToOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
921
|
+
addToOrder(path.join(pageDir, '_order.yaml'), slug, assertContained);
|
|
844
922
|
|
|
845
923
|
changes.added.push(path.relative(refDir, pagePath));
|
|
846
924
|
opPaths.set(key, pagePath);
|
|
@@ -861,11 +939,15 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
861
939
|
const indexPath = path.join(dir, 'index.md');
|
|
862
940
|
if (!fs.existsSync(indexPath)) continue;
|
|
863
941
|
const isNew = createdIndexes.has(indexPath);
|
|
864
|
-
const allHidden = children.every(
|
|
942
|
+
const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
|
|
865
943
|
if (!allHidden) continue;
|
|
866
|
-
const updated = updateFrontmatter(
|
|
867
|
-
|
|
868
|
-
|
|
944
|
+
const updated = updateFrontmatter(
|
|
945
|
+
indexPath,
|
|
946
|
+
data => {
|
|
947
|
+
data.hidden = true;
|
|
948
|
+
},
|
|
949
|
+
assertContained,
|
|
950
|
+
);
|
|
869
951
|
if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
|
|
870
952
|
}
|
|
871
953
|
|
|
@@ -873,7 +955,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
873
955
|
// category folder, and never a folder an operation still lives in.
|
|
874
956
|
for (const dir of vacatedDirs) {
|
|
875
957
|
if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue;
|
|
876
|
-
cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes });
|
|
958
|
+
cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained });
|
|
877
959
|
}
|
|
878
960
|
|
|
879
961
|
// `apply-endpoint-order`: reorder each folder's operation pages to match
|
|
@@ -897,7 +979,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
897
979
|
const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : [];
|
|
898
980
|
const next = applyOASOrder(current, slugs);
|
|
899
981
|
if (next.join('\n') === current.join('\n')) continue;
|
|
900
|
-
writeOrderYaml(orderPath, next);
|
|
982
|
+
writeOrderYaml(orderPath, next, assertContained);
|
|
901
983
|
changes.updated.push(path.relative(refDir, orderPath));
|
|
902
984
|
}
|
|
903
985
|
}
|
|
@@ -916,7 +998,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
916
998
|
*
|
|
917
999
|
* Pure programmatic API: returns per-file change descriptors and prints
|
|
918
1000
|
* nothing. Used by the CLI command, the lint --fix flow, and external
|
|
919
|
-
* callers.
|
|
1001
|
+
* callers. Throws `PathEscapeError` if `reference/` contains a symlink
|
|
1002
|
+
* (before anything is modified) or a destination would leave it.
|
|
1003
|
+
* Callers report that and exit non-zero.
|
|
920
1004
|
*
|
|
921
1005
|
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
922
1006
|
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
@@ -925,13 +1009,22 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
925
1009
|
* Returns null if there's no reference/ dir or no specs.
|
|
926
1010
|
*/
|
|
927
1011
|
export function syncOas(input) {
|
|
928
|
-
const gitRoot = typeof input === 'string' ? input :
|
|
1012
|
+
const gitRoot = typeof input === 'string' ? input : input?.cwd || process.cwd();
|
|
929
1013
|
const refDir = path.join(gitRoot, 'reference');
|
|
930
1014
|
if (!fs.existsSync(refDir)) return null;
|
|
931
1015
|
|
|
1016
|
+
// Before the no-spec return and before any write. `findOasFiles` skips
|
|
1017
|
+
// symlink specs, so a reference/ whose only spec is a link would otherwise
|
|
1018
|
+
// report success and leave pages unsynced. Refusing first also keeps a later
|
|
1019
|
+
// real spec from being half-applied, and keeps a link the page walk would
|
|
1020
|
+
// have skipped from being deleted or written through.
|
|
1021
|
+
refuseSymlinks(refDir);
|
|
1022
|
+
|
|
932
1023
|
const oasFiles = findOasFiles(refDir);
|
|
933
1024
|
if (oasFiles.length === 0) return null;
|
|
934
1025
|
|
|
1026
|
+
const assertContained = makeContainment(refDir);
|
|
1027
|
+
|
|
935
1028
|
// Reference slugs share one flat namespace across every spec, so build the set
|
|
936
1029
|
// of in-use slugs once and let each spec read/extend it.
|
|
937
1030
|
const takenSlugs = collectReferenceSlugs(refDir);
|
|
@@ -939,7 +1032,7 @@ export function syncOas(input) {
|
|
|
939
1032
|
|
|
940
1033
|
for (const { filename, spec } of oasFiles) {
|
|
941
1034
|
const ops = extractOperations(spec);
|
|
942
|
-
const changes = syncOneOas(refDir, filename, spec, takenSlugs);
|
|
1035
|
+
const changes = syncOneOas(refDir, filename, spec, takenSlugs, assertContained);
|
|
943
1036
|
allChanges.push({ filename, spec, opCount: ops.size, changes });
|
|
944
1037
|
}
|
|
945
1038
|
|
|
@@ -977,7 +1070,9 @@ export function printSyncResults(results) {
|
|
|
977
1070
|
|
|
978
1071
|
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
979
1072
|
console.log();
|
|
980
|
-
console.log(
|
|
1073
|
+
console.log(
|
|
1074
|
+
` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`,
|
|
1075
|
+
);
|
|
981
1076
|
|
|
982
1077
|
if (!hasChanges) {
|
|
983
1078
|
continue;
|
|
@@ -996,9 +1091,7 @@ export function printSyncResults(results) {
|
|
|
996
1091
|
console.log(` ${styles.warn('~')} Updated ${file}`);
|
|
997
1092
|
}
|
|
998
1093
|
for (const { path: file, operationId } of changes.skipped) {
|
|
999
|
-
console.log(
|
|
1000
|
-
` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
|
|
1001
|
-
);
|
|
1094
|
+
console.log(` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`);
|
|
1002
1095
|
skipped.push({ filename, path: file, operationId });
|
|
1003
1096
|
}
|
|
1004
1097
|
|
|
@@ -1030,7 +1123,23 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1030
1123
|
process.exit(1);
|
|
1031
1124
|
}
|
|
1032
1125
|
|
|
1033
|
-
|
|
1126
|
+
let results;
|
|
1127
|
+
try {
|
|
1128
|
+
results = syncOas(gitRoot);
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
if (!(err instanceof PathEscapeError)) throw err;
|
|
1131
|
+
styles.error(err.message);
|
|
1132
|
+
writeGithubActionsOutputs({
|
|
1133
|
+
'added-count': '0',
|
|
1134
|
+
'deleted-count': '0',
|
|
1135
|
+
'moved-count': '0',
|
|
1136
|
+
'updated-count': '0',
|
|
1137
|
+
'skipped-count': '0',
|
|
1138
|
+
skipped: [],
|
|
1139
|
+
'has-errors': 'true',
|
|
1140
|
+
});
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1034
1143
|
|
|
1035
1144
|
if (!results) {
|
|
1036
1145
|
styles.info('No OpenAPI spec files found in reference/.');
|
|
@@ -1046,8 +1155,7 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1046
1155
|
return;
|
|
1047
1156
|
}
|
|
1048
1157
|
|
|
1049
|
-
const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } =
|
|
1050
|
-
printSyncResults(results);
|
|
1158
|
+
const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = printSyncResults(results);
|
|
1051
1159
|
|
|
1052
1160
|
console.log();
|
|
1053
1161
|
const total = totalAdded + totalDeleted + totalMoved + totalUpdated;
|
|
@@ -1064,7 +1172,9 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1064
1172
|
// to whoever wraps this command in CI to notice and fail on it themselves.
|
|
1065
1173
|
if (totalSkipped > 0) {
|
|
1066
1174
|
const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : '';
|
|
1067
|
-
styles.error(
|
|
1175
|
+
styles.error(
|
|
1176
|
+
`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`,
|
|
1177
|
+
);
|
|
1068
1178
|
} else if (total === 0) {
|
|
1069
1179
|
styles.ok('Reference pages are already in sync.');
|
|
1070
1180
|
} else {
|