From baa8069c2d26041fac32083c7248a4ed65b8afa6 Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Tue, 7 Jul 2026 17:08:01 +0300 Subject: [PATCH 1/4] feat(cli): add deploy --preserve-resources + destructive-delete guard (AI-426) `checkly deploy --preserve-resources` detaches resources removed from code (kept in the account, returned to UI management) instead of deleting them, forwarding preserveResources to the deploy endpoint and rendering the removed resources as "Detached" in the preview. Default deploy now guards against silent data loss: for an interactive, non-forced run a preflight dry-run surfaces any resources that would be permanently deleted and requires an explicit confirmation (skipped by --force). The pre-deploy summary also advertises --preserve-resources. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/deploy.ts | 107 +++++++++++++++--- .../rest/__tests__/projects-deploy.spec.ts | 35 ++++++ packages/cli/src/rest/projects.ts | 4 +- 3 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/rest/__tests__/projects-deploy.spec.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 61f4f0c27..68c52e9fb 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -28,6 +28,23 @@ enum ResourceDeployStatus { DELETE = 'DELETE', } +const PRETTY_RESOURCE_TYPES: Record = { + [Check.__checklyType]: 'Check', + [AlertChannel.__checklyType]: 'AlertChannel', + [CheckGroup.__checklyType]: 'CheckGroup', + [MaintenanceWindow.__checklyType]: 'MaintenanceWindow', + [PrivateLocation.__checklyType]: 'PrivateLocation', + [Dashboard.__checklyType]: 'Dashboard', +} + +// Internal resources that users don't create directly. They are reported as +// part of their owning check, so we exclude them from delete previews/guards. +const NON_REPORTED_TYPES = [ + AlertChannelSubscription.__checklyType, + PrivateLocationCheckAssignment.__checklyType, + PrivateLocationGroupAssignment.__checklyType, +] + export default class Deploy extends AuthCommand { static coreCommand = true static hidden = false @@ -55,6 +72,10 @@ export default class Deploy extends AuthCommand { default: true, allowNo: true, }), + 'preserve-resources': Flags.boolean({ + description: 'Detach resources removed from code (keep them and their run history) instead of deleting them.', + default: false, + }), 'force': forceFlag(), 'config': Flags.string({ char: 'c', @@ -84,6 +105,7 @@ export default class Deploy extends AuthCommand { force, preview, 'schedule-on-deploy': scheduleOnDeploy, + 'preserve-resources': preserveResources, output: outputFlag, verbose, config: configFilename, @@ -108,6 +130,9 @@ export default class Deploy extends AuthCommand { scheduleOnDeploy ? 'Schedule checks after deploy' : 'Checks will NOT be scheduled after deploy', + preserveResources + ? 'Detach any resources removed from code, keeping them and their run history for management in the UI' + : 'Delete any resources removed from code, losing their run history — pass --preserve-resources to detach and keep them instead', ], flags, classification: { @@ -238,10 +263,55 @@ export default class Deploy extends AuthCommand { return } + // Preflight destructive-delete guard. Deletions are only known from the diff, + // which we don't have until after a deploy call. For a non-preview, non-preserve + // run that isn't already forced, do a dry-run first to surface resources that + // would be permanently deleted and require an explicit confirmation. + if (!preview && !preserveResources && !force) { + let deletions: Array<{ resourceType: string, logicalId: string }> = [] + try { + const { data: dryRunData } = await api.projects.deploy( + { ...projectPayload, repoInfo }, + { dryRun: true, scheduleOnDeploy, preserveResources }, + ) + deletions = this.collectDeletions(dryRunData) + } catch (err: any) { + this.style.longError(`Your project could not be deployed.`, err) + this.exit(1) + } + + if (deletions.length) { + this.log(chalk.bold.red('\nThe following resources were removed from code and will be DELETED, losing their run history:')) + for (const { resourceType, logicalId } of deletions) { + this.log(chalk.red(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`)) + } + this.log(chalk.yellow('\nPass --preserve-resources to detach and keep them (and their run history) instead.\n')) + + await this.confirmOrAbort({ + command: 'deploy', + description: 'Delete resources removed from code', + changes: [ + `Permanently delete ${deletions.length} resource(s) removed from code, losing their run history`, + ...deletions.map(({ resourceType, logicalId }) => + `Delete ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`), + ], + flags, + classification: { + readOnly: Deploy.readOnly, + destructive: Deploy.destructive, + idempotent: Deploy.idempotent, + }, + }, { force }) + } + } + try { - const { data } = await api.projects.deploy({ ...projectPayload, repoInfo }, { dryRun: preview, scheduleOnDeploy }) + const { data } = await api.projects.deploy( + { ...projectPayload, repoInfo }, + { dryRun: preview, scheduleOnDeploy, preserveResources }, + ) if (preview || output) { - this.log(this.formatPreview(data, project, verbose)) + this.log(this.formatPreview(data, project, verbose, preserveResources)) } if (!preview) { await setTimeout(500) @@ -263,7 +333,24 @@ export default class Deploy extends AuthCommand { } } - private formatPreview (previewData: ProjectDeployResponse, project: Project, verbose = false): string { + private collectDeletions (previewData: ProjectDeployResponse): Array<{ resourceType: string, logicalId: string }> { + return (previewData?.diff ?? []) + .filter(change => + change.action === ResourceDeployStatus.DELETE + && !NON_REPORTED_TYPES.some(t => t === change.type), + ) + .map(({ type, logicalId }) => ({ resourceType: type, logicalId })) + .sort((a, b) => + a.resourceType.localeCompare(b.resourceType) || a.logicalId.localeCompare(b.logicalId), + ) + } + + private formatPreview ( + previewData: ProjectDeployResponse, + project: Project, + verbose = false, + preserveResources = false, + ): string { // Current format of the data is: { checks: { logical-id-1: 'UPDATE' }, groups: { another-logical-id: 'CREATE' } } // We convert it into update: [{ logicalId, resourceType, construct }, ...], create: [], delete: [] // This makes it easier to display. @@ -350,17 +437,11 @@ export default class Deploy extends AuthCommand { output.push('') } if (sortedDeleting.length) { - output.push(chalk.bold.red('Delete:')) - const prettyResourceTypes: Record = { - [Check.__checklyType]: 'Check', - [AlertChannel.__checklyType]: 'AlertChannel', - [CheckGroup.__checklyType]: 'CheckGroup', - [MaintenanceWindow.__checklyType]: 'MaintenanceWindow', - [PrivateLocation.__checklyType]: 'PrivateLocation', - [Dashboard.__checklyType]: 'Dashboard', - } + output.push(preserveResources + ? chalk.bold.yellow('Detached (kept in account, now UI-managed):') + : chalk.bold.red('Delete:')) for (const { resourceType, logicalId } of sortedDeleting) { - output.push(` ${prettyResourceTypes[resourceType] ?? resourceType}: ${logicalId}`) + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) } output.push('') } diff --git a/packages/cli/src/rest/__tests__/projects-deploy.spec.ts b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts new file mode 100644 index 000000000..3094e80ac --- /dev/null +++ b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' +import type { AxiosInstance } from 'axios' +import Projects, { type ProjectSync } from '../projects.js' + +function createProjects () { + const post = vi.fn().mockResolvedValue({ data: { project: {}, diff: [] } }) + const api = { post } as unknown as AxiosInstance + return { projects: new Projects(api), post } +} + +const resources: ProjectSync = { + project: { name: 'p', logicalId: 'p' }, + resources: [], + repoInfo: null, +} + +describe('Projects.deploy query params', () => { + it('defaults preserveResources to false', () => { + const { projects, post } = createProjects() + projects.deploy(resources) + const url = post.mock.calls[0][0] as string + expect(url).toContain('dryRun=false') + expect(url).toContain('scheduleOnDeploy=true') + expect(url).toContain('preserveResources=false') + }) + + it('forwards preserveResources=true', () => { + const { projects, post } = createProjects() + projects.deploy(resources, { dryRun: true, scheduleOnDeploy: false, preserveResources: true }) + const url = post.mock.calls[0][0] as string + expect(url).toContain('dryRun=true') + expect(url).toContain('scheduleOnDeploy=false') + expect(url).toContain('preserveResources=true') + }) +}) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 26f069996..434039801 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -208,9 +208,9 @@ class Projects { } } - deploy (resources: ProjectSync, { dryRun = false, scheduleOnDeploy = true } = {}) { + deploy (resources: ProjectSync, { dryRun = false, scheduleOnDeploy = true, preserveResources = false } = {}) { return this.api.post( - `/next-v2/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, + `/next-v2/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}&preserveResources=${preserveResources}`, resources, { transformRequest: compressJSONPayload }, ) From a7e42d0d3f40e082e3df9dd6ac70127c14cc120a Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Tue, 7 Jul 2026 18:55:16 +0300 Subject: [PATCH 2/4] feat(cli): read DETACHED deploy action from backend (AI-426) The deploy preview now reads an explicit DETACHED action from the diff instead of inferring detachment solely from the client-side --preserve-resources flag. Detached resources render in their own "Detached (kept in account, now UI-managed)" section, separate from "Delete:". Backwards compatible both ways: older backends that still return DELETE for preserved resources are folded into the detached bucket when --preserve-resources is set, and older CLIs simply ignore the unknown DETACHED action (no crash, since the action field is an unvalidated string matched by an if/else chain). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/deploy.ts | 32 +++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 68c52e9fb..a8b94af6e 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -26,6 +26,9 @@ enum ResourceDeployStatus { UPDATE = 'UPDATE', CREATE = 'CREATE', DELETE = 'DELETE', + // Returned by newer backends for resources removed from code that are kept in + // the account (detached, now UI-managed) instead of deleted. + DETACHED = 'DETACHED', } const PRETTY_RESOURCE_TYPES: Record = { @@ -357,6 +360,7 @@ export default class Deploy extends AuthCommand { const updating = [] const creating = [] const deleting: Array<{ resourceType: string, logicalId: string }> = [] + const detaching: Array<{ resourceType: string, logicalId: string }> = [] for (const change of previewData?.diff ?? []) { const { type, logicalId, physicalId, action } = change if ([ @@ -376,9 +380,20 @@ export default class Deploy extends AuthCommand { } else if (action === ResourceDeployStatus.DELETE) { // Since the resource is being deleted, the construct isn't in the project. deleting.push({ resourceType: type, logicalId }) + } else if (action === ResourceDeployStatus.DETACHED) { + // Newer backends report detached resources explicitly. The construct + // isn't in the project since it was removed from code. + detaching.push({ resourceType: type, logicalId }) } } + // Backwards compatibility: older backends don't emit DETACHED and instead + // return DELETE for resources removed from code even when preserveResources + // is set. When we asked to preserve, treat those deletions as detachments. + if (preserveResources) { + detaching.push(...deleting.splice(0)) + } + // testOnly checks weren't sent to the BE and won't be in previewData. // We load them from the `project` instead. const skipping = project @@ -417,7 +432,11 @@ export default class Deploy extends AuthCommand { const sortedDeleting = deleting .sort(compareEntries) - if (!sortedCreating.length && !sortedDeleting.length && !sortedUpdating.length && !skipping.length) { + const sortedDetaching = detaching + .sort(compareEntries) + + if (!sortedCreating.length && !sortedDeleting.length && !sortedDetaching.length + && !sortedUpdating.length && !skipping.length) { return '\nNo checks were detected. More information on how to set up a Checkly CLI project is available at https://checklyhq.com/docs/cli/.\n' } @@ -437,14 +456,19 @@ export default class Deploy extends AuthCommand { output.push('') } if (sortedDeleting.length) { - output.push(preserveResources - ? chalk.bold.yellow('Detached (kept in account, now UI-managed):') - : chalk.bold.red('Delete:')) + output.push(chalk.bold.red('Delete:')) for (const { resourceType, logicalId } of sortedDeleting) { output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) } output.push('') } + if (sortedDetaching.length) { + output.push(chalk.bold.yellow('Detached (kept in account, now UI-managed):')) + for (const { resourceType, logicalId } of sortedDetaching) { + output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) + } + output.push('') + } if (sortedUpdating.length) { output.push(chalk.bold.magenta('Update and Unchanged:')) for (const { logicalId, physicalId, construct } of sortedUpdating) { From 874f7ff3f07a138afb36aac4f2d463a17f51b4e9 Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Wed, 8 Jul 2026 12:03:45 +0300 Subject: [PATCH 3/4] refactor(cli): address deploy --preserve-resources review feedback (AI-426) - Add StatusPage and StatusPageService to PRETTY_RESOURCE_TYPES so they render with friendly names in delete/detach previews and guards. - Reword user-facing "UI" references to "Checkly Webapp". - Replace the em dash before --preserve-resources with a period for readability. - Remove the transitional DELETE-to-detach display fallback in formatPreview (and its now-unused preserveResources param); the backend ships before the CLI and always emits DETACHED. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/deploy.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index a8b94af6e..b46344793 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -9,7 +9,7 @@ import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard, MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, Project, ProjectData, Diagnostics, - Session, + Session, StatusPage, StatusPageService, } from '../constructs/index.js' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util.js' @@ -27,7 +27,7 @@ enum ResourceDeployStatus { CREATE = 'CREATE', DELETE = 'DELETE', // Returned by newer backends for resources removed from code that are kept in - // the account (detached, now UI-managed) instead of deleted. + // the account (detached, now managed in the Checkly Webapp) instead of deleted. DETACHED = 'DETACHED', } @@ -38,6 +38,8 @@ const PRETTY_RESOURCE_TYPES: Record = { [MaintenanceWindow.__checklyType]: 'MaintenanceWindow', [PrivateLocation.__checklyType]: 'PrivateLocation', [Dashboard.__checklyType]: 'Dashboard', + [StatusPage.__checklyType]: 'StatusPage', + [StatusPageService.__checklyType]: 'StatusPageService', } // Internal resources that users don't create directly. They are reported as @@ -134,8 +136,8 @@ export default class Deploy extends AuthCommand { ? 'Schedule checks after deploy' : 'Checks will NOT be scheduled after deploy', preserveResources - ? 'Detach any resources removed from code, keeping them and their run history for management in the UI' - : 'Delete any resources removed from code, losing their run history — pass --preserve-resources to detach and keep them instead', + ? 'Detach any resources removed from code, keeping them and their run history for management in the Checkly Webapp' + : 'Delete any resources removed from code, losing their run history. Pass --preserve-resources to detach and keep them instead', ], flags, classification: { @@ -314,7 +316,7 @@ export default class Deploy extends AuthCommand { { dryRun: preview, scheduleOnDeploy, preserveResources }, ) if (preview || output) { - this.log(this.formatPreview(data, project, verbose, preserveResources)) + this.log(this.formatPreview(data, project, verbose)) } if (!preview) { await setTimeout(500) @@ -352,7 +354,6 @@ export default class Deploy extends AuthCommand { previewData: ProjectDeployResponse, project: Project, verbose = false, - preserveResources = false, ): string { // Current format of the data is: { checks: { logical-id-1: 'UPDATE' }, groups: { another-logical-id: 'CREATE' } } // We convert it into update: [{ logicalId, resourceType, construct }, ...], create: [], delete: [] @@ -387,13 +388,6 @@ export default class Deploy extends AuthCommand { } } - // Backwards compatibility: older backends don't emit DETACHED and instead - // return DELETE for resources removed from code even when preserveResources - // is set. When we asked to preserve, treat those deletions as detachments. - if (preserveResources) { - detaching.push(...deleting.splice(0)) - } - // testOnly checks weren't sent to the BE and won't be in previewData. // We load them from the `project` instead. const skipping = project @@ -463,7 +457,7 @@ export default class Deploy extends AuthCommand { output.push('') } if (sortedDetaching.length) { - output.push(chalk.bold.yellow('Detached (kept in account, now UI-managed):')) + output.push(chalk.bold.yellow('Detached (kept in account, now managed in the Checkly Webapp):')) for (const { resourceType, logicalId } of sortedDetaching) { output.push(` ${PRETTY_RESOURCE_TYPES[resourceType] ?? resourceType}: ${logicalId}`) } From 59fec1958a89864c2d176218b0f7ee3b6054914c Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Wed, 8 Jul 2026 12:28:00 +0300 Subject: [PATCH 4/4] fix(cli): only send preserveResources deploy param when opted in (AI-426) The /v1/projects/deploy endpoint rejects unknown query params, and the deploy-side backend support for preserveResources is not deployed yet, so sending preserveResources=false on every deploy made all deploys fail with `"preserveResources" is not allowed` (all e2e deploy tests red). preserveResources=false is identical to the backend's default (delete resources removed from code), so omit the param unless the user opted in with --preserve-resources. Default deploys are now byte-for-byte identical to before the feature; the param only rides along when explicitly requested (which requires the deploy-side backend support to ship first). Also fix the merged projects-deploy unit test to drive the async event-stream deploy, and update assertions to the conditional param. Co-Authored-By: Claude Opus 4.8 --- .../rest/__tests__/projects-deploy.spec.ts | 26 ++++++++++++++----- .../cli/src/rest/__tests__/projects.spec.ts | 12 ++++----- packages/cli/src/rest/projects.ts | 6 ++++- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/rest/__tests__/projects-deploy.spec.ts b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts index 3094e80ac..58faea83a 100644 --- a/packages/cli/src/rest/__tests__/projects-deploy.spec.ts +++ b/packages/cli/src/rest/__tests__/projects-deploy.spec.ts @@ -1,10 +1,22 @@ import { describe, it, expect, vi } from 'vitest' +import { Readable } from 'node:stream' import type { AxiosInstance } from 'axios' import Projects, { type ProjectSync } from '../projects.js' +// Build an SSE frame and a readable stream that emits the given frames then ends. +const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) }) + +const applied = { project: { name: 'p', logicalId: 'p' }, diff: [] } + function createProjects () { - const post = vi.fn().mockResolvedValue({ data: { project: {}, diff: [] } }) - const api = { post } as unknown as AxiosInstance + // A real (non-dry-run) deploy submits, then follows the SSE stream to completion, + // so post returns a deployment id and get yields a terminal 'complete' frame. + const post = vi.fn().mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + const get = vi.fn().mockResolvedValue( + sseStream(sse('complete', { id: 'dep-1', status: 'SUCCEEDED', progress: 100, result: applied, error: null })), + ) + const api = { post, get } as unknown as AxiosInstance return { projects: new Projects(api), post } } @@ -15,18 +27,18 @@ const resources: ProjectSync = { } describe('Projects.deploy query params', () => { - it('defaults preserveResources to false', () => { + it('omits preserveResources by default', async () => { const { projects, post } = createProjects() - projects.deploy(resources) + await projects.deploy(resources) const url = post.mock.calls[0][0] as string expect(url).toContain('dryRun=false') expect(url).toContain('scheduleOnDeploy=true') - expect(url).toContain('preserveResources=false') + expect(url).not.toContain('preserveResources') }) - it('forwards preserveResources=true', () => { + it('forwards preserveResources=true', async () => { const { projects, post } = createProjects() - projects.deploy(resources, { dryRun: true, scheduleOnDeploy: false, preserveResources: true }) + await projects.deploy(resources, { dryRun: true, scheduleOnDeploy: false, preserveResources: true }) const url = post.mock.calls[0][0] as string expect(url).toContain('dryRun=true') expect(url).toContain('scheduleOnDeploy=false') diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts index 03d6d7e75..94521840c 100644 --- a/packages/cli/src/rest/__tests__/projects.spec.ts +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -46,7 +46,7 @@ describe('Projects.deploy', () => { const { data } = await projects.deploy(sync, { dryRun: true }) expect(api.post).toHaveBeenCalledWith( - '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true&preserveResources=false', + '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true', sync, expect.objectContaining({ transformRequest: expect.any(Function) }), ) @@ -54,16 +54,16 @@ describe('Projects.deploy', () => { expect(data).toEqual(preview) }) - it('passes preserveResources through to the deploy endpoint', async () => { + it('omits preserveResources by default and only sends it when opted in', async () => { const preview = { project: sync.project, diff: [] } vi.mocked(api.post).mockResolvedValue({ data: preview }) - await projects.deploy(sync, { dryRun: true, preserveResources: true }) + await projects.deploy(sync, { dryRun: true }) + expect(vi.mocked(api.post).mock.calls[0][0]).toBe('/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true') - expect(api.post).toHaveBeenCalledWith( + await projects.deploy(sync, { dryRun: true, preserveResources: true }) + expect(vi.mocked(api.post).mock.calls[1][0]).toBe( '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true&preserveResources=true', - sync, - expect.objectContaining({ transformRequest: expect.any(Function) }), ) }) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 1d84fc7b9..85bd0dce9 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -443,8 +443,12 @@ class Projects { onProgress?: (progress: number) => void }, ): Promise<{ data: ProjectDeployResponse }> { + // Only send preserveResources when the user opted in. The endpoint rejects + // unknown query params, and preserveResources=false is the default (delete) + // behavior, so omitting it keeps default deploys backwards compatible. + const preserveParam = preserveResources ? '&preserveResources=true' : '' const { data } = await this.api.post( - `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}&preserveResources=${preserveResources}`, + `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}${preserveParam}`, resources, { transformRequest: compressJSONPayload }, )