TL;DR: the SOQL in getUninstallErrors (src/package/packageUninstall.ts L30) is wrapped in
literal ". The patch below removes them and adds a test for each caller. I'd like to open it as
a PR on forcedotcom/packaging. If that's OK, assign this issue to me and I'll open it.
Summary
I'm a Salesforce employee (product management for ISV extensibility). I found this while testing
managed-package installs and uninstalls the way our ISV partners run them.
When an uninstall fails after the org accepts the request, sf package uninstall --wait and
sf package uninstall report hide the failure: no "uninstall failed", no request id, no reasons
(see Actual). I've hit it four times in three months in my own ISV test pipelines, once mistaking
it for a platform error and once losing a real failure behind a passing retry.
Cause
src/package/packageUninstall.ts L28-33:
const errorQueryResult = await conn.tooling.query<{ Message: string }>(
`"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message"`
);
The query throws before UNINSTALL_ERROR is built. Its only two callers:
No test caught it: the pollUninstall error test
(uninstallPackage.test.ts L85)
stubs conn.tooling.query without checking its argument, and uninstallStatus's Error branch
has no test in
subscriberPackageVersion.test.ts.
Steps to reproduce
- Unit tests, under Node 22. Save the patch below as
tool01.patch in an empty directory, then
from that directory:
git clone https://github.com/forcedotcom/packaging && cd packaging
git checkout 51fc61b # the commit the patch is cut against
yarn install
git apply ../tool01.patch # fix + tests
git checkout -- src/package/packageUninstall.ts # undo only the fix
npx mocha test/package/uninstallPackage.test.ts test/package/subscriberPackageVersion.test.ts
Result: 13 passing, 2 failing ("should send the uninstall request, and handle errors
appropriately" and "uninstallStatus reports a failed request with its errors, using valid
SOQL"), each with
AssertionError: expected '"SELECT Message FROM PackageVersionUn…' to equal 'SELECT Message FROM PackageVersionUni…'
- Against a scratch org, the query string from L30:
sf data query --use-tooling-api -o <org> -q "\"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '06y000000000000AAA' ORDER BY Message\""
Result: MALFORMED_QUERY: unexpected token: '"'. Without the outer quotes it runs
(totalSize: 0).
End to end, with a real uninstall that fails late (optional; needs a Dev Hub with a namespace)
Only an uninstall the org accepts and that fails later reaches this code. One the org refuses up
front (for example, a subscriber class references the package) is reported correctly. So this
recipe adds the blocking reference while a slow uninstall script holds the job open.
- Build a managed beta with one custom object (
UninstProbe__c) and an uninstall script that
waits 40 seconds:
// sfdx-project.json, package directory
{
"path": "force-app",
"package": "UninstLateFailProbe",
"versionNumber": "0.1.0.NEXT",
"uninstallScript": "UninstProbeHandler",
"default": true
}
global without sharing class UninstProbeHandler implements UninstallHandler {
global void onUninstall(UninstallContext ctx) {
Long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < 40000) {
}
}
}
sf package version create -p UninstLateFailProbe -x --skip-validation -w 30
- Install it in a scratch org and start
sf package uninstall -p <04t> -o <org> -w 20.
- Once the uninstall prints
Status = InProgress, deploy this class from a second terminal, finishing
within the script's 40 seconds (<ns> is the package's namespace):
public with sharing class SubRefProbe {
public static Integer countRows() { return [SELECT COUNT() FROM <ns>__UninstProbe__c]; }
}
Result: the uninstall command ends with the Actual output; the request's Status is Error and the package
is still installed. sf package uninstall report -i <06y> prints the same.
- Control: delete
SubRefProbe and uninstall again (the package is still installed): Successfully uninstalled package.
Measured 2026-10-04, CLI 2.152.14, Winter '27 scratch org.
Expected
UNINSTALL_ERROR: Can't uninstall the package <06y> during uninstall request <06y>., plus the
=== Errors list when PackageVersionUninstallRequestError has rows. (Both ids are the request id
today, at packageUninstall.ts L58 and subscriberPackageVersion.ts L219; a separate, minor issue.)
Actual
The CLI prints Error (1): unexpected token: '"' (exit 1). With --json, the error code is
MALFORMED_QUERY. This is the uninstall command's output, from the end-to-end steps; the quick
repro (step 2) shows the underlying query error.
Fix and tests
Checked on main at 51fc61b (5.0.14): with the patch, the two files pass 15 tests.
- Fix: drop the outer
" at L30.
uninstallPackage.test.ts, "should send the uninstall request, and handle errors
appropriately": now also checks the SOQL pollUninstall sends. The id it expects,
04t4p000002BaHYAA0, is the request id this file's create() stub returns, not the package id.
subscriberPackageVersion.test.ts, new "uninstallStatus reports a failed request with its
errors, using valid SOQL": reuses the queryStub from beforeEach, and checks the error after
the try, so a call that doesn't throw fails.
diff --git a/src/package/packageUninstall.ts b/src/package/packageUninstall.ts
index 927a1c4..7680743 100644
--- a/src/package/packageUninstall.ts
+++ b/src/package/packageUninstall.ts
@@ -27,7 +27,7 @@ type UninstallResult = PackagingSObjects.SubscriberPackageVersionUninstallReques
export async function getUninstallErrors(conn: Connection, id: string): Promise<Array<{ Message: string }>> {
const errorQueryResult = await conn.tooling.query<{ Message: string }>(
- `"SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message"`
+ `SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message`
);
return errorQueryResult?.records ?? [];
}
diff --git a/test/package/subscriberPackageVersion.test.ts b/test/package/subscriberPackageVersion.test.ts
index 0f191f4..44e5ccf 100644
--- a/test/package/subscriberPackageVersion.test.ts
+++ b/test/package/subscriberPackageVersion.test.ts
@@ -170,6 +170,24 @@ describe('subscriberPackageVersion', () => {
expect(queryStub.called).to.be.true;
}
});
+ it('uninstallStatus reports a failed request with its errors, using valid SOQL', async () => {
+ const id = '06y000000000001AAA';
+ // @ts-ignore
+ $$.SANDBOX.stub(connection.tooling, 'retrieve').resolves({ Id: id, Status: 'Error' });
+ queryStub.resolves({ records: [{ Message: 'this is a server-side error message' }], done: true, totalSize: 1 });
+
+ let error: Error | undefined;
+ try {
+ await SubscriberPackageVersion.uninstallStatus(id, connection);
+ } catch (e) {
+ error = e as Error;
+ }
+ expect(error?.name).to.equal('UNINSTALL_ERROR');
+ expect(error?.message).to.include('(1) this is a server-side error message');
+ expect(queryStub.firstCall.args[0]).to.equal(
+ `SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '${id}' ORDER BY Message`
+ );
+ });
it('should propagate the same error from the SPV query', async () => {
connection = await testOrg.getConnection();
diff --git a/test/package/uninstallPackage.test.ts b/test/package/uninstallPackage.test.ts
index 3ebf4cc..89c5ca0 100644
--- a/test/package/uninstallPackage.test.ts
+++ b/test/package/uninstallPackage.test.ts
@@ -91,7 +91,7 @@ describe('Package Uninstall', () => {
}),
});
// @ts-ignore
- $$.SANDBOX.stub(conn.tooling, 'query').resolves({
+ const queryStub = $$.SANDBOX.stub(conn.tooling, 'query').resolves({
records: [{ Message: 'this is a server-side error message' }, { Message: 'this is a second error message' }],
});
@@ -108,6 +108,9 @@ describe('Package Uninstall', () => {
expect(error.message).to.include('(2) this is a second error message');
expect(error.actions).to.deep.equal(['Verify installed package ID and resolve errors, then try again.']);
}
+ expect(queryStub.firstCall.args[0]).to.equal(
+ "SELECT Message FROM PackageVersionUninstallRequestError WHERE ParentRequest.Id = '04t4p000002BaHYAA0' ORDER BY Message"
+ );
});
it('should send the uninstall request, and handle errors appropriately (0 error messages)', async () => {
Environment
- Affected:
@salesforce/packaging 5.0.12 (L30 at its commit 7ec944d) through 5.0.14 (main, 51fc61b).
- CLI runs (step 2 and the end-to-end recipe):
@salesforce/cli/2.152.14 darwin-arm64 node-v24.20.0, which bundles the @salesforce/packaging library 5.0.12 (via plugin-packaging 3.0.7).
- Unit tests (step 1): Node 22, on 51fc61b. The repo's mocha doesn't start on Node 26.
sf doctor (version detail, trimmed)
cliVersion: @salesforce/cli/2.152.14
architecture: darwin-arm64 (Darwin 25.6.0), shell zsh
nodeVersion: node-v24.20.0
plugin-packaging: 3.0.7 (core); it loads the @salesforce/packaging library 5.0.12, where this bug is
other plugins: deploy-retrieve 4.2.2, auth 5.0.7, org 6.0.13, data 5.1.8 (core); code-analyzer 5.16.0 (user)
diagnostics: all pass except "sourceApiVersion matches apiVersion" (warn: run outside a project)
TL;DR: the SOQL in
getUninstallErrors(src/package/packageUninstall.tsL30) is wrapped inliteral
". The patch below removes them and adds a test for each caller. I'd like to open it asa PR on forcedotcom/packaging. If that's OK, assign this issue to me and I'll open it.
Summary
I'm a Salesforce employee (product management for ISV extensibility). I found this while testing
managed-package installs and uninstalls the way our ISV partners run them.
When an uninstall fails after the org accepts the request,
sf package uninstall --waitandsf package uninstall reporthide the failure: no "uninstall failed", no request id, no reasons(see Actual). I've hit it four times in three months in my own ISV test pipelines, once mistaking
it for a platform error and once losing a real failure behind a passing retry.
Cause
src/package/packageUninstall.ts L28-33:
The query throws before
UNINSTALL_ERRORis built. Its only two callers:pollUninstall,defaultbranch (L57-64):sf package uninstall --waitSubscriberPackageVersion.uninstallStatus,Status === 'Error'(subscriberPackageVersion.ts L215-222):sf package uninstall reportNo test caught it: the
pollUninstallerror test(uninstallPackage.test.ts L85)
stubs
conn.tooling.querywithout checking its argument, anduninstallStatus'sErrorbranchhas no test in
subscriberPackageVersion.test.ts.
Steps to reproduce
tool01.patchin an empty directory, thenfrom that directory:
appropriately" and "uninstallStatus reports a failed request with its errors, using valid
SOQL"), each with
MALFORMED_QUERY: unexpected token: '"'. Without the outer quotes it runs(
totalSize: 0).End to end, with a real uninstall that fails late (optional; needs a Dev Hub with a namespace)
Only an uninstall the org accepts and that fails later reaches this code. One the org refuses up
front (for example, a subscriber class references the package) is reported correctly. So this
recipe adds the blocking reference while a slow uninstall script holds the job open.
UninstProbe__c) and an uninstall script thatwaits 40 seconds:
sf package version create -p UninstLateFailProbe -x --skip-validation -w 30sf package uninstall -p <04t> -o <org> -w 20.Status = InProgress, deploy this class from a second terminal, finishingwithin the script's 40 seconds (
<ns>is the package's namespace):StatusisErrorand the packageis still installed.
sf package uninstall report -i <06y>prints the same.SubRefProbeand uninstall again (the package is still installed):Successfully uninstalled package.Measured 2026-10-04, CLI 2.152.14, Winter '27 scratch org.
Expected
UNINSTALL_ERROR:Can't uninstall the package <06y> during uninstall request <06y>., plus the=== Errorslist whenPackageVersionUninstallRequestErrorhas rows. (Both ids are the request idtoday, at packageUninstall.ts L58 and subscriberPackageVersion.ts L219; a separate, minor issue.)
Actual
The CLI prints
Error (1): unexpected token: '"'(exit 1). With--json, the error code isMALFORMED_QUERY. This is the uninstall command's output, from the end-to-end steps; the quickrepro (step 2) shows the underlying query error.
Fix and tests
Checked on
mainat 51fc61b (5.0.14): with the patch, the two files pass 15 tests."at L30.uninstallPackage.test.ts, "should send the uninstall request, and handle errorsappropriately": now also checks the SOQL
pollUninstallsends. The id it expects,04t4p000002BaHYAA0, is the request id this file'screate()stub returns, not the package id.subscriberPackageVersion.test.ts, new "uninstallStatus reports a failed request with itserrors, using valid SOQL": reuses the
queryStubfrombeforeEach, and checks the error afterthe
try, so a call that doesn't throw fails.Environment
@salesforce/packaging5.0.12 (L30 at its commit 7ec944d) through 5.0.14 (main, 51fc61b).@salesforce/cli/2.152.14 darwin-arm64 node-v24.20.0, which bundles the@salesforce/packaginglibrary 5.0.12 (via plugin-packaging 3.0.7).sf doctor(version detail, trimmed)