@readme/cli 0.2.0 → 0.3.1
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/package.json +23 -4
- package/src/commands/import.js +12 -3
- package/src/commands/oas-sync.js +185 -78
- package/src/commands/pretty.js +14 -4
- package/src/dev/lib/docs.js +2 -2
- package/src/utils/containment.js +87 -0
- package/src/utils/reporter.js +1 -4
- package/src/validators/content.js +12 -5
- package/src/validators/frontmatter.js +4 -10
- package/src/validators/nesting.js +9 -4
- package/src/validators/oas-reference.js +18 -4
- package/vendor/git-format/CLAUDE.md +352 -0
- package/vendor/git-format/frontmatter.schema.json +199 -0
package/src/commands/oas-sync.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
|
|
5
5
|
|
|
6
|
+
import { makeContainment, PathEscapeError } from '../utils/containment.js';
|
|
6
7
|
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
7
8
|
import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
|
|
8
9
|
import * as styles from '../utils/styles.js';
|
|
@@ -22,20 +23,23 @@ const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
|
|
|
22
23
|
* Find OAS files at the root of reference/ (JSON or YAML).
|
|
23
24
|
*/
|
|
24
25
|
export function findOasFiles(refDir) {
|
|
25
|
-
const entries = fs.readdirSync(refDir);
|
|
26
|
+
const entries = fs.readdirSync(refDir, { withFileTypes: true });
|
|
26
27
|
const oasFiles = [];
|
|
27
28
|
|
|
28
29
|
for (const entry of entries) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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);
|
|
32
36
|
|
|
33
37
|
try {
|
|
34
38
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
-
const parsed = entry.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
|
|
39
|
+
const parsed = entry.name.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
|
|
36
40
|
|
|
37
41
|
if (parsed && (parsed.openapi || parsed.swagger)) {
|
|
38
|
-
oasFiles.push({ filename: entry, spec: parsed });
|
|
42
|
+
oasFiles.push({ filename: entry.name, spec: parsed });
|
|
39
43
|
}
|
|
40
44
|
} catch {
|
|
41
45
|
// Skip files that can't be parsed.
|
|
@@ -227,12 +231,14 @@ export function collectExistingPages(refDir) {
|
|
|
227
231
|
const pages = [];
|
|
228
232
|
|
|
229
233
|
function walk(dir) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
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()) {
|
|
234
240
|
walk(full);
|
|
235
|
-
} else if (entry.endsWith('.md')) {
|
|
241
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
236
242
|
const content = fs.readFileSync(full, 'utf-8');
|
|
237
243
|
const { data } = parseFrontmatter(content);
|
|
238
244
|
if (data.api && data.api.file && data.api.operationId) {
|
|
@@ -270,31 +276,45 @@ function parseOrderYaml(content) {
|
|
|
270
276
|
);
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
function writeOrderYaml(filePath, slugs) {
|
|
279
|
+
function writeOrderYaml(filePath, slugs, assertContained) {
|
|
280
|
+
assertContained(filePath);
|
|
274
281
|
const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
|
|
275
282
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
276
283
|
fs.writeFileSync(filePath, content);
|
|
277
284
|
}
|
|
278
285
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
} else {
|
|
288
|
-
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;
|
|
289
294
|
}
|
|
295
|
+
assertContained(orderPath);
|
|
296
|
+
return fs.readFileSync(orderPath, 'utf-8');
|
|
290
297
|
}
|
|
291
298
|
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
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;
|
|
295
315
|
const slugs = parseOrderYaml(content).filter(s => s !== slug);
|
|
296
316
|
if (slugs.length > 0) {
|
|
297
|
-
writeOrderYaml(orderPath, slugs);
|
|
317
|
+
writeOrderYaml(orderPath, slugs, assertContained);
|
|
298
318
|
} else {
|
|
299
319
|
fs.unlinkSync(orderPath);
|
|
300
320
|
}
|
|
@@ -310,11 +330,35 @@ function safeSegment(value, fallback) {
|
|
|
310
330
|
return !segment || segment === '.' || segment === '..' ? fallback : segment;
|
|
311
331
|
}
|
|
312
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
|
+
*/
|
|
313
339
|
function isWithin(baseDir, target) {
|
|
314
340
|
const rel = path.relative(baseDir, target);
|
|
315
341
|
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
316
342
|
}
|
|
317
343
|
|
|
344
|
+
/**
|
|
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.
|
|
348
|
+
*/
|
|
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);
|
|
360
|
+
}
|
|
361
|
+
|
|
318
362
|
function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
|
|
319
363
|
const frontmatter = {
|
|
320
364
|
api: {
|
|
@@ -396,13 +440,16 @@ function operationGroup(op) {
|
|
|
396
440
|
*/
|
|
397
441
|
function existingFoldersBySlug(apiDir) {
|
|
398
442
|
const bySlug = new Map();
|
|
399
|
-
let entries;
|
|
400
443
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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;
|
|
404
450
|
}
|
|
405
|
-
|
|
451
|
+
|
|
452
|
+
for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) {
|
|
406
453
|
if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name);
|
|
407
454
|
}
|
|
408
455
|
return bySlug;
|
|
@@ -426,20 +473,16 @@ function collectReferenceSlugs(refDir) {
|
|
|
426
473
|
const counts = new Map();
|
|
427
474
|
|
|
428
475
|
function walk(dir) {
|
|
429
|
-
for (const entry of fs.readdirSync(dir)) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
} catch {
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
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()) {
|
|
438
481
|
walk(full);
|
|
439
|
-
} else if (entry.endsWith('.md')) {
|
|
482
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
440
483
|
// A folder's index.md contributes the folder name as a slug; any other
|
|
441
484
|
// page contributes its own filename.
|
|
442
|
-
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');
|
|
443
486
|
takeSlug(counts, slug);
|
|
444
487
|
}
|
|
445
488
|
}
|
|
@@ -489,7 +532,8 @@ function reserveSlug(takenSlugs, base) {
|
|
|
489
532
|
* a copy of the parsed frontmatter and edits it. Returns true if the file
|
|
490
533
|
* changed.
|
|
491
534
|
*/
|
|
492
|
-
function updateFrontmatter(filePath, mutate) {
|
|
535
|
+
function updateFrontmatter(filePath, mutate, assertContained) {
|
|
536
|
+
assertContained(filePath);
|
|
493
537
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
494
538
|
const parsed = parseFrontmatter(content);
|
|
495
539
|
// Refuse to rewrite from empty `data` when YAML failed to parse.
|
|
@@ -498,6 +542,7 @@ function updateFrontmatter(filePath, mutate) {
|
|
|
498
542
|
mutate(data);
|
|
499
543
|
const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
|
|
500
544
|
if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
|
|
545
|
+
assertContained(filePath);
|
|
501
546
|
fs.writeFileSync(filePath, next);
|
|
502
547
|
return true;
|
|
503
548
|
}
|
|
@@ -574,23 +619,32 @@ function isGeneratedTagIndex(indexPath, specDescriptions) {
|
|
|
574
619
|
* by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the
|
|
575
620
|
* parent `_order.yaml` entry still applies).
|
|
576
621
|
*/
|
|
577
|
-
function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) {
|
|
578
|
-
|
|
622
|
+
function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained }) {
|
|
623
|
+
assertContained(dir);
|
|
579
624
|
try {
|
|
580
|
-
|
|
581
|
-
} catch {
|
|
582
|
-
return;
|
|
625
|
+
fs.lstatSync(dir);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
if (err.code === 'ENOENT') return;
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
632
|
+
for (const entry of entries) {
|
|
633
|
+
assertContained(path.join(dir, entry.name));
|
|
583
634
|
}
|
|
584
|
-
|
|
635
|
+
|
|
636
|
+
const names = entries.map(e => e.name);
|
|
637
|
+
const others = names.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
|
|
585
638
|
if (others.length) return;
|
|
586
639
|
|
|
587
640
|
const parentDir = path.dirname(dir);
|
|
588
641
|
const slug = path.basename(dir);
|
|
589
|
-
const indexFile =
|
|
642
|
+
const indexFile = names.find(e => INDEX_FILES.includes(e));
|
|
590
643
|
|
|
591
644
|
if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
|
|
645
|
+
assertContained(dir);
|
|
592
646
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
593
|
-
removeFromOrder(path.join(parentDir, '_order.yaml'), slug);
|
|
647
|
+
removeFromOrder(path.join(parentDir, '_order.yaml'), slug, assertContained);
|
|
594
648
|
if (indexFile) {
|
|
595
649
|
releaseSlug(takenSlugs, slug);
|
|
596
650
|
changes.deleted.push(path.relative(refDir, path.join(dir, indexFile)));
|
|
@@ -600,8 +654,11 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
600
654
|
|
|
601
655
|
const from = path.join(dir, indexFile);
|
|
602
656
|
const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`);
|
|
657
|
+
assertContained(from);
|
|
658
|
+
assertContained(to);
|
|
603
659
|
if (fs.existsSync(to)) return;
|
|
604
660
|
fs.renameSync(from, to);
|
|
661
|
+
assertContained(dir);
|
|
605
662
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
606
663
|
changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) });
|
|
607
664
|
}
|
|
@@ -612,7 +669,7 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
612
669
|
* `takenSlugs` is the reference-wide set of slugs already in use; it is read and
|
|
613
670
|
* mutated so slugs stay unique across every spec processed in one sync run.
|
|
614
671
|
*/
|
|
615
|
-
function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
672
|
+
function syncOneOas(refDir, oasFilename, spec, takenSlugs, assertContained) {
|
|
616
673
|
const specOps = extractOperations(spec);
|
|
617
674
|
const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
|
|
618
675
|
|
|
@@ -623,6 +680,10 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
623
680
|
// folder for this API as an O(1) lookup, rather than walking apiDir again
|
|
624
681
|
// for each distinct tag/group.
|
|
625
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);
|
|
626
687
|
const foldersBySlug = existingFoldersBySlug(apiDir);
|
|
627
688
|
|
|
628
689
|
const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
|
|
@@ -651,6 +712,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
651
712
|
// Deletes: pages referencing operations that no longer exist.
|
|
652
713
|
for (const [opId, page] of pagesByOpId) {
|
|
653
714
|
if (!specOps.has(opId)) {
|
|
715
|
+
assertContained(page.filePath);
|
|
654
716
|
fs.unlinkSync(page.filePath);
|
|
655
717
|
|
|
656
718
|
const pageDir = path.dirname(page.filePath);
|
|
@@ -665,7 +727,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
665
727
|
const isIndexPage = path.basename(page.filePath) === 'index.md';
|
|
666
728
|
const pageSlug = path.basename(page.filePath, '.md');
|
|
667
729
|
const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
|
|
668
|
-
removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
|
|
730
|
+
removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug, assertContained);
|
|
669
731
|
releaseSlug(takenSlugs, referenceSlug);
|
|
670
732
|
|
|
671
733
|
changes.deleted.push(page.relativePath);
|
|
@@ -727,7 +789,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
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
|
-
|
|
792
|
+
assertContained(pageDir);
|
|
731
793
|
|
|
732
794
|
const indexPath = path.join(pageDir, 'index.md');
|
|
733
795
|
if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
|
|
@@ -735,6 +797,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
735
797
|
|
|
736
798
|
if (!fs.existsSync(indexPath)) {
|
|
737
799
|
// Never overwrite an existing index.md — it may be a hand-written category.
|
|
800
|
+
assertContained(indexPath);
|
|
738
801
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
739
802
|
fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
|
|
740
803
|
changes.added.push(path.relative(refDir, indexPath));
|
|
@@ -747,15 +810,19 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
747
810
|
// With `apply-tag-changes`, the spec owns the category page's title and
|
|
748
811
|
// excerpt too (a tag with no description clears the excerpt). Its body
|
|
749
812
|
// and any other frontmatter are the user's and are kept.
|
|
750
|
-
const updated = updateFrontmatter(
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
);
|
|
755
822
|
if (updated) changes.updated.push(path.relative(refDir, indexPath));
|
|
756
823
|
}
|
|
757
|
-
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
|
|
758
|
-
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);
|
|
759
826
|
}
|
|
760
827
|
|
|
761
828
|
// Where each of this spec's operations ends up this run, by operationKey.
|
|
@@ -783,6 +850,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
783
850
|
path.dirname(filePath) !== targetDir
|
|
784
851
|
) {
|
|
785
852
|
const target = path.join(targetDir, path.basename(filePath));
|
|
853
|
+
assertContained(filePath);
|
|
854
|
+
assertContained(target);
|
|
786
855
|
if (fs.existsSync(target)) {
|
|
787
856
|
changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId });
|
|
788
857
|
} else {
|
|
@@ -790,8 +859,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
790
859
|
const slug = path.basename(filePath, '.md');
|
|
791
860
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
792
861
|
fs.renameSync(filePath, target);
|
|
793
|
-
removeFromOrder(path.join(fromDir, '_order.yaml'), slug);
|
|
794
|
-
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);
|
|
795
864
|
vacatedDirs.add(fromDir);
|
|
796
865
|
changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) });
|
|
797
866
|
filePath = target;
|
|
@@ -803,9 +872,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
803
872
|
// its own `hidden` — removing the extension never unhides a page.
|
|
804
873
|
if (op.xInternal.present) {
|
|
805
874
|
const hidden = Boolean(op.xInternal.value);
|
|
806
|
-
const updated = updateFrontmatter(
|
|
807
|
-
|
|
808
|
-
|
|
875
|
+
const updated = updateFrontmatter(
|
|
876
|
+
filePath,
|
|
877
|
+
data => {
|
|
878
|
+
data.hidden = hidden;
|
|
879
|
+
},
|
|
880
|
+
assertContained,
|
|
881
|
+
);
|
|
809
882
|
if (updated) changes.updated.push(path.relative(refDir, filePath));
|
|
810
883
|
}
|
|
811
884
|
|
|
@@ -826,9 +899,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
826
899
|
const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
|
|
827
900
|
const pagePath = path.join(pageDir, `${slug}.md`);
|
|
828
901
|
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
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)) {
|
|
832
908
|
changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
|
|
833
909
|
continue;
|
|
834
910
|
}
|
|
@@ -842,7 +918,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
842
918
|
});
|
|
843
919
|
fs.writeFileSync(pagePath, content);
|
|
844
920
|
|
|
845
|
-
addToOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
921
|
+
addToOrder(path.join(pageDir, '_order.yaml'), slug, assertContained);
|
|
846
922
|
|
|
847
923
|
changes.added.push(path.relative(refDir, pagePath));
|
|
848
924
|
opPaths.set(key, pagePath);
|
|
@@ -865,9 +941,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
865
941
|
const isNew = createdIndexes.has(indexPath);
|
|
866
942
|
const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
|
|
867
943
|
if (!allHidden) continue;
|
|
868
|
-
const updated = updateFrontmatter(
|
|
869
|
-
|
|
870
|
-
|
|
944
|
+
const updated = updateFrontmatter(
|
|
945
|
+
indexPath,
|
|
946
|
+
data => {
|
|
947
|
+
data.hidden = true;
|
|
948
|
+
},
|
|
949
|
+
assertContained,
|
|
950
|
+
);
|
|
871
951
|
if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
|
|
872
952
|
}
|
|
873
953
|
|
|
@@ -875,7 +955,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
875
955
|
// category folder, and never a folder an operation still lives in.
|
|
876
956
|
for (const dir of vacatedDirs) {
|
|
877
957
|
if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue;
|
|
878
|
-
cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes });
|
|
958
|
+
cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained });
|
|
879
959
|
}
|
|
880
960
|
|
|
881
961
|
// `apply-endpoint-order`: reorder each folder's operation pages to match
|
|
@@ -899,7 +979,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
899
979
|
const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : [];
|
|
900
980
|
const next = applyOASOrder(current, slugs);
|
|
901
981
|
if (next.join('\n') === current.join('\n')) continue;
|
|
902
|
-
writeOrderYaml(orderPath, next);
|
|
982
|
+
writeOrderYaml(orderPath, next, assertContained);
|
|
903
983
|
changes.updated.push(path.relative(refDir, orderPath));
|
|
904
984
|
}
|
|
905
985
|
}
|
|
@@ -918,7 +998,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
918
998
|
*
|
|
919
999
|
* Pure programmatic API: returns per-file change descriptors and prints
|
|
920
1000
|
* nothing. Used by the CLI command, the lint --fix flow, and external
|
|
921
|
-
* 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.
|
|
922
1004
|
*
|
|
923
1005
|
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
924
1006
|
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
@@ -931,9 +1013,18 @@ export function syncOas(input) {
|
|
|
931
1013
|
const refDir = path.join(gitRoot, 'reference');
|
|
932
1014
|
if (!fs.existsSync(refDir)) return null;
|
|
933
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
|
+
|
|
934
1023
|
const oasFiles = findOasFiles(refDir);
|
|
935
1024
|
if (oasFiles.length === 0) return null;
|
|
936
1025
|
|
|
1026
|
+
const assertContained = makeContainment(refDir);
|
|
1027
|
+
|
|
937
1028
|
// Reference slugs share one flat namespace across every spec, so build the set
|
|
938
1029
|
// of in-use slugs once and let each spec read/extend it.
|
|
939
1030
|
const takenSlugs = collectReferenceSlugs(refDir);
|
|
@@ -941,7 +1032,7 @@ export function syncOas(input) {
|
|
|
941
1032
|
|
|
942
1033
|
for (const { filename, spec } of oasFiles) {
|
|
943
1034
|
const ops = extractOperations(spec);
|
|
944
|
-
const changes = syncOneOas(refDir, filename, spec, takenSlugs);
|
|
1035
|
+
const changes = syncOneOas(refDir, filename, spec, takenSlugs, assertContained);
|
|
945
1036
|
allChanges.push({ filename, spec, opCount: ops.size, changes });
|
|
946
1037
|
}
|
|
947
1038
|
|
|
@@ -1032,7 +1123,23 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1032
1123
|
process.exit(1);
|
|
1033
1124
|
}
|
|
1034
1125
|
|
|
1035
|
-
|
|
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
|
+
}
|
|
1036
1143
|
|
|
1037
1144
|
if (!results) {
|
|
1038
1145
|
styles.info('No OpenAPI spec files found in reference/.');
|
package/src/commands/pretty.js
CHANGED
|
@@ -6,6 +6,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
6
6
|
import { InvalidArgumentError, Option } from 'commander';
|
|
7
7
|
|
|
8
8
|
import { prettifyPagePrompt, prettifyPageOutputSchema } from '../prompts/index.js';
|
|
9
|
+
import { makeContainment, PathEscapeError } from '../utils/containment.js';
|
|
9
10
|
import { printHeader, isAgenticCli } from '../utils/eyes.js';
|
|
10
11
|
import { collectFiles } from '../utils/lint.js';
|
|
11
12
|
import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
|
|
@@ -205,6 +206,10 @@ export async function run(file, options, _cmd, ctx) {
|
|
|
205
206
|
console.log();
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
// Lexical `path.relative` treats a symlink inside the repo as inside it,
|
|
210
|
+
// and the later write follows the link. Same check `oas:sync` uses.
|
|
211
|
+
const assertContained = makeContainment(gitRoot, { label: 'repository' });
|
|
212
|
+
|
|
208
213
|
let files;
|
|
209
214
|
if (file) {
|
|
210
215
|
const absFile = path.resolve(file);
|
|
@@ -212,12 +217,16 @@ export async function run(file, options, _cmd, ctx) {
|
|
|
212
217
|
styles.error(`File not found: ${styles.bold(file)}`);
|
|
213
218
|
process.exit(1);
|
|
214
219
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
assertContained(absFile);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (!(err instanceof PathEscapeError)) throw err;
|
|
225
|
+
styles.error(err.message);
|
|
218
226
|
process.exit(1);
|
|
219
227
|
}
|
|
220
|
-
|
|
228
|
+
|
|
229
|
+
files = [path.relative(gitRoot, absFile)];
|
|
221
230
|
} else {
|
|
222
231
|
files = collectFiles(gitRoot);
|
|
223
232
|
}
|
|
@@ -290,6 +299,7 @@ export async function run(file, options, _cmd, ctx) {
|
|
|
290
299
|
|
|
291
300
|
try {
|
|
292
301
|
const filePath = path.join(gitRoot, relativePath);
|
|
302
|
+
assertContained(filePath);
|
|
293
303
|
const original = fs.readFileSync(filePath, 'utf-8');
|
|
294
304
|
const parsed = parseFrontmatter(original);
|
|
295
305
|
const parseError = frontmatterWriteError(parsed);
|
package/src/dev/lib/docs.js
CHANGED
|
@@ -25,8 +25,8 @@ const SECTIONS = [
|
|
|
25
25
|
// Max folder nesting per section — anything deeper doesn't render in ReadMe's
|
|
26
26
|
// sidebar, so we mirror the cap here. Keep in sync with src/validators/nesting.js.
|
|
27
27
|
const MAX_DEPTH = {
|
|
28
|
-
docs:
|
|
29
|
-
reference:
|
|
28
|
+
docs: 5,
|
|
29
|
+
reference: 5,
|
|
30
30
|
recipes: 0,
|
|
31
31
|
custom_pages: 0,
|
|
32
32
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A path that would leave `baseDir`, including by following a symlink. `target` is the refused
|
|
6
|
+
* path (the symlink itself when that is what was hit).
|
|
7
|
+
*/
|
|
8
|
+
export class PathEscapeError extends Error {
|
|
9
|
+
constructor(target, reason) {
|
|
10
|
+
super(`${target}: ${reason}`);
|
|
11
|
+
this.name = 'PathEscapeError';
|
|
12
|
+
this.target = target;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Guard for "this path stays inside `baseDir`" that does not trust the lexical path.
|
|
18
|
+
*
|
|
19
|
+
* `path.relative` only compares strings. A directory symlink committed under the base (git records
|
|
20
|
+
* it as mode 120000, so a clone recreates it) still looks inside the base, and `mkdir`,
|
|
21
|
+
* `writeFile`, `rename`, `unlink`, and `rm` all follow it. Walk each segment with `lstat` so a
|
|
22
|
+
* symlink is refused even when its target is also inside the base, and `realpath` the nearest
|
|
23
|
+
* existing ancestor so a link that points out of the base cannot hide behind a not-yet-created
|
|
24
|
+
* child.
|
|
25
|
+
*
|
|
26
|
+
* Paths that do not exist yet are allowed when that ancestor is a real directory inside the base —
|
|
27
|
+
* callers are about to create them.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} baseDir Existing directory that bounds every path.
|
|
30
|
+
* @param {{ label?: string }} [opts] Noun used in the error (`reference directory`).
|
|
31
|
+
* @returns {(target: string) => void}
|
|
32
|
+
*/
|
|
33
|
+
export function makeContainment(baseDir, { label = 'reference directory' } = {}) {
|
|
34
|
+
const baseReal = fs.realpathSync(baseDir);
|
|
35
|
+
const baseResolved = path.resolve(baseDir);
|
|
36
|
+
|
|
37
|
+
return function assertContained(target) {
|
|
38
|
+
const resolved = path.resolve(target);
|
|
39
|
+
const rel = path.relative(baseResolved, resolved);
|
|
40
|
+
|
|
41
|
+
// '' is the base itself. Callers pass a child, so deleting or writing the base is not a
|
|
42
|
+
// contained operation.
|
|
43
|
+
if (rel === '' || escapes(rel)) {
|
|
44
|
+
throw new PathEscapeError(resolved, `path escapes the ${label}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let cursor = baseReal;
|
|
48
|
+
for (const segment of rel.split(path.sep)) {
|
|
49
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
50
|
+
throw new PathEscapeError(resolved, `path escapes the ${label}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cursor = path.join(cursor, segment);
|
|
54
|
+
|
|
55
|
+
let st;
|
|
56
|
+
try {
|
|
57
|
+
st = fs.lstatSync(cursor);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code !== 'ENOENT') throw err;
|
|
60
|
+
|
|
61
|
+
// `cursor` is the first missing segment. Its parent is either the base (relative path '')
|
|
62
|
+
// or a segment just lstat'd as a real directory. realpath it anyway so a link above the
|
|
63
|
+
// base cannot hide behind a not-yet-created child.
|
|
64
|
+
const parentRel = path.relative(baseReal, fs.realpathSync(path.dirname(cursor)));
|
|
65
|
+
if (escapes(parentRel)) {
|
|
66
|
+
throw new PathEscapeError(resolved, `path escapes the ${label}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (st.isSymbolicLink()) {
|
|
73
|
+
throw new PathEscapeError(cursor, `symbolic links inside the ${label} are not followed`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const realRel = path.relative(baseReal, fs.realpathSync(cursor));
|
|
78
|
+
if (realRel === '' || escapes(realRel)) {
|
|
79
|
+
throw new PathEscapeError(resolved, `path escapes the ${label}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when `path.relative(base, candidate)` climbed out of `base`. '' did not. */
|
|
85
|
+
function escapes(rel) {
|
|
86
|
+
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
87
|
+
}
|