@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/setup.js
CHANGED
|
@@ -2,14 +2,10 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import readline from 'node:readline';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
PLATFORMS,
|
|
8
|
-
detectExistingCi,
|
|
9
|
-
detectPlatform,
|
|
10
|
-
usesBlacksmith,
|
|
11
|
-
} from '../utils/git.js';
|
|
5
|
+
|
|
6
|
+
import { PLATFORMS, detectExistingCi, detectPlatform, usesBlacksmith } from '../utils/git.js';
|
|
12
7
|
import { templateFor } from '../utils/setup-templates.js';
|
|
8
|
+
import * as styles from '../utils/styles.js';
|
|
13
9
|
|
|
14
10
|
// Detect the user's CI platform at module load so the top-level `--help`
|
|
15
11
|
// can advertise the right `setup:<platform>` form (e.g. `setup:github` for
|
|
@@ -43,12 +39,8 @@ export const helpHint = _hasExistingCi
|
|
|
43
39
|
// Hidden aliases cover every other form: bare `setup` (with optional positional),
|
|
44
40
|
// the generic `setup:ci`, and one entry per platform. Whichever form is the
|
|
45
41
|
// primary above is filtered out so it isn't double-registered.
|
|
46
|
-
const ALL_FORMS = [
|
|
47
|
-
|
|
48
|
-
'setup:ci',
|
|
49
|
-
...Object.keys(PLATFORMS).map((k) => `setup:${k}`),
|
|
50
|
-
];
|
|
51
|
-
export const aliases = ALL_FORMS.filter((a) => a.split(' ')[0] !== _primaryName);
|
|
42
|
+
const ALL_FORMS = ['setup [platform]', 'setup:ci', ...Object.keys(PLATFORMS).map(k => `setup:${k}`)];
|
|
43
|
+
export const aliases = ALL_FORMS.filter(a => a.split(' ')[0] !== _primaryName);
|
|
52
44
|
|
|
53
45
|
export function args(cmd) {
|
|
54
46
|
cmd.option('--blacksmith', 'Use Blacksmith runners (GitHub Actions only)');
|
|
@@ -56,9 +48,9 @@ export function args(cmd) {
|
|
|
56
48
|
|
|
57
49
|
cmd.addHelpText('after', () => {
|
|
58
50
|
const bin = styles.binName();
|
|
59
|
-
const cmdStrs = Object.keys(PLATFORMS).map(
|
|
60
|
-
const cmdWidth = Math.max(...cmdStrs.map(
|
|
61
|
-
const labelWidth = Math.max(...Object.values(PLATFORMS).map(
|
|
51
|
+
const cmdStrs = Object.keys(PLATFORMS).map(k => `${bin} setup:${k}`);
|
|
52
|
+
const cmdWidth = Math.max(...cmdStrs.map(s => s.length));
|
|
53
|
+
const labelWidth = Math.max(...Object.values(PLATFORMS).map(d => d.label.length));
|
|
62
54
|
const rows = Object.entries(PLATFORMS).map(([key, def], i) => {
|
|
63
55
|
const cmdStr = cmdStrs[i].padEnd(cmdWidth);
|
|
64
56
|
const label = def.label.padEnd(labelWidth);
|
|
@@ -153,12 +145,20 @@ export async function run(...callArgs) {
|
|
|
153
145
|
|
|
154
146
|
console.log(` This will create a workflow that runs on every pull request:`);
|
|
155
147
|
console.log();
|
|
156
|
-
console.log(
|
|
148
|
+
console.log(
|
|
149
|
+
` ${styles.success('✔')} ${styles.bold('Lint docs')} — runs ${styles.orange(`${styles.binName()} lint`)} and posts results as a PR comment`,
|
|
150
|
+
);
|
|
157
151
|
if (platform === 'github') {
|
|
158
|
-
console.log(
|
|
159
|
-
|
|
152
|
+
console.log(
|
|
153
|
+
` ${styles.success('✔')} ${styles.bold('OAS change detection')} — flags modified OpenAPI spec files and suggests syncing`,
|
|
154
|
+
);
|
|
155
|
+
console.log(
|
|
156
|
+
` ${styles.success('✔')} ${styles.bold('Auto-fix base branch')} — redirects PRs from ${styles.orange('main')} to the correct version branch`,
|
|
157
|
+
);
|
|
160
158
|
if (useBlacksmith) {
|
|
161
|
-
console.log(
|
|
159
|
+
console.log(
|
|
160
|
+
` ${styles.success('✔')} ${styles.bold('Blacksmith runner')} — uses ${styles.orange('blacksmith-2vcpu-ubuntu-2404')} instead of ${styles.orange('ubuntu-latest')}`,
|
|
161
|
+
);
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
console.log();
|
|
@@ -169,9 +169,7 @@ export async function run(...callArgs) {
|
|
|
169
169
|
|
|
170
170
|
// 4. Confirm.
|
|
171
171
|
if (!opts.yes) {
|
|
172
|
-
const prompt = isOverwrite
|
|
173
|
-
? 'This will overwrite the existing workflow. Continue?'
|
|
174
|
-
: 'Set up this workflow?';
|
|
172
|
+
const prompt = isOverwrite ? 'This will overwrite the existing workflow. Continue?' : 'Set up this workflow?';
|
|
175
173
|
const confirmed = await confirm(prompt);
|
|
176
174
|
if (!confirmed) {
|
|
177
175
|
styles.info('Setup cancelled.');
|
|
@@ -203,7 +201,7 @@ function normalizePlatform(input) {
|
|
|
203
201
|
function printDetectionSummary(detection, chosen) {
|
|
204
202
|
const lines = [];
|
|
205
203
|
if (detection.existing.length > 0) {
|
|
206
|
-
const labels = detection.existing.map(
|
|
204
|
+
const labels = detection.existing.map(k => PLATFORMS[k].label).join(', ');
|
|
207
205
|
lines.push(`${styles.dim('Detected existing CI:')} ${labels}`);
|
|
208
206
|
}
|
|
209
207
|
if (detection.remote) {
|
|
@@ -246,8 +244,8 @@ function confirm(message) {
|
|
|
246
244
|
output: process.stdout,
|
|
247
245
|
});
|
|
248
246
|
|
|
249
|
-
return new Promise(
|
|
250
|
-
rl.question(`${styles.brand('?')} ${message} ${styles.dim('(Y/n)')} `,
|
|
247
|
+
return new Promise(resolve => {
|
|
248
|
+
rl.question(`${styles.brand('?')} ${message} ${styles.dim('(Y/n)')} `, answer => {
|
|
251
249
|
rl.close();
|
|
252
250
|
const val = answer.trim().toLowerCase();
|
|
253
251
|
resolve(val === '' || val === 'y' || val === 'yes');
|
package/src/commands/versions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
2
3
|
import * as styles from '../utils/styles.js';
|
|
3
4
|
|
|
4
5
|
export const command = 'versions';
|
|
@@ -18,7 +19,10 @@ export function run(_options, _cmd, ctx) {
|
|
|
18
19
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
20
|
}).trim();
|
|
20
21
|
|
|
21
|
-
const branches = raw
|
|
22
|
+
const branches = raw
|
|
23
|
+
.split('\n')
|
|
24
|
+
.map(b => b.trim())
|
|
25
|
+
.filter(Boolean);
|
|
22
26
|
|
|
23
27
|
// Strip "origin/" prefix and deduplicate.
|
|
24
28
|
const seen = new Set();
|
|
@@ -73,7 +77,7 @@ export function run(_options, _cmd, ctx) {
|
|
|
73
77
|
for (let i = 0; i < subs.length; i++) {
|
|
74
78
|
const isLast = i === subs.length - 1;
|
|
75
79
|
const connector = isLast ? '└─' : '├─';
|
|
76
|
-
console.log(` ${styles.dim(connector)} ${styles.dim(version
|
|
80
|
+
console.log(` ${styles.dim(connector)} ${styles.dim(`${version}_`)}${subs[i].name} ${styles.dim('(branch)')}`);
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
|
package/src/dev/app/layout.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { collectSidebar } from '../lib/docs';
|
|
2
|
-
import { SidebarNav, SidebarPanel } from './Sidebar';
|
|
3
1
|
import '@readme/markdown/dist/main.css';
|
|
2
|
+
|
|
3
|
+
import { collectSidebar } from '../lib/docs';
|
|
4
|
+
|
|
4
5
|
import './globals.css';
|
|
6
|
+
import { SidebarNav, SidebarPanel } from './Sidebar';
|
|
5
7
|
|
|
6
8
|
export const metadata = {
|
|
7
9
|
title: {
|
|
@@ -21,10 +23,13 @@ export default async function RootLayout({ children }) {
|
|
|
21
23
|
</head>
|
|
22
24
|
<body className="bg-[#000343] text-gray-900 antialiased">
|
|
23
25
|
{/* Top Navigation */}
|
|
24
|
-
<header
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
<header
|
|
27
|
+
className="fixed top-0 inset-x-0 h-[48px] bg-[#000343] flex items-center px-5 z-50"
|
|
28
|
+
style={{
|
|
29
|
+
backgroundImage: `linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)`,
|
|
30
|
+
backgroundSize: '20px 20px',
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
28
33
|
<div className="flex items-center gap-3 mr-8 shrink-0">
|
|
29
34
|
<div className="flex items-center gap-2">
|
|
30
35
|
<i className="fa-brands fa-readme text-[#018ef5] text-[18px] relative top-[1px]" />
|
|
@@ -50,17 +55,19 @@ export default async function RootLayout({ children }) {
|
|
|
50
55
|
|
|
51
56
|
{/* Content */}
|
|
52
57
|
<main className="ml-[300px] flex-1 min-w-0 bg-white rounded-tr-xl">
|
|
53
|
-
<div className="max-w-[820px] py-10 px-16">
|
|
54
|
-
{children}
|
|
55
|
-
</div>
|
|
58
|
+
<div className="max-w-[820px] py-10 px-16">{children}</div>
|
|
56
59
|
</main>
|
|
57
60
|
</div>
|
|
58
61
|
|
|
59
|
-
<style
|
|
62
|
+
<style
|
|
63
|
+
dangerouslySetInnerHTML={{
|
|
64
|
+
__html: `
|
|
60
65
|
[data-sidebar-group][data-expanded="false"] .sidebar-children { display: none; }
|
|
61
66
|
[data-sidebar-group][data-expanded="true"] .sidebar-children { display: block; }
|
|
62
67
|
[data-sidebar-group][data-expanded="true"] .sidebar-chevron { transform: rotate(90deg); }
|
|
63
|
-
|
|
68
|
+
`,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
64
71
|
<script
|
|
65
72
|
dangerouslySetInnerHTML={{
|
|
66
73
|
__html: `(function(){var es=new EventSource('/__reload');es.onmessage=function(e){if(e.data==='reload')location.reload()};})();
|
package/src/dev/app/page.js
CHANGED
package/src/dev/lib/docs.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import fs from 'fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
import { compile, run } from '@readme/markdown';
|
|
6
|
-
import
|
|
6
|
+
import { load as loadYAML } from 'js-yaml';
|
|
7
|
+
|
|
8
|
+
import { parseFrontmatter } from '../../utils/markdown.js';
|
|
7
9
|
|
|
8
10
|
// Use createRequire to load react-dom/server at runtime,
|
|
9
11
|
// bypassing Next.js App Router's static import restriction.
|
|
@@ -36,7 +38,7 @@ function walkDir(dirPath, sectionDir, depth = 0) {
|
|
|
36
38
|
let order = [];
|
|
37
39
|
const orderPath = path.join(dirPath, '_order.yaml');
|
|
38
40
|
if (fs.existsSync(orderPath)) {
|
|
39
|
-
order =
|
|
41
|
+
order = loadYAML(fs.readFileSync(orderPath, 'utf-8')) || [];
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
// Get all items (exclude dotfiles and _-prefixed files)
|
|
@@ -90,16 +92,14 @@ function walkDir(dirPath, sectionDir, depth = 0) {
|
|
|
90
92
|
// If either exists, treat this as a clickable parent page.
|
|
91
93
|
const indexPath = path.join(dirPath, name, 'index.md');
|
|
92
94
|
const siblingMdPath = path.join(dirPath, `${name}.md`);
|
|
93
|
-
const parentPagePath = fs.existsSync(indexPath)
|
|
94
|
-
? indexPath
|
|
95
|
-
: fs.existsSync(siblingMdPath) ? siblingMdPath : null;
|
|
95
|
+
const parentPagePath = fs.existsSync(indexPath) ? indexPath : fs.existsSync(siblingMdPath) ? siblingMdPath : null;
|
|
96
96
|
|
|
97
97
|
let title = name;
|
|
98
98
|
let href = null;
|
|
99
99
|
let hidden = false;
|
|
100
100
|
let icon = null;
|
|
101
101
|
if (parentPagePath) {
|
|
102
|
-
const { data } =
|
|
102
|
+
const { data } = parseFrontmatter(fs.readFileSync(parentPagePath, 'utf-8'));
|
|
103
103
|
title = data.title || name;
|
|
104
104
|
href = `/${sectionDir}/${encodeURIComponent(name)}`;
|
|
105
105
|
hidden = !!data.hidden;
|
|
@@ -112,7 +112,7 @@ function walkDir(dirPath, sectionDir, depth = 0) {
|
|
|
112
112
|
mdFiles.delete(name);
|
|
113
113
|
} else if (isMd) {
|
|
114
114
|
const filePath = path.join(dirPath, `${name}.md`);
|
|
115
|
-
const { data } =
|
|
115
|
+
const { data } = parseFrontmatter(fs.readFileSync(filePath, 'utf-8'));
|
|
116
116
|
const linkUrl = data.link?.url;
|
|
117
117
|
entries.push({
|
|
118
118
|
title: data.title || name,
|
|
@@ -152,7 +152,7 @@ function loadCustomBlocks(docsRoot) {
|
|
|
152
152
|
|
|
153
153
|
for (const file of files) {
|
|
154
154
|
const raw = fs.readFileSync(path.join(blocksDir, file), 'utf-8');
|
|
155
|
-
const { data, content } =
|
|
155
|
+
const { data, content } = parseFrontmatter(raw);
|
|
156
156
|
const name = data.name || path.basename(file, path.extname(file));
|
|
157
157
|
sources[name] = content;
|
|
158
158
|
modules[name] = run(compile(content));
|
|
@@ -202,7 +202,7 @@ export function getPage(slugArray) {
|
|
|
202
202
|
if (!filePath) return null;
|
|
203
203
|
|
|
204
204
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
205
|
-
const { data, content } =
|
|
205
|
+
const { data, content } = parseFrontmatter(raw);
|
|
206
206
|
|
|
207
207
|
let html;
|
|
208
208
|
|
|
@@ -254,7 +254,7 @@ function renderOasOperationPreview(specFile, operationId) {
|
|
|
254
254
|
let spec;
|
|
255
255
|
try {
|
|
256
256
|
const rawSpec = fs.readFileSync(specPath, 'utf-8');
|
|
257
|
-
spec = specFile.toLowerCase().endsWith('.json') ? JSON.parse(rawSpec) :
|
|
257
|
+
spec = specFile.toLowerCase().endsWith('.json') ? JSON.parse(rawSpec) : loadYAML(rawSpec);
|
|
258
258
|
} catch (e) {
|
|
259
259
|
return `<p><em>Couldn't parse <code>reference/${specFile}</code>: ${escapeHtml(e.message)}</em></p>`;
|
|
260
260
|
}
|
|
@@ -272,16 +272,21 @@ function renderOasOperationPreview(specFile, operationId) {
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
const { method, path: opPath, op } = found;
|
|
275
|
-
const methodColor =
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
const methodColor =
|
|
276
|
+
{
|
|
277
|
+
GET: '#22c55e',
|
|
278
|
+
POST: '#3b82f6',
|
|
279
|
+
PUT: '#f59e0b',
|
|
280
|
+
PATCH: '#a855f7',
|
|
281
|
+
DELETE: '#ef4444',
|
|
282
|
+
}[method] || '#6b7280';
|
|
278
283
|
|
|
279
284
|
const parts = [];
|
|
280
285
|
parts.push(
|
|
281
286
|
`<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem;">` +
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
287
|
+
`<span style="background: ${methodColor}; color: white; padding: 0.15rem 0.5rem; border-radius: 0.25rem; font-family: monospace; font-weight: 600; font-size: 0.85rem;">${method}</span>` +
|
|
288
|
+
`<code style="font-size: 1rem;">${escapeHtml(opPath)}</code>` +
|
|
289
|
+
`</div>`,
|
|
285
290
|
);
|
|
286
291
|
if (op.description) parts.push(`<p>${escapeHtml(op.description)}</p>`);
|
|
287
292
|
|
|
@@ -317,7 +322,10 @@ function renderOasOperationPreview(specFile, operationId) {
|
|
|
317
322
|
}
|
|
318
323
|
|
|
319
324
|
function escapeHtml(s) {
|
|
320
|
-
return String(s).replace(
|
|
325
|
+
return String(s).replace(
|
|
326
|
+
/[&<>"']/g,
|
|
327
|
+
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
328
|
+
);
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
function findFirstHref(items) {
|
package/src/dev/next.config.mjs
CHANGED
|
@@ -10,8 +10,8 @@ export default {
|
|
|
10
10
|
// Next's SWC loader excludes `node_modules` from JSX transformation by
|
|
11
11
|
// default. Marking our own package as a transpile target bypasses that.
|
|
12
12
|
transpilePackages: ['@readme/cli'],
|
|
13
|
-
serverExternalPackages: ['
|
|
14
|
-
webpack:
|
|
13
|
+
serverExternalPackages: ['js-yaml', '@readme/markdown'],
|
|
14
|
+
webpack: config => {
|
|
15
15
|
// Suppress noisy cache serialization warnings from large @readme/markdown bundle
|
|
16
16
|
config.infrastructureLogging = {
|
|
17
17
|
...config.infrastructureLogging,
|
package/src/gha.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// let it do what it already does, exit code included.
|
|
18
18
|
|
|
19
19
|
import * as lint from './commands/lint.js';
|
|
20
|
-
import * as oasValidate from './commands/oas-validate.js';
|
|
21
20
|
import * as oasSync from './commands/oas-sync.js';
|
|
21
|
+
import * as oasValidate from './commands/oas-validate.js';
|
|
22
22
|
import { writeGithubActionsOutputs } from './utils/gha-output.js';
|
|
23
23
|
|
|
24
24
|
// Each command's run(options, cmd, ctx) only ever reads specific boolean
|
|
@@ -26,8 +26,8 @@ import { writeGithubActionsOutputs } from './utils/gha-output.js';
|
|
|
26
26
|
// (the commander Command instance normal CLI invocation would pass) — so a
|
|
27
27
|
// plain options object and a null cmd are enough here.
|
|
28
28
|
const COMMANDS = {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
lint: { run: lint.run, flags: ['json', 'github', 'fix'] },
|
|
30
|
+
validate: { run: lint.run, flags: ['json', 'github', 'fix'] }, // alias, matches src/commands/lint.js
|
|
31
31
|
'oas:validate': { run: oasValidate.run, flags: ['dereference'] },
|
|
32
32
|
'oas:sync': { run: oasSync.run, flags: [] },
|
|
33
33
|
};
|
|
@@ -46,7 +46,7 @@ export async function run() {
|
|
|
46
46
|
if (!entry) {
|
|
47
47
|
console.error(
|
|
48
48
|
`::error::Unknown or unsupported command "${command || '(empty)'}" for the \`readme\` input. ` +
|
|
49
|
-
`Supported: ${[...new Set(Object.keys(COMMANDS).filter(
|
|
49
|
+
`Supported: ${[...new Set(Object.keys(COMMANDS).filter(k => k !== 'validate'))].join(', ')}.`,
|
|
50
50
|
);
|
|
51
51
|
writeGithubActionsOutputs({ 'has-errors': 'true' });
|
|
52
52
|
process.exit(1);
|
|
@@ -72,8 +72,8 @@ export async function run() {
|
|
|
72
72
|
|
|
73
73
|
if (badTokens.length > 0) {
|
|
74
74
|
console.error(
|
|
75
|
-
`::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map(
|
|
76
|
-
`Supported: ${entry.flags.length > 0 ? entry.flags.map(
|
|
75
|
+
`::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map(t => `"${t}"`).join(', ')} for "${command}". ` +
|
|
76
|
+
`Supported: ${entry.flags.length > 0 ? entry.flags.map(f => `--${f}`).join(', ') : '(none)'}.`,
|
|
77
77
|
);
|
|
78
78
|
writeGithubActionsOutputs({ 'has-errors': 'true' });
|
|
79
79
|
process.exit(1);
|
|
@@ -89,7 +89,7 @@ export async function run() {
|
|
|
89
89
|
await entry.run(options, null, { gitRoot });
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
run().catch(
|
|
92
|
+
run().catch(err => {
|
|
93
93
|
console.error(err);
|
|
94
94
|
// A thrown error (as opposed to a command's own controlled process.exit)
|
|
95
95
|
// means whatever run() was doing never reached its own
|
package/src/prompts/index.js
CHANGED
|
@@ -25,7 +25,7 @@ export const slotOrphansSystemPrompt = [
|
|
|
25
25
|
'Example output: {"assignments": [0, 2, 0, 1, -1, 3]}',
|
|
26
26
|
'',
|
|
27
27
|
'Guidance:',
|
|
28
|
-
|
|
28
|
+
"- Choose the best semantic fit based on the orphan's title and URL path.",
|
|
29
29
|
'- Only use -1 when no existing category is plausible.',
|
|
30
30
|
].join('\n');
|
|
31
31
|
|
|
@@ -54,13 +54,18 @@ export const slotOrphansOutputSchema = {
|
|
|
54
54
|
*/
|
|
55
55
|
export function buildSlotOrphansUserPrompt({ categories, orphans }) {
|
|
56
56
|
const catList = categories.map((c, i) => {
|
|
57
|
-
const sample = (c.pages || [])
|
|
57
|
+
const sample = (c.pages || [])
|
|
58
|
+
.slice(0, 3)
|
|
59
|
+
.map(p => p.title)
|
|
60
|
+
.join(', ');
|
|
58
61
|
return `${i}. ${c.title}${sample ? ` (e.g. ${sample})` : ''}`;
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
const orphanList = orphans.map((p, i) => {
|
|
62
65
|
let relPath = p.url;
|
|
63
|
-
try {
|
|
66
|
+
try {
|
|
67
|
+
relPath = new URL(p.url).pathname;
|
|
68
|
+
} catch {}
|
|
64
69
|
return `${i}. ${p.title} — ${relPath}`;
|
|
65
70
|
});
|
|
66
71
|
|
|
@@ -264,13 +269,17 @@ export const organizeFromScratchOutputSchema = {
|
|
|
264
269
|
export function buildOrganizeFromScratchUserPrompt({ siteTitle, items }) {
|
|
265
270
|
const compactLines = items.map((it, idx) => {
|
|
266
271
|
let relPath = it.url;
|
|
267
|
-
try {
|
|
272
|
+
try {
|
|
273
|
+
relPath = new URL(it.url).pathname + new URL(it.url).search;
|
|
274
|
+
} catch {}
|
|
268
275
|
return `${idx}\t${it.title}\t${relPath}`;
|
|
269
276
|
});
|
|
270
277
|
|
|
271
278
|
let origin = '(unknown)';
|
|
272
279
|
if (items.length > 0) {
|
|
273
|
-
try {
|
|
280
|
+
try {
|
|
281
|
+
origin = new URL(items[0].url).origin;
|
|
282
|
+
} catch {}
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
return [
|
|
@@ -507,7 +516,8 @@ export function prettifyPagePrompt(input) {
|
|
|
507
516
|
* @returns {string}
|
|
508
517
|
*/
|
|
509
518
|
export function stripCodeFences(text) {
|
|
510
|
-
return String(text)
|
|
519
|
+
return String(text)
|
|
520
|
+
.trim()
|
|
511
521
|
.replace(/^```json\s*/i, '')
|
|
512
522
|
.replace(/^```\s*/i, '')
|
|
513
523
|
.replace(/\s*```$/, '')
|
|
@@ -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
|
+
}
|