Skip to content

feat(apps): add standalone uninstall command - #3720

Merged
thymikee merged 6 commits into
callstack:mainfrom
mikolajadamowicz:feat/uninstall-command
Oct 16, 2026
Merged

thymikee merged 6 commits into
callstack:mainfrom
mikolajadamowicz:feat/uninstall-command

Conversation

@mikolajadamowicz

@mikolajadamowicz mikolajadamowicz commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds uninstall <app>, so a test can start with the app removed, for example before installing a fresh build or the store version.

agent-device uninstall com.example.app --platform android
await client.apps.uninstall({ app: 'com.example.app' });

Android uses the existing uninstallAndroidPackage. iOS simulators and CoreDevice devices use uninstallAppleApp, the helper reinstall already calls (simctl uninstall and devicectl device uninstall app). Other platforms report the operation as unavailable. reinstall doesn't change.

The app name resolves against installed apps. A package name or bundle id that isn't installed returns wasInstalled: false. Any other name that matches nothing fails with APP_NOT_INSTALLED.

48 files, about 590 lines. Roughly 120 are behavior. The rest is the registry declarations a new command needs, coverage pins and tests.

Known gaps I left alone:

  • On iOS, a dotted target that matches nothing reports "not installed", even a mistyped dotted display name.
  • A bundle id in a different case, or an empty Android or CoreDevice app list, also reports "not installed".
  • If a CoreDevice disconnects mid-uninstall, the command can report success.

Validation

Gates ran on 36c92021f with pnpm gate <id>. dd532cb3f adds the Android readiness fix and its regression test, and lint, typecheck, platform and daemon tests pass on it.

  • Pass: format, lint, typecheck, layering, fallow, build, macos-coverage, command-docs, docs-links, docs-prose, provider-integration (198/198).
  • unit has 5 failures and integration-node has 2 smoke-daemon-clean failures. All of them fail on base too, in a root-user container.
  • No live device run yet.

View guided diff

Expose `uninstall <app>` so a test can start without the app installed,
for example before a fresh install from a build or a store.

Android reuses the adb uninstall helper reinstall already owns; iOS
simulators use `simctl uninstall` and CoreDevice devices use `devicectl
device uninstall app`, through the helper reinstall already uses. The app
resolves against installed apps: a package name or bundle id that is not
installed succeeds with `wasInstalled: false`, and another name that
matches nothing fails with APP_NOT_INSTALLED. Other owners report the
operation unavailable.
A stopped AVD is inventoried by its AVD name; resolve and remove the
package through the live device ensureAndroidReady returns, and keep the
handler harness's uninstall fact to the kinds the platform owners admit.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 48 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/deployment/runtime.ts">

<violation number="1" location="packages/platform-apple/src/deployment/runtime.ts:146">
P2: This condition treats every dotted target as a bundle ID. An unmatched app name containing a period therefore succeeds with `wasInstalled: false`, although only unmatched bundle IDs should be idempotent; distinguish bundle-ID targets from display-name targets before converting `APP_NOT_INSTALLED`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts Outdated
Comment thread packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts Outdated
Comment thread packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts Outdated
Comment thread website/docs/docs/commands.md
Comment thread packages/platform-android/src/deployment/runtime.ts Outdated
});
} catch (error) {
const notInstalled = error instanceof AppError && error.code === 'APP_NOT_INSTALLED';
if (notInstalled && input.app.trim().includes('.')) return { wasInstalled: false };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This condition treats every dotted target as a bundle ID. An unmatched app name containing a period therefore succeeds with wasInstalled: false, although only unmatched bundle IDs should be idempotent; distinguish bundle-ID targets from display-name targets before converting APP_NOT_INSTALLED.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/deployment/runtime.ts, line 146:

<comment>This condition treats every dotted target as a bundle ID. An unmatched app name containing a period therefore succeeds with `wasInstalled: false`, although only unmatched bundle IDs should be idempotent; distinguish bundle-ID targets from display-name targets before converting `APP_NOT_INSTALLED`.</comment>

<file context>
@@ -122,6 +128,29 @@ async function deployAppleApp(
+      });
+    } catch (error) {
+      const notInstalled = error instanceof AppError && error.code === 'APP_NOT_INSTALLED';
+      if (notInstalled && input.app.trim().includes('.')) return { wasInstalled: false };
+      throw error;
+    }
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changing this one here. When nothing installed matches, the inventory can't tell whether Booking.co was meant as a bundle id or a display name. A bundle-id regex doesn't help either, since Booking.com has the same shape as a bundle id. I'd rather keep the simple dotted rule and document it. It's listed under known gaps in the PR description.

Comment thread CHANGELOG.md Outdated
A cold AVD boots under a different adb serial, so the cache invalidation
now wraps the device that readiness returns. The uninstall handler test
covers iOS simulator and CoreDevice removals, checks the runtime input,
and asserts the typed refusal reason. Docs list uninstall among the
commands macOS does not support, and the changelog names
client.apps.uninstall.
… feat/uninstall-command

# Conflicts:
#	CHANGELOG.md
@thymikee

Copy link
Copy Markdown
Member

The uninstall code looks right at a918aa6, but the new device routes have no live proof yet. The PR adds adb pm list packages plus adb uninstall, simctl uninstall, and devicectl device uninstall app through a new runtime operation (https://github.com/callstack/agent-device/blob/a918aa6/packages/platform-apple/src/deployment/runtime.ts#L131). Today they are covered only by scripted ADB and mocked xcrun runs, and the PR body says no live device run was done. So we do not know the real simctl/devicectl exit status for an already removed bundle, the CoreDevice app inventory on the installedOnly path, or readiness on a non-booted emulator. Please attach live --json runs on an Android emulator (ADB) and an iOS simulator, plus a CoreDevice iPhone if you have one. Each run must show four things. First, agent-device uninstall <installed bundle/package> --json returns wasInstalled:true with appId, and a later apps call no longer lists the app. Second, the same command again returns wasInstalled:false. Third, the installed app's display name resolves and removes it. Fourth, a non-dotted unmatched name fails with APP_NOT_INSTALLED.

Not blocking, and you can take or leave these: the CHANGELOG says uninstall works "on Android", but Appium-backed Android and Limrun Android refuse it, so "adb-backed Android" (as the mobile-data entry says) is more accurate, and the Apple id-versus-name check at runtime.ts:146 re-derives the dotted rule that app-resolution.ts already owns, so it could key on a typed detail such as details.targetKind on APP_NOT_INSTALLED, shared with Android.

On the open inline threads, the cubic-dev-ai P2 on the dotted-name limitation (#3720 (comment)) still applies, but I read it as a documented limitation, not a merge stopper, since the resolver cannot tell the two apart either and commands.md and the PR body state the gap.

I did not run tests or gates. I judged this from reading the diff only. CI is still pending: Smoke Tests is in progress. This diff touches what the help and command-surface smoke checks enumerate, so please check that result when it finishes. The live uninstall runs above must land before merge.

@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Live run on an Android emulator (ADB), from a918aa643 built locally with an isolated --state-dir. Device: emulator-5554, Pixel_9_Pro_XL, Android 17. The test app is a throwaway launchable APK, com.callstack.uninstallproof, installed with agent-device install before steps 1 and 3.

1. Installed package id removes it

$ agent-device uninstall com.callstack.uninstallproof --platform android --serial emulator-5554 --json
{"success":true,"data":{"platform":"android","app":"com.callstack.uninstallproof","wasInstalled":true,"appId":"com.callstack.uninstallproof","message":"Uninstalled: com.callstack.uninstallproof"}}

Before the uninstall, apps --json listed "Uninstallproof (com.callstack.uninstallproof)". After it, apps --json no longer lists the app and adb shell pm list packages com.callstack.uninstallproof prints nothing.

2. Same command again

$ agent-device uninstall com.callstack.uninstallproof --platform android --serial emulator-5554 --json
{"success":true,"data":{"platform":"android","app":"com.callstack.uninstallproof","wasInstalled":false,"message":"Not installed: com.callstack.uninstallproof"}}

3. Display name resolves and removes it

$ agent-device uninstall Uninstallproof --platform android --serial emulator-5554 --json
{"success":true,"data":{"platform":"android","app":"Uninstallproof","wasInstalled":true,"appId":"com.callstack.uninstallproof","message":"Uninstalled: com.callstack.uninstallproof"}}

The app is gone from apps and from pm list packages afterwards. On Android the display name is the name apps prints, which comes from the package. The manifest label (Uninstall Proof here) is not matched. A label-only name fails with APP_NOT_INSTALLED, as commands.md describes.

4. Unmatched name without a dot

$ agent-device uninstall NoSuchAppXyz --platform android --serial emulator-5554 --json
{"success":false,"error":{"code":"APP_NOT_INSTALLED","message":"No package found matching \"NoSuchAppXyz\"","hint":"Run agent-device apps --platform android to discover the installed package name, then retry open with that exact package.",...}}

Exit code 1. The hint still says "retry open" because uninstall shares the Android resolver's hint.

The emulator was already booted, so this run doesn't cover readiness on a cold emulator.

iOS simulator: not run yet. I'm stuck on an Xcode update on this machine. I'll post the same four checks for an iOS simulator as soon as that's fixed. I don't have a CoreDevice iPhone to test on right now.

@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Live run on an iOS simulator, from a918aa643 built locally with an isolated --state-dir. Device: iPhone 18 Pro simulator B927EFE0-F0E9-486E-91A4-465238341FCC, iOS 27.0, Xcode 27.1. The test app is a throwaway simulator build, com.callstack.uninstallproof with display name Uninstall Proof, installed with agent-device install before steps 1 and 3.

0. Cold simulator

Before the command, simctl list devices showed the simulator as (Shutdown).

$ agent-device uninstall com.callstack.uninstallproof --platform ios --udid B927EFE0-F0E9-486E-91A4-465238341FCC --json
{"success":true,"data":{"platform":"ios","app":"com.callstack.uninstallproof","wasInstalled":false,"message":"Not installed: com.callstack.uninstallproof"}}

Afterwards it showed (Booted), so uninstall boots a shut-down simulator before reading its inventory.

1. Installed bundle id removes it

$ agent-device uninstall com.callstack.uninstallproof --platform ios --udid B927EFE0-F0E9-486E-91A4-465238341FCC --json
{"success":true,"data":{"platform":"ios","app":"com.callstack.uninstallproof","wasInstalled":true,"appId":"com.callstack.uninstallproof","message":"Uninstalled: com.callstack.uninstallproof"}}

Before the uninstall, apps --json listed "Uninstall Proof (com.callstack.uninstallproof)". After it, apps --json no longer lists the app and simctl get_app_container fails with NSPOSIXErrorDomain, code=2.

2. Same command again

$ agent-device uninstall com.callstack.uninstallproof --platform ios --udid B927EFE0-F0E9-486E-91A4-465238341FCC --json
{"success":true,"data":{"platform":"ios","app":"com.callstack.uninstallproof","wasInstalled":false,"message":"Not installed: com.callstack.uninstallproof"}}

3. Display name resolves and removes it

$ agent-device uninstall "Uninstall Proof" --platform ios --udid B927EFE0-F0E9-486E-91A4-465238341FCC --json
{"success":true,"data":{"platform":"ios","app":"Uninstall Proof","wasInstalled":true,"appId":"com.callstack.uninstallproof","message":"Uninstalled: com.callstack.uninstallproof"}}

The app is gone from apps and from get_app_container afterwards.

4. Unmatched name without a dot

$ agent-device uninstall NoSuchAppXyz --platform ios --udid B927EFE0-F0E9-486E-91A4-465238341FCC --json
{"success":false,"error":{"code":"APP_NOT_INSTALLED","message":"No app found matching \"NoSuchAppXyz\"","hint":"Run apps to discover the exact installed package or bundle id, or install the app before open.",...}}

Exit code 1.

simctl on a bundle that's already gone: xcrun simctl uninstall <udid> com.callstack.uninstallproof on the simulator without the app prints nothing and exits 0. So simctl can't tell a removal from a no-op. wasInstalled: false comes from the installed-app inventory that uninstall reads before it calls simctl.

I still don't have a CoreDevice run.

@thymikee

Copy link
Copy Markdown
Member

Thanks for the live runs, @mikolajadamowicz. They cover what I asked for at a918aa6 on both an ADB Android emulator and a cold iOS simulator: an installed id is removed and gone from apps, a second call returns wasInstalled: false, the display name resolves and removes the app, and an unmatched non-dotted name fails with APP_NOT_INSTALLED. The simctl note helps too: wasInstalled comes from the inventory read, not from the simctl exit status. A CoreDevice run is still missing, but I asked for it only if you had a device, so it does not block. No conflicts, and all checks pass. This is ready for human review.

Not blocking: the Android hint for an unmatched name still says "retry open", because uninstall shares the resolver hint.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 16, 2026
Appium and Limrun Android sessions refuse uninstall, so the entry now
names the backends it covers, the way the mobile-data entry does.
@thymikee
thymikee merged commit e24dc45 into callstack:main Oct 16, 2026
8 of 14 checks passed
thymikee added a commit that referenced this pull request Oct 16, 2026
#3720 added the uninstall row with `linux.gap(...)` after #3717 had removed the Linux
known-gap builder, so main fails to load the coverage manifest
(`TypeError: linux.gap is not a function`). Uninstall belongs to the app-deployment family
the Linux runtime leaves unavailable, so it is an accepted limitation like install,
reinstall and install-from-source.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants