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
6 changes: 1 addition & 5 deletions .mocharc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
32 changes: 28 additions & 4 deletions lib/devicectl.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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';
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
Expand All @@ -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();
}

/**
Expand All @@ -48,6 +53,7 @@ export class Devicectl {
noDevice = false,
subcommandOptions,
timeout,
runAsNonRootWhenSudo = this.preferNonRootWhenSudo,
} = opts ?? {};

const finalArgs = ['devicectl', ...subcommand, ...(noDevice ? [] : ['--device', this.udid])];
Expand All @@ -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<T>;
}

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}`);
Expand All @@ -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};
}
}
17 changes: 17 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions test/e2e/sudo-execution-specs.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions test/unit/devicectl-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment on lines +30 to +32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is identical to the test on line 16

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

});

describe('execute', function () {
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"outDir": "build",
"checkJs": true,
"esModuleInterop": true,
"types": ["node"],
"types": ["node", "mocha"],
"strict": true
},
"include": ["lib"]
"include": ["lib", "test"]
}
Loading