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.
Files changed (2) hide show
  1. package/dist/index.js +318 -2
  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(" Scan the QR code with the Lunel app to connect.\n");
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("Connecting to Lunel...");
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.0",
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
- "qrcode-terminal": "^0.12.0"
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": {