Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))

### 🐛 Bug fixes
Expand Down
60 changes: 60 additions & 0 deletions packages/eas-cli/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions packages/eas-cli/src/commands/update/embedded/__tests__/delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateMutation } from '../../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../../log';
import * as prompts from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedDelete from '../delete';

jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { deleteEmbeddedUpdateAsync: jest.fn() },
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../prompts');

const mockDelete = jest.mocked(EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync);
const mockToggleConfirm = jest.mocked(prompts.toggleConfirmAsync);
const mockLogLog = jest.mocked(Log.log);
const mockLogWithTick = jest.mocked(Log.withTick);
const mockLogError = jest.mocked(Log.error);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);

const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';

const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};

describe(UpdateEmbeddedDelete, () => {
const mockConfig = getMockOclifConfig();
let processExitSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
mockDelete.mockResolvedValue({ id: VALID_UUID });
mockToggleConfirm.mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});

afterEach(() => {
processExitSpy.mockRestore();
});

function createCommand(argv: string[]): UpdateEmbeddedDelete {
const command = new UpdateEmbeddedDelete(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}

it('deletes when the user confirms', async () => {
await createCommand([VALID_UUID]).run();

expect(mockToggleConfirm).toHaveBeenCalledTimes(1);
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});

it('skips confirmation in non-interactive mode', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();

expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});

it('aborts (process.exit) without calling the mutation when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');

expect(mockDelete).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
});

it('--json prints { id } and skips the human-readable success line', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();

expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
expect(mockLogWithTick).not.toHaveBeenCalled();
});

it('rethrows unexpected mutation errors', async () => {
const boom = new Error('boom');
mockDelete.mockRejectedValue(boom);
await expect(createCommand([VALID_UUID, '--non-interactive']).run()).rejects.toThrow('boom');
});

it('succeeds for an unknown id (server best-effort)', async () => {
// Server returns { id } even when nothing was deleted, so we should still tick.
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});

it('prints an explanatory message before prompting (interactive)', async () => {
await createCommand([VALID_UUID]).run();
const lines = mockLogLog.mock.calls.map(c => String(c[0])).join('\n');
expect(lines).toContain(`permanently delete embedded update: "${VALID_UUID}"`);
expect(lines).toContain('Diff patches already generated');
expect(lines).toContain('re-upload');
});

it('logs a cancel message when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockLogError).toHaveBeenCalledWith(
`Canceled deletion of embedded update: "${VALID_UUID}".`
);
});

it('passes the id through to deleteEmbeddedUpdateAsync', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});

it('--json + --non-interactive: skips prompt, skips tick, prints { id }', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
});
});
72 changes: 72 additions & 0 deletions packages/eas-cli/src/commands/update/embedded/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Args } from '@oclif/core';

import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EmbeddedUpdateMutation } from '../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';

export default class UpdateEmbeddedDelete extends EasCommand {
static override description = 'delete an embedded update registered with EAS Update';

static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};

static override flags = {
...EasNonInteractiveAndJsonFlags,
};

static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};

async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags,
} = await this.parse(UpdateEmbeddedDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);

const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedDelete, { nonInteractive });

if (jsonFlag) {
enableJsonOutput();
}

if (!nonInteractive) {
Log.log(
`You are about to permanently delete embedded update: "${embeddedUpdateId}". ` +
`Diff patches already generated against this bundle keep serving, but new diffs ` +
`can't be generated until you re-upload it.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of embedded update: "${embeddedUpdateId}".`);
process.exit(1);
}
}

// Best-effort delete on the server: deleting an unknown id succeeds (idempotent),
// so we don't need a not-found branch here.
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(graphqlClient, {
id: embeddedUpdateId,
});

if (jsonFlag) {
printJsonOnlyOutput({ id: embeddedUpdateId });
return;
}
Log.withTick(`Deleted embedded update ${embeddedUpdateId}`);
}
}
23 changes: 23 additions & 0 deletions packages/eas-cli/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/eas-cli/src/graphql/mutations/EmbeddedUpdateMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeleteEmbeddedUpdateMutation,
DeleteEmbeddedUpdateMutationVariables,
UploadEmbeddedUpdateInput,
UploadEmbeddedUpdateMutation,
UploadEmbeddedUpdateMutationVariables,
Expand Down Expand Up @@ -61,4 +63,27 @@ export const EmbeddedUpdateMutation = {
);
return data.embeddedUpdate.uploadEmbeddedUpdate;
},

async deleteEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
{ id }: { id: string }
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteEmbeddedUpdateMutation, DeleteEmbeddedUpdateMutationVariables>(
gql`
mutation DeleteEmbeddedUpdate($id: ID!) {
embeddedUpdate {
deleteEmbeddedUpdate(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.embeddedUpdate.deleteEmbeddedUpdate;
},
};
Loading
Loading