@rayrun/cli 0.3.0 → 0.5.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.
@@ -0,0 +1,16 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+
3
+ /** Keeps untrusted API diagnostics and filesystem paths from controlling the caller's terminal. */
4
+ export const sanitizeTerminalError = (value) =>
5
+ stripVTControlCharacters(String(value))
6
+ .replaceAll(/\r\n?/gu, '\n')
7
+ .split('\n')
8
+ .map((line) =>
9
+ line
10
+ .replaceAll(/\p{Cc}+/gu, ' ')
11
+ .replaceAll(
12
+ /\p{Bidi_Control}/gu,
13
+ (character) => `\\u{${character.codePointAt(0).toString(16).toUpperCase()}}`,
14
+ ),
15
+ )
16
+ .join('\n');
package/src/skills.js ADDED
@@ -0,0 +1,122 @@
1
+ import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const MAX_FILES = 512;
5
+ const MAX_BYTES = 16 * 1_024 * 1_024;
6
+
7
+ const safeRelativePath = (value) => {
8
+ const normalized = value.normalize('NFC').split(path.sep).join('/');
9
+ if (
10
+ normalized === '' ||
11
+ normalized.startsWith('/') ||
12
+ normalized.includes('\\') ||
13
+ normalized.split('/').some((segment) => ['', '.', '..'].includes(segment))
14
+ ) {
15
+ throw new Error(`Unsafe skill file path: ${JSON.stringify(value)}.`);
16
+ }
17
+ return normalized;
18
+ };
19
+
20
+ export const readSkillDirectory = async (directory) => {
21
+ const root = path.resolve(directory);
22
+ const files = [];
23
+ let byteCount = 0;
24
+
25
+ const visit = async (current) => {
26
+ const entries = await readdir(current, { withFileTypes: true });
27
+ for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
28
+ const target = path.join(current, entry.name);
29
+ if (entry.isSymbolicLink()) {
30
+ throw new Error(`Skill directories cannot contain symbolic links: ${target}`);
31
+ }
32
+ if (entry.isDirectory()) {
33
+ await visit(target);
34
+ continue;
35
+ }
36
+ if (!entry.isFile()) {
37
+ throw new Error(`Skill directories cannot contain special files: ${target}`);
38
+ }
39
+ if (files.length >= MAX_FILES) throw new Error('A skill can contain at most 512 files.');
40
+ const content = await readFile(target);
41
+ byteCount += content.byteLength;
42
+ if (byteCount > MAX_BYTES) throw new Error('A skill can contain at most 16 MiB.');
43
+ files.push({
44
+ contentBase64: content.toString('base64'),
45
+ path: safeRelativePath(path.relative(root, target)),
46
+ });
47
+ }
48
+ };
49
+
50
+ const metadata = await lstat(root);
51
+ if (!metadata.isDirectory()) throw new Error(`${directory} is not a directory.`);
52
+ await visit(root);
53
+ return files;
54
+ };
55
+
56
+ export const writeSkillDirectory = async (directory, files) => {
57
+ const root = path.resolve(directory);
58
+ if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES) {
59
+ throw new Error('A downloaded skill must contain 1–512 files.');
60
+ }
61
+ try {
62
+ await lstat(root);
63
+ throw new Error(`Refusing to overwrite existing path: ${root}`);
64
+ } catch (error) {
65
+ if (error?.code !== 'ENOENT') throw error;
66
+ }
67
+
68
+ const prepared = [];
69
+ const paths = new Set();
70
+ let byteCount = 0;
71
+ for (const file of files) {
72
+ const relativePath = safeRelativePath(file.path);
73
+ const collisionKey = relativePath.toLocaleLowerCase('en-US');
74
+ if (paths.has(collisionKey)) {
75
+ throw new Error(`Duplicate skill file path: ${JSON.stringify(file.path)}.`);
76
+ }
77
+ paths.add(collisionKey);
78
+ if (
79
+ typeof file.contentBase64 !== 'string' ||
80
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(file.contentBase64)
81
+ ) {
82
+ throw new Error(`Invalid base64 content for skill file: ${JSON.stringify(file.path)}.`);
83
+ }
84
+ const content = Buffer.from(file.contentBase64, 'base64');
85
+ byteCount += content.byteLength;
86
+ if (byteCount > MAX_BYTES) throw new Error('A downloaded skill can contain at most 16 MiB.');
87
+ prepared.push({ content, relativePath });
88
+ }
89
+ for (const file of prepared) {
90
+ const segments = file.relativePath.split('/');
91
+ for (let length = 1; length < segments.length; length += 1) {
92
+ if (paths.has(segments.slice(0, length).join('/').toLocaleLowerCase('en-US'))) {
93
+ throw new Error(
94
+ `A skill path cannot be both a file and a directory: ${JSON.stringify(file.relativePath)}.`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ await mkdir(path.dirname(root), { mode: 0o700, recursive: true });
101
+ try {
102
+ await mkdir(root, { mode: 0o700 });
103
+ } catch (error) {
104
+ if (error?.code === 'EEXIST') throw new Error(`Refusing to overwrite existing path: ${root}`);
105
+ throw error;
106
+ }
107
+ try {
108
+ for (const file of prepared) {
109
+ const relativePath = file.relativePath;
110
+ const content = file.content;
111
+ const target = path.resolve(root, ...relativePath.split('/'));
112
+ if (!target.startsWith(`${root}${path.sep}`)) {
113
+ throw new Error(`Unsafe skill file path: ${JSON.stringify(relativePath)}.`);
114
+ }
115
+ await mkdir(path.dirname(target), { mode: 0o700, recursive: true });
116
+ await writeFile(target, content, { flag: 'wx', mode: 0o600 });
117
+ }
118
+ } catch (error) {
119
+ await rm(root, { force: true, recursive: true });
120
+ throw error;
121
+ }
122
+ };