Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions packages/mcp-server/tests/unit/prepare-node-dist.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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'
);
});
});
102 changes: 102 additions & 0 deletions scripts/prepare-node-dist.js
Original file line number Diff line number Diff line change
@@ -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<readonly string[]>} 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<boolean>} 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;
});
}