From c8438564c8921954031493cd5499f458d700a39d Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:00:00 +0200 Subject: [PATCH 1/2] feat: add sdk publish command --- docs/command-graph.html | 5 +- docs/user-guide/sdk-commands.md | 10 ++++ src/commands/sdk/module.ts | 12 ++++ src/commands/sdk/sdk-api.ts | 12 ++++ src/commands/sdk/sdk.service.ts | 25 +++++++- src/core/http/http-client.ts | 18 ++++++ tests/commands/sdk/module.spec.ts | 22 ++++++- tests/commands/sdk/sdk-publish.spec.ts | 79 ++++++++++++++++++++++++++ tests/core/http/http-client.spec.ts | 24 +++++++- 9 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 tests/commands/sdk/sdk-publish.spec.ts diff --git a/docs/command-graph.html b/docs/command-graph.html index a9f736cd..e20f2ffe 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -438,6 +438,9 @@ { id: "sdk_download", label: "download (beta)", group: "command", path: "sdk download", description: "Download the latest Celonis SDK generated for a Codespace asset.", options: ["-p, --profile ", "--appId ", "-h, --help"] }, + { id: "sdk_publish", label: "publish (beta)", group: "command", path: "sdk publish", + description: "Publish a build of a custom app as a new version of its Codespace asset.", + options: ["-p, --profile ", "--appId ", "--dist ", "-h, --help"] }, // git profile { id: "git_profile_area", label: "profile", group: "subarea", path: "git profile", @@ -540,7 +543,7 @@ ["deployment_list_area","deployment_list_history"],["deployment_list_area","deployment_list_active"], ["deployment_list_area","deployment_list_deployables"],["deployment_list_area","deployment_list_targets"], - ["area_sdk","sdk_download"], + ["area_sdk","sdk_download"],["area_sdk","sdk_publish"], ["area_git","git_profile_area"],["git_profile_area","git_profile_list"], ["git_profile_area","git_profile_create"],["git_profile_area","git_profile_default"], diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index 9e5c3ff5..bbbfb491 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -17,3 +17,13 @@ npm install ./.celonis-sdk/.tgz ``` Running the command again replaces the previously downloaded SDK with the latest version. + +## Publish App + +Publish a build of a custom app as a new version of its Codespace asset. + +``` +content-cli sdk publish --appId --dist +``` + +The command compresses the given build directory and uploads it for the app. On success, the version assigned to the build is printed, which can then be selected on the Codespace asset. diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts index 056dac5e..267949bc 100644 --- a/src/commands/sdk/module.ts +++ b/src/commands/sdk/module.ts @@ -16,11 +16,23 @@ class Module extends IModule { .description("Download the latest Celonis SDK generated for a Codespace asset") .requiredOption("--appId ", "Identifier of the app to download the SDK for") .action(this.downloadSdk); + + sdkCommand + .command("publish") + .beta() + .description("Publish a build of a custom app as a new version of its Codespace asset") + .requiredOption("--appId ", "Identifier of the app to publish the build for") + .requiredOption("--dist ", "Directory containing the build of the app") + .action(this.publishApp); } private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise { await new SdkService(context).downloadSdk(options.appId); } + + private async publishApp(context: Context, command: Command, options: OptionValues): Promise { + await new SdkService(context).publishApp(options.appId, options.dist); + } } export = Module; diff --git a/src/commands/sdk/sdk-api.ts b/src/commands/sdk/sdk-api.ts index 3de2a7ec..77abcd2d 100644 --- a/src/commands/sdk/sdk-api.ts +++ b/src/commands/sdk/sdk-api.ts @@ -2,6 +2,10 @@ import { HttpClient } from "../../core/http/http-client"; import { Context } from "../../core/command/cli-context"; import { FatalError } from "../../core/utils/logger"; +export interface SdkPublishResponse { + version: string; +} + export class SdkApi { private readonly httpClient: () => HttpClient; @@ -17,4 +21,12 @@ export class SdkApi { throw new FatalError(e ? `${problem}: ${e}` : problem); }); } + + public async publishApp(appId: string, bundle: Buffer): Promise { + return this.httpClient() + .postBinary(`/pro-code-app-manager/api/codespace/apps/${appId}/publish`, bundle, "application/zip") + .catch((e) => { + throw new FatalError(`Problem publishing app ${appId}: ${e}`); + }); + } } diff --git a/src/commands/sdk/sdk.service.ts b/src/commands/sdk/sdk.service.ts index 1b4418a2..f6481ac7 100644 --- a/src/commands/sdk/sdk.service.ts +++ b/src/commands/sdk/sdk.service.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import AdmZip = require("adm-zip"); import { SdkApi } from "./sdk-api"; import { Context } from "../../core/command/cli-context"; -import { logger } from "../../core/utils/logger"; +import { FatalError, logger } from "../../core/utils/logger"; export class SdkService { public static readonly SDK_DIRECTORY = ".celonis-sdk"; @@ -25,4 +26,26 @@ export class SdkService { logger.info(`SDK downloaded successfully: ${sdkFile}`); } + + public async publishApp(appId: string, dist: string): Promise { + const distDirectory = path.resolve(process.cwd(), dist); + this.requireDirectory(distDirectory); + + const bundle = this.compressDirectory(distDirectory); + const { version } = await this.sdkApi.publishApp(appId, bundle); + + logger.info(`App published successfully as version '${version}'`); + } + + private requireDirectory(directory: string): void { + if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { + throw new FatalError(`The dist directory ${directory} does not exist or is no directory`); + } + } + + private compressDirectory(directory: string): Buffer { + const zip = new AdmZip(); + zip.addLocalFolder(directory); + return zip.toBuffer(); + } } diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index 23e4c86a..0e09fbfe 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -123,6 +123,24 @@ export class HttpClient { }) } + public async postBinary(url: string, body: Buffer, contentType: string): Promise { + return new Promise((resolve, reject) => { + this.axios.post( + this.resolveUrl(url), + body, + { + headers: this.buildHeaders(contentType) + } + ).then(response => { + this.handleResponse(response, resolve, reject); + }).catch(err => { + this.handleError(err, resolve, reject); + }) + }).catch(e => { + throw new FatalError(e); + }) + } + public async put(url: string, body: object): Promise { return new Promise((resolve, reject) => { this.axios.put( diff --git a/tests/commands/sdk/module.spec.ts b/tests/commands/sdk/module.spec.ts index cfa7e9fb..fb7aeac0 100644 --- a/tests/commands/sdk/module.spec.ts +++ b/tests/commands/sdk/module.spec.ts @@ -14,7 +14,16 @@ describe("SDK Module", () => { expect(mockConfigurator.command).toHaveBeenCalledWith("sdk"); expect(mockConfigurator.command).toHaveBeenCalledWith("download"); expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId ", expect.any(String)); - expect(mockConfigurator.beta).toHaveBeenCalledTimes(2); + expect(mockConfigurator.beta).toHaveBeenCalledTimes(3); + }); + + it("registers the sdk publish command", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + expect(mockConfigurator.command).toHaveBeenCalledWith("publish"); + expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--dist ", expect.any(String)); }); it("downloads the SDK of the given app when the download command runs", async () => { @@ -27,5 +36,16 @@ describe("SDK Module", () => { expect(downloadSdk).toHaveBeenCalledWith("app-id"); }); + + it("publishes the given dist directory when the publish command runs", async () => { + const publishApp = jest.spyOn(SdkService.prototype, "publishApp").mockResolvedValue(); + const mockConfigurator = createMockConfigurator(); + new Module().register(testContext, mockConfigurator); + + const publishAction = mockConfigurator.action.mock.calls[1][0]; + await publishAction(testContext, new Command(), { appId: "app-id", dist: "dist" }); + + expect(publishApp).toHaveBeenCalledWith("app-id", "dist"); + }); }); }); diff --git a/tests/commands/sdk/sdk-publish.spec.ts b/tests/commands/sdk/sdk-publish.spec.ts new file mode 100644 index 00000000..0734954a --- /dev/null +++ b/tests/commands/sdk/sdk-publish.spec.ts @@ -0,0 +1,79 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip = require("adm-zip"); +import { + mockAxiosPost, + mockAxiosPostError, + mockedAxiosInstance, + mockedPostRequestBodyByUrl, +} from "../../utls/http-requests-mock"; +import { SdkService } from "../../../src/commands/sdk/sdk.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; +import { makeTempDir, rmTempDir, uniqueDirName } from "../../utls/fs-utils"; + +describe("SDK publish", () => { + const appId = "app-id"; + const publishUrl = `https://myTeam.celonis.cloud/pro-code-app-manager/api/codespace/apps/${appId}/publish`; + + let distDirectory: string; + + function publishedBundle(): AdmZip { + return new AdmZip(mockedPostRequestBodyByUrl.get(publishUrl)); + } + + beforeEach(() => { + distDirectory = makeTempDir(); + mkdirSync(join(distDirectory, "assets")); + writeFileSync(join(distDirectory, "index.html"), ""); + writeFileSync(join(distDirectory, "assets", "main.js"), "console.log()"); + }); + + afterEach(() => { + rmTempDir(distDirectory); + }); + + it("Should upload the compressed dist directory and print the assigned version", async () => { + mockAxiosPost(publishUrl, { version: "release-1" }); + + await new SdkService(testContext).publishApp(appId, distDirectory); + + expect(publishedBundle().readAsText("index.html")).toBe(""); + expect(publishedBundle().readAsText("assets/main.js")).toBe("console.log()"); + expect(loggingTestTransport.logMessages[0].message).toContain("App published successfully. Version: release-1"); + }); + + it("Should upload the bundle as zip archive", async () => { + mockAxiosPost(publishUrl, { version: "release-1" }); + + await new SdkService(testContext).publishApp(appId, distDirectory); + + const requestConfig = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][2]; + expect(requestConfig.headers["Content-Type"]).toBe("application/zip"); + }); + + it("Should reject a dist directory that does not exist without uploading", async () => { + const missingDirectory = join(distDirectory, uniqueDirName()); + + await expect(new SdkService(testContext).publishApp(appId, missingDirectory)) + .rejects.toThrow(`The dist directory ${missingDirectory} does not exist or is no directory`); + + expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); + }); + + it("Should reject a dist path that is a file without uploading", async () => { + const file = join(distDirectory, "index.html"); + + await expect(new SdkService(testContext).publishApp(appId, file)) + .rejects.toThrow(`The dist directory ${file} does not exist or is no directory`); + + expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); + }); + + it("Should report the backend error when publishing fails", async () => { + mockAxiosPostError(publishUrl, 404, "Custom app 'app-id' not found"); + + await expect(new SdkService(testContext).publishApp(appId, distDirectory)) + .rejects.toThrow(`Problem publishing app ${appId}: Error: "Custom app 'app-id' not found"`); + }); +}); diff --git a/tests/core/http/http-client.spec.ts b/tests/core/http/http-client.spec.ts index 2764c4fb..8b18087d 100644 --- a/tests/core/http/http-client.spec.ts +++ b/tests/core/http/http-client.spec.ts @@ -1,7 +1,7 @@ import { Context } from "../../../src/core/command/cli-context"; import { HttpClient } from "../../../src/core/http/http-client"; import { Profile } from "../../../src/core/profile/profile.interface"; -import { mockAxiosGet, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { mockAxiosGet, mockAxiosPost, mockAxiosPostError, mockedAxiosInstance } from "../../utls/http-requests-mock"; const TEAM_URL = "https://myTeam.celonis.cloud"; const RESOURCE_PATH = "/api/test/resource"; @@ -64,3 +64,25 @@ describe("HttpClient authorization headers", () => { expect(headersOfSentRequest()["Content-Type"]).toBe("application/json"); }); }); + +describe("HttpClient binary post", () => { + const body = Buffer.from("binary-content"); + + it("should send the body unchanged with the given content type", async () => { + mockAxiosPost(TEAM_URL + RESOURCE_PATH, { result: "ok" }); + + const response = await httpClientFor({ apiToken: "test-token" }).postBinary(RESOURCE_PATH, body, "application/zip"); + + const [, sentBody, config] = (mockedAxiosInstance.post as jest.Mock).mock.calls[0]; + expect(response).toEqual({ result: "ok" }); + expect(sentBody).toBe(body); + expect(config.headers["Content-Type"]).toBe("application/zip"); + }); + + it("should reject with the response body when the backend responds with an error", async () => { + mockAxiosPostError(TEAM_URL + RESOURCE_PATH, 400, "Invalid bundle"); + + await expect(httpClientFor({ apiToken: "test-token" }).postBinary(RESOURCE_PATH, body, "application/zip")) + .rejects.toThrow("\"Invalid bundle\""); + }); +}); From 6cb85e95365adcb50570099f59e10a4368d7ad15 Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:06:49 +0200 Subject: [PATCH 2/2] fix: align publish test with success message --- tests/commands/sdk/sdk-publish.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/sdk/sdk-publish.spec.ts b/tests/commands/sdk/sdk-publish.spec.ts index 0734954a..9cb53db6 100644 --- a/tests/commands/sdk/sdk-publish.spec.ts +++ b/tests/commands/sdk/sdk-publish.spec.ts @@ -40,7 +40,7 @@ describe("SDK publish", () => { expect(publishedBundle().readAsText("index.html")).toBe(""); expect(publishedBundle().readAsText("assets/main.js")).toBe("console.log()"); - expect(loggingTestTransport.logMessages[0].message).toContain("App published successfully. Version: release-1"); + expect(loggingTestTransport.logMessages[0].message).toContain("App published successfully as version 'release-1'"); }); it("Should upload the bundle as zip archive", async () => {