diff --git a/.changeset/simulator-control-input-corrections.md b/.changeset/simulator-control-input-corrections.md new file mode 100644 index 0000000000..5300494b5a --- /dev/null +++ b/.changeset/simulator-control-input-corrections.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Fix simulator control help routing, preserve literal arguments after `--`, and require `host:` for simulator-host file paths. Package local `.app` bundles when the session supports app archives. diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index 923c3da9e6..c3566297d9 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -2788,8 +2788,8 @@ USAGE $ eas sim:control [ACTION...] [-h] [--id ] [--list] [--output ] [--json] [--non-interactive] ARGUMENTS - [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before - values that start with --. + [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are + positional literals, including text that starts with -- or name=. FLAGS -h, --help Show command help, or usage for the selected action from the session. @@ -2821,7 +2821,7 @@ EXAMPLES $ eas simulator:control permissions.set grant camera com.example.app - $ eas simulator:control app.install ./MyApp.ipa + $ eas simulator:control app.install ./MyApp.app $ eas simulator:control screenshot.capture --output ./screenshot.png @@ -3251,8 +3251,8 @@ USAGE $ eas simulator:control [ACTION...] [-h] [--id ] [--list] [--output ] [--json] [--non-interactive] ARGUMENTS - [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before - values that start with --. + [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are + positional literals, including text that starts with -- or name=. FLAGS -h, --help Show command help, or usage for the selected action from the session. @@ -3284,7 +3284,7 @@ EXAMPLES $ eas simulator:control permissions.set grant camera com.example.app - $ eas simulator:control app.install ./MyApp.ipa + $ eas simulator:control app.install ./MyApp.app $ eas simulator:control screenshot.capture --output ./screenshot.png diff --git a/packages/eas-cli/src/__tests__/help.test.ts b/packages/eas-cli/src/__tests__/help.test.ts index d0cb73dc2c..f5254631cc 100644 --- a/packages/eas-cli/src/__tests__/help.test.ts +++ b/packages/eas-cli/src/__tests__/help.test.ts @@ -1,6 +1,5 @@ import { Config, Help } from '@oclif/core'; -import SimulatorControl from '../commands/simulator/control'; import EasHelp from '../help'; describe(EasHelp, () => { @@ -9,7 +8,7 @@ describe(EasHelp, () => { let showHelp: jest.SpyInstance; beforeEach(() => { - run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve()); + run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve()); showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue(); }); @@ -21,12 +20,14 @@ describe(EasHelp, () => { ['simulator:control', '--id', 'session-123', '--help', 'rotate'], ])('routes action help through the command: %j', async (...argv) => { await new EasHelp(config).showHelp(argv); - expect(run).toHaveBeenCalledWith(argv.slice(1), config); + expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1)); expect(showHelp).not.toHaveBeenCalled(); }); it.each([ ['simulator:control', '--help'], + ['simulator:control', 'screenshot.capture', '--help', '--output'], + ['sim:control', 'rotate', '-h', '--id'], ['sim:control', '-h'], ['build', '--help'], ['simulator:control', 'clipboard.write', '--', '--help'], diff --git a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts index 53af67182c..0806daf5bb 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -2,6 +2,7 @@ import { Config } from '@oclif/core'; import * as fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; +import * as tar from 'tar'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { @@ -395,17 +396,185 @@ describe(SimulatorControl, () => { expect(actionRequests()).toEqual([]); }); - it('passes a path that is not a local file through unchanged', async () => { - actionResult = ok(''); + it('refuses a missing absolute local file without running an action', async () => { + const missing = path.join(tmpDir, 'missing.png'); + await expect( + createCommand(['media.add', missing, '--id', 'session-123']).runAsync() + ).rejects.toThrow('does not exist on this computer'); + expect(actionRequests()).toEqual([]); + }); + + it('passes an explicit host path through without uploading a matching local file', async () => { + const localFile = path.join(tmpDir, 'existing.png'); + await fs.writeFile(localFile, 'local bytes'); + await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync(); + expect(actionRequests()).toEqual([['media.add', { path: localFile }]]); + }); + + it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => { + await expect( + createCommand(['media.add', value, '--id', 'session-123']).runAsync() + ).rejects.toThrow('absolute'); + expect(actionRequests()).toEqual([]); + }); + + it.each(['text=hello', '--', '--help'])( + 'preserves the literal value after the separator: %s', + async value => { + served = { + ...catalog, + 'clipboard.write': { + type: 'object', + properties: { udid: { type: 'string' }, text: { type: 'string' } }, + required: ['udid', 'text'], + }, + }; + await createCommand(['clipboard.write', '--id', 'session-123', '--', value]).runAsync(); + expect(actionRequests()).toEqual([['clipboard.write', { text: value }]]); + } + ); + + it('does not treat a flag value of -- as the literal separator', async () => { + served = { + ...catalog, + 'clipboard.write': { + type: 'object', + properties: { udid: { type: 'string' }, text: { type: 'string' } }, + required: ['udid', 'text'], + }, + }; + await createCommand(['clipboard.write', 'text=hello', '--id', '--']).runAsync(); + expect(actionRequests()).toEqual([['clipboard.write', { text: 'hello' }]]); + }); + it('keeps named arguments before the separator and positional values after it', async () => { await createCommand([ - 'app.install', - '/Users/runner/MyApp.ipa', + 'permissions.set', + 'action=grant', '--id', 'session-123', + '--', + 'service=camera', + 'bundleId=literal', ]).runAsync(); + expect(actionRequests()).toEqual([ + [ + 'permissions.set', + { action: 'grant', service: 'service=camera', bundleId: 'bundleId=literal' }, + ], + ]); + }); + + it.each([false, true])( + 'packages a local app bundle and cleans it after failure=%s', + async fails => { + const bundle = path.join(tmpDir, 'My App.app'); + await fs.mkdir(bundle); + await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist'); + served = { + ...catalog, + 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' }, + }; + const directories = await fs.readdir(os.tmpdir()); + actionResult = fails ? { stdout: '', stderr: 'install rejected', exitCode: 1 } : ok(''); + const command = createCommand([ + 'app.install', + `${bundle}/`, + '--id', + 'session-123', + ]).runAsync(); + if (fails) { + await expect(command).rejects.toThrow('install rejected'); + } else { + await command; + } + const requests = actionRequests(); + expect(requests.map(([action]) => action)).toEqual([ + 'upload.append', + 'app.install', + 'upload.remove', + ]); + const data = Buffer.concat( + requests + .filter(([action]) => action === 'upload.append') + .map(([, params]) => Buffer.from(String(params?.data), 'base64')) + ); + const file = path.join(tmpDir, 'received.tgz'); + await fs.writeFile(file, data); + const entries: string[] = []; + await tar.list({ + file, + onReadEntry: entry => { + entries.push(entry.path); + }, + }); + expect(entries).toContain('My App.app/Info.plist'); + const newDirectories = (await fs.readdir(os.tmpdir())).filter( + name => name.startsWith('eas-simulator-app-') && !directories.includes(name) + ); + expect(newDirectories).toEqual([]); + } + ); + + it('packages the contents of an app bundle reached through a local symlink', async () => { + const actual = path.join(tmpDir, 'Actual'); + const link = path.join(tmpDir, 'Linked.app'); + await fs.mkdir(actual); + await fs.writeFile(path.join(actual, 'Info.plist'), 'app plist'); + await fs.symlink(actual, link); + served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } }; + await createCommand(['app.install', link, '--id', 'session-123']).runAsync(); + const data = Buffer.concat( + actionRequests() + .filter(([action]) => action === 'upload.append') + .map(([, params]) => Buffer.from(String(params?.data), 'base64')) + ); + const file = path.join(tmpDir, 'received.tgz'); + await fs.writeFile(file, data); + const entries: string[] = []; + await tar.list({ + file, + onReadEntry: entry => { + entries.push(entry.path); + }, + }); + expect(entries).toContain('Linked.app/Info.plist'); + }); + + it('cleans a packaged bundle when uploading fails', async () => { + const bundle = path.join(tmpDir, 'Upload.app'); + await fs.mkdir(bundle); + await fs.writeFile(path.join(bundle, 'Info.plist'), 'app plist'); + served = { ...catalog, 'app.install': { ...catalog['app.install'], uploadArchive: 'tar.gz' } }; + const before = await fs.readdir(os.tmpdir()); + requestAsync + .mockResolvedValueOnce(ok(JSON.stringify(served))) + .mockResolvedValueOnce({ stdout: '', stderr: 'upload rejected', exitCode: 1 }); + await expect( + createCommand(['app.install', bundle, '--id', 'session-123']).runAsync() + ).rejects.toThrow('upload rejected'); + expect(actionRequests().map(([action]) => action)).toEqual(['upload.append', 'upload.remove']); + expect( + (await fs.readdir(os.tmpdir())).filter( + name => name.startsWith('eas-simulator-app-') && !before.includes(name) + ) + ).toEqual([]); + }); - expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]); + it('refuses bundle upload to a host that does not advertise app archives', async () => { + const bundle = path.join(tmpDir, 'My.app'); + await fs.mkdir(bundle); + await expect( + createCommand(['app.install', bundle, '--id', 'session-123']).runAsync() + ).rejects.toThrow('does not support app archives'); + expect(actionRequests()).toEqual([]); + }); + + it('continues rejecting unrelated folders', async () => { + await expect( + createCommand(['media.add', tmpDir, '--id', 'session-123']).runAsync() + ).rejects.toThrow('is a folder'); + expect(actionRequests()).toEqual([]); }); it('downloads the file an action returns', async () => { diff --git a/packages/eas-cli/src/commands/simulator/control.ts b/packages/eas-cli/src/commands/simulator/control.ts index ea19c1b41a..1a64271ff4 100644 --- a/packages/eas-cli/src/commands/simulator/control.ts +++ b/packages/eas-cli/src/commands/simulator/control.ts @@ -2,6 +2,8 @@ import { Args, Flags } from '@oclif/core'; import chalk from 'chalk'; import * as fs from 'fs-extra'; import path from 'path'; +import os from 'os'; +import * as tar from 'tar'; import EasCommand from '../../commandUtils/EasCommand'; import { @@ -45,7 +47,7 @@ export default class SimulatorControl extends EasCommand { '$ eas simulator:control clipboard.write -- --some-text', '$ eas simulator:control hardwareKeyboard.set off', '$ eas simulator:control permissions.set grant camera com.example.app', - '$ eas simulator:control app.install ./MyApp.ipa', + '$ eas simulator:control app.install ./MyApp.app', '$ eas simulator:control screenshot.capture --output ./screenshot.png', '$ eas simulator:control screenshot.capture --output ./screenshot.png --id ', ]; @@ -53,7 +55,7 @@ export default class SimulatorControl extends EasCommand { static override args = { action: Args.string({ description: - 'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.', + 'Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are positional literals, including text that starts with -- or name=.', }), }; @@ -82,6 +84,21 @@ export default class SimulatorControl extends EasCommand { async runAsync(): Promise { const { args, argv, flags } = await this.parse(SimulatorControl); + let literalFrom = argv.length - 1; + for (let index = 0; index < this.argv.length; index++) { + const value = this.argv[index]; + if (value === '--') { + literalFrom = argv.length - 1 - (this.argv.length - index - 1); + break; + } + // A standalone -- consumed as a flag value is not an argument separator. + const flag = value.startsWith('--') + ? SimulatorControl.flags[value.slice(2) as keyof typeof SimulatorControl.flags] + : undefined; + if (flag?.type === 'option') { + index++; + } + } const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); if (jsonFlag) { @@ -138,6 +155,7 @@ export default class SimulatorControl extends EasCommand { sessionId, action, values: argv.slice(1).map(String), + literalFrom, outputPath: flags.output, jsonFlag, }); @@ -198,6 +216,10 @@ export default class SimulatorControl extends EasCommand { ' Action arguments refer to the simulator host. Use --output for the local filename.' ); } + Log.log(' Values after -- are positional literals.'); + if (entry.upload) { + Log.log(' File paths are local. Use host:/absolute/path for files on the simulator host.'); + } Log.newLine(); } @@ -208,19 +230,21 @@ export default class SimulatorControl extends EasCommand { sessionId, action, values, + literalFrom, outputPath, jsonFlag, }: { sessionId: string; action: string; values: string[]; + literalFrom: number; outputPath: string | undefined; jsonFlag: boolean; } ): Promise { - const params = buildHostActionParams(action, entry, values); + const params = buildHostActionParams(action, entry, values, literalFrom); await assertHostActionOutputAsync(action, entry, outputPath); - const localFile = await findLocalUploadAsync(entry, params); + const localFile = await findLocalUploadAsync(action, entry, params); const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start(); let uploadId: string | undefined; @@ -251,6 +275,7 @@ export default class SimulatorControl extends EasCommand { if (uploadId && (!entry.keepUpload || !succeeded)) { await connection.requestAsync('upload.remove', { uploadId }).catch(() => {}); } + await localFile?.cleanupAsync?.(); } if (jsonFlag) { @@ -271,21 +296,66 @@ export default class SimulatorControl extends EasCommand { } async function findLocalUploadAsync( - { upload: param, uploadWhen = {} }: HostActionCatalogEntry, + action: string, + { upload: param, uploadWhen = {}, uploadArchive }: HostActionCatalogEntry, params: Record -): Promise<{ param: string; path: string } | null> { +): Promise<{ param: string; path: string; cleanupAsync?: () => Promise } | null> { const value = param ? params[param] : undefined; const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected); if (!param || typeof value !== 'string' || !applies) { return null; } - const stat = await fs.stat(value).catch(() => null); + if (value.startsWith('host:')) { + const hostPath = value.slice('host:'.length); + if (!path.posix.isAbsolute(hostPath)) { + throw new Error('A host: path must be absolute, for example host:/tmp/app.tar.gz.'); + } + params[param] = hostPath; + return null; + } + const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return null; + } + throw error; + }); if (stat?.isDirectory()) { - throw new Error(`${value} is a folder. Pass the path of a file.`); + if (action !== 'app.install' || path.extname(path.resolve(value)) !== '.app') { + throw new Error( + `${value} is a folder. Pass the path of a file or an app.install .app bundle.` + ); + } + if (uploadArchive !== 'tar.gz') { + throw new Error( + 'This simulator host does not support app archives. Start a session with an updated serve-sim package.' + ); + } + const bundle = await fs.realpath(value); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-simulator-app-')); + const archive = path.join(directory, 'app.tar.gz'); + const cleanupAsync = async (): Promise => { + await fs.remove(directory); + }; + try { + await tar.create( + { + cwd: bundle, + file: archive, + prefix: path.basename(path.resolve(value)), + gzip: true, + portable: true, + }, + ['.'] + ); + return { param, path: archive, cleanupAsync }; + } catch (error) { + await cleanupAsync(); + throw error; + } } - if (!stat && !path.isAbsolute(value)) { + if (!stat) { throw new Error( - `${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.` + `${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.` ); } return stat ? { param, path: value } : null; diff --git a/packages/eas-cli/src/help.ts b/packages/eas-cli/src/help.ts index 455232f8eb..c1af7344d2 100644 --- a/packages/eas-cli/src/help.ts +++ b/packages/eas-cli/src/help.ts @@ -6,13 +6,19 @@ export default class EasHelp extends Help { const [command, ...args] = argv; if (command === 'simulator:control' || command === 'sim:control') { const { default: SimulatorControl } = await import('./commands/simulator/control'); - const parsed = await Parser.parse(args, { - args: SimulatorControl.args, - flags: SimulatorControl.flags, - strict: false, - }); + let parsed; + try { + parsed = await Parser.parse(args, { + args: SimulatorControl.args, + flags: SimulatorControl.flags, + strict: false, + }); + } catch { + await super.showHelp(argv); + return; + } if (parsed.args.action && parsed.flags.help) { - await SimulatorControl.run(args, this.config); + await this.config.runCommand('simulator:control', args); return; } } diff --git a/packages/eas-cli/src/simulator/hostActions.ts b/packages/eas-cli/src/simulator/hostActions.ts index f5bcc4d60c..ed86904840 100644 --- a/packages/eas-cli/src/simulator/hostActions.ts +++ b/packages/eas-cli/src/simulator/hostActions.ts @@ -31,6 +31,7 @@ const CatalogEntry = SchemaNode.extend({ description: z.string().optional(), args: z.array(z.string()).optional(), upload: z.string().optional(), + uploadArchive: z.string().optional(), uploadAs: z.enum(['path', 'id']).optional(), uploadWhen: z.record(z.string(), z.unknown()).optional(), keepUpload: z.boolean().optional(), @@ -392,13 +393,14 @@ export function formatHostActionUsage(name: string, entry: HostActionCatalogEntr export function buildHostActionParams( name: string, entry: HostActionCatalogEntry, - values: string[] + values: string[], + literalFrom = values.length ): Record { const properties = hostActionProperties(entry); const params: Record = {}; const positional: string[] = []; - for (const value of values) { - const named = NAMED_VALUE.exec(value); + for (const [index, value] of values.entries()) { + const named = index < literalFrom ? NAMED_VALUE.exec(value) : null; if (!named || !properties.has(named[1])) { positional.push(value); } else if (Object.hasOwn(params, named[1])) {