@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.
@@ -0,0 +1,80 @@
1
+ #! /usr/bin/env node
2
+
3
+ // Vendored copies of the two files this CLI reads from readmeio/git-format.
4
+ // Checked into the repo so installs do not fetch a git dependency (npm 12
5
+ // refuses those by default). `schemas:check` fails when upstream main drifts;
6
+ // `schemas:write` refreshes the copies.
7
+ import { createHash } from 'node:crypto';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ const REPO = 'https://raw.githubusercontent.com/readmeio/git-format/main';
12
+
13
+ const files = [
14
+ {
15
+ filePath: 'vendor/git-format/frontmatter.schema.json',
16
+ url: `${REPO}/frontmatter.schema.json`,
17
+ json: true,
18
+ },
19
+ {
20
+ filePath: 'vendor/git-format/CLAUDE.md',
21
+ url: `${REPO}/CLAUDE.md`,
22
+ json: false,
23
+ },
24
+ ];
25
+
26
+ function getHash(data) {
27
+ const text = typeof data === 'string' ? data : JSON.stringify(data);
28
+ return createHash('sha1').update(Buffer.from(text, 'utf8')).digest('hex');
29
+ }
30
+
31
+ // `writeFile` does not create missing parents. `schemas:write` is how those
32
+ // files get restored, so the directory has to be created first.
33
+ async function writeVendored(filePath, contents) {
34
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
35
+ await fs.writeFile(filePath, contents);
36
+ }
37
+
38
+ async function refresh() {
39
+ const isUpdate = process.argv.includes('--update');
40
+
41
+ try {
42
+ await Promise.all(
43
+ files.map(async file => {
44
+ const res = await fetch(file.url);
45
+ if (!res.ok) throw new Error(`Received ${res.status} when attempting to fetch ${file.url}`);
46
+
47
+ if (file.json) {
48
+ const rawFile = await res.json();
49
+ if (isUpdate) {
50
+ await writeVendored(file.filePath, JSON.stringify(rawFile));
51
+ } else {
52
+ const currentData = JSON.parse(await fs.readFile(file.filePath, 'utf-8'));
53
+ if (getHash(currentData) !== getHash(rawFile)) {
54
+ throw new Error(`${file.filePath} is outdated! Run \`npm run schemas:write\` to update.`);
55
+ }
56
+ }
57
+ return;
58
+ }
59
+
60
+ const rawFile = await res.text();
61
+ if (isUpdate) {
62
+ await writeVendored(file.filePath, rawFile);
63
+ } else {
64
+ const current = await fs.readFile(file.filePath, 'utf-8');
65
+ if (getHash(current) !== getHash(rawFile)) {
66
+ throw new Error(`${file.filePath} is outdated! Run \`npm run schemas:write\` to update.`);
67
+ }
68
+ }
69
+ }),
70
+ );
71
+
72
+ console.log(isUpdate ? 'git-format files updated!' : 'git-format files are up-to-date!');
73
+ process.exit(0);
74
+ } catch (e) {
75
+ console.error(e.message);
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ await refresh();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readme/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "The ReadMe CLI",
5
5
  "keywords": [
6
6
  "cli",
@@ -36,8 +36,10 @@
36
36
  "scripts": {
37
37
  "build:gha": "ncc build src/gha.js -o dist-gha --external @readme/markdown",
38
38
  "format": "npx oxlint --fix && npx oxfmt --write",
39
- "lint": "npx oxlint && npx oxfmt --check .",
39
+ "lint": "npx oxlint && npx oxfmt --check . && npm run schemas:check",
40
40
  "pretest": "npm run lint",
41
+ "schemas:check": "./bin/git-format.js",
42
+ "schemas:write": "./bin/git-format.js --update && npx oxfmt --write vendor/git-format/frontmatter.schema.json",
41
43
  "start": "node bin/readme.js",
42
44
  "test": "vitest run"
43
45
  },
@@ -45,14 +47,13 @@
45
47
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
46
48
  "@readme/markdown": "^13.3.0",
47
49
  "@readme/openapi-parser": "^5.5.0",
48
- "@readmeio/git-format": "git+https://github.com/readmeio/git-format.git",
49
50
  "ajv": "^8.18.0",
50
51
  "chalk": "^5.4.1",
51
52
  "cli-spinners": "^3.4.0",
52
53
  "commander": "^13.1.0",
53
54
  "js-yaml": "^5.5.0",
54
55
  "log-update": "^8.0.0",
55
- "next": "^15.0.0",
56
+ "next": "^15.5.29",
56
57
  "oas-normalize": "^15.7.1",
57
58
  "ora": "^9.3.0",
58
59
  "react": "^18.3.1",
@@ -68,6 +69,24 @@
68
69
  "oxlint": "^1.89.0",
69
70
  "vitest": "^5.0.5"
70
71
  },
72
+ "overrides": {
73
+ "@graphql-tools/executor-graphql-ws": {
74
+ "ws": "8.22.1"
75
+ },
76
+ "@graphql-tools/executor-legacy-ws": {
77
+ "ws": "8.22.1"
78
+ },
79
+ "cosmiconfig": {
80
+ "js-yaml": "4.3.2"
81
+ },
82
+ "express-rate-limit": {
83
+ "express": "4.22.3"
84
+ },
85
+ "graphql-config": {
86
+ "minimatch": "4.2.6"
87
+ },
88
+ "uuid": "^11.1.1"
89
+ },
71
90
  "engines": {
72
91
  "node": ">=22"
73
92
  }
@@ -18,6 +18,7 @@ import {
18
18
  organizeFromScratchPrompt,
19
19
  organizeFromScratchOutputSchema,
20
20
  } from '../prompts/index.js';
21
+ import { makeContainment, PathEscapeError } from '../utils/containment.js';
21
22
  import { analyzeLlmsTxt } from '../utils/llms.js';
22
23
  import { parseFrontmatter, toFrontmatter } from '../utils/markdown.js';
23
24
  import * as styles from '../utils/styles.js';
@@ -1322,6 +1323,11 @@ export async function runAgent({ userPrompt, systemPrompt, cwd, model }) {
1322
1323
 
1323
1324
  function makeStagingGuard(stagingDir) {
1324
1325
  const absStaging = path.resolve(stagingDir);
1326
+
1327
+ // `path.relative` is lexical, so a symlink planted in the staging dir
1328
+ // (or a path whose existing ancestor is one) would look inside it while
1329
+ // the Write tool follows the link. Same containment check as oas:sync.
1330
+ const assertContained = makeContainment(stagingDir, { label: 'staging directory' });
1325
1331
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
1326
1332
 
1327
1333
  return async (toolName, input) => {
@@ -1330,10 +1336,13 @@ function makeStagingGuard(stagingDir) {
1330
1336
  if (typeof fp !== 'string' || !fp) {
1331
1337
  return { behavior: 'deny', message: `${toolName}: missing file_path` };
1332
1338
  }
1339
+
1333
1340
  const abs = path.isAbsolute(fp) ? path.resolve(fp) : path.resolve(absStaging, fp);
1334
- const rel = path.relative(absStaging, abs);
1335
- const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
1336
- if (!inside) {
1341
+
1342
+ try {
1343
+ assertContained(abs);
1344
+ } catch (err) {
1345
+ if (!(err instanceof PathEscapeError)) throw err;
1337
1346
  styles.warning(`Blocked ${toolName} outside staging: ${fp}`);
1338
1347
  return {
1339
1348
  behavior: 'deny',