From 2a216e3b06c817744c94060f119aff68975c541c Mon Sep 17 00:00:00 2001 From: Hushlor <25059159+Hushlor@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:23:49 +0100 Subject: [PATCH] fix: make package builds cross-platform --- .github/workflows/test.yml | 28 ++++- CHANGELOG.md | 3 + packages/cli/CHANGELOG.md | 3 + packages/cli/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 3 + packages/mcp-server/package.json | 2 +- .../tests/unit/prepare-node-dist.test.ts | 95 ++++++++++++++++ scripts/prepare-node-dist.js | 102 ++++++++++++++++++ 8 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 packages/mcp-server/tests/unit/prepare-node-dist.test.ts create mode 100644 scripts/prepare-node-dist.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10f4e04..5e17592 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,6 +160,32 @@ jobs: projectPath: packages/test-app args: ${{ matrix.args }} + test-cli-build: + name: Build @hypothesi/tauri-mcp-cli (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci --workspace=@hypothesi/tauri-mcp-cli --include-workspace-root + + - name: Build CLI dependencies + run: npm run build --workspace=@hypothesi/tauri-mcp-server + + - name: Build CLI + run: npm run build --workspace=@hypothesi/tauri-mcp-cli + lint-and-standards: name: Lint and Standards Check runs-on: ubuntu-latest @@ -205,7 +231,7 @@ jobs: all-tests-pass: name: All Tests Pass - needs: [test-plugin, test-server, test-app, lint-and-standards, validate-cli-skills] + needs: [test-plugin, test-server, test-app, test-cli-build, lint-and-standards, validate-cli-skills] runs-on: ubuntu-latest steps: - name: Success diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b646b6..0234f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Node package builds no longer depend on Unix-only `cp` and `chmod` commands, allowing the server, CLI, and root workspace builds to run under native Windows shells. + ## [0.12.0] - 2026-07-05 ### Added diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index c8461c4..331e6ea 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed +- Replace the Unix-only executable-permission command with cross-platform Node-based dist preparation. + ## [0.12.0] - 2026-07-05 ### Changed diff --git a/packages/cli/package.json b/packages/cli/package.json index fcaf3a2..1142a05 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,7 @@ "intent": "./bin/intent.js" }, "scripts": { - "build": "tsc && chmod +x dist/index.js", + "build": "tsc && node ../../scripts/prepare-node-dist.js --executable dist/index.js", "test": "vitest run", "dev": "tsc --watch", "skills:validate": "node bin/intent.js list", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 3a8fefd..484d5eb 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Replace Unix-only asset-copy and permission commands with cross-platform Node-based dist preparation. + ## [0.12.0] - 2026-07-05 ### Added diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 5522a41..1783932 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -17,7 +17,7 @@ }, "scripts": { "prebuild": "tsx scripts/bundle-aria-api.ts", - "build": "tsc && cp -r src/driver/scripts/*.js dist/driver/scripts/ && chmod +x dist/index.js", + "build": "tsc && node ../../scripts/prepare-node-dist.js --copy-js-from src/driver/scripts --copy-js-to dist/driver/scripts --executable dist/index.js", "start": "node dist/index.js", "test": "vitest run", "test:unit": "vitest run --config vitest.config.unit.ts", diff --git a/packages/mcp-server/tests/unit/prepare-node-dist.test.ts b/packages/mcp-server/tests/unit/prepare-node-dist.test.ts new file mode 100644 index 0000000..9f3e1a6 --- /dev/null +++ b/packages/mcp-server/tests/unit/prepare-node-dist.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + copyJavaScriptFiles, + markExecutable, + prepareNodeDist, +} from '../../../../scripts/prepare-node-dist.js'; + +describe('prepare-node-dist', () => { + const temporaryDirectories: string[] = []; + + async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'tauri-mcp-dist-')); + + temporaryDirectories.push(directory); + return directory; + } + + afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => { + return rm(directory, { recursive: true, force: true }); + })); + }); + + it('copies only top-level JavaScript runtime assets', async () => { + const root = await createTemporaryDirectory(), + source = path.join(root, 'source'), + target = path.join(root, 'target'); + + await mkdir(path.join(source, 'nested'), { recursive: true }); + await Promise.all([ + writeFile(path.join(source, 'alpha.js'), 'alpha'), + writeFile(path.join(source, 'beta.js'), 'beta'), + writeFile(path.join(source, 'ignored.ts'), 'ignored'), + writeFile(path.join(source, 'nested', 'nested.js'), 'nested'), + ]); + + const copied = await copyJavaScriptFiles(source, target); + + expect(copied).toEqual([ 'alpha.js', 'beta.js' ]); + await expect(readFile(path.join(target, 'alpha.js'), 'utf-8')).resolves.toBe('alpha'); + await expect(readFile(path.join(target, 'beta.js'), 'utf-8')).resolves.toBe('beta'); + await expect(stat(path.join(target, 'ignored.ts'))).rejects.toThrow(); + await expect(stat(path.join(target, 'nested', 'nested.js'))).rejects.toThrow(); + }); + + it('fails when the source contains no JavaScript assets', async () => { + const root = await createTemporaryDirectory(), + source = path.join(root, 'source'), + target = path.join(root, 'target'); + + await mkdir(source); + await writeFile(path.join(source, 'ignored.ts'), 'ignored'); + + await expect(copyJavaScriptFiles(source, target)).rejects.toThrow('No JavaScript files found'); + }); + + it('skips executable permissions on Windows', async () => { + const root = await createTemporaryDirectory(), + entryPoint = path.join(root, 'index.js'); + + await writeFile(entryPoint, '#!/usr/bin/env node'); + + await expect(markExecutable(entryPoint, 'win32')).resolves.toBe(false); + }); + + it('requires the entry point to exist on Windows', async () => { + const root = await createTemporaryDirectory(); + + await expect(markExecutable(path.join(root, 'missing.js'), 'win32')).rejects.toThrow(); + }); + + it.skipIf(process.platform === 'win32')('sets executable permissions on POSIX', async () => { + const root = await createTemporaryDirectory(), + entryPoint = path.join(root, 'index.js'); + + await writeFile(entryPoint, '#!/usr/bin/env node', { mode: 0o600 }); + + await expect(markExecutable(entryPoint)).resolves.toBe(true); + + const file = await stat(entryPoint); + + expect(file.mode % 0o1000).toBe(0o755); + }); + + it('requires both copy arguments', async () => { + await expect(prepareNodeDist([ '--copy-js-from', 'source' ], 'win32')).rejects.toThrow( + 'Both --copy-js-from and --copy-js-to are required' + ); + }); +}); diff --git a/scripts/prepare-node-dist.js b/scripts/prepare-node-dist.js new file mode 100644 index 0000000..acc6f6a --- /dev/null +++ b/scripts/prepare-node-dist.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +/* eslint-disable no-undef */ + +import { access, chmod, copyFile, mkdir, readdir } from 'fs/promises'; +import { join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { parseArgs } from 'util'; + +const EXECUTABLE_MODE = 0o755; + +/** + * Copy top-level JavaScript runtime assets into a package's dist directory. + * + * @param {string} sourceDirectory Directory containing source assets + * @param {string} targetDirectory Directory that receives JavaScript assets + * @returns {Promise} Copied filenames + */ +export async function copyJavaScriptFiles(sourceDirectory, targetDirectory) { + const entries = await readdir(sourceDirectory, { withFileTypes: true }); + + const filenames = entries + .filter((entry) => { return entry.isFile() && entry.name.endsWith('.js'); }) + .map((entry) => { return entry.name; }) + .sort(); + + if (filenames.length === 0) { + throw new Error(`No JavaScript files found in ${sourceDirectory}`); + } + + await mkdir(targetDirectory, { recursive: true }); + await Promise.all(filenames.map((filename) => { + return copyFile(join(sourceDirectory, filename), join(targetDirectory, filename)); + })); + + return filenames; +} + +/** + * Mark a Node entry point executable on platforms with POSIX mode bits. + * + * @param {string} filePath Entry point to update + * @param {NodeJS.Platform} platform Current operating system + * @returns {Promise} Whether permissions were changed + */ +export async function markExecutable(filePath, platform = process.platform) { + await access(filePath); + + if (platform === 'win32') { + return false; + } + + await chmod(filePath, EXECUTABLE_MODE); + return true; +} + +/** + * Run dist preparation from command-line arguments. + * + * @param {readonly string[]} args Command-line arguments + * @param {NodeJS.Platform} platform Current operating system + */ +export async function prepareNodeDist(args, platform = process.platform) { + const { values } = parseArgs({ + args, + options: { + 'copy-js-from': { type: 'string' }, + 'copy-js-to': { type: 'string' }, + executable: { type: 'string' }, + }, + strict: true, + }); + + const copySource = values['copy-js-from'], + copyTarget = values['copy-js-to']; + + if (Boolean(copySource) !== Boolean(copyTarget)) { + throw new Error('Both --copy-js-from and --copy-js-to are required when copying assets'); + } + + if (!copySource && !values.executable) { + throw new Error('No dist preparation operation was requested'); + } + + if (copySource && copyTarget) { + await copyJavaScriptFiles(copySource, copyTarget); + } + + if (values.executable) { + await markExecutable(values.executable, platform); + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null, + modulePath = fileURLToPath(import.meta.url); + +if (invokedPath === modulePath) { + prepareNodeDist(process.argv.slice(2)).catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +}