@lucenaone/coder 1.1.5 → 1.1.16

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/package.json CHANGED
@@ -1,17 +1,25 @@
1
1
  {
2
2
  "name": "@lucenaone/coder",
3
- "version": "1.1.5",
3
+ "version": "1.1.16",
4
4
  "description": "Private tunnel for connecting LucenaCoder.com to your local folder. Always remains folder scoped while providing full terminal access.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lucenacoder": "./bin/lucena.js"
8
8
  },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "grammars",
13
+ "README.md"
14
+ ],
9
15
  "scripts": {
10
16
  "start": "node bin/lucena.js"
11
17
  },
12
18
  "dependencies": {
13
19
  "chokidar": "^4.0.0",
20
+ "execa": "^9.6.1",
14
21
  "firebase": "^11.0.0",
22
+ "shell-quote": "^1.8.4",
15
23
  "web-tree-sitter": "^0.26.9"
16
24
  },
17
25
  "keywords": [
package/src/agent.js CHANGED
@@ -1,18 +1,23 @@
1
1
  import { initializeApp } from 'firebase/app';
2
2
  import { getDatabase, ref, push, set, onChildAdded, onDisconnect, serverTimestamp, remove, get } from 'firebase/database';
3
- import { spawn } from 'child_process';
3
+ import { spawn, spawnSync } from 'child_process';
4
4
  import { watch } from 'chokidar';
5
5
  import { readFile, writeFile, mkdir, readdir, stat, unlink, rm } from 'fs/promises';
6
6
  import { join, resolve, dirname, basename, relative, isAbsolute, extname } from 'path';
7
7
  import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
8
  import { FIREBASE_CONFIG } from './config.js';
9
9
  import { buildIndex, reindexFile } from './cli-indexer.js';
10
+ import { LucenaShell } from './lucena-shell.js';
10
11
 
11
12
  const IGNORED_PATTERNS = [
12
13
  'node_modules', '.git', '.next', '.wrangler', '.DS_Store',
13
- 'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase'
14
+ 'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase',
15
+ '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache',
16
+ 'coverage', '.coverage', 'target', 'out', '.gradle'
14
17
  ];
15
18
 
19
+ const SEARCH_GLOB = '*.{js,jsx,ts,tsx,json,md,css,html,py,rb,go,rs}';
20
+
16
21
  // ── The CLI Jailer ──
17
22
  function getJailedPath(baseDir, rawPath) {
18
23
  let p = rawPath.replace(/\\/g, '/');
@@ -59,8 +64,15 @@ export class LucenaAgent {
59
64
  this.watcher = null;
60
65
  this.activeCommands = new Map();
61
66
  this.connected = false;
67
+ this.stripCwd = true; // Default: strip absolute paths (Browser Mode safety)
62
68
  this.indexData = null; // Pre-built index from CLI-side parsing
63
69
  this.indexPromise = null; // The in-flight indexing promise
70
+ this.shell = new LucenaShell(this.cwd);
71
+ }
72
+
73
+ /** Conditionally strip cwd — only in Browser Mode */
74
+ _sanitize(text) {
75
+ return this.stripCwd ? stripCwd(this.cwd, text) : text;
64
76
  }
65
77
 
66
78
  _scaffoldWorkspaceBrain() {
@@ -129,6 +141,11 @@ export class LucenaAgent {
129
141
  if (!data) return;
130
142
  remove(snapshot.ref);
131
143
 
144
+ // Browser tells us whether to strip cwd from output
145
+ if (typeof data.stripCwd === 'boolean') {
146
+ this.stripCwd = data.stripCwd;
147
+ }
148
+
132
149
  // Index is guaranteed ready (awaited in start()), push immediately
133
150
  if (this.indexData) {
134
151
  this.pushIndexSnapshot(this.indexData);
@@ -144,7 +161,7 @@ export class LucenaAgent {
144
161
  try {
145
162
  await this.handleCommand(command);
146
163
  } catch (err) {
147
- this.pushResponse(command.messageId, 'error', stripCwd(this.cwd, err.message));
164
+ this.pushResponse(command.messageId, 'error', this._sanitize(err.message));
148
165
  }
149
166
  });
150
167
 
@@ -195,6 +212,7 @@ export class LucenaAgent {
195
212
  case 'execute': return this.executeCommand(command);
196
213
  case 'read_file': return this.readFileCmd(command);
197
214
  case 'write_file': return this.writeFileCmd(command);
215
+ case 'list_files': return this.listFiles(command);
198
216
  case 'list_dir': return this.listDir(command);
199
217
  case 'stat': return this.statFile(command);
200
218
  case 'delete_file': return this.deleteFile(command);
@@ -216,15 +234,26 @@ export class LucenaAgent {
216
234
  });
217
235
  }
218
236
 
219
- async executeCommand({ messageId, command }) {
220
- const child = spawn('sh', ['-c', command], { cwd: this.cwd });
237
+ async executeCommand({ messageId, command, mode = 'safe', approved = false, outsideWorkspaceApproved = false }) {
238
+ let child;
221
239
 
222
- child.stdout.on('data', (data) => {
223
- this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
240
+ try {
241
+ child = this.shell.execute(command, { mode, approved, outsideWorkspaceApproved });
242
+ } catch (err) {
243
+ return this.pushResponse(messageId, 'error', this._sanitize(err.message));
244
+ }
245
+
246
+ child.stdout?.on('data', (data) => {
247
+ this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
224
248
  });
225
- child.stderr.on('data', (data) => {
226
- this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
249
+ child.stderr?.on('data', (data) => {
250
+ this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
227
251
  });
252
+
253
+ // Immediately close stdin so commands that try to read it get EOF
254
+ // instead of hanging until the 60s timeout
255
+ try { child.stdin?.end(); } catch { /* already closed */ }
256
+
228
257
  child.on('close', (code) => {
229
258
  this.pushResponse(messageId, 'done', '', { exitCode: code ?? 0 });
230
259
  this.activeCommands.delete(messageId);
@@ -240,19 +269,19 @@ export class LucenaAgent {
240
269
  this.pushResponse(messageId, 'output', content);
241
270
  this.pushResponse(messageId, 'done', '');
242
271
  } catch (err) {
243
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
272
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
244
273
  }
245
274
  }
246
275
 
247
276
  async writeFileCmd({ messageId, path: filePath, content }) {
248
277
  const fullPath = getJailedPath(this.cwd, filePath);
249
- const relPath = '/' + relative(this.cwd, fullPath);
278
+ const relPath = toBrowserPath(relative(this.cwd, fullPath));
250
279
  try {
251
280
  await mkdir(dirname(fullPath), { recursive: true });
252
281
  await writeFile(fullPath, content, 'utf-8');
253
282
  this.pushResponse(messageId, 'done', `Wrote ${relPath}`);
254
283
  } catch (err) {
255
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
284
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
256
285
  }
257
286
  }
258
287
 
@@ -266,8 +295,40 @@ export class LucenaAgent {
266
295
  .join('\n');
267
296
  this.pushResponse(messageId, 'done', listing || '(empty)');
268
297
  } catch (err) {
269
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
298
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
299
+ }
300
+ }
301
+
302
+ async listFiles({ messageId }) {
303
+ try {
304
+ const files = await this.walkFiles(this.cwd);
305
+ this.pushResponse(messageId, 'done', JSON.stringify(files));
306
+ } catch (err) {
307
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
308
+ }
309
+ }
310
+
311
+ async walkFiles(dirPath, relativeDir = '') {
312
+ const entries = await readdir(dirPath, { withFileTypes: true });
313
+ const files = [];
314
+
315
+ for (const entry of entries) {
316
+ if (IGNORED_PATTERNS.includes(entry.name)) continue;
317
+
318
+ const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
319
+ const fullPath = join(dirPath, entry.name);
320
+
321
+ if (entry.isDirectory()) {
322
+ files.push(...await this.walkFiles(fullPath, relPath));
323
+ } else if (entry.isFile()) {
324
+ files.push({
325
+ path: relPath,
326
+ lineCount: await countTextFileLines(fullPath),
327
+ });
328
+ }
270
329
  }
330
+
331
+ return files;
271
332
  }
272
333
 
273
334
  async statFile({ messageId, path: filePath }) {
@@ -282,13 +343,13 @@ export class LucenaAgent {
282
343
  created: s.birthtime
283
344
  }));
284
345
  } catch (err) {
285
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
346
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
286
347
  }
287
348
  }
288
349
 
289
350
  async deleteFile({ messageId, path: filePath }) {
290
351
  const fullPath = getJailedPath(this.cwd, filePath);
291
- const relPath = '/' + relative(this.cwd, fullPath);
352
+ const relPath = toBrowserPath(relative(this.cwd, fullPath));
292
353
  try {
293
354
  if ((await stat(fullPath)).isDirectory()) {
294
355
  await rm(fullPath, { recursive: true });
@@ -297,41 +358,64 @@ export class LucenaAgent {
297
358
  }
298
359
  this.pushResponse(messageId, 'done', `Deleted ${relPath}`);
299
360
  } catch (err) {
300
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
361
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
301
362
  }
302
363
  }
303
364
 
304
365
  async mkdirCmd({ messageId, path: dirPath }) {
305
366
  const fullPath = getJailedPath(this.cwd, dirPath);
306
- const relPath = '/' + relative(this.cwd, fullPath);
367
+ const relPath = toBrowserPath(relative(this.cwd, fullPath));
307
368
  try {
308
369
  await mkdir(fullPath, { recursive: true });
309
370
  this.pushResponse(messageId, 'done', `Created ${relPath}`);
310
371
  } catch (err) {
311
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
372
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
312
373
  }
313
374
  }
314
375
 
315
376
  async searchCodebase({ messageId, query, directory }) {
316
377
  const searchDir = getJailedPath(this.cwd, directory || '.');
317
378
  try {
318
- const child = spawn('grep', [
319
- '-rn', '--include=*.{js,jsx,ts,tsx,json,md,css,html,py,rb,go,rs}',
320
- query, searchDir
321
- ], { cwd: this.cwd });
379
+ const child = this._createSearchProcess(query, searchDir);
322
380
 
323
381
  let output = '';
324
382
  child.stdout.on('data', (d) => { output += d.toString(); });
325
383
  child.stderr.on('data', (d) => { output += d.toString(); });
326
384
 
327
385
  child.on('close', (code) => {
328
- this.pushResponse(messageId, 'done', stripCwd(this.cwd, output) || 'No matches found');
386
+ this.pushResponse(messageId, 'done', this._sanitize(output) || 'No matches found');
387
+ });
388
+ child.on('error', (err) => {
389
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
329
390
  });
330
391
  } catch (err) {
331
- this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
392
+ this.pushResponse(messageId, 'error', this._sanitize(err.message));
332
393
  }
333
394
  }
334
395
 
396
+ _createSearchProcess(query, searchDir) {
397
+ try {
398
+ const rg = spawnSync('rg', ['--version'], { cwd: this.cwd, encoding: 'utf8' });
399
+ if (rg.status === 0) {
400
+ return spawn('rg', ['-n', '--glob', SEARCH_GLOB, query, searchDir], { cwd: this.cwd });
401
+ }
402
+ } catch {
403
+ // Fall back to the OS-native search tool below.
404
+ }
405
+
406
+ if (process.platform === 'win32') {
407
+ return spawn('findstr', ['/s', '/n', `/c:${query}`, join(searchDir, '*')], {
408
+ cwd: this.cwd,
409
+ shell: true,
410
+ });
411
+ }
412
+
413
+ return spawn('grep', [
414
+ '-rn', `--include=${SEARCH_GLOB}`,
415
+ query, searchDir
416
+ ], { cwd: this.cwd });
417
+ }
418
+
335
419
  startWatcher() {
336
420
  this.watcher = watch(this.cwd, {
337
421
  ignored: (path) => IGNORED_PATTERNS.some(p => path.includes(p)),
@@ -341,22 +425,23 @@ export class LucenaAgent {
341
425
 
342
426
  this.watcher.on('all', (event, filePath) => {
343
427
  const relPath = relative(this.cwd, filePath);
428
+ const normalizedRelPath = relPath.replace(/\\/g, '/');
344
429
 
345
430
  // Stop anything escaping local disk
346
- if (!relPath || relPath.startsWith('..') || isAbsolute(relPath)) {
431
+ if (!normalizedRelPath || normalizedRelPath.startsWith('..') || isAbsolute(relPath)) {
347
432
  return;
348
433
  }
349
434
 
350
435
  const changesRef = ref(this.db, `tunnels/${this.tunnelId}/fileChanges`);
351
436
  push(changesRef, {
352
437
  event,
353
- path: relPath,
438
+ path: normalizedRelPath,
354
439
  timestamp: serverTimestamp()
355
440
  });
356
441
 
357
442
  // ── Push incremental index delta for changed files ──
358
443
  if (event === 'change' || event === 'add') {
359
- this.pushIndexDelta(relPath).catch(() => {}); // Non-blocking, non-fatal
444
+ this.pushIndexDelta(normalizedRelPath).catch(() => {}); // Non-blocking, non-fatal
360
445
  }
361
446
  });
362
447
  }
@@ -376,4 +461,26 @@ export class LucenaAgent {
376
461
 
377
462
  this.connected = false;
378
463
  }
379
- }
464
+ }
465
+
466
+ function toBrowserPath(pathValue) {
467
+ return '/' + String(pathValue || '').replace(/\\/g, '/');
468
+ }
469
+
470
+ async function countTextFileLines(fullPath) {
471
+ if (!isTextLikeFile(fullPath)) return null;
472
+
473
+ try {
474
+ const info = await stat(fullPath);
475
+ if (info.size > 5 * 1024 * 1024) return null;
476
+ const content = await readFile(fullPath, 'utf-8');
477
+ if (!content) return 0;
478
+ return content.split('\n').length;
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ function isTextLikeFile(filePath) {
485
+ return /\.(cjs|css|csv|go|html?|js|jsx|json|mdx?|mjs|py|rb|rs|sql|svg|ts|tsx|txt|xml|ya?ml)$/i.test(filePath);
486
+ }
@@ -0,0 +1,303 @@
1
+ import { execaCommand } from 'execa';
2
+ import { parse } from 'shell-quote';
3
+ import { isAbsolute, resolve, relative } from 'path';
4
+
5
+ const READ_ONLY_COMMANDS = new Set([
6
+ 'awk', 'basename', 'cat', 'cd', 'curl', 'cut', 'dirname', 'echo', 'false', 'find',
7
+ 'grep', 'head', 'ls', 'pwd', 'rg', 'sed', 'sort', 'tail', 'test', 'true',
8
+ 'uniq', 'wc', 'which',
9
+ 'dir', 'type', 'findstr', 'where', 'tree', 'more', 'clip', 'ver', 'vol',
10
+ 'hostname', 'systeminfo',
11
+ 'get-childitem', 'get-content', 'select-string', 'get-location',
12
+ 'get-command', 'get-process', 'get-service', 'get-item', 'get-itemproperty',
13
+ 'test-path', 'get-help', 'write-output', 'write-host',
14
+ ]);
15
+
16
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
17
+ 'branch', 'diff', 'grep', 'log', 'ls-files', 'rev-parse', 'show', 'status',
18
+ ]);
19
+
20
+ const CHAIN_OPERATORS = new Set(['&&', '||', ';', '|']);
21
+ const WRITE_OPERATORS = new Set(['>', '>>', '<>', '>|', '<<', '<<-', '<<<']);
22
+ const COMMANDS_WITH_PATH_OPERANDS = new Set([
23
+ 'cat', 'chmod', 'chown', 'cp', 'find', 'grep', 'head', 'ls', 'mkdir', 'mv',
24
+ 'rm', 'rmdir', 'sed', 'tail', 'touch', 'wc',
25
+ ]);
26
+
27
+ export class LucenaShell {
28
+ constructor(workspaceRoot) {
29
+ this.workspaceRoot = resolve(workspaceRoot);
30
+ this.cwd = this.workspaceRoot;
31
+ }
32
+
33
+ analyze(rawCommand) {
34
+ const sanitized = sanitizeCommand(rawCommand);
35
+ const analysis = {
36
+ command: sanitized.command,
37
+ rejected: false,
38
+ rejectReason: '',
39
+ isReadOnly: true,
40
+ needsApproval: false,
41
+ touchesOutsideWorkspace: false,
42
+ reasons: [],
43
+ segments: [],
44
+ };
45
+
46
+ if (!sanitized.ok) {
47
+ return {
48
+ ...analysis,
49
+ rejected: true,
50
+ rejectReason: sanitized.reason,
51
+ isReadOnly: false,
52
+ needsApproval: true,
53
+ };
54
+ }
55
+
56
+ if (!sanitized.command) return analysis;
57
+
58
+ if (/`|\$\(/.test(sanitized.command)) {
59
+ analysis.isReadOnly = false;
60
+ analysis.needsApproval = true;
61
+ analysis.reasons.push('uses command substitution');
62
+ }
63
+
64
+ let tokens;
65
+ try {
66
+ tokens = parse(sanitized.command);
67
+ } catch (err) {
68
+ return {
69
+ ...analysis,
70
+ rejected: true,
71
+ rejectReason: err.message || 'Command could not be parsed.',
72
+ isReadOnly: false,
73
+ needsApproval: true,
74
+ };
75
+ }
76
+
77
+ let current = [];
78
+ for (const token of tokens) {
79
+ if (isOperator(token) && CHAIN_OPERATORS.has(token.op)) {
80
+ this._addSegmentAnalysis(analysis, current);
81
+ current = [];
82
+ continue;
83
+ }
84
+
85
+ if (isOperator(token) && WRITE_OPERATORS.has(token.op)) {
86
+ analysis.isReadOnly = false;
87
+ analysis.needsApproval = true;
88
+ analysis.reasons.push(`uses shell redirection (${token.op})`);
89
+ }
90
+
91
+ if (isOperator(token) && token.op === '&') {
92
+ analysis.isReadOnly = false;
93
+ analysis.needsApproval = true;
94
+ analysis.reasons.push('runs a background process');
95
+ }
96
+
97
+ current.push(token);
98
+ }
99
+ this._addSegmentAnalysis(analysis, current);
100
+
101
+ analysis.reasons = [...new Set(analysis.reasons)];
102
+ return analysis;
103
+ }
104
+
105
+ canExecute(rawCommand, options = {}) {
106
+ const mode = options.mode === 'yolo' ? 'yolo' : 'safe';
107
+ const approved = options.approved === true;
108
+ const outsideWorkspaceApproved = options.outsideWorkspaceApproved === true;
109
+ const analysis = this.analyze(rawCommand);
110
+
111
+ if (analysis.rejected) {
112
+ return { ok: false, analysis, reason: analysis.rejectReason };
113
+ }
114
+
115
+ if (analysis.isReadOnly) {
116
+ return { ok: true, analysis };
117
+ }
118
+
119
+ if (mode === 'safe' && !approved) {
120
+ return {
121
+ ok: false,
122
+ analysis,
123
+ reason: `Safe Mode blocked a mutating command. Approval is required. ${formatReasons(analysis.reasons)}`,
124
+ };
125
+ }
126
+
127
+ if (mode === 'yolo' && analysis.touchesOutsideWorkspace && !outsideWorkspaceApproved) {
128
+ return {
129
+ ok: false,
130
+ analysis,
131
+ reason: `YOLO Mode blocked an outside-workspace mutation. Approval is required. ${formatReasons(analysis.reasons)}`,
132
+ };
133
+ }
134
+
135
+ return { ok: true, analysis };
136
+ }
137
+
138
+ execute(rawCommand, options = {}) {
139
+ const decision = this.canExecute(rawCommand, options);
140
+ if (!decision.ok) {
141
+ const err = new Error(decision.reason);
142
+ err.analysis = decision.analysis;
143
+ throw err;
144
+ }
145
+
146
+ return execaCommand(decision.analysis.command, {
147
+ cwd: this.cwd,
148
+ shell: true,
149
+ reject: false,
150
+ preferLocal: true,
151
+ stdin: 'pipe',
152
+ env: {
153
+ LUCENA_WORKSPACE_ROOT: this.workspaceRoot,
154
+ },
155
+ });
156
+ }
157
+
158
+ _addSegmentAnalysis(analysis, tokens) {
159
+ const words = tokens.filter((token) => typeof token === 'string');
160
+ if (words.length === 0) return;
161
+
162
+ let index = 0;
163
+ while (words[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
164
+ index++;
165
+ }
166
+
167
+ const command = stripQuotes(words[index] || '').toLowerCase();
168
+ if (!command) return;
169
+
170
+ const segment = {
171
+ command,
172
+ words,
173
+ isReadOnly: true,
174
+ touchesOutsideWorkspace: false,
175
+ };
176
+ analysis.segments.push(segment);
177
+
178
+ if (command === 'cd') {
179
+ const target = words[index + 1] ? stripQuotes(words[index + 1]) : this.workspaceRoot;
180
+ const nextCwd = resolvePath(this.cwd, target);
181
+ if (!isInside(this.workspaceRoot, nextCwd)) {
182
+ segment.touchesOutsideWorkspace = true;
183
+ analysis.touchesOutsideWorkspace = true;
184
+ analysis.reasons.push(`changes directory outside workspace (${displayPath(this.workspaceRoot, nextCwd)})`);
185
+ }
186
+ return;
187
+ }
188
+
189
+ if (command === 'git') {
190
+ const subcommand = stripQuotes(words[index + 1] || '').toLowerCase();
191
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) {
192
+ markMutating(analysis, segment, subcommand ? `git ${subcommand} may modify state` : 'git command is incomplete');
193
+ }
194
+ } else if (command === 'curl' && words.slice(index + 1).some(isCurlWriteOption)) {
195
+ markMutating(analysis, segment, 'curl output options may write files');
196
+ } else if (command === 'sed' && words.some((word) => /^-.*i/.test(stripQuotes(word)))) {
197
+ markMutating(analysis, segment, 'sed in-place editing may modify files');
198
+ } else if (!READ_ONLY_COMMANDS.has(command)) {
199
+ markMutating(analysis, segment, `${command} is not classified as read-only`);
200
+ }
201
+
202
+ if (segment.isReadOnly) return;
203
+
204
+ const pathOperands = collectPathOperands(command, words.slice(index + 1));
205
+ if (pathOperands.length === 0 && !isInside(this.workspaceRoot, this.cwd)) {
206
+ segment.touchesOutsideWorkspace = true;
207
+ analysis.touchesOutsideWorkspace = true;
208
+ analysis.reasons.push('mutates from a working directory outside the workspace');
209
+ return;
210
+ }
211
+
212
+ for (const operand of pathOperands) {
213
+ const absolutePath = resolvePath(this.cwd, operand);
214
+ if (!isInside(this.workspaceRoot, absolutePath)) {
215
+ segment.touchesOutsideWorkspace = true;
216
+ analysis.touchesOutsideWorkspace = true;
217
+ analysis.reasons.push(`references outside-workspace path (${displayPath(this.workspaceRoot, absolutePath)})`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ function sanitizeCommand(rawCommand) {
224
+ const command = String(rawCommand || '').replace(/\r\n?/g, '\n').trim();
225
+
226
+ if (!command) return { ok: true, command };
227
+ if (command.length > 8000) {
228
+ return { ok: false, command, reason: 'Command is too long.' };
229
+ }
230
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(command)) {
231
+ return { ok: false, command, reason: 'Command contains unsupported control characters.' };
232
+ }
233
+
234
+ return { ok: true, command };
235
+ }
236
+
237
+ function isOperator(token) {
238
+ return token && typeof token === 'object' && typeof token.op === 'string';
239
+ }
240
+
241
+ function markMutating(analysis, segment, reason) {
242
+ segment.isReadOnly = false;
243
+ analysis.isReadOnly = false;
244
+ analysis.needsApproval = true;
245
+ analysis.reasons.push(reason);
246
+ }
247
+
248
+ function collectPathOperands(command, args) {
249
+ if (!COMMANDS_WITH_PATH_OPERANDS.has(command)) return [];
250
+ const operands = [];
251
+
252
+ for (let i = 0; i < args.length; i++) {
253
+ const arg = stripQuotes(args[i]);
254
+ if (!arg || arg === '--') continue;
255
+
256
+ if (arg.startsWith('-')) {
257
+ if (optionConsumesNext(command, arg)) i++;
258
+ continue;
259
+ }
260
+
261
+ if (looksLikePath(arg)) operands.push(arg);
262
+ }
263
+
264
+ return operands;
265
+ }
266
+
267
+ function optionConsumesNext(command, option) {
268
+ if (command === 'find' && ['-name', '-path', '-type', '-maxdepth', '-mindepth'].includes(option)) return true;
269
+ if (command === 'grep' && ['-e', '-f', '--exclude', '--include'].includes(option)) return true;
270
+ return false;
271
+ }
272
+
273
+ function isCurlWriteOption(word) {
274
+ const option = stripQuotes(word);
275
+ return option === '-o' || option === '-O' || option === '-J' || option === '--output' || option === '--remote-name' || option === '--remote-header-name' || option.startsWith('--output=');
276
+ }
277
+
278
+ function looksLikePath(value) {
279
+ if (!value) return false;
280
+ if (value.includes('*') || value.includes('?') || value.includes('[')) return true;
281
+ return value === '.' || value === '..' || value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.includes('/');
282
+ }
283
+
284
+ function resolvePath(cwd, value) {
285
+ return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
286
+ }
287
+
288
+ function isInside(root, candidate) {
289
+ const rel = relative(root, candidate);
290
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
291
+ }
292
+
293
+ function displayPath(root, absolutePath) {
294
+ return isInside(root, absolutePath) ? `/${relative(root, absolutePath)}` : absolutePath;
295
+ }
296
+
297
+ function stripQuotes(value) {
298
+ return String(value).replace(/^["']|["']$/g, '');
299
+ }
300
+
301
+ function formatReasons(reasons) {
302
+ return reasons.length ? `Reasons: ${reasons.join('; ')}.` : '';
303
+ }
package/src/main.js CHANGED
@@ -63,7 +63,7 @@ export async function main() {
63
63
  const border = '─'.repeat(boxWidth);
64
64
 
65
65
  console.log(` ${c.dim}┌${border}┐${c.reset}`);
66
- console.log(` ${c.dim}│${c.reset} ${c.dim}${idLabel}${c.reset} ${c.bold}${c.cyan}${tunnelId}${c.reset} ${c.dim}│${c.reset}`);
66
+ console.log(` ${c.dim}│${c.reset} ${idLabel}${c.reset} ${c.bold}${tunnelId}${c.reset} ${c.dim}│${c.reset}`);
67
67
  console.log(` ${c.dim}└${border}┘${c.reset}`);
68
68
 
69
69
  const webUrl = `https://lucenacoder.com/?tunnel=${tunnelId}`;
@@ -73,7 +73,7 @@ export async function main() {
73
73
  const urlBorder = '─'.repeat(urlBoxWidth);
74
74
 
75
75
  console.log(`\n ${c.dim}┌${urlBorder}┐${c.reset}`);
76
- console.log(` ${c.dim}│${c.reset} ${c.dim}${urlLabel}${c.reset} ${c.cyan}${webUrl}${c.reset} ${c.dim}│${c.reset}`);
76
+ console.log(` ${c.dim}│${c.reset} ${urlLabel}${c.reset} ${c.bold}${webUrl}${c.reset} ${c.dim}│${c.reset}`);
77
77
  console.log(` ${c.dim}└${urlBorder}┘${c.reset}`);
78
78
 
79
79
  console.log(`\n ${c.dim}Open the URL above in your browser to connect.${c.reset}`);
@@ -82,4 +82,4 @@ export async function main() {
82
82
  console.error(`\n ${c.yellow}✖ Failed to start tunnel: ${err.message}${c.reset}\n`);
83
83
  process.exit(1);
84
84
  }
85
- }
85
+ }
@@ -1,4 +0,0 @@
1
- {
2
- "name": "lucenacoder",
3
- "createdAt": "2026-05-24T01:17:13.939Z"
4
- }