diff --git a/plugins/draw/package.json b/plugins/draw/package.json index 7fe9079..67ebcfe 100644 --- a/plugins/draw/package.json +++ b/plugins/draw/package.json @@ -11,7 +11,8 @@ "build": "tsc && cd ui && npm install && npm run build", "build:server": "tsc", "build:ui": "cd ui && npm install && npm run build", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "npm run build:server && node --test test/**/*.test.mjs" }, "dependencies": { "nanoid": "^5.0.0", diff --git a/plugins/draw/src/server/tunnel.ts b/plugins/draw/src/server/tunnel.ts index f5f964a..8290905 100644 --- a/plugins/draw/src/server/tunnel.ts +++ b/plugins/draw/src/server/tunnel.ts @@ -1,4 +1,6 @@ -import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; export interface TunnelResult { url: string; @@ -9,8 +11,30 @@ export interface TunnelResult { type TunnelBackend = 'cloudflared' | 'ngrok' | 'bore'; function commandExists(cmd: string): boolean { + if (!cmd) { + return false; + } + + if (cmd.includes(path.sep)) { + return isExecutable(cmd); + } + + for (const entry of (process.env.PATH || '').split(path.delimiter)) { + if (!entry) { + continue; + } + + if (isExecutable(path.join(entry, cmd))) { + return true; + } + } + + return false; +} + +function isExecutable(filePath: string): boolean { try { - execSync(`command -v ${cmd}`, { stdio: 'ignore' }); + fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; diff --git a/plugins/draw/test/tunnel.test.mjs b/plugins/draw/test/tunnel.test.mjs new file mode 100644 index 0000000..dc559bd --- /dev/null +++ b/plugins/draw/test/tunnel.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import test from 'node:test'; + +import { detectTunnel } from '../dist/server/tunnel.js'; + +test('detectTunnel checks PATH without invoking a shell', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crabcode-draw-')); + const originalPath = process.env.PATH; + const ngrokPath = path.join(tempDir, 'ngrok'); + + try { + fs.writeFileSync(ngrokPath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + process.env.PATH = tempDir; + + assert.equal(detectTunnel('ngrok'), 'ngrok'); + assert.equal(detectTunnel('cloudflared'), null); + assert.equal(detectTunnel('ngrok; echo nope'), null); + } finally { + process.env.PATH = originalPath; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +});