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/Makefile b/Makefile index a27559132..dbe79930d 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,15 @@ # 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 +else +SHELL := /bin/bash +endif +.SHELLFLAGS := -eu -o pipefail -c + # Enable parallel execution by default (only if user didn't specify -j) ifeq (,$(filter -j%,$(MAKEFLAGS))) MAKEFLAGS += -j @@ -92,9 +101,10 @@ help: ## Show this help message ## 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\"" \ - "vite" + @npx concurrently -k --raw \ + "$(TSGO) -w -p tsconfig.main.json" \ + "bun tsc-alias -w -p tsconfig.main.json" \ + "bun x 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..." @@ -102,10 +112,10 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel @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\"" \ + @npx concurrently -k \ + "npx 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" + "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) bun x vite" @@ -162,16 +172,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 diff --git a/bun.lock b/bun.lock index cf63a5f2f..6d598a6d2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@coder/cmux", @@ -70,7 +71,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "concurrently": "^8.2.0", + "concurrently": "^9.2.1", "dotenv": "^17.2.3", "electron": "^38.2.1", "electron-builder": "^24.6.0", @@ -1198,7 +1199,7 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "concurrently": ["concurrently@8.2.2", "", { "dependencies": { "chalk": "^4.1.2", "date-fns": "^2.30.0", "lodash": "^4.17.21", "rxjs": "^7.8.1", "shell-quote": "^1.8.1", "spawn-command": "0.0.2", "supports-color": "^8.1.1", "tree-kill": "^1.2.2", "yargs": "^17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg=="], + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], @@ -1316,8 +1317,6 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - "date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], - "dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -2572,8 +2571,6 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "spawn-command": ["spawn-command@0.0.2", "", {}, "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="], - "spawn-wrap": ["spawn-wrap@2.0.0", "", { "dependencies": { "foreground-child": "^2.0.0", "is-windows": "^1.0.2", "make-dir": "^3.0.0", "rimraf": "^3.0.0", "signal-exit": "^3.0.2", "which": "^2.0.1" } }, "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg=="], "spawnd": ["spawnd@5.0.0", "", { "dependencies": { "exit": "^0.1.2", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "wait-port": "^0.2.9" } }, "sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA=="], diff --git a/package.json b/package.json index 32f554e83..c2f76d238 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "concurrently": "^8.2.0", + "concurrently": "^9.2.1", "dotenv": "^17.2.3", "electron": "^38.2.1", "electron-builder": "^24.6.0", @@ -202,7 +202,9 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "win": { - "target": "nsis" + "target": "nsis", + "icon": "build/icon.png", + "artifactName": "${productName}-${version}-${arch}.${ext}" } } } diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 9b1d7e51f..213d13e95 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -248,7 +248,12 @@ const NewWorkspaceModal: React.FC = ({ {runtimeMode === RUNTIME_MODE.SSH ? `${sshHost || ""}:~/cmux/${branchName || ""}` - : `~/.cmux/src/${projectName}/${branchName || ""}`} + : (() => { + const isWindows = window.api?.platform === "win32"; + const homePlaceholder = isWindows ? "%USERPROFILE%" : "~"; + const sep = isWindows ? "\\" : "/"; + return `${homePlaceholder}${sep}.cmux${sep}src${sep}${projectName}${sep}${branchName || ""}`; + })()} diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index af94e0ace..02c5a949d 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -29,8 +29,9 @@ import { getControlPath } from "./sshConnectionPool"; /** * Shescape instance for bash shell escaping. * Reused across all SSH runtime operations for performance. + * Shescape properly auto-detects win32 & linux shells, but fails on macOS. */ -const shescape = new Shescape({ shell: "bash" }); +const shescape = new Shescape({ shell: false }); /** * SSH Runtime Configuration diff --git a/src/services/log.ts b/src/services/log.ts index ead6ff1e9..5c7a552c1 100644 --- a/src/services/log.ts +++ b/src/services/log.ts @@ -16,6 +16,19 @@ import { parseBoolEnv } from "@/utils/env"; const DEBUG_OBJ_DIR = path.join(defaultConfig.rootDir, "debug_obj"); +/** + * Safely get a chalk styling function or return an identity function if unavailable. + */ +function getChalkFn(key: K): (text: string) => string { + const candidate = (chalk as unknown as Record)[key]; + return typeof candidate === "function" ? (candidate as (text: string) => string) : (s) => s; +} + +const chalkDim = getChalkFn("dim"); +const chalkCyan = getChalkFn("cyan"); +const chalkGray = getChalkFn("gray"); +const chalkRed = getChalkFn("red"); + /** * Check if debug mode is enabled */ @@ -96,13 +109,13 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi // Apply colors based on level (if terminal supports it) let prefix: string; if (useColor) { - const coloredTimestamp = chalk.dim(timestamp); - const coloredLocation = chalk.cyan(location); + const coloredTimestamp = chalkDim(timestamp); + const coloredLocation = chalkCyan(location); if (level === "error") { prefix = `${coloredTimestamp} ${coloredLocation}`; } else if (level === "debug") { - prefix = `${coloredTimestamp} ${chalk.gray(location)}`; + prefix = `${coloredTimestamp} ${chalkGray(location)}`; } else { // info prefix = `${coloredTimestamp} ${coloredLocation}`; @@ -118,7 +131,7 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi if (useColor) { console.error( prefix, - ...args.map((arg) => (typeof arg === "string" ? chalk.red(arg) : arg)) + ...args.map((arg) => (typeof arg === "string" ? chalkRed(arg) : arg)) ); } else { console.error(prefix, ...args); diff --git a/tests/__mocks__/chalk.js b/tests/__mocks__/chalk.js index 4db122ac5..65ba5dcbe 100644 --- a/tests/__mocks__/chalk.js +++ b/tests/__mocks__/chalk.js @@ -1,7 +1,11 @@ -// Mock chalk for Jest (chalk is ESM-only and not needed in test output) -const chalk = new Proxy(() => "", { - get: () => chalk, - apply: (_target, _thisArg, args) => args[0], -}); +// Minimal Chalk mock for Jest - returns input unchanged, exposes used methods +const identity = (s) => (typeof s === "string" ? s : String(s)); -module.exports = { default: chalk }; +const chalkMock = { + dim: identity, + cyan: identity, + gray: identity, + red: identity, +}; + +module.exports = { default: chalkMock };