From ba01079dc208b1d2bd919da038c050caf8fff893 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:14 +0200 Subject: [PATCH] Avoid full script reads for command headers --- scripts/bench-script-command-discovery.mjs | 102 ++++++++++ scripts/lib/script-command-runner-harness.mjs | 178 ++++++++++++++++++ scripts/test-script-command-runner.mjs | 168 +++++++++++++++++ src/main/script-command-runner.ts | 71 +++++-- 4 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 scripts/bench-script-command-discovery.mjs create mode 100644 scripts/lib/script-command-runner-harness.mjs create mode 100644 scripts/test-script-command-runner.mjs diff --git a/scripts/bench-script-command-discovery.mjs b/scripts/bench-script-command-discovery.mjs new file mode 100644 index 00000000..91223f5a --- /dev/null +++ b/scripts/bench-script-command-discovery.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const fileCount = Number(process.env.SUPERCMD_BENCH_SCRIPT_COUNT || 40); +const bodyBytesPerFile = Number(process.env.SUPERCMD_BENCH_SCRIPT_BODY_BYTES || 1024 * 1024); + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function makeLargeScript(title, bodyBytes) { + const header = `#!/bin/bash +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title ${title} +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.packageName Benchmark +# @raycast.icon ⚡ +# @raycast.description Large script command fixture +# @raycast.needsConfirmation false + +exit 0 +`; + const line = `# ${'x'.repeat(253)}\n`; + const repeatCount = Math.max(0, Math.ceil(bodyBytes / Buffer.byteLength(line))); + return header + line.repeat(repeatCount); +} + +function snapshot(metrics) { + return { + readFileSyncCalls: metrics.readFileSyncCalls, + readFileSyncBytes: metrics.readFileSyncBytes, + readSyncCalls: metrics.readSyncCalls, + readSyncBytes: metrics.readSyncBytes, + openSyncCalls: metrics.openSyncCalls, + totalBytes: metrics.readFileSyncBytes + metrics.readSyncBytes, + }; +} + +function printMetric(label, durationMs, metricSnapshot) { + console.log(`${label}: ${durationMs.toFixed(1)}ms`); + console.log(` readFileSync: ${metricSnapshot.readFileSyncCalls} calls, ${formatBytes(metricSnapshot.readFileSyncBytes)}`); + console.log(` readSync: ${metricSnapshot.readSyncCalls} calls, ${formatBytes(metricSnapshot.readSyncBytes)}`); + console.log(` total runner bytes read: ${formatBytes(metricSnapshot.totalBytes)}`); +} + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-bench-')); +const scriptsDir = path.join(tempRoot, 'script-commands'); +const userDataDir = path.join(tempRoot, 'user-data'); +fs.mkdirSync(scriptsDir, { recursive: true }); +fs.mkdirSync(userDataDir, { recursive: true }); + +try { + for (let i = 0; i < fileCount; i += 1) { + const scriptPath = path.join(scriptsDir, `large-command-${String(i + 1).padStart(3, '0')}.sh`); + fs.writeFileSync(scriptPath, makeLargeScript(`Large Command ${i + 1}`, bodyBytesPerFile), { + mode: 0o755, + }); + } + + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + const { module: runner, metrics, resetMetrics } = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs: true, + }); + + resetMetrics(); + const discoveryStart = performance.now(); + const commands = runner.discoverScriptCommands(); + const discoveryMs = performance.now() - discoveryStart; + const discoveryMetrics = snapshot(metrics); + + if (commands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands, discovered ${commands.length}`); + } + + resetMetrics(); + const executeStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const executeMs = performance.now() - executeStart; + const executeMetrics = snapshot(metrics); + + console.log('Script command discovery benchmark'); + console.log(`files: ${fileCount}`); + console.log(`body bytes per file: ${formatBytes(bodyBytesPerFile)}`); + printMetric('discovery', discoveryMs, discoveryMetrics); + printMetric('cached execution shebang lookup', executeMs, executeMetrics); +} finally { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..f6ee9b67 --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,178 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + globalThis[metricsKey] = metrics; + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..b805dae4 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +async function withScriptCommandRunner(t, files, { instrumentFs = false } = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.equal(command.interpreter, '/usr/bin/env'); + assert.deepEqual(command.interpreterArgs, ['node', '--no-warnings']); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { + const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }, { instrumentFs: true }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/bash'); + assert.deepEqual(command.interpreterArgs, []); + + resetMetrics(); + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + assert.equal(metrics.readFileSyncBytes + metrics.readSyncBytes, 0); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, undefined); + assert.deepEqual(command.interpreterArgs, []); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + await t.test('discovers metadata in large scripts with bounded prefix reads', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner, metrics } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok( + metrics.readSyncBytes < 512 * 1024, + `expected a bounded prefix read, got ${metrics.readSyncBytes} bytes`, + ); + }); +}); + diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..cb1f5157 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -33,6 +33,8 @@ export interface ScriptCommandInfo { refreshTime?: string; interval?: string; currentDirectoryPath?: string; + interpreter?: string; + interpreterArgs?: string[]; needsConfirmation: boolean; arguments: ScriptArgumentDefinition[]; keywords: string[]; @@ -55,6 +57,9 @@ export interface ScriptExecutionResult { const CACHE_TTL_MS = 12_000; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; // 2MB const DEFAULT_TIMEOUT_MS = 60_000; +const SCRIPT_COMMAND_HEADER_MAX_LINES = 120; +const SCRIPT_COMMAND_HEADER_MAX_BYTES = 256 * 1024; +const SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES = 8 * 1024; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; @@ -302,24 +307,66 @@ function discoverScriptFiles(rootDir: string): string[] { return out; } -function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { - const scriptPath = path.resolve(filePath); - const scriptDir = path.dirname(scriptPath); +function countLineBreaks(buffer: Buffer, length: number): number { + let count = 0; + for (let i = 0; i < length; i += 1) { + if (buffer[i] === 10) count += 1; + } + return count; +} - let raw = ''; +function readScriptCommandHeader(filePath: string): string | null { + let fd: number | null = null; try { - raw = fs.readFileSync(scriptPath, 'utf-8'); + fd = fs.openSync(filePath, 'r'); + const chunks: Buffer[] = []; + let totalBytes = 0; + let lineBreaks = 0; + + while ( + totalBytes < SCRIPT_COMMAND_HEADER_MAX_BYTES && + lineBreaks < SCRIPT_COMMAND_HEADER_MAX_LINES + ) { + const bytesToRead = Math.min( + SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES, + SCRIPT_COMMAND_HEADER_MAX_BYTES - totalBytes + ); + if (bytesToRead <= 0) break; + + const chunk = Buffer.allocUnsafe(bytesToRead); + const bytesRead = fs.readSync(fd, chunk, 0, bytesToRead, totalBytes); + if (bytesRead <= 0) break; + + chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)); + totalBytes += bytesRead; + lineBreaks += countLineBreaks(chunk, bytesRead); + } + + return Buffer.concat(chunks, totalBytes).toString('utf8'); } catch { return null; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch {} + } } +} + +function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { + const scriptPath = path.resolve(filePath); + const scriptDir = path.dirname(scriptPath); + + const raw = readScriptCommandHeader(scriptPath); + if (raw === null) return null; if (!raw.trim()) return null; const lines = raw.split(/\r?\n/); + const shebang = shebangArgs(lines[0] || ''); const metadata: Record = {}; const argumentDefs = new Map(); - for (const line of lines.slice(0, 120)) { + for (const line of lines.slice(0, SCRIPT_COMMAND_HEADER_MAX_LINES)) { const match = line.match(/^\s*(#|\/\/|--)\s*@raycast\.([A-Za-z0-9]+)\s*(.*)$/); if (!match) continue; const key = String(match[2] || '').trim(); @@ -393,6 +440,8 @@ function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { refreshTime, interval: mode === 'inline' ? refreshTime : undefined, currentDirectoryPath, + interpreter: shebang[0], + interpreterArgs: shebang.slice(1), needsConfirmation, arguments: argumentsList, keywords: Array.from(new Set(keywords)), @@ -497,9 +546,7 @@ export async function executeScriptCommand( return { missingArguments: missing, command: cmd }; } - const source = fs.readFileSync(cmd.scriptPath, 'utf-8'); - const firstLine = source.split(/\r?\n/)[0] || ''; - const shebang = shebangArgs(firstLine); + fs.accessSync(cmd.scriptPath, fs.constants.R_OK); const env = { ...process.env, @@ -514,10 +561,10 @@ export async function executeScriptCommand( const cwd = cmd.currentDirectoryPath || cmd.scriptDir; const spawnCommand = - shebang.length > 0 ? shebang[0] : '/bin/bash'; + cmd.interpreter ? cmd.interpreter : '/bin/bash'; const spawnArgs = - shebang.length > 0 - ? [...shebang.slice(1), cmd.scriptPath, ...args] + cmd.interpreter + ? [...(cmd.interpreterArgs || []), cmd.scriptPath, ...args] : [cmd.scriptPath, ...args]; const run = await new Promise<{