From 6c7f34dfef84279788fbf91f568a3aef6c96dfdf Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 17 Oct 2026 19:29:00 -0700 Subject: [PATCH 1/3] [eas-cli] Require explicit simulator-host upload paths --- .changeset/simulator-control-paths.md | 5 ++++ .../simulator/__tests__/control.test.ts | 27 ++++++++++++------- .../eas-cli/src/commands/simulator/control.ts | 22 ++++++++++++--- 3 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 .changeset/simulator-control-paths.md diff --git a/.changeset/simulator-control-paths.md b/.changeset/simulator-control-paths.md new file mode 100644 index 0000000000..b722b61771 --- /dev/null +++ b/.changeset/simulator-control-paths.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Require explicit `host:` simulator-host upload paths and report missing local files. 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..90178a6653 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -395,17 +395,26 @@ 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([]); + }); - await createCommand([ - 'app.install', - '/Users/runner/MyApp.ipa', - '--id', - 'session-123', - ]).runAsync(); + 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 }]]); + }); - expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]); + 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('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 00eb4dd2df..c27645c37c 100644 --- a/packages/eas-cli/src/commands/simulator/control.ts +++ b/packages/eas-cli/src/commands/simulator/control.ts @@ -184,6 +184,9 @@ export default class SimulatorControl extends EasCommand { Log.log(` ${chalk.bold('OPTIONS')}`); Log.log(` --id Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`); Log.log(' --json Print the action schema as JSON. Implies --non-interactive.'); + if (entry.upload) { + Log.log(' File paths are local. Use host:/absolute/path for files on the simulator host.'); + } if (entry.output) { Log.log( entry.output === 'base64' @@ -279,13 +282,26 @@ async function findLocalUploadAsync( 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 (!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; From 25a0e54fcd7f553d169f083929d3a1f139a15ac6 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 17 Oct 2026 19:45:19 -0700 Subject: [PATCH 2/3] [eas-cli] Cover simulator upload path help and filesystem failures --- .../simulator/__tests__/control.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 90178a6653..207d2abbff 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -44,6 +44,10 @@ jest.mock('../../../ora', () => ({ }), })); jest.mock('../../../utils/json'); +jest.mock('fs-extra', () => { + const actual = jest.requireActual('fs-extra'); + return { ...actual, stat: jest.fn(actual.stat) }; +}); type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId']; @@ -236,6 +240,16 @@ describe(SimulatorControl, () => { expect(close).toHaveBeenCalledTimes(1); }); + it('explains local and explicit host upload paths without running an action', async () => { + await createCommand(['media.add', '--help', '--id', 'session-123']).runAsync(); + + expect(jest.mocked(Log.log)).toHaveBeenCalledWith( + ' File paths are local. Use host:/absolute/path for files on the simulator host.' + ); + expect(actionRequests()).toEqual([]); + expect(close).toHaveBeenCalledTimes(1); + }); + it('prints the host action schema as JSON for help', async () => { await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync(); @@ -403,6 +417,29 @@ describe(SimulatorControl, () => { expect(actionRequests()).toEqual([]); }); + it('preserves a local filesystem failure without sending an action', async () => { + const denied = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + const stat = jest + .mocked<(path: string) => Promise>(fs.stat) + .mockRejectedValueOnce(denied); + try { + await expect( + createCommand([ + 'media.add', + path.join(tmpDir, 'private.png'), + '--id', + 'session-123', + ]).runAsync() + ).rejects.toBe(denied); + expect(actionRequests()).toEqual([]); + expect(close).toHaveBeenCalledTimes(1); + } finally { + stat + .mockReset() + .mockImplementation(jest.requireActual('fs-extra').stat); + } + }); + 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'); From 4ec974cdf945ce5619d0c155be3fd353b7492ee0 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 22 Oct 2026 23:55:23 -0700 Subject: [PATCH 3/3] [eas-cli] Reject host paths for upload ID actions Keep upload guidance consistent in text and JSON help, and explain local filenames beginning with host:. Add rejection and help regressions. Co-authored-by: Codex --- .changeset/simulator-control-paths.md | 2 + .../simulator/__tests__/control.test.ts | 59 ++++++++++++++++++- .../eas-cli/src/commands/simulator/control.ts | 43 +++++++++++--- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/.changeset/simulator-control-paths.md b/.changeset/simulator-control-paths.md index b722b61771..a5c38c505b 100644 --- a/.changeset/simulator-control-paths.md +++ b/.changeset/simulator-control-paths.md @@ -3,3 +3,5 @@ --- Require explicit `host:` simulator-host upload paths and report missing local files. + +Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. 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 207d2abbff..9db9d795e7 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -256,6 +256,7 @@ describe(SimulatorControl, () => { expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ action: 'app.install', ...catalog['app.install'], + description: expect.stringContaining('host:/absolute/path'), }); expect(actionRequests()).toEqual([]); expect(close).toHaveBeenCalledTimes(1); @@ -342,6 +343,39 @@ describe(SimulatorControl, () => { ]); }); + it('rejects host paths for ID-only uploads and does not advertise them', async () => { + served = { + ...catalog, + 'app.file.upload': { + type: 'object', + properties: { uploadId: { type: 'string' } }, + upload: 'uploadId', + uploadAs: 'id', + }, + }; + await expect( + createCommand(['app.file.upload', 'host:/tmp/a.json', '--id', 'session-123']).runAsync() + ).rejects.toThrow('requires a local file'); + expect(actionRequests()).toEqual([]); + await createCommand(['app.file.upload', '--help', '--id', 'session-123']).runAsync(); + const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line)); + expect(lines.join('\n')).not.toContain('host:/absolute/path'); + expect(lines.join('\n')).toContain('requires a local file'); + await createCommand(['app.file.upload', '--help', '--json', '--id', 'session-123']).runAsync(); + expect(mockPrintJsonOnlyOutput).toHaveBeenLastCalledWith( + expect.objectContaining({ + description: expect.stringContaining('requires a local file'), + }) + ); + }); + + it('explains how to upload a local filename beginning with host:', async () => { + await expect( + createCommand(['media.add', 'host:photo.png', '--id', 'session-123']).runAsync() + ).rejects.toThrow('./host:photo.png'); + expect(actionRequests()).toEqual([]); + }); + it('keeps an upload that the action goes on reading', async () => { const localFile = path.join(tmpDir, 'feed.mp4'); await fs.writeFile(localFile, 'video bytes'); @@ -454,6 +488,22 @@ describe(SimulatorControl, () => { expect(actionRequests()).toEqual([]); }); + it('uploads a local filename beginning with host: when explicitly relative', async () => { + const previous = process.cwd(); + await fs.writeFile(path.join(tmpDir, 'host:photo.png'), 'png bytes'); + try { + process.chdir(tmpDir); + await createCommand(['media.add', './host:photo.png', '--id', 'session-123']).runAsync(); + expect(actionRequests().map(([action]) => action)).toEqual([ + 'upload.append', + 'media.add', + 'upload.remove', + ]); + } finally { + process.chdir(previous); + } + }); + it('downloads the file an action returns', async () => { const outputPath = path.join(tmpDir, 'shot.png'); actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n'); @@ -489,7 +539,14 @@ describe(SimulatorControl, () => { it('lists the actions as JSON when no action is provided', async () => { await createCommand(['--id', 'session-123', '--json']).runAsync(); - expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + 'app.install': expect.objectContaining({ + description: expect.stringContaining('host:/absolute/path'), + }), + 'hardwareKeyboard.set': catalog['hardwareKeyboard.set'], + }) + ); expect(actionRequests()).toEqual([]); }); diff --git a/packages/eas-cli/src/commands/simulator/control.ts b/packages/eas-cli/src/commands/simulator/control.ts index c27645c37c..977411dbf0 100644 --- a/packages/eas-cli/src/commands/simulator/control.ts +++ b/packages/eas-cli/src/commands/simulator/control.ts @@ -148,7 +148,11 @@ export default class SimulatorControl extends EasCommand { private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void { if (jsonFlag) { - printJsonOnlyOutput(catalog); + printJsonOnlyOutput( + Object.fromEntries( + Object.entries(catalog).map(([name, entry]) => [name, withUploadPathHelp(entry)]) + ) + ); return; } const names = Object.keys(catalog).sort(); @@ -167,7 +171,7 @@ export default class SimulatorControl extends EasCommand { private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void { if (jsonFlag) { - printJsonOnlyOutput({ ...entry, action }); + printJsonOnlyOutput({ ...withUploadPathHelp(entry), action }); return; } Log.newLine(); @@ -184,9 +188,6 @@ export default class SimulatorControl extends EasCommand { Log.log(` ${chalk.bold('OPTIONS')}`); Log.log(` --id Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`); Log.log(' --json Print the action schema as JSON. Implies --non-interactive.'); - if (entry.upload) { - Log.log(' File paths are local. Use host:/absolute/path for files on the simulator host.'); - } if (entry.output) { Log.log( entry.output === 'base64' @@ -201,6 +202,11 @@ export default class SimulatorControl extends EasCommand { ' Action arguments refer to the simulator host. Use --output for the local filename.' ); } + const uploadHelp = uploadPathHelp(entry); + if (uploadHelp) { + Log.newLine(); + Log.log(` ${uploadHelp}`); + } Log.newLine(); } @@ -273,8 +279,24 @@ export default class SimulatorControl extends EasCommand { } } +function uploadPathHelp(entry: HostActionCatalogEntry): string | undefined { + if (!entry.upload) { + return undefined; + } + return entry.uploadAs === 'id' + ? 'This action requires a local file to upload. host: paths are not supported.' + : 'File paths are local. Use host:/absolute/path for files on the simulator host.'; +} + +function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEntry { + const help = uploadPathHelp(entry); + return help + ? { ...entry, description: [entry.description, help].filter(Boolean).join('\n\n') } + : entry; +} + async function findLocalUploadAsync( - { upload: param, uploadWhen = {} }: HostActionCatalogEntry, + { upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry, params: Record ): Promise<{ param: string; path: string } | null> { const value = param ? params[param] : undefined; @@ -283,9 +305,16 @@ async function findLocalUploadAsync( return null; } if (value.startsWith('host:')) { + if (uploadAs === 'id') { + throw new Error( + 'This action requires a local file to upload; host: paths are not supported.' + ); + } 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.'); + throw new Error( + `A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.` + ); } params[param] = hostPath; return null;