diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..71277b2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Biome enforces lf (biome.json formatter.lineEnding); force lf on checkout +# everywhere so windows checkouts (core.autocrlf=true) don't fail lint. +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d81bd8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: ${{ matrix.os }} / node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: [20, 22, 24] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + # pnpm 11 cannot run on node 20 (it requires node >= 22.13), so the + # node 20 jobs need the standalone build, which bundles its own + # runtime while project scripts still run on the matrix node. Both + # pnpm/action-setup standalone mode and `npm i -g @pnpm/exe` are + # broken for 11.13.0 — the @pnpm/* platform packages on npm were + # published without their binary (pnpm/action-setup#277) — so fetch + # the standalone binary straight from the GitHub release, versioned + # by package.json's packageManager field. + - name: Setup pnpm (standalone binary) + shell: bash + run: | + version="$(node -p "require('./package.json').packageManager.split('@').pop()")" + case "${RUNNER_OS}-${RUNNER_ARCH}" in + Linux-X64) asset="pnpm-linux-x64.tar.gz" ;; + Linux-ARM64) asset="pnpm-linux-arm64.tar.gz" ;; + macOS-ARM64) asset="pnpm-darwin-arm64.tar.gz" ;; + Windows-X64) asset="pnpm-win32-x64.zip" ;; + Windows-ARM64) asset="pnpm-win32-arm64.zip" ;; + *) echo "no pnpm standalone asset for ${RUNNER_OS}-${RUNNER_ARCH}" >&2; exit 1 ;; + esac + dest="${RUNNER_TEMP}/pnpm-standalone" + mkdir -p "$dest" + cd "$dest" + curl -fsSL --retry 3 -o "$asset" \ + "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset}" + if [[ "$asset" == *.zip ]]; then + powershell.exe -NoProfile -Command "Expand-Archive -Path '${asset}' -DestinationPath '.' -Force" + else + tar -xzf "$asset" + chmod +x pnpm + fi + rm "$asset" + ./pnpm --version + echo "$dest" >> "$GITHUB_PATH" + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/src/commands/self-update.test.ts b/src/commands/self-update.test.ts index 1ef265e..9156086 100644 --- a/src/commands/self-update.test.ts +++ b/src/commands/self-update.test.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { EXIT } from '../errors' import type { RunResult } from '../run-command' @@ -235,7 +236,11 @@ describe('runSelfUpdate — install and verify', () => { ) expect(c.runCommand).toHaveBeenCalledWith( 'node', - [`${NPM_ROOT}/@motrix/cli/dist/bin/motrix.js`, '--version'], + // join()-built: the code joins the entry with the HOST separator. + [ + join(NPM_ROOT, '@motrix', 'cli', 'dist', 'bin', 'motrix.js'), + '--version', + ], expect.objectContaining({ cwd: '/tmp' }) ) }) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 84a66f7..cd3691b 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -9,16 +9,18 @@ import { tokenForEndpoint, } from './credentials' +// Expectations are join()-built: credentialsFilePath joins with the HOST +// separator, so literal `/` strings would fail on a Windows host. describe('credentialsFilePath', () => { it('lands under ~/.config/motrix by default', () => { expect(credentialsFilePath({}, '/home/me')).toBe( - '/home/me/.config/motrix/credentials.json' + join('/home/me', '.config', 'motrix', 'credentials.json') ) }) it('honors XDG_CONFIG_HOME', () => { expect(credentialsFilePath({ XDG_CONFIG_HOME: '/cfg' }, '/home/me')).toBe( - '/cfg/motrix/credentials.json' + join('/cfg', 'motrix', 'credentials.json') ) }) }) @@ -50,11 +52,16 @@ describe('credentials store', () => { expect(await tokenForEndpoint('http://127.0.0.1:16801', path)).toBe('tok-a') }) - it('writes the file at mode 0600', async () => { - await saveToken('http://x', { token: 't' }, path) - const st = await stat(path) - expect(st.mode & 0o777).toBe(0o600) - }) + // Windows has no POSIX permission bits (stat.mode reports 0o666); the + // chmod call is still exercised there as a no-op by the other tests. + it.skipIf(process.platform === 'win32')( + 'writes the file at mode 0600', + async () => { + await saveToken('http://x', { token: 't' }, path) + const st = await stat(path) + expect(st.mode & 0o777).toBe(0o600) + } + ) it('normalizes a trailing slash in the endpoint key', async () => { await saveToken('http://nas.local:16801/', { token: 'tok-b' }, path) diff --git a/src/device-id.test.ts b/src/device-id.test.ts index dd0aa3e..a02f8d2 100644 --- a/src/device-id.test.ts +++ b/src/device-id.test.ts @@ -40,11 +40,16 @@ describe('device-id store', () => { expect(a).not.toBe(b) }) - it('writes the store file with 0600 permissions', async () => { - await ensureDeviceId('http://127.0.0.1:1', path) - const mode = (await stat(path)).mode & 0o777 - expect(mode).toBe(0o600) - }) + // Windows has no POSIX permission bits (stat.mode reports 0o666); the + // mode-setting write path is still exercised there by the other tests. + it.skipIf(process.platform === 'win32')( + 'writes the store file with 0600 permissions', + async () => { + await ensureDeviceId('http://127.0.0.1:1', path) + const mode = (await stat(path)).mode & 0o777 + expect(mode).toBe(0o600) + } + ) async function seed(obj: unknown): Promise { await mkdir(dirname(path), { recursive: true }) diff --git a/src/discovery.test.ts b/src/discovery.test.ts index f8e2892..1bdc4d9 100644 --- a/src/discovery.test.ts +++ b/src/discovery.test.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { type EndpointFile, @@ -17,34 +18,46 @@ const file: EndpointFile = { writtenAt: 1, } +// Expectations are join()-built: the code under test joins with the HOST +// separator even when simulating another platform, so literal `/` strings +// would fail on a Windows host. describe('userDataDir', () => { it('resolves the macOS Application Support path', () => { expect(userDataDir('darwin', {}, '/Users/me')).toBe( - '/Users/me/Library/Application Support/Motrix' + join('/Users/me', 'Library', 'Application Support', 'Motrix') ) }) it('resolves the Linux XDG config path', () => { - expect(userDataDir('linux', {}, '/home/me')).toBe('/home/me/.config/Motrix') + expect(userDataDir('linux', {}, '/home/me')).toBe( + join('/home/me', '.config', 'Motrix') + ) }) it('honors XDG_CONFIG_HOME on Linux', () => { expect(userDataDir('linux', { XDG_CONFIG_HOME: '/cfg' }, '/home/me')).toBe( - '/cfg/Motrix' + join('/cfg', 'Motrix') ) }) it('resolves the Windows APPDATA path', () => { expect( userDataDir('win32', { APPDATA: 'C:\\Users\\me\\AppData\\Roaming' }, 'C:') - ).toContain('Motrix') + ).toBe(join('C:\\Users\\me\\AppData\\Roaming', 'Motrix')) }) }) describe('endpointFilePath', () => { it('lands under /bridge/endpoint.json', () => { expect(endpointFilePath('darwin', {}, '/Users/me')).toBe( - '/Users/me/Library/Application Support/Motrix/bridge/endpoint.json' + join( + '/Users/me', + 'Library', + 'Application Support', + 'Motrix', + 'bridge', + 'endpoint.json' + ) ) }) }) diff --git a/src/run-command.test.ts b/src/run-command.test.ts index daaee23..356acd9 100644 --- a/src/run-command.test.ts +++ b/src/run-command.test.ts @@ -71,12 +71,19 @@ describe('runCommand', () => { expect(r).toMatchObject({ code: 3, stderr: 'bad' }) }) - it('reports a spawn failure as code null with the error attached', async () => { + it('reports a missing binary through the platform signal', async () => { const r = await runCommand('motrix-test-no-such-binary-xyz', []) - expect(r.code).toBe(null) - expect(r.spawnError).toBeDefined() - // A POSIX ENOENT is a missing command; on win32 it surfaces via 9009. - if (process.platform !== 'win32') expect(r.commandMissing).toBe(true) + expect(r.commandMissing).toBe(true) + if (process.platform === 'win32') { + // Through cmd.exe a missing command is not a spawn failure: cmd runs, + // prints "... is not recognized ..." and exits non-zero. + expect(r.code).not.toBe(0) + expect(r.code).not.toBe(null) + } else { + // POSIX: the spawn itself fails with ENOENT. + expect(r.code).toBe(null) + expect(r.spawnError).toBeDefined() + } }) it('kills a process that exceeds the timeout and flags timedOut', async () => { @@ -84,8 +91,14 @@ describe('runCommand', () => { timeoutMs: 200, }) expect(r.timedOut).toBe(true) - // Killed → no clean exit code. - expect(r.code).toBe(null) + if (process.platform === 'win32') { + // taskkill /f terminates the cmd.exe wrapper with a non-zero exit code. + expect(r.code).not.toBe(0) + expect(r.code).not.toBe(null) + } else { + // Killed by signal → no clean exit code. + expect(r.code).toBe(null) + } }) it('caps captured output and flags truncation', async () => { diff --git a/src/run-command.ts b/src/run-command.ts index 4eb51ea..70e5ebe 100644 --- a/src/run-command.ts +++ b/src/run-command.ts @@ -77,29 +77,51 @@ export function escapeCmdArgument( return arg } +/** + * The JS package managers we invoke by bare name. On Windows they ship as + * `.cmd` shims, which cmd.exe re-parses — their arguments need the double + * meta-escape (BatBadBut). No other bare name may be treated as a shim: + * cmd.exe parses an `.exe` command line only once, so a double-escaped + * argument reaches the program with literal carets — `node -e