From de0cda38be9159a25a1810170953955b21befffe Mon Sep 17 00:00:00 2001 From: Luis Almeida Date: Wed, 8 Jul 2026 17:31:01 +0200 Subject: [PATCH 1/2] feat(themes): add themes:export command Add zcli themes:export to create a Help Center theme export job, poll it to completion, and download the resulting zip to a target directory (default: cwd) as theme_.zip. --- docs/themes.md | 24 +++ .../zcli-themes/src/commands/themes/export.ts | 48 +++++ .../src/lib/createThemeExportJob.test.ts | 59 ++++++ .../src/lib/createThemeExportJob.ts | 31 +++ .../src/lib/downloadThemePackage.test.ts | 45 +++++ .../src/lib/downloadThemePackage.ts | 27 +++ packages/zcli-themes/src/types.ts | 8 + .../tests/functional/export.test.ts | 180 ++++++++++++++++++ 8 files changed, 422 insertions(+) create mode 100644 packages/zcli-themes/src/commands/themes/export.ts create mode 100644 packages/zcli-themes/src/lib/createThemeExportJob.test.ts create mode 100644 packages/zcli-themes/src/lib/createThemeExportJob.ts create mode 100644 packages/zcli-themes/src/lib/downloadThemePackage.test.ts create mode 100644 packages/zcli-themes/src/lib/downloadThemePackage.ts create mode 100644 packages/zcli-themes/tests/functional/export.test.ts diff --git a/docs/themes.md b/docs/themes.md index 72879c16..ab7a729b 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -9,6 +9,7 @@ zcli themes commands helps with managing Zendesk Help Center theming workflow. * [`zcli themes:publish`](#zcli-themespublish) * [`zcli themes:delete`](#zcli-themesdelete) * [`zcli themes:list`](#zcli-themeslist) +* [`zcli themes:export [THEMEDIRECTORY]`](#zcli-themesexport-themedirectory) ## Configuration @@ -147,3 +148,26 @@ EXAMPLES $ zcli themes:list --brandId=123456 $ zcli themes:list --brandId=123456 --json ``` + +## `zcli themes:export [THEMEDIRECTORY]` + +exports a theme as a zip file + +``` +USAGE + $ zcli themes:export [THEMEDIRECTORY] + +ARGUMENTS + THEMEDIRECTORY [default: .] directory where the exported theme zip is written + +OPTIONS + --themeId The id of the theme to export + --json Return JSON output (useful in CI) + +EXAMPLES + $ zcli themes:export --themeId=123456789100 + $ zcli themes:export ./exports --themeId=123456789100 + $ zcli themes:export --themeId=123456789100 --json +``` + +The exported theme is written to the theme directory (default: current working directory) as `theme_.zip`. diff --git a/packages/zcli-themes/src/commands/themes/export.ts b/packages/zcli-themes/src/commands/themes/export.ts new file mode 100644 index 00000000..0108400a --- /dev/null +++ b/packages/zcli-themes/src/commands/themes/export.ts @@ -0,0 +1,48 @@ +import { Command, Flags, CliUx } from '@oclif/core' +import * as path from 'path' +import * as chalk from 'chalk' +import createThemeExportJob from '../../lib/createThemeExportJob' +import pollJobStatus from '../../lib/pollJobStatus' +import downloadThemePackage from '../../lib/downloadThemePackage' + +export default class Export extends Command { + static description = 'export a theme' + + static enableJsonFlag = true + + static flags = { + themeId: Flags.string({ description: 'The id of the theme to export' }) + } + + static args = [ + { name: 'themeDirectory', required: true, default: '.' } + ] + + static examples = [ + '$ zcli themes:export --themeId=abcd', + '$ zcli themes:export ./exports --themeId=abcd' + ] + + static strict = false + + async run () { + let { flags: { themeId }, argv: [themeDirectory] } = await this.parse(Export) + const destination = path.resolve(themeDirectory) + + themeId = themeId || await CliUx.ux.prompt('Theme ID') + + const job = await createThemeExportJob(themeId) + + // The download URL is provided when the job is created; the poll only + // waits for the export to finish (the completed job's `data` is null). + const downloadUrl = job.data.download.url + + await pollJobStatus(destination, job.id) + + const filePath = await downloadThemePackage(downloadUrl, themeId, destination) + + this.log(chalk.green('Theme exported successfully'), `theme ID: ${themeId}`, filePath) + + return { themeId, path: filePath } + } +} diff --git a/packages/zcli-themes/src/lib/createThemeExportJob.test.ts b/packages/zcli-themes/src/lib/createThemeExportJob.test.ts new file mode 100644 index 00000000..eae602dc --- /dev/null +++ b/packages/zcli-themes/src/lib/createThemeExportJob.test.ts @@ -0,0 +1,59 @@ +import * as sinon from 'sinon' +import { expect } from '@oclif/test' +import * as axios from 'axios' +import { request } from '@zendesk/zcli-core' +import createThemeExportJob from './createThemeExportJob' +import * as chalk from 'chalk' +import * as errors from '@oclif/core/lib/errors' + +describe('createThemeExportJob', () => { + beforeEach(() => { + sinon.restore() + }) + + it('calls the jobs/themes/exports endpoint with the correct payload and returns the job', async () => { + const requestStub = sinon.stub(request, 'requestAPI') + const job = { + id: '9999', + status: 'pending', + data: { download: { url: 'download/url' } } + } + + requestStub.returns(Promise.resolve({ data: { job } }) as axios.AxiosPromise) + + expect(await createThemeExportJob('1234')).to.equal(job) + + expect(requestStub.calledWith('/api/v2/guide/theming/jobs/themes/exports', sinon.match({ + method: 'POST', + data: { + job: { + attributes: { + theme_id: '1234', + format: 'zip' + } + } + } + }))).to.equal(true) + }) + + it('errors when creation fails', async () => { + const errorStub = sinon.stub(errors, 'error').callThrough() + + sinon.stub(request, 'requestAPI').throws({ + response: { + data: { + errors: [{ + code: 'ThemeNotFound', + title: 'Invalid id' + }] + } + } + }) + + try { + await createThemeExportJob('1234') + } catch { + expect(errorStub.calledWith(`${chalk.bold('ThemeNotFound')} - Invalid id`)).to.equal(true) + } + }) +}) diff --git a/packages/zcli-themes/src/lib/createThemeExportJob.ts b/packages/zcli-themes/src/lib/createThemeExportJob.ts new file mode 100644 index 00000000..84573f06 --- /dev/null +++ b/packages/zcli-themes/src/lib/createThemeExportJob.ts @@ -0,0 +1,31 @@ +import type { ExportJob } from '../types' +import { CliUx } from '@oclif/core' +import { request } from '@zendesk/zcli-core' +import type { AxiosError } from 'axios' +import handleThemeApiError from './handleThemeApiError' + +export default async function createThemeExportJob (themeId: string): Promise { + CliUx.ux.action.start('Creating theme export job') + + try { + const { data: { job } } = await request.requestAPI('/api/v2/guide/theming/jobs/themes/exports', { + method: 'POST', + headers: { + 'X-Zendesk-Request-Originator': 'zcli themes:export' + }, + data: { + job: { + attributes: { + theme_id: themeId, + format: 'zip' + } + } + }, + validateStatus: (status: number) => status === 202 + }) + CliUx.ux.action.stop('Ok') + return job + } catch (error) { + handleThemeApiError(error as AxiosError) + } +} diff --git a/packages/zcli-themes/src/lib/downloadThemePackage.test.ts b/packages/zcli-themes/src/lib/downloadThemePackage.test.ts new file mode 100644 index 00000000..0730cb0c --- /dev/null +++ b/packages/zcli-themes/src/lib/downloadThemePackage.test.ts @@ -0,0 +1,45 @@ +import * as sinon from 'sinon' +import { expect } from '@oclif/test' +import * as axios from 'axios' +import * as fs from 'fs' +import * as path from 'path' +import { request } from '@zendesk/zcli-core' +import downloadThemePackage from './downloadThemePackage' +import * as errors from '@oclif/core/lib/errors' + +describe('downloadThemePackage', () => { + beforeEach(() => { + sinon.restore() + }) + + it('downloads the package from the presigned url and writes it to the destination', async () => { + const requestStub = sinon.stub(request, 'requestRaw') + const writeFileStub = sinon.stub(fs, 'writeFileSync') + + requestStub.returns(Promise.resolve({ data: Buffer.from('theme content') }) as axios.AxiosPromise) + + const filePath = await downloadThemePackage('download/url', '1234', '/tmp/exports') + + expect(requestStub.calledWith('download/url', sinon.match({ + method: 'GET', + responseType: 'arraybuffer' + }))).to.equal(true) + + expect(filePath).to.equal(path.join('/tmp/exports', 'theme_1234.zip')) + expect(writeFileStub.calledWith(filePath, sinon.match.instanceOf(Buffer))).to.equal(true) + }) + + it('errors when the download fails', async () => { + const requestStub = sinon.stub(request, 'requestRaw') + const errorStub = sinon.stub(errors, 'error').callThrough() + const error = new axios.AxiosError('Network error') + + requestStub.throws(error) + + try { + await downloadThemePackage('download/url', '1234', '/tmp/exports') + } catch { + expect(errorStub.calledWith(error)).to.equal(true) + } + }) +}) diff --git a/packages/zcli-themes/src/lib/downloadThemePackage.ts b/packages/zcli-themes/src/lib/downloadThemePackage.ts new file mode 100644 index 00000000..c956e242 --- /dev/null +++ b/packages/zcli-themes/src/lib/downloadThemePackage.ts @@ -0,0 +1,27 @@ +import { CliUx } from '@oclif/core' +import { request } from '@zendesk/zcli-core' +import { error } from '@oclif/core/lib/errors' +import type { AxiosError } from 'axios' +import * as fs from 'fs' +import * as path from 'path' + +export default async function downloadThemePackage (downloadUrl: string, themeId: string, destination: string): Promise { + CliUx.ux.action.start('Downloading theme package') + + const filePath = path.join(destination, `theme_${themeId}.zip`) + + try { + // `requestRaw` hits the presigned download URL directly, without the + // Zendesk `Authorization` header or base URL that `requestAPI` adds. + const response = await request.requestRaw(downloadUrl, { + method: 'GET', + responseType: 'arraybuffer', + validateStatus: (status: number) => status === 200 + }) + fs.writeFileSync(filePath, Buffer.from(response.data)) + CliUx.ux.action.stop('Ok') + return filePath + } catch (e) { + error(e as AxiosError) + } +} diff --git a/packages/zcli-themes/src/types.ts b/packages/zcli-themes/src/types.ts index 72d57d1b..201158bf 100644 --- a/packages/zcli-themes/src/types.ts +++ b/packages/zcli-themes/src/types.ts @@ -89,6 +89,14 @@ export type PendingJob = { data: JobData } +export type ExportJob = PendingJob & { + data: { + download: { + url: string + } + } +} + export type CompletedJob = { id: string, status: 'completed', diff --git a/packages/zcli-themes/tests/functional/export.test.ts b/packages/zcli-themes/tests/functional/export.test.ts new file mode 100644 index 00000000..f9b477d4 --- /dev/null +++ b/packages/zcli-themes/tests/functional/export.test.ts @@ -0,0 +1,180 @@ +import type { ExportJob } from '../../../zcli-themes/src/types' +import { expect, test } from '@oclif/test' +import * as sinon from 'sinon' +import * as fs from 'fs' +import * as path from 'path' +import ExportCommand from '../../src/commands/themes/export' +import env from './env' +import { CLIError } from '@oclif/core/lib/errors' + +describe('themes:export', function () { + const downloadUrl = 'https://s3.com/download/theme.zip' + const job: ExportJob = { + id: '9999', + status: 'pending', + data: { + theme_id: '1234', + upload: { + url: 'https://s3.com/upload/path', + parameters: {} + }, + download: { + url: downloadUrl + } + } + } + + let fetchStub: sinon.SinonStub + let writeFileStub: sinon.SinonStub + + beforeEach(() => { + fetchStub = sinon.stub(global, 'fetch') + writeFileStub = sinon.stub(fs, 'writeFileSync') + }) + + afterEach(() => { + fetchStub.restore() + writeFileStub.restore() + }) + + describe('successful export', () => { + const success = test + .env(env) + .do(() => { + fetchStub.withArgs(sinon.match({ + url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/jobs/themes/exports', + method: 'POST' + })).resolves({ + status: 202, + ok: true, + text: () => Promise.resolve(JSON.stringify({ job })) + }) + + // The completed poll response carries `data: null`; the download URL + // must come from the job returned at creation time. + fetchStub.withArgs(sinon.match({ + url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/jobs/9999', + method: 'GET' + })).resolves({ + status: 200, + ok: true, + text: () => Promise.resolve(JSON.stringify({ + job: { + id: job.id, + status: 'completed', + errors: null, + data: null + } + })) + }) + + fetchStub.withArgs(sinon.match({ + url: downloadUrl, + method: 'GET' + })).resolves({ + status: 200, + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(new TextEncoder().encode('theme-zip-bytes').buffer) + }) + }) + + success + .stdout() + .it('should display success message when the theme is exported successfully', async ctx => { + await ExportCommand.run(['--themeId', '1234']) + expect(ctx.stdout).to.contain('Theme exported successfully theme ID: 1234') + }) + + success + .stdout() + .it('should return an object containing the theme ID and path when ran with --json', async ctx => { + await ExportCommand.run(['--themeId', '1234', '--json']) + const output = JSON.parse(ctx.stdout) + expect(output.themeId).to.equal('1234') + expect(output.path).to.contain('theme_1234.zip') + expect(writeFileStub.calledOnce).to.equal(true) + }) + + success + .stdout() + .it('should write the theme to the provided theme directory', async ctx => { + await ExportCommand.run(['./exports', '--themeId', '1234', '--json']) + const output = JSON.parse(ctx.stdout) + expect(output.path).to.equal(path.join(process.cwd(), 'exports', 'theme_1234.zip')) + }) + }) + + describe('export failure', () => { + test + .stderr() + .env(env) + .do(() => { + fetchStub.withArgs(sinon.match({ + url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/jobs/themes/exports', + method: 'POST' + })).resolves({ + status: 400, + ok: false, + text: () => Promise.resolve(JSON.stringify({ + errors: [{ + code: 'ThemeNotFound', + title: 'Invalid id' + }] + })) + }) + }) + .it('should report errors when creating the export job fails', async (ctx) => { + try { + await ExportCommand.run(['--themeId', '1234']) + } catch (error) { + expect(ctx.stderr).to.contain('!') + expect((error as CLIError).message).to.contain('ThemeNotFound') + expect((error as CLIError).message).to.contain('Invalid id') + } + }) + + test + .env(env) + .do(() => { + fetchStub.withArgs(sinon.match({ + url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/jobs/themes/exports', + method: 'POST' + })).resolves({ + status: 202, + ok: true, + text: () => Promise.resolve(JSON.stringify({ job })) + }) + + fetchStub.withArgs(sinon.match({ + url: 'https://z3ntest.zendesk.com/api/v2/guide/theming/jobs/9999', + method: 'GET' + })).resolves({ + status: 200, + ok: true, + text: () => Promise.resolve(JSON.stringify({ + job: { + ...job, + status: 'failed', + data: null, + errors: [ + { + message: 'Something went wrong', + code: 'ExportFailed', + meta: {} + } + ] + } + })) + }) + }) + .it('should report errors when the export job fails', async () => { + try { + await ExportCommand.run(['--themeId', '1234']) + } catch (error) { + expect((error as CLIError).message).to.contain('ExportFailed') + expect((error as CLIError).message).to.contain('Something went wrong') + } + }) + }) +}) From 8ca6093412b2ea6e5df85a03528cc59043bf8cea Mon Sep 17 00:00:00 2001 From: Luis Almeida Date: Fri, 10 Jul 2026 15:59:11 +0200 Subject: [PATCH 2/2] fix(themes): create export destination dir and harden failure tests Address PR review: themes:export now creates the destination directory (recursive) before writing, so exporting to a not-yet-existing path no longer fails with ENOENT. Failure-path tests now use a sentinel throw so they can't silently pass when no error is raised. --- packages/zcli-themes/src/lib/createThemeExportJob.test.ts | 6 +++++- packages/zcli-themes/src/lib/downloadThemePackage.test.ts | 8 +++++++- packages/zcli-themes/src/lib/downloadThemePackage.ts | 1 + packages/zcli-themes/tests/functional/export.test.ts | 8 ++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/zcli-themes/src/lib/createThemeExportJob.test.ts b/packages/zcli-themes/src/lib/createThemeExportJob.test.ts index eae602dc..1f851cc7 100644 --- a/packages/zcli-themes/src/lib/createThemeExportJob.test.ts +++ b/packages/zcli-themes/src/lib/createThemeExportJob.test.ts @@ -52,7 +52,11 @@ describe('createThemeExportJob', () => { try { await createThemeExportJob('1234') - } catch { + throw new Error('Should have thrown an error') + } catch (error) { + if (error instanceof Error && error.message === 'Should have thrown an error') { + throw error + } expect(errorStub.calledWith(`${chalk.bold('ThemeNotFound')} - Invalid id`)).to.equal(true) } }) diff --git a/packages/zcli-themes/src/lib/downloadThemePackage.test.ts b/packages/zcli-themes/src/lib/downloadThemePackage.test.ts index 0730cb0c..238f5100 100644 --- a/packages/zcli-themes/src/lib/downloadThemePackage.test.ts +++ b/packages/zcli-themes/src/lib/downloadThemePackage.test.ts @@ -14,6 +14,7 @@ describe('downloadThemePackage', () => { it('downloads the package from the presigned url and writes it to the destination', async () => { const requestStub = sinon.stub(request, 'requestRaw') + const mkdirStub = sinon.stub(fs, 'mkdirSync') const writeFileStub = sinon.stub(fs, 'writeFileSync') requestStub.returns(Promise.resolve({ data: Buffer.from('theme content') }) as axios.AxiosPromise) @@ -26,6 +27,7 @@ describe('downloadThemePackage', () => { }))).to.equal(true) expect(filePath).to.equal(path.join('/tmp/exports', 'theme_1234.zip')) + expect(mkdirStub.calledWith('/tmp/exports', { recursive: true })).to.equal(true) expect(writeFileStub.calledWith(filePath, sinon.match.instanceOf(Buffer))).to.equal(true) }) @@ -38,7 +40,11 @@ describe('downloadThemePackage', () => { try { await downloadThemePackage('download/url', '1234', '/tmp/exports') - } catch { + throw new Error('Should have thrown an error') + } catch (thrown) { + if (thrown instanceof Error && thrown.message === 'Should have thrown an error') { + throw thrown + } expect(errorStub.calledWith(error)).to.equal(true) } }) diff --git a/packages/zcli-themes/src/lib/downloadThemePackage.ts b/packages/zcli-themes/src/lib/downloadThemePackage.ts index c956e242..a7adf81f 100644 --- a/packages/zcli-themes/src/lib/downloadThemePackage.ts +++ b/packages/zcli-themes/src/lib/downloadThemePackage.ts @@ -18,6 +18,7 @@ export default async function downloadThemePackage (downloadUrl: string, themeId responseType: 'arraybuffer', validateStatus: (status: number) => status === 200 }) + fs.mkdirSync(destination, { recursive: true }) fs.writeFileSync(filePath, Buffer.from(response.data)) CliUx.ux.action.stop('Ok') return filePath diff --git a/packages/zcli-themes/tests/functional/export.test.ts b/packages/zcli-themes/tests/functional/export.test.ts index f9b477d4..8d4dcf83 100644 --- a/packages/zcli-themes/tests/functional/export.test.ts +++ b/packages/zcli-themes/tests/functional/export.test.ts @@ -127,7 +127,11 @@ describe('themes:export', function () { .it('should report errors when creating the export job fails', async (ctx) => { try { await ExportCommand.run(['--themeId', '1234']) + throw new Error('Should have thrown an error') } catch (error) { + if (error instanceof Error && error.message === 'Should have thrown an error') { + throw error + } expect(ctx.stderr).to.contain('!') expect((error as CLIError).message).to.contain('ThemeNotFound') expect((error as CLIError).message).to.contain('Invalid id') @@ -171,7 +175,11 @@ describe('themes:export', function () { .it('should report errors when the export job fails', async () => { try { await ExportCommand.run(['--themeId', '1234']) + throw new Error('Should have thrown an error') } catch (error) { + if (error instanceof Error && error.message === 'Should have thrown an error') { + throw error + } expect((error as CLIError).message).to.contain('ExportFailed') expect((error as CLIError).message).to.contain('Something went wrong') }