diff --git a/.github/actions/setup-cmux/action.yml b/.github/actions/setup-cmux/action.yml index 4c1fb0d65..e15f216b8 100644 --- a/.github/actions/setup-cmux/action.yml +++ b/.github/actions/setup-cmux/action.yml @@ -62,3 +62,14 @@ runs: sudo apt-get install -y --no-install-recommends imagemagick fi convert --version | head -1 + - name: Install ImageMagick (Windows) + if: inputs.install-imagemagick == 'true' && runner.os == 'Windows' + shell: powershell + run: | + if (Get-Command magick -ErrorAction SilentlyContinue) { + Write-Host "✅ ImageMagick already available" + } else { + Write-Host "📦 Installing ImageMagick..." + choco install -y imagemagick + } + magick --version | Select-Object -First 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d236cc80..aaff074b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,3 +58,34 @@ jobs: run: bun x electron-builder --linux --publish always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-windows: + name: Build and Release Windows + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for git describe to find tags + + - uses: ./.github/actions/setup-cmux + with: + install-imagemagick: true + + - name: Install GNU Make (for build) + run: choco install -y make + + - name: Verify tools + shell: bash + run: | + make --version + bun --version + magick --version | head -1 + + - name: Build application + run: bun run build + + - name: Package and publish for Windows (.exe) + run: bun x electron-builder --win --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml index 50cb87418..d0107868a 100644 --- a/.github/workflows/terminal-bench.yml +++ b/.github/workflows/terminal-bench.yml @@ -4,34 +4,34 @@ on: workflow_call: inputs: model_name: - description: 'Model to use (e.g., anthropic:claude-sonnet-4-5)' + description: "Model to use (e.g., anthropic:claude-sonnet-4-5)" required: false type: string thinking_level: - description: 'Thinking level (off, low, medium, high)' + description: "Thinking level (off, low, medium, high)" required: false type: string dataset: - description: 'Terminal-Bench dataset to use' + description: "Terminal-Bench dataset to use" required: false type: string - default: 'terminal-bench-core==0.1.1' + default: "terminal-bench-core==0.1.1" concurrency: - description: 'Number of concurrent tasks (--n-concurrent)' + description: "Number of concurrent tasks (--n-concurrent)" required: false type: string - default: '4' + default: "4" livestream: - description: 'Enable livestream mode (verbose output to console)' + description: "Enable livestream mode (verbose output to console)" required: false type: boolean default: false sample_size: - description: 'Number of random tasks to run (empty = all tasks)' + description: "Number of random tasks to run (empty = all tasks)" required: false type: string extra_args: - description: 'Additional arguments to pass to terminal-bench' + description: "Additional arguments to pass to terminal-bench" required: false type: string secrets: @@ -42,34 +42,34 @@ on: workflow_dispatch: inputs: dataset: - description: 'Terminal-Bench dataset to use' + description: "Terminal-Bench dataset to use" required: false - default: 'terminal-bench-core==0.1.1' + default: "terminal-bench-core==0.1.1" type: string concurrency: - description: 'Number of concurrent tasks (--n-concurrent)' + description: "Number of concurrent tasks (--n-concurrent)" required: false - default: '4' + default: "4" type: string livestream: - description: 'Enable livestream mode (verbose output to console)' + description: "Enable livestream mode (verbose output to console)" required: false default: false type: boolean sample_size: - description: 'Number of random tasks to run (empty = all tasks)' + description: "Number of random tasks to run (empty = all tasks)" required: false type: string model_name: - description: 'Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5-codex)' + description: "Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5-codex)" required: false type: string thinking_level: - description: 'Thinking level (off, low, medium, high)' + description: "Thinking level (off, low, medium, high)" required: false type: string extra_args: - description: 'Additional arguments to pass to terminal-bench' + description: "Additional arguments to pass to terminal-bench" required: false type: string @@ -147,4 +147,3 @@ jobs: benchmark.log if-no-files-found: warn retention-days: 30 - diff --git a/Makefile b/Makefile index a27559132..2c3ff9a07 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,19 @@ # Branches reduce reproducibility - builds should fail fast with clear errors # if dependencies are missing, not silently fall back to different behavior. +# Use PATH-resolved bash on Windows to avoid hardcoded /usr/bin/bash which doesn't +# exist in Chocolatey's make environment or on GitHub Actions windows-latest. +ifeq ($(OS),Windows_NT) +SHELL := bash +# Windows: Use npm/npx because bun x doesn't correctly pass arguments on Windows +RUNNER := npx +else +SHELL := /bin/bash +# Non-Windows: Use bun x for better performance +RUNNER := bun x +endif +.SHELLFLAGS := -eu -o pipefail -c + # Enable parallel execution by default (only if user didn't specify -j) ifeq (,$(filter -j%,$(MAKEFLAGS))) MAKEFLAGS += -j @@ -32,7 +45,7 @@ endif # Include formatting rules include fmt.mk -.PHONY: all build dev start clean help +.PHONY: all build dev start clean clean-cache help .PHONY: build-renderer version build-icons build-static .PHONY: lint lint-fix typecheck static-check .PHONY: test test-unit test-integration test-watch test-coverage test-e2e @@ -91,26 +104,33 @@ help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' ## Development -dev: node_modules/.installed build-main ## Start development server (Vite + tsgo watcher for 10x faster type checking) - @bun x concurrently -k \ - "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ +dev: node_modules/.installed build-main clean-cache ## Start development server (Vite + nodemon watcher for Windows compatibility) + @echo "Starting dev mode (2 watchers: nodemon for main process, vite for renderer)..." + @# On Windows, use npm run because bun x doesn't correctly pass arguments + @NODE_OPTIONS="--max-old-space-size=4096" $(RUNNER) concurrently -k --raw \ + "$(RUNNER) nodemon --exec node scripts/build-main-watch.js" \ "vite" +clean-cache: ## Clean Vite cache (helps with EMFILE errors on Windows) + @echo "Cleaning Vite cache..." + @rm -rf node_modules/.vite + dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access @echo "Starting dev-server..." @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)" @echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)" @echo "" @echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0" - @bun x concurrently -k \ - "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ - "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \ - "CMUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) CMUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite" + @# On Windows, use npm run because bun x doesn't correctly pass arguments + @$(RUNNER) concurrently -k \ + "$(RUNNER) nodemon --exec node scripts/build-main-watch.js" \ + "$(RUNNER) nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec \"node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \ + "$(SHELL) -lc \"CMUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) CMUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\"" start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app - @bun x electron --remote-debugging-port=9222 . + @$(RUNNER) electron --remote-debugging-port=9222 . ## Build targets (can run in parallel) build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static ## Build all targets @@ -120,7 +140,7 @@ build-main: node_modules/.installed dist/main.js ## Build main process dist/main.js: src/version.ts tsconfig.main.json tsconfig.json $(TS_SOURCES) @echo "Building main process..." @NODE_ENV=production $(TSGO) -p tsconfig.main.json - @NODE_ENV=production bun x tsc-alias -p tsconfig.main.json + @NODE_ENV=production $(RUNNER) tsc-alias -p tsconfig.main.json build-preload: node_modules/.installed dist/preload.js ## Build preload script @@ -135,7 +155,7 @@ dist/preload.js: src/preload.ts $(TS_SOURCES) build-renderer: node_modules/.installed src/version.ts ## Build renderer process @echo "Building renderer..." - @bun x vite build + @$(RUNNER) vite build build-static: ## Copy static assets to dist @echo "Copying static assets..." @@ -162,16 +182,16 @@ MAGICK_CMD := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev build/icon.png: docs/img/logo.webp @echo "Generating Linux icon..." @mkdir -p build - @$(MAGICK_CMD) docs/img/logo.webp -resize 512x512 build/icon.png + @"$(MAGICK_CMD)" docs/img/logo.webp -resize 512x512 build/icon.png build/icon.icns: docs/img/logo.webp @echo "Generating macOS icon..." @mkdir -p build/icon.iconset @for size in 16 32 64 128 256 512; do \ - $(MAGICK_CMD) docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \ + "$(MAGICK_CMD)" docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \ if [ $$size -le 256 ]; then \ double=$$((size * 2)); \ - $(MAGICK_CMD) docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \ + "$(MAGICK_CMD)" docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \ fi; \ done @iconutil -c icns build/icon.iconset -o build/icon.icns @@ -187,7 +207,8 @@ lint-fix: node_modules/.installed ## Run linter with --fix @./scripts/lint.sh --fix typecheck: node_modules/.installed src/version.ts ## Run TypeScript type checking (uses tsgo for 10x speedup) - @bun x concurrently -g \ + @# On Windows, use npm run because bun x doesn't correctly pass arguments + @$(RUNNER) concurrently -g \ "$(TSGO) --noEmit" \ "$(TSGO) --noEmit -p tsconfig.main.json" @@ -195,7 +216,7 @@ check-deadcode: node_modules/.installed ## Check for potential dead code (manual @echo "Checking for potential dead code with ts-prune..." @echo "(Note: Some unused exports are legitimate - types, public APIs, entry points, etc.)" @echo "" - @bun x ts-prune -i '(test|spec|mock|bench|debug|storybook)' \ + @$(RUNNER) ts-prune -i '(test|spec|mock|bench|debug|storybook)' \ | grep -v "used in module" \ | grep -v "src/App.tsx.*default" \ | grep -v "src/types/" \ @@ -205,7 +226,7 @@ check-deadcode: node_modules/.installed ## Check for potential dead code (manual ## Testing test-integration: node_modules/.installed build-main ## Run all tests (unit + integration) @bun test src - @TEST_INTEGRATION=1 bun x jest tests + @TEST_INTEGRATION=1 $(RUNNER) jest tests test-unit: node_modules/.installed build-main ## Run unit tests @bun test src @@ -220,22 +241,22 @@ test-coverage: ## Run tests with coverage test-e2e: ## Run end-to-end tests @$(MAKE) build - @CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron $(PLAYWRIGHT_ARGS) + @CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 $(RUNNER) playwright test --project=electron $(PLAYWRIGHT_ARGS) ## Distribution dist: build ## Build distributable packages - @bun x electron-builder --publish never + @$(RUNNER) electron-builder --publish never # Parallel macOS builds - notarization happens concurrently dist-mac: build ## Build macOS distributables (x64 + arm64) @if [ -n "$$CSC_LINK" ]; then \ echo "🔐 Code signing enabled - building sequentially to avoid keychain conflicts..."; \ - bun x electron-builder --mac --x64 --publish never && \ - bun x electron-builder --mac --arm64 --publish never; \ + $(RUNNER) electron-builder --mac --x64 --publish never && \ + $(RUNNER) electron-builder --mac --arm64 --publish never; \ else \ echo "Building macOS architectures in parallel..."; \ - bun x electron-builder --mac --x64 --publish never & pid1=$$! ; \ - bun x electron-builder --mac --arm64 --publish never & pid2=$$! ; \ + $(RUNNER) electron-builder --mac --x64 --publish never & pid1=$$! ; \ + $(RUNNER) electron-builder --mac --arm64 --publish never & pid2=$$! ; \ wait $$pid1 && wait $$pid2; \ fi @echo "✅ Both architectures built successfully" @@ -243,29 +264,29 @@ dist-mac: build ## Build macOS distributables (x64 + arm64) dist-mac-release: build ## Build and publish macOS distributables (x64 + arm64) @if [ -n "$$CSC_LINK" ]; then \ echo "🔐 Code signing enabled - building sequentially to avoid keychain conflicts..."; \ - bun x electron-builder --mac --x64 --publish always && \ - bun x electron-builder --mac --arm64 --publish always; \ + $(RUNNER) electron-builder --mac --x64 --publish always && \ + $(RUNNER) electron-builder --mac --arm64 --publish always; \ else \ echo "Building and publishing macOS architectures in parallel..."; \ - bun x electron-builder --mac --x64 --publish always & pid1=$$! ; \ - bun x electron-builder --mac --arm64 --publish always & pid2=$$! ; \ + $(RUNNER) electron-builder --mac --x64 --publish always & pid1=$$! ; \ + $(RUNNER) electron-builder --mac --arm64 --publish always & pid2=$$! ; \ wait $$pid1 && wait $$pid2; \ fi @echo "✅ Both architectures built and published successfully" dist-mac-x64: build ## Build macOS x64 distributable only @echo "Building macOS x64..." - @bun x electron-builder --mac --x64 --publish never + @$(RUNNER) electron-builder --mac --x64 --publish never dist-mac-arm64: build ## Build macOS arm64 distributable only @echo "Building macOS arm64..." - @bun x electron-builder --mac --arm64 --publish never + @$(RUNNER) electron-builder --mac --arm64 --publish never dist-win: build ## Build Windows distributable - @bun x electron-builder --win --publish never + @$(RUNNER) electron-builder --win --publish never dist-linux: build ## Build Linux distributable - @bun x electron-builder --linux --publish never + @$(RUNNER) electron-builder --linux --publish never ## Documentation docs: ## Serve documentation locally @@ -280,19 +301,19 @@ docs-watch: ## Watch and rebuild documentation ## Storybook storybook: node_modules/.installed ## Start Storybook development server $(check_node_version) - @bun x storybook dev -p 6006 $(STORYBOOK_OPEN_FLAG) + @$(RUNNER) storybook dev -p 6006 $(STORYBOOK_OPEN_FLAG) storybook-build: node_modules/.installed src/version.ts ## Build static Storybook $(check_node_version) - @bun x storybook build + @$(RUNNER) storybook build test-storybook: node_modules/.installed ## Run Storybook interaction tests (requires Storybook to be running or built) $(check_node_version) - @bun x test-storybook + @$(RUNNER) test-storybook chromatic: node_modules/.installed ## Run Chromatic for visual regression testing $(check_node_version) - @bun x chromatic --exit-zero-on-changes + @$(RUNNER) chromatic --exit-zero-on-changes ## Benchmarks benchmark-terminal: ## Run Terminal-Bench with the cmux agent (use TB_DATASET/TB_SAMPLE_SIZE/TB_TIMEOUT/TB_ARGS to customize) diff --git a/bun.lock b/bun.lock index 29f3f6229..60af5ca48 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "mux", diff --git a/docs/theme/copy-buttons.js b/docs/theme/copy-buttons.js index 12b5f7867..35bbc87ce 100644 --- a/docs/theme/copy-buttons.js +++ b/docs/theme/copy-buttons.js @@ -3,29 +3,32 @@ * Attaches click handlers to pre-rendered buttons */ -(function() { - 'use strict'; +(function () { + "use strict"; // Initialize copy buttons after DOM loads - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initCopyButtons); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initCopyButtons); } else { initCopyButtons(); } function initCopyButtons() { - document.querySelectorAll('.code-copy-button').forEach(function(button) { - button.addEventListener('click', function() { - var wrapper = button.closest('.code-block-wrapper'); + document.querySelectorAll(".code-copy-button").forEach(function (button) { + button.addEventListener("click", function () { + var wrapper = button.closest(".code-block-wrapper"); var code = wrapper.dataset.code; - + if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(code).then(function() { - showFeedback(button, true); - }).catch(function(err) { - console.warn('Failed to copy:', err); - showFeedback(button, false); - }); + navigator.clipboard + .writeText(code) + .then(function () { + showFeedback(button, true); + }) + .catch(function (err) { + console.warn("Failed to copy:", err); + showFeedback(button, false); + }); } else { // Fallback for older browsers fallbackCopy(code); @@ -37,7 +40,7 @@ function showFeedback(button, success) { var originalContent = button.innerHTML; - + // Match the main app's CopyButton feedback - show "Copied!" text if (success) { button.innerHTML = 'Copied!'; @@ -45,21 +48,21 @@ button.innerHTML = 'Failed!'; } button.disabled = true; - - setTimeout(function() { + + setTimeout(function () { button.innerHTML = originalContent; button.disabled = false; }, 2000); } function fallbackCopy(text) { - var textarea = document.createElement('textarea'); + var textarea = document.createElement("textarea"); textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); - document.execCommand('copy'); + document.execCommand("copy"); document.body.removeChild(textarea); } })(); diff --git a/docs/theme/custom.css b/docs/theme/custom.css index 8c48e22b9..dc2ef0f94 100644 --- a/docs/theme/custom.css +++ b/docs/theme/custom.css @@ -510,9 +510,8 @@ details[open] > summary::before { background-repeat: no-repeat; } - /* Page TOC (Table of Contents) overrides */ -@media only screen and (min-width:1440px) { +@media only screen and (min-width: 1440px) { .pagetoc a { /* Reduce vertical spacing for more compact TOC */ padding-top: 2px !important; @@ -546,10 +545,6 @@ details[open] > summary::before { } } - - - - /* Code block wrapper with line numbers and copy button (from cmux app) */ .code-block-wrapper { position: relative; diff --git a/package.json b/package.json index d64845575..aab160fbe 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,9 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "win": { - "target": "nsis" + "target": "nsis", + "icon": "build/icon.png", + "artifactName": "${productName}-${version}-${arch}.${ext}" } } } diff --git a/public/service-worker.js b/public/service-worker.js index 7d8eba2d1..961d9f026 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -51,7 +51,20 @@ self.addEventListener("fetch", (event) => { }) .catch(() => { // If network fails, try cache - return caches.match(event.request); + return caches.match(event.request).then((cachedResponse) => { + // If cache has it, return it; otherwise return a proper error response + if (cachedResponse) { + return cachedResponse; + } + // Return a proper Response object for failed requests + return new Response("Network error and no cached version available", { + status: 503, + statusText: "Service Unavailable", + headers: new Headers({ + "Content-Type": "text/plain", + }), + }); + }); }) ); }); diff --git a/scripts/build-main-watch.js b/scripts/build-main-watch.js new file mode 100644 index 000000000..6709782c5 --- /dev/null +++ b/scripts/build-main-watch.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Build script for main process in watch mode + * Used by nodemon - ignores file arguments passed by nodemon + */ + +const { execSync } = require("child_process"); +const path = require("path"); + +const rootDir = path.join(__dirname, ".."); +const tsgoPath = path.join(rootDir, "node_modules/@typescript/native-preview/bin/tsgo.js"); +const tscAliasPath = path.join(rootDir, "node_modules/tsc-alias/dist/bin/index.js"); + +try { + console.log("Building main process..."); + + // Run tsgo + execSync(`node "${tsgoPath}" -p tsconfig.main.json`, { + cwd: rootDir, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "development" }, + }); + + // Run tsc-alias + execSync(`node "${tscAliasPath}" -p tsconfig.main.json`, { + cwd: rootDir, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "development" }, + }); + + console.log("✓ Main process build complete"); +} catch (error) { + console.error("Build failed:", error.message); + process.exit(1); +} diff --git a/scripts/mdbook-shiki.ts b/scripts/mdbook-shiki.ts index f46abbe95..5be73f86e 100755 --- a/scripts/mdbook-shiki.ts +++ b/scripts/mdbook-shiki.ts @@ -6,7 +6,11 @@ */ import { createHighlighter } from "shiki"; -import { SHIKI_THEME, mapToShikiLang, extractShikiLines } from "../src/utils/highlighting/shiki-shared"; +import { + SHIKI_THEME, + mapToShikiLang, + extractShikiLines, +} from "../src/utils/highlighting/shiki-shared"; import { renderToStaticMarkup } from "react-dom/server"; import { CodeBlockSSR } from "../src/components/Messages/CodeBlockSSR"; @@ -44,33 +48,34 @@ type PreprocessorInput = [Context, Book]; */ function generateGridHtml(shikiHtml: string, originalCode: string): string { const lines = extractShikiLines(shikiHtml); - + // Render the React component to static HTML - const html = renderToStaticMarkup( - CodeBlockSSR({ code: originalCode, highlightedLines: lines }) - ); - + const html = renderToStaticMarkup(CodeBlockSSR({ code: originalCode, highlightedLines: lines })); + return html; } /** * Process markdown content to replace code blocks with highlighted HTML */ -async function processMarkdown(content: string, highlighter: Awaited>): Promise { +async function processMarkdown( + content: string, + highlighter: Awaited> +): Promise { // Match ```lang\ncode\n``` blocks (lang is optional) const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; - + let result = content; const matches = Array.from(content.matchAll(codeBlockRegex)); - + for (const match of matches) { const [fullMatch, lang, code] = match; // Default to plaintext if no language specified const shikiLang = mapToShikiLang(lang || "plaintext"); - + // Remove trailing newlines from code (markdown often has extra newline before closing ```) const trimmedCode = code.replace(/\n+$/, ""); - + try { // Load language if needed const loadedLangs = highlighter.getLoadedLanguages(); @@ -84,36 +89,39 @@ async function processMarkdown(content: string, highlighter: Awaited>): Promise { +async function processChapter( + chapter: Chapter, + highlighter: Awaited> +): Promise { if (chapter.content) { chapter.content = await processMarkdown(chapter.content, highlighter); } - + if (chapter.sub_items) { for (const subItem of chapter.sub_items) { if (subItem.Chapter) { @@ -129,7 +137,7 @@ async function processChapter(chapter: Chapter, highlighter: Awaited((resolve) => { - const proc = spawn("bash", ["-c", `"${hookPath}"`], { + const bashPath = getBashPath(); + const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { cwd: workspacePath, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 138a064cb..4343d206d 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -1,7 +1,6 @@ import { spawn } from "child_process"; import { Readable, Writable } from "stream"; import * as path from "path"; -import { Shescape } from "shescape"; import type { Runtime, ExecOptions, @@ -25,12 +24,20 @@ import { getProjectName } from "../utils/runtime/helpers"; import { getErrorMessage } from "../utils/errors"; import { execAsync, DisposableProcess } from "../utils/disposableExec"; import { getControlPath } from "./sshConnectionPool"; +import { getBashPath } from "../utils/bashPath"; /** - * Shescape instance for bash shell escaping. + * Shell-escape helper for remote bash. * Reused across all SSH runtime operations for performance. */ -const shescape = new Shescape({ shell: "bash" }); +const shescape = { + quote(value: unknown): string { + const s = String(value); + if (s.length === 0) return "''"; + // Use POSIX-safe pattern to embed single quotes within single-quoted strings + return "'" + s.replace(/'/g, "'\"'\"'") + "'"; + }, +}; /** * SSH Runtime Configuration @@ -574,7 +581,8 @@ export class SSHRuntime implements Runtime { const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; log.debug(`Creating bundle: ${command}`); - const proc = spawn("bash", ["-c", command]); + const bashPath = getBashPath(); + const proc = spawn(bashPath, ["-c", command]); const cleanup = streamProcessToLogger(proc, initLogger, { logStdout: false, diff --git a/src/services/bashExecutionService.ts b/src/services/bashExecutionService.ts index 623165f03..175376b43 100644 --- a/src/services/bashExecutionService.ts +++ b/src/services/bashExecutionService.ts @@ -1,6 +1,7 @@ import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; import { log } from "./log"; +import { getBashPath } from "../utils/bashPath"; /** * Configuration for bash execution @@ -120,10 +121,13 @@ export class BashExecutionService { `BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}` ); - const spawnCommand = config.niceness !== undefined ? "nice" : "bash"; + // Windows doesn't have nice command, so just spawn bash directly + const isWindows = process.platform === "win32"; + const bashPath = getBashPath(); + const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath; const spawnArgs = - config.niceness !== undefined - ? ["-n", config.niceness.toString(), "bash", "-c", script] + config.niceness !== undefined && !isWindows + ? ["-n", config.niceness.toString(), bashPath, "-c", script] : ["-c", script]; const child = spawn(spawnCommand, spawnArgs, { diff --git a/src/utils/bashPath.ts b/src/utils/bashPath.ts new file mode 100644 index 000000000..e0f44e7f5 --- /dev/null +++ b/src/utils/bashPath.ts @@ -0,0 +1,116 @@ +/** + * Platform-specific bash path resolution + * + * On Unix/Linux/macOS, bash is in PATH by default. + * On Windows, bash comes from Git Bash and needs to be located. + */ + +import { execSync } from "child_process"; +import { existsSync } from "fs"; +import path from "path"; + +let cachedBashPath: string | null = null; + +/** + * Find bash executable path on Windows + * Checks common Git Bash installation locations + */ +function findWindowsBash(): string | null { + // Common Git Bash installation paths + const commonPaths = [ + // Git for Windows default paths + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", + // User-local Git installation + path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"), + // Portable Git + path.join(process.env.USERPROFILE || "", "scoop", "apps", "git", "current", "bin", "bash.exe"), + // Chocolatey installation + "C:\\tools\\git\\bin\\bash.exe", + ]; + + // Check if bash is in PATH first + try { + const result = execSync("where bash", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); + const firstPath = result.split("\n")[0].trim(); + if (firstPath && existsSync(firstPath)) { + return firstPath; + } + } catch { + // Not in PATH, continue to check common locations + } + + // Check common installation paths + for (const bashPath of commonPaths) { + if (existsSync(bashPath)) { + return bashPath; + } + } + + // Also check if Git is in PATH and derive bash path from it + try { + const gitPath = execSync("where git", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); + const firstGitPath = gitPath.split("\n")[0].trim(); + if (firstGitPath) { + // Git is usually in Git/cmd/git.exe, bash is in Git/bin/bash.exe + const gitDir = path.dirname(path.dirname(firstGitPath)); + const bashPath = path.join(gitDir, "bin", "bash.exe"); + if (existsSync(bashPath)) { + return bashPath; + } + // Also try usr/bin/bash.exe (newer Git for Windows structure) + const usrBashPath = path.join(gitDir, "usr", "bin", "bash.exe"); + if (existsSync(usrBashPath)) { + return usrBashPath; + } + } + } catch { + // Git not in PATH + } + + return null; +} + +/** + * Get the bash executable path for the current platform + * + * @returns Path to bash executable. On Unix/macOS returns "bash", + * on Windows returns full path to bash.exe if found. + * @throws Error if bash cannot be found on Windows + */ +export function getBashPath(): string { + // On Unix/Linux/macOS, bash is in PATH + if (process.platform !== "win32") { + return "bash"; + } + + // Use cached path if available + if (cachedBashPath !== null) { + return cachedBashPath; + } + + // Find bash on Windows + const bashPath = findWindowsBash(); + if (!bashPath) { + throw new Error( + "Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win" + ); + } + + cachedBashPath = bashPath; + return bashPath; +} + +/** + * Check if bash is available on the system + * + * @returns true if bash is available, false otherwise + */ +export function isBashAvailable(): boolean { + try { + getBashPath(); + return true; + } catch { + return false; + } +} diff --git a/tsconfig.json b/tsconfig.json index 33d44c08e..a567f709c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,10 @@ "@/*": ["./src/*"] } }, + "watchOptions": { + "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"], + "excludeFiles": ["**/*.d.ts.map"] + }, "include": [ "src/**/*.tsx", "src/**/*.ts", diff --git a/tsconfig.main.json b/tsconfig.main.json index b63625bb8..489e5f627 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -6,6 +6,10 @@ "noEmit": false, "sourceMap": true }, + "watchOptions": { + "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"], + "excludeFiles": ["**/*.d.ts.map"] + }, "include": [ "src/main.ts", "src/main-server.ts", diff --git a/vite.config.ts b/vite.config.ts index 0816c1d2b..4609528d0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,33 @@ export default defineConfig(({ mode }) => ({ strictPort: true, allowedHosts: devServerHost === "0.0.0.0" ? undefined : ["localhost", "127.0.0.1"], sourcemapIgnoreList: () => false, // Show all sources in DevTools + + watch: { + // Ignore node_modules to drastically reduce file handle usage + ignored: ["**/node_modules/**", "**/dist/**", "**/.git/**"], + + // Use polling on Windows to avoid file handle exhaustion + // This is slightly less efficient but much more stable + usePolling: process.platform === "win32", + + // If using polling, set a reasonable interval (in milliseconds) + interval: 1000, + + // Limit the depth of directory traversal + depth: 3, + + // Additional options for Windows specifically + ...(process.platform === "win32" && { + // Increase the binary interval for better Windows performance + binaryInterval: 1000, + // Use a more conservative approach to watching + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }), + }, + hmr: { // Configure HMR to use the correct host for remote access host: devServerHost, @@ -104,5 +131,24 @@ export default defineConfig(({ mode }) => ({ esbuildOptions: { target: "esnext", }, + + // Force include CommonJS packages that need pre-bundling + include: ["fast-deep-equal"], + + // Exclude problematic large packages from optimization + // These packages have many files and can cause EMFILE errors on Windows + exclude: [ + "langium", + "vscode-languageserver", + "vscode-languageserver-protocol", + "vscode-languageserver-types", + "vscode-languageclient", + ], + + // Include only what's actually imported to reduce scanning + entries: ["src/**/*.{ts,tsx}"], + + // Force re-optimize dependencies + force: false, }, }));