@yep-ai/cli 0.0.0-stage → 1.0.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,260 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants as FS } from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ const MAX_FILE = 10 * 1024 * 1024;
6
+ const MAX_OUTPUT = 64 * 1024;
7
+ function safeRelative(value = "") {
8
+ if (typeof value !== "string" || value.length > 4096 || /[\\:\0\r\n]/u.test(value) || value.startsWith("/") || value.split("/").some((x) => x === ".." || x === ".")) throw new Error("Unsafe relative path");
9
+ return value;
10
+ }
11
+ function samePath(a, b) {
12
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
13
+ }
14
+ function within(root, candidate) {
15
+ const rel = path.relative(root, candidate);
16
+ return rel === "" || !rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel);
17
+ }
18
+ async function rootDescriptor(id, location, privateDir) {
19
+ const absolute = path.resolve(location);
20
+ const stat = await fs.lstat(absolute);
21
+ if (!stat.isDirectory() || stat.isSymbolicLink() || !samePath(await fs.realpath(absolute), absolute)) throw new Error("Root must be an ordinary directory without links");
22
+ const privatePath = privateDir && path.resolve(privateDir);
23
+ if (privatePath && within(privatePath, absolute)) throw new Error("Private CLI directory cannot be shared");
24
+ return { id, absolute, name: path.basename(absolute) || "文件系统", dev: stat.dev, ino: stat.ino, privateDir: privatePath };
25
+ }
26
+ async function resolveSafe(root, relative = "", kind) {
27
+ safeRelative(relative);
28
+ const rootStat = await fs.lstat(root.absolute);
29
+ if (rootStat.isSymbolicLink() || rootStat.dev !== root.dev || rootStat.ino !== root.ino || !samePath(await fs.realpath(root.absolute), root.absolute)) throw new Error("Authorized directory was replaced");
30
+ let candidate = root.absolute;
31
+ for (const segment of relative.split("/").filter(Boolean)) {
32
+ candidate = path.join(candidate, segment);
33
+ const st2 = await fs.lstat(candidate);
34
+ if (st2.isSymbolicLink() || !st2.isDirectory() && !st2.isFile()) throw new Error("Links and special files are forbidden");
35
+ }
36
+ const st = await fs.lstat(candidate);
37
+ const real = await fs.realpath(candidate);
38
+ if (!samePath(real, candidate) || !within(root.absolute, real) || root.privateDir && within(root.privateDir, real)) throw new Error("Path escapes authorization");
39
+ if (kind === "file" && !st.isFile() || kind === "directory" && !st.isDirectory()) throw new Error("Unexpected file type");
40
+ return { absolute: candidate, stat: st };
41
+ }
42
+ async function pinnedOpen(root, relative, directory = false) {
43
+ const checked = await resolveSafe(root, relative, directory ? "directory" : "file");
44
+ if (process.platform !== "linux") return fs.open(checked.absolute, FS.O_RDONLY | (FS.O_NOFOLLOW || 0) | (directory ? FS.O_DIRECTORY || 0 : 0));
45
+ const parts = relative.split("/").filter(Boolean);
46
+ const held = [];
47
+ try {
48
+ let parent = await fs.open(root.absolute, FS.O_RDONLY | FS.O_NOFOLLOW | FS.O_DIRECTORY);
49
+ held.push(parent);
50
+ const rootIdentity = await parent.stat();
51
+ if (rootIdentity.dev !== root.dev || rootIdentity.ino !== root.ino) throw new Error("Authorized directory was replaced");
52
+ for (let i = 0; i < parts.length; i++) {
53
+ const isDirectory = i < parts.length - 1 || directory;
54
+ parent = await fs.open(`/proc/self/fd/${parent.fd}/${parts[i]}`, FS.O_RDONLY | FS.O_NOFOLLOW | (isDirectory ? FS.O_DIRECTORY : 0));
55
+ held.push(parent);
56
+ const st = await parent.stat();
57
+ if (isDirectory ? !st.isDirectory() : !st.isFile()) throw new Error("Links and special files are forbidden");
58
+ }
59
+ const result = held.pop();
60
+ return result;
61
+ } finally {
62
+ await Promise.all(held.map((handle) => handle.close()));
63
+ }
64
+ }
65
+ async function readFile(root, relative, maximum = MAX_FILE) {
66
+ const resolved = await resolveSafe(root, relative, "file");
67
+ if (resolved.stat.size > maximum) throw new Error("File exceeds size limit");
68
+ const fd = await pinnedOpen(root, relative);
69
+ try {
70
+ const before = await fd.stat();
71
+ if (before.ino !== resolved.stat.ino || before.dev !== resolved.stat.dev || !before.isFile()) throw new Error("File changed during authorization");
72
+ const buffer = Buffer.alloc(Math.min(maximum + 1, before.size + 1));
73
+ const { bytesRead } = await fd.read(buffer, 0, buffer.length, 0);
74
+ if (bytesRead > maximum) throw new Error("File exceeds size limit");
75
+ await resolveSafe(root, relative, "file");
76
+ return buffer.subarray(0, bytesRead);
77
+ } finally {
78
+ await fd.close();
79
+ }
80
+ }
81
+ async function list(root, relative = "", offset = 0, includeFiles = true) {
82
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1e4) throw new Error("Invalid offset");
83
+ const { absolute } = await resolveSafe(root, relative, "directory");
84
+ let entries;
85
+ const pinned = await pinnedOpen(root, relative, true);
86
+ try {
87
+ entries = await fs.readdir(process.platform === "linux" ? `/proc/self/fd/${pinned.fd}` : absolute, { withFileTypes: true });
88
+ await resolveSafe(root, relative, "directory");
89
+ } finally {
90
+ await pinned.close();
91
+ }
92
+ ;
93
+ if (entries.length > 1e4) throw new Error("Directory exceeds 10000 entries");
94
+ const filtered = entries.filter((e) => !e.isSymbolicLink() && (e.isDirectory() || includeFiles && e.isFile()) && !/[\\:\0\r\n]/u.test(e.name)).sort((a, b) => a.name.localeCompare(b.name));
95
+ const page = [];
96
+ for (const e of filtered.slice(offset, offset + 200)) {
97
+ const p = [relative, e.name].filter(Boolean).join("/");
98
+ try {
99
+ await resolveSafe(root, p);
100
+ page.push({ name: e.name, kind: e.isDirectory() ? "directory" : "file", path: p });
101
+ } catch {
102
+ }
103
+ }
104
+ return { folderId: root.id, path: relative, parentPath: relative ? relative.split("/").slice(0, -1).join("/") : null, entries: page, offset, hasMore: offset + 200 < filtered.length };
105
+ }
106
+ function utf8(data) {
107
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
108
+ if (text.includes("\0")) throw new Error("File is not UTF-8 text");
109
+ return text;
110
+ }
111
+ function bounded(text) {
112
+ const b = Buffer.from(text);
113
+ return { text: b.subarray(0, MAX_OUTPUT).toString("utf8"), truncated: b.length > MAX_OUTPUT };
114
+ }
115
+ async function fileOperation(root, request) {
116
+ const op = request.operation;
117
+ const relative = request.path ?? "";
118
+ if (op === "list") return list(root, relative, request.offset ?? 0, true);
119
+ if (op === "read") {
120
+ const data = await readFile(root, relative);
121
+ return { name: path.basename(relative), data: data.toString("base64"), size: data.length };
122
+ }
123
+ const text = utf8(await readFile(root, relative));
124
+ const lines = text.split("\n");
125
+ if (op === "read_lines") {
126
+ const start = request.startLine ?? 1;
127
+ const end = request.endLine ?? Math.min(start + 499, lines.length);
128
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end - start >= 500) throw new Error("Invalid line range (maximum 500 lines)");
129
+ return { name: path.basename(relative), ...bounded(lines.slice(start - 1, end).join("\n")), startLine: start, endLine: Math.min(end, lines.length) };
130
+ }
131
+ if (op === "search") {
132
+ if (typeof request.query !== "string" || !request.query || request.query.length > 1e3) throw new Error("Literal query required");
133
+ let matches = [];
134
+ let bytes = 0;
135
+ for (let i = 0; i < lines.length; i++) {
136
+ if (lines[i].length > 65536) continue;
137
+ if (lines[i].includes(request.query)) {
138
+ const item = { line: i + 1, text: lines[i] };
139
+ bytes += Buffer.byteLength(item.text);
140
+ if (matches.length >= 50 || bytes > MAX_OUTPUT) break;
141
+ matches.push(item);
142
+ }
143
+ }
144
+ return { matches };
145
+ }
146
+ throw new Error("Unknown file operation");
147
+ }
148
+ function tokenize(command) {
149
+ if (typeof command !== "string" || !command.trim() || command.length > 4096 || /[|&;<>$`\n\r\0%!*?()[\]{}]/u.test(command)) throw new Error("Only restricted built-in commands are allowed");
150
+ const tokens = [];
151
+ const regex = /"([^"\\]*)"|'([^'\\]*)'|([^\s"']+)/gu;
152
+ let match;
153
+ let end = 0;
154
+ while (match = regex.exec(command)) {
155
+ if (command.slice(end, match.index).trim()) throw new Error("Invalid command quoting");
156
+ tokens.push(match[1] ?? match[2] ?? match[3]);
157
+ end = regex.lastIndex;
158
+ }
159
+ if (command.slice(end).trim()) throw new Error("Invalid command quoting");
160
+ return tokens;
161
+ }
162
+ async function builtin(root, cwd, command) {
163
+ const [cmd, ...args] = tokenize(command);
164
+ await resolveSafe(root, cwd, "directory");
165
+ const relative = (v = "") => [cwd, v].filter(Boolean).join("/");
166
+ const read = async (v) => utf8(await readFile(root, relative(v)));
167
+ if (cmd === "pwd" && args.length === 0) return { text: cwd || "." };
168
+ if (["ls", "dir"].includes(cmd) && args.length <= 1) {
169
+ const r = await list(root, relative(args[0]), 0, true);
170
+ return bounded(r.entries.map((e) => e.name + (e.kind === "directory" ? "/" : "")).join("\n"));
171
+ }
172
+ if (cmd === "echo") return bounded(args.join(" ") + "\n");
173
+ if (["cat", "type"].includes(cmd) && args.length === 1) return bounded(await read(args[0]));
174
+ if (["head", "tail"].includes(cmd)) {
175
+ let count = 10, file;
176
+ if (args.length === 1) file = args[0];
177
+ else if (args.length === 3 && args[0] === "-n" && /^\d+$/u.test(args[1])) {
178
+ count = Number(args[1]);
179
+ file = args[2];
180
+ } else throw new Error("Usage: head/tail [-n count] file");
181
+ if (count > 500) throw new Error("Maximum 500 lines");
182
+ const lines = (await read(file)).split("\n");
183
+ return bounded((cmd === "head" ? lines.slice(0, count) : lines.slice(-count)).join("\n"));
184
+ }
185
+ if (["grep", "findstr"].includes(cmd) && args.length === 2) {
186
+ const lines = (await read(args[1])).split("\n");
187
+ return bounded(lines.filter((l) => l.includes(args[0])).slice(0, 50).join("\n"));
188
+ }
189
+ if (cmd === "wc" && args.length === 2 && args[0] === "-l") return { text: String((await read(args[1])).split("\n").length - 1) + "\n" };
190
+ throw new Error("Unsupported restricted command");
191
+ }
192
+ async function execute(root, request, { restricted = true, signal, secrets = [] } = {}) {
193
+ if (request.operation !== "exec") return fileOperation(root, request);
194
+ if (restricted) {
195
+ const result = await builtin(root, request.path ?? "", request.command);
196
+ return { stdout: result.text, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
197
+ }
198
+ const { absolute } = await resolveSafe(root, request.path ?? "", "directory");
199
+ if (typeof request.command !== "string" || request.command.length > 4096 || request.command.includes("\0")) throw new Error("Invalid command");
200
+ return new Promise((resolve, reject) => {
201
+ const env = { ...process.env };
202
+ for (const k of Object.keys(env)) if (/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION/iu.test(k)) delete env[k];
203
+ const child = spawn(process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/d", "/s", "/c", request.command] : ["-c", request.command], { cwd: absolute, env, detached: process.platform !== "win32", windowsHide: true });
204
+ let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false;
205
+ const kill = () => {
206
+ if (process.platform === "win32") {
207
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
208
+ } else {
209
+ try {
210
+ process.kill(-child.pid, "SIGKILL");
211
+ } catch {
212
+ child.kill("SIGKILL");
213
+ }
214
+ }
215
+ };
216
+ const timer = setTimeout(() => {
217
+ timedOut = true;
218
+ kill();
219
+ }, 3e4);
220
+ const abort = () => kill();
221
+ signal?.addEventListener("abort", abort, { once: true });
222
+ if (signal?.aborted) kill();
223
+ const append = (which, chunk) => {
224
+ const room = Math.max(0, MAX_OUTPUT - bytes);
225
+ const s = chunk.subarray(0, room).toString("utf8");
226
+ bytes += chunk.length;
227
+ if (bytes > MAX_OUTPUT) truncated = true;
228
+ if (which === "stdout") stdout += s;
229
+ else stderr += s;
230
+ };
231
+ child.stdout.on("data", (c) => append("stdout", c));
232
+ child.stderr.on("data", (c) => append("stderr", c));
233
+ child.once("error", (e) => {
234
+ clearTimeout(timer);
235
+ signal?.removeEventListener("abort", abort);
236
+ reject(e);
237
+ });
238
+ child.once("close", (code, sig) => {
239
+ clearTimeout(timer);
240
+ signal?.removeEventListener("abort", abort);
241
+ if (signal?.aborted) return reject(new Error("Operation canceled"));
242
+ for (const secret of secrets) if (secret) {
243
+ stdout = stdout.split(secret).join("[REDACTED]");
244
+ stderr = stderr.split(secret).join("[REDACTED]");
245
+ }
246
+ ;
247
+ resolve({ stdout, stderr, exitCode: code, signal: sig, truncated, timedOut });
248
+ });
249
+ });
250
+ }
251
+ export {
252
+ MAX_FILE,
253
+ builtin,
254
+ execute,
255
+ fileOperation,
256
+ list,
257
+ resolveSafe,
258
+ rootDescriptor,
259
+ safeRelative
260
+ };
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@yep-ai/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Yep Marketplace unified CLI: skills, agents, plugins and authorized device nodes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "yep": "bin/yep.js"
8
+ },
9
+ "files": [
10
+ "bin/yep.js",
11
+ "bin/market.js",
12
+ "bin/clients.js",
13
+ "lib",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.0.0"
18
+ },
19
+ "dependencies": {
20
+ "ws": "^8.21.0"
21
+ },
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org"
26
+ },
27
+ "scripts": {
28
+ "build": "node bin/yep.js --version",
29
+ "check": "node --check bin/yep.js && node --check bin/market.js && node --check lib/device/command.js && node --check lib/device/client.js && node --check lib/device/tools.js",
30
+ "test": "node --test"
31
+ }
32
+ }