lunel-cli 0.1.0 → 0.1.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/dist/index.js +318 -2
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,267 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { WebSocket } from "ws";
|
|
2
3
|
import qrcode from "qrcode-terminal";
|
|
4
|
+
import Ignore from "ignore";
|
|
5
|
+
const ignore = Ignore.default;
|
|
6
|
+
import * as fs from "fs/promises";
|
|
7
|
+
import * as path from "path";
|
|
3
8
|
const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://proxy-1.lunel.dev";
|
|
9
|
+
// Root directory - sandbox all file operations to this
|
|
10
|
+
const ROOT_DIR = process.cwd();
|
|
11
|
+
// Validate and resolve path within sandbox
|
|
12
|
+
function resolveSafePath(requestedPath) {
|
|
13
|
+
// Resolve the requested path relative to ROOT_DIR
|
|
14
|
+
const resolved = path.resolve(ROOT_DIR, requestedPath);
|
|
15
|
+
// Ensure the resolved path is within ROOT_DIR
|
|
16
|
+
if (!resolved.startsWith(ROOT_DIR)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
// Handle ls command
|
|
22
|
+
async function handleLs(requestedPath) {
|
|
23
|
+
const safePath = resolveSafePath(requestedPath);
|
|
24
|
+
if (!safePath) {
|
|
25
|
+
throw new Error("Access denied: path outside root directory");
|
|
26
|
+
}
|
|
27
|
+
const entries = await fs.readdir(safePath, { withFileTypes: true });
|
|
28
|
+
return entries.map((entry) => ({
|
|
29
|
+
name: entry.name,
|
|
30
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
// Handle read command
|
|
34
|
+
async function handleRead(requestedPath) {
|
|
35
|
+
const safePath = resolveSafePath(requestedPath);
|
|
36
|
+
if (!safePath) {
|
|
37
|
+
throw new Error("Access denied: path outside root directory");
|
|
38
|
+
}
|
|
39
|
+
const content = await fs.readFile(safePath, "utf-8");
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
// Handle write command
|
|
43
|
+
async function handleWrite(requestedPath, content) {
|
|
44
|
+
const safePath = resolveSafePath(requestedPath);
|
|
45
|
+
if (!safePath) {
|
|
46
|
+
throw new Error("Access denied: path outside root directory");
|
|
47
|
+
}
|
|
48
|
+
// Ensure parent directory exists
|
|
49
|
+
const parentDir = path.dirname(safePath);
|
|
50
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
51
|
+
await fs.writeFile(safePath, content, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
// Load gitignore patterns from a directory
|
|
54
|
+
async function loadGitignore(dirPath) {
|
|
55
|
+
const ig = ignore();
|
|
56
|
+
// Always ignore .git directory
|
|
57
|
+
ig.add(".git");
|
|
58
|
+
try {
|
|
59
|
+
const gitignorePath = path.join(dirPath, ".gitignore");
|
|
60
|
+
const content = await fs.readFile(gitignorePath, "utf-8");
|
|
61
|
+
ig.add(content);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// No .gitignore file, that's fine
|
|
65
|
+
}
|
|
66
|
+
return ig;
|
|
67
|
+
}
|
|
68
|
+
// Handle grep command - search for pattern in files
|
|
69
|
+
async function handleGrep(requestedPath, pattern, options = {}) {
|
|
70
|
+
const safePath = resolveSafePath(requestedPath);
|
|
71
|
+
if (!safePath) {
|
|
72
|
+
throw new Error("Access denied: path outside root directory");
|
|
73
|
+
}
|
|
74
|
+
const { caseSensitive = true, maxResults = 100 } = options;
|
|
75
|
+
const matches = [];
|
|
76
|
+
const regex = new RegExp(pattern, caseSensitive ? "g" : "gi");
|
|
77
|
+
// Load root gitignore
|
|
78
|
+
const rootIgnore = await loadGitignore(ROOT_DIR);
|
|
79
|
+
async function searchFile(filePath, relativePath) {
|
|
80
|
+
if (matches.length >= maxResults)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
84
|
+
const lines = content.split("\n");
|
|
85
|
+
for (let i = 0; i < lines.length && matches.length < maxResults; i++) {
|
|
86
|
+
if (regex.test(lines[i])) {
|
|
87
|
+
matches.push({
|
|
88
|
+
file: relativePath,
|
|
89
|
+
line: i + 1,
|
|
90
|
+
content: lines[i].substring(0, 500), // Limit line length
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Reset regex lastIndex for global flag
|
|
94
|
+
regex.lastIndex = 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Skip files that can't be read (binary, permissions, etc.)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function searchDir(dirPath, relativePath, ig) {
|
|
102
|
+
if (matches.length >= maxResults)
|
|
103
|
+
return;
|
|
104
|
+
// Check for local .gitignore and merge with parent
|
|
105
|
+
const localIgnore = ignore().add(ig);
|
|
106
|
+
try {
|
|
107
|
+
const localGitignorePath = path.join(dirPath, ".gitignore");
|
|
108
|
+
const content = await fs.readFile(localGitignorePath, "utf-8");
|
|
109
|
+
localIgnore.add(content);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// No local .gitignore
|
|
113
|
+
}
|
|
114
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (matches.length >= maxResults)
|
|
117
|
+
break;
|
|
118
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
119
|
+
// Check if ignored by gitignore
|
|
120
|
+
// For directories, append / to match gitignore patterns correctly
|
|
121
|
+
const checkPath = entry.isDirectory() ? `${relPath}/` : relPath;
|
|
122
|
+
if (localIgnore.ignores(checkPath)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
await searchDir(fullPath, relPath, localIgnore);
|
|
128
|
+
}
|
|
129
|
+
else if (entry.isFile()) {
|
|
130
|
+
await searchFile(fullPath, relPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const stat = await fs.stat(safePath);
|
|
135
|
+
if (stat.isDirectory()) {
|
|
136
|
+
await searchDir(safePath, requestedPath === "." ? "" : requestedPath, rootIgnore);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await searchFile(safePath, requestedPath);
|
|
140
|
+
}
|
|
141
|
+
return matches;
|
|
142
|
+
}
|
|
143
|
+
// Handle replace command - find and replace in a file
|
|
144
|
+
async function handleReplace(requestedPath, search, replace, options = {}) {
|
|
145
|
+
const safePath = resolveSafePath(requestedPath);
|
|
146
|
+
if (!safePath) {
|
|
147
|
+
throw new Error("Access denied: path outside root directory");
|
|
148
|
+
}
|
|
149
|
+
const { caseSensitive = true, replaceAll = true } = options;
|
|
150
|
+
const content = await fs.readFile(safePath, "utf-8");
|
|
151
|
+
const flags = replaceAll ? (caseSensitive ? "g" : "gi") : (caseSensitive ? "" : "i");
|
|
152
|
+
const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
|
|
153
|
+
// Count replacements
|
|
154
|
+
const matchCount = (content.match(regex) || []).length;
|
|
155
|
+
if (matchCount === 0) {
|
|
156
|
+
return { file: requestedPath, replacements: 0 };
|
|
157
|
+
}
|
|
158
|
+
const newContent = content.replace(regex, replace);
|
|
159
|
+
await fs.writeFile(safePath, newContent, "utf-8");
|
|
160
|
+
return { file: requestedPath, replacements: matchCount };
|
|
161
|
+
}
|
|
162
|
+
// Process incoming request from app
|
|
163
|
+
async function processRequest(message) {
|
|
164
|
+
const { id, action, payload } = message;
|
|
165
|
+
try {
|
|
166
|
+
switch (action) {
|
|
167
|
+
case "ls": {
|
|
168
|
+
const reqPath = payload.path || ".";
|
|
169
|
+
const entries = await handleLs(reqPath);
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
type: "response",
|
|
173
|
+
action: "ls",
|
|
174
|
+
payload: { path: reqPath, entries },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
case "read": {
|
|
178
|
+
const reqPath = payload.path;
|
|
179
|
+
if (!reqPath) {
|
|
180
|
+
throw new Error("path is required");
|
|
181
|
+
}
|
|
182
|
+
const content = await handleRead(reqPath);
|
|
183
|
+
return {
|
|
184
|
+
id,
|
|
185
|
+
type: "response",
|
|
186
|
+
action: "read",
|
|
187
|
+
payload: { path: reqPath, content },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
case "write": {
|
|
191
|
+
const reqPath = payload.path;
|
|
192
|
+
const content = payload.content;
|
|
193
|
+
if (!reqPath) {
|
|
194
|
+
throw new Error("path is required");
|
|
195
|
+
}
|
|
196
|
+
if (typeof content !== "string") {
|
|
197
|
+
throw new Error("content is required");
|
|
198
|
+
}
|
|
199
|
+
await handleWrite(reqPath, content);
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
type: "response",
|
|
203
|
+
action: "write",
|
|
204
|
+
payload: { path: reqPath, success: true },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
case "grep": {
|
|
208
|
+
const reqPath = payload.path || ".";
|
|
209
|
+
const pattern = payload.pattern;
|
|
210
|
+
if (!pattern) {
|
|
211
|
+
throw new Error("pattern is required");
|
|
212
|
+
}
|
|
213
|
+
const matches = await handleGrep(reqPath, pattern, {
|
|
214
|
+
caseSensitive: payload.caseSensitive,
|
|
215
|
+
maxResults: payload.maxResults,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
type: "response",
|
|
220
|
+
action: "grep",
|
|
221
|
+
payload: { path: reqPath, pattern, matches },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
case "replace": {
|
|
225
|
+
const reqPath = payload.path;
|
|
226
|
+
const search = payload.search;
|
|
227
|
+
const replaceWith = payload.replace;
|
|
228
|
+
if (!reqPath) {
|
|
229
|
+
throw new Error("path is required");
|
|
230
|
+
}
|
|
231
|
+
if (!search) {
|
|
232
|
+
throw new Error("search is required");
|
|
233
|
+
}
|
|
234
|
+
if (typeof replaceWith !== "string") {
|
|
235
|
+
throw new Error("replace is required");
|
|
236
|
+
}
|
|
237
|
+
const result = await handleReplace(reqPath, search, replaceWith, {
|
|
238
|
+
caseSensitive: payload.caseSensitive,
|
|
239
|
+
replaceAll: payload.replaceAll,
|
|
240
|
+
});
|
|
241
|
+
return {
|
|
242
|
+
id,
|
|
243
|
+
type: "response",
|
|
244
|
+
action: "replace",
|
|
245
|
+
payload: { ...result },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
default:
|
|
249
|
+
throw new Error(`Unknown action: ${action}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
type: "error",
|
|
257
|
+
action,
|
|
258
|
+
payload: {
|
|
259
|
+
code: error.code || "ERROR",
|
|
260
|
+
message: errorMessage,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
4
265
|
async function createSession() {
|
|
5
266
|
const response = await fetch(`${PROXY_URL}/v1/session`, {
|
|
6
267
|
method: "POST",
|
|
@@ -19,14 +280,69 @@ function displayQR(code) {
|
|
|
19
280
|
qrcode.generate(code, { small: true }, (qr) => {
|
|
20
281
|
console.log(qr);
|
|
21
282
|
console.log(`\n Session code: ${code}\n`);
|
|
22
|
-
console.log(
|
|
283
|
+
console.log(` Root directory: ${ROOT_DIR}\n`);
|
|
284
|
+
console.log(" Scan the QR code with the Lunel app to connect.");
|
|
285
|
+
console.log(" Press Ctrl+C to exit.\n");
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function connectWebSocket(code) {
|
|
289
|
+
const wsUrl = PROXY_URL.replace(/^http/, "ws") + `/v1/ws/cli?code=${code}`;
|
|
290
|
+
console.log("Connecting to proxy...");
|
|
291
|
+
const ws = new WebSocket(wsUrl);
|
|
292
|
+
ws.on("open", () => {
|
|
293
|
+
console.log("Connected to proxy. Waiting for app...\n");
|
|
294
|
+
});
|
|
295
|
+
ws.on("message", async (data) => {
|
|
296
|
+
try {
|
|
297
|
+
const message = JSON.parse(data.toString());
|
|
298
|
+
// Handle system messages
|
|
299
|
+
if (message.type === "connected") {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (message.type === "peer_connected") {
|
|
303
|
+
console.log("App connected!\n");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (message.type === "peer_disconnected") {
|
|
307
|
+
console.log("App disconnected.\n");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Handle file operation requests
|
|
311
|
+
if (message.type === "request") {
|
|
312
|
+
const response = await processRequest(message);
|
|
313
|
+
ws.send(JSON.stringify(response));
|
|
314
|
+
}
|
|
315
|
+
// Handle proxy errors
|
|
316
|
+
if (message.type === "error" && !("action" in message)) {
|
|
317
|
+
console.error("Proxy error:", message.payload?.message);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error("Error processing message:", error);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
ws.on("close", (code, reason) => {
|
|
326
|
+
console.log(`\nDisconnected from proxy (${code}: ${reason.toString()})`);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
});
|
|
329
|
+
ws.on("error", (error) => {
|
|
330
|
+
console.error("WebSocket error:", error.message);
|
|
331
|
+
});
|
|
332
|
+
// Handle graceful shutdown
|
|
333
|
+
process.on("SIGINT", () => {
|
|
334
|
+
console.log("\nShutting down...");
|
|
335
|
+
ws.close();
|
|
336
|
+
process.exit(0);
|
|
23
337
|
});
|
|
24
338
|
}
|
|
25
339
|
async function main() {
|
|
26
|
-
console.log("
|
|
340
|
+
console.log("Lunel CLI");
|
|
341
|
+
console.log("=========\n");
|
|
27
342
|
try {
|
|
28
343
|
const code = await createSession();
|
|
29
344
|
displayQR(code);
|
|
345
|
+
connectWebSocket(code);
|
|
30
346
|
}
|
|
31
347
|
catch (error) {
|
|
32
348
|
if (error instanceof Error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lunel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"author": [
|
|
5
5
|
{
|
|
6
6
|
"name": "Soham Bharambe",
|
|
@@ -25,11 +25,14 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"
|
|
28
|
+
"ignore": "^6.0.2",
|
|
29
|
+
"qrcode-terminal": "^0.12.0",
|
|
30
|
+
"ws": "^8.18.0"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@types/node": "^20.0.0",
|
|
32
34
|
"@types/qrcode-terminal": "^0.12.2",
|
|
35
|
+
"@types/ws": "^8.5.13",
|
|
33
36
|
"typescript": "^5.0.0"
|
|
34
37
|
},
|
|
35
38
|
"engines": {
|