diff --git a/.mocharc.js b/.mocharc.js index 495c6e8..3136ed9 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -2,9 +2,5 @@ module.exports = { require: ['ts-node/register'], timeout: 60000, exit: true, - recursive: true, - spec: [ - './test/unit/**/*-specs.ts', - './test/e2e/**/*-specs.ts' - ] + recursive: true }; diff --git a/README.md b/README.md index 14a4e95..82d6d75 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,25 @@ await devicectl.launchApp('com.example.app', { }); ``` +When Node is running under `sudo`, `node-devicectl` runs `xcrun devicectl` as the original +non-root user by default (`SUDO_UID`/`SUDO_GID`) to avoid CoreDevice provider lookup errors. + +You can disable this globally via constructor options: + +```typescript +const devicectl = new Devicectl('device-udid', { + preferNonRootWhenSudo: false, +}); +``` + +Or override for a single command: + +```typescript +await devicectl.execute(['device', 'info'], { + runAsNonRootWhenSudo: false, +}); +``` + ## Requirements - Xcode 15+ diff --git a/lib/devicectl.ts b/lib/devicectl.ts index fef7200..a1013be 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -1,7 +1,7 @@ import {exec, SubProcess} from 'teen_process'; import _ from 'lodash'; import logger from '@appium/logger'; -import type {ExecuteOptions, ExecuteResult} from './types'; +import type {DevicectlOptions, ExecuteOptions, ExecuteResult} from './types'; import * as processMixins from './mixins/process'; import * as infoMixins from './mixins/info'; import * as copyMixins from './mixins/copy'; @@ -9,6 +9,7 @@ import * as listMixins from './mixins/list'; const XCRUN = 'xcrun'; const LOG_TAG = 'Devicectl'; +type SudoUser = {uid: number; gid: number}; /** * Node.js wrapper around Apple's devicectl tool @@ -20,14 +21,18 @@ const LOG_TAG = 'Devicectl'; export class Devicectl { /** The unique device identifier */ public readonly udid: string; + private readonly preferNonRootWhenSudo: boolean; + private readonly sudoUser: SudoUser | null; /** * Creates a new Devicectl instance * * @param udid - The unique device identifier */ - constructor(udid: string) { + constructor(udid: string, opts?: DevicectlOptions) { this.udid = udid; + this.preferNonRootWhenSudo = opts?.preferNonRootWhenSudo ?? true; + this.sudoUser = this.resolveSudoUser(); } /** @@ -48,6 +53,7 @@ export class Devicectl { noDevice = false, subcommandOptions, timeout, + runAsNonRootWhenSudo = this.preferNonRootWhenSudo, } = opts ?? {}; const finalArgs = ['devicectl', ...subcommand, ...(noDevice ? [] : ['--device', this.udid])]; @@ -62,17 +68,22 @@ export class Devicectl { finalArgs.push('--quiet', '--json-output', '-'); } + const userOpts = runAsNonRootWhenSudo && this.sudoUser ? this.sudoUser : undefined; const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); logger.debug(LOG_TAG, `Executing ${cmdStr}`); try { if (asynchronous) { - const result = new SubProcess(XCRUN, finalArgs); + const result = new SubProcess(XCRUN, finalArgs, userOpts); await result.start(0); return result as ExecuteResult; } - const result = await exec(XCRUN, finalArgs, ...(_.isNumber(timeout) ? [{timeout}] : [])); + const execOpts = { + ...userOpts, + ...(_.isNumber(timeout) ? {timeout} : {}), + }; + const result = await exec(XCRUN, finalArgs, execOpts); if (logStdout) { logger.debug(LOG_TAG, `Command output: ${result.stdout}`); @@ -95,4 +106,17 @@ export class Devicectl { pullFile = copyMixins.pullFile; listDevices = listMixins.listDevices; + + private resolveSudoUser(): SudoUser | null { + if (!process.geteuid || process.geteuid() !== 0) { + return null; + } + + const uid = Number(process.env.SUDO_UID); + const gid = Number(process.env.SUDO_GID); + if (!Number.isInteger(uid) || !Number.isInteger(gid)) { + return null; + } + return {uid, gid}; + } } diff --git a/lib/types.ts b/lib/types.ts index c83d3f7..9fd7178 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,6 +66,23 @@ export interface ExecuteOptions { subcommandOptions?: string[] | string; /** Timeout in milliseconds */ timeout?: number; + /** + * Whether to run as the original non-root user when current process is running via sudo + * + * If not set, this falls back to `DevicectlOptions.preferNonRootWhenSudo`. + */ + runAsNonRootWhenSudo?: boolean; +} + +/** + * Options for creating a Devicectl instance + */ +export interface DevicectlOptions { + /** + * Whether to run `devicectl` as the original non-root user when parent process runs via sudo + * @default true + */ + preferNonRootWhenSudo?: boolean; } /** diff --git a/package.json b/package.json index aa73bf2..b4976bf 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,13 @@ }, "files": [ "lib", - "build", - "CHANGELOG.md", - "!build/test" + "build/lib", + "CHANGELOG.md" ], "dependencies": { "@appium/logger": "^2.0.0-rc.1", "lodash": "^4.2.1", - "teen_process": "^4.0.4" + "teen_process": "^4.1.0" }, "scripts": { "build": "tsc -b", @@ -49,8 +48,8 @@ "format": "prettier -w ./lib ./test", "format:check": "prettier --check ./lib ./test", "prepare": "npm run build", - "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.*js\"", - "e2e-test": "mocha --exit --timeout 5m \"./test/e2e/**/*-specs.js\"" + "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.ts\"", + "e2e-test": "mocha --exit --timeout 5m \"./test/e2e/**/*-specs.ts\"" }, "prettier": { "bracketSpacing": false, @@ -63,6 +62,7 @@ "@appium/types": "^1.0.0-rc.1", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", + "@types/chai": "^5.2.3", "@types/lodash": "^4.14.196", "@types/mocha": "^10.0.1", "@types/node": "^25.0.3", diff --git a/test/e2e/sudo-execution-specs.ts b/test/e2e/sudo-execution-specs.ts new file mode 100644 index 0000000..8a86b99 --- /dev/null +++ b/test/e2e/sudo-execution-specs.ts @@ -0,0 +1,27 @@ +import {expect} from 'chai'; +import {Devicectl} from '../../lib/devicectl'; + +describe('manual sudo execution e2e', function () { + it('runs devicectl as original non-root user under sudo', async function () { + if ( + process.env.CI || + process.platform !== 'darwin' || + !process.geteuid || + process.geteuid() !== 0 || + !process.env.SUDO_UID || + !process.env.SUDO_GID + ) { + this.skip(); + return; + } + + const devicectl = new Devicectl(''); + const result = await devicectl.execute(['list', 'devices'], { + noDevice: true, + asJson: true, + }); + + expect(result.stdout).to.be.a('string'); + expect(result.stdout.length).to.be.greaterThan(0); + }); +}); diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 70ffe4c..70f1410 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -12,6 +12,24 @@ describe('Devicectl', function () { it('should create a Devicectl instance with the provided UDID and default logger', function () { expect(devicectl.udid).to.equal('test-device-udid'); }); + + it('should allow disabling non-root sudo execution in constructor options', function () { + const localDevicectl = new Devicectl('test-device-udid', {preferNonRootWhenSudo: false}); + expect((localDevicectl as any).preferNonRootWhenSudo).to.equal(false); + }); + }); + + describe('sudo behavior', function () { + it('should cache sudo user identity when available', function () { + const localDevicectl = new Devicectl('test-device-udid'); + expect( + (localDevicectl as any).sudoUser === null || !!(localDevicectl as any).sudoUser, + ).to.equal(true); + }); + + it('should keep constructor default for runAsNonRootWhenSudo behavior', function () { + expect((devicectl as any).preferNonRootWhenSudo).to.equal(true); + }); }); describe('execute', function () { diff --git a/tsconfig.json b/tsconfig.json index c578b3c..499781d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,8 @@ "outDir": "build", "checkJs": true, "esModuleInterop": true, - "types": ["node"], + "types": ["node", "mocha"], "strict": true }, - "include": ["lib"] + "include": ["lib", "test"] }