Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion src/commands/self-update.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' })
)
})
Expand Down
21 changes: 14 additions & 7 deletions src/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
})
})
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions src/device-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await mkdir(dirname(path), { recursive: true })
Expand Down
23 changes: 18 additions & 5 deletions src/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
type EndpointFile,
Expand All @@ -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 <userData>/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'
)
)
})
})
Expand Down
27 changes: 20 additions & 7 deletions src/run-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,34 @@ 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 () => {
const r = await runCommand('node', ['-e', 'setTimeout(() => {}, 10000)'], {
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 () => {
Expand Down
48 changes: 35 additions & 13 deletions src/run-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>`
* breaks outright. (A standalone pnpm.exe invoked as bare `pnpm` still gets
* the double escape and its arguments arrive caret-mangled — a pre-existing
* limitation, unchanged here, and it fails loudly rather than silently.
* Resolving the real extension through PATHEXT, as cross-spawn does, remains
* the complete fix.)
*/
const BATCH_SHIM_NAMES = new Set([
'npm',
'npx',
'pnpm',
'pnpx',
'yarn',
'corepack',
])

/**
* Whether a command, run through cmd.exe, resolves to a `.cmd`/`.bat` shim
* (which cmd re-parses, requiring double meta-escaping of arguments). The JS
* package managers (`npm`, `pnpm`, `yarn`) ship as `.cmd` shims on Windows and
* are passed as bare names; `node`/the resolved bin paths we spawn are `.exe`.
* Over-including an `.exe` here is harmless for our argument set (none contains
* a metacharacter once the version spec passes `SPEC_RE`), so a bare name with
* no extension is conservatively treated as a shim.
* (which cmd re-parses, requiring double meta-escaping of arguments). An
* explicit `.cmd`/`.bat` path is a shim; a bare name is one only when it is a
* known package-manager shim. Paths and extension-bearing names (`node.exe`,
* resolved bin paths) are not.
*/
function looksLikeBatchShim(command: string): boolean {
if (/\.(?:cmd|bat)$/i.test(command)) return true
return !/[\\/]/.test(command) && !/\.[a-z0-9]+$/i.test(command)
if (/[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command)) return false
return BATCH_SHIM_NAMES.has(command.toLowerCase())
}

/**
* Whether a *completed* process indicates the command was not found. On POSIX
* a missing binary never reaches here — it fails as a spawn `ENOENT` error
* (handled separately). On win32 the command runs through cmd.exe, which exits
* 9009 ("'x' is not recognized as an internal or external command") for a
* missing command rather than emitting a spawn error. 9009 is the reliable,
* locale-independent signal; the English phrase is a best-effort backup.
* Deliberately narrow so a real tool error like npm's `E404 ... not found`
* (exit 1) is NOT misclassified as a missing command.
* (handled separately). On win32 the command runs through cmd.exe, which
* prints "'x' is not recognized as an internal or external command" to stderr
* and — empirically, under `spawn(..., { shell: true })` — exits 1, not the
* documented ERRORLEVEL 9009. The phrase match is therefore the live signal
* (English-locale systems; a localized message is missed — accepted
* limitation), with 9009 kept as a defensive extra. Deliberately narrow so a
* real tool error like npm's `E404 ... not found` (exit 1) is NOT
* misclassified as a missing command.
*/
export function isCommandMissing(
code: number | null,
Expand Down
Loading