Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs/command-graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profile>", "--appId <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 <profile>", "--appId <appId>", "--dist <dist>", "-h, --help"] },

// git profile
{ id: "git_profile_area", label: "profile", group: "subarea", path: "git profile",
Expand Down Expand Up @@ -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"],
Expand Down
10 changes: 10 additions & 0 deletions docs/user-guide/sdk-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ npm install ./.celonis-sdk/<appId>.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 <appId> --dist <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.
12 changes: 12 additions & 0 deletions src/commands/sdk/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ class Module extends IModule {
.description("Download the latest Celonis SDK generated for a Codespace asset")
.requiredOption("--appId <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 <appId>", "Identifier of the app to publish the build for")
.requiredOption("--dist <dist>", "Directory containing the build of the app")
.action(this.publishApp);
}

private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SdkService(context).downloadSdk(options.appId);
}

private async publishApp(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SdkService(context).publishApp(options.appId, options.dist);
}
}

export = Module;
12 changes: 12 additions & 0 deletions src/commands/sdk/sdk-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,4 +21,12 @@ export class SdkApi {
throw new FatalError(e ? `${problem}: ${e}` : problem);
});
}

public async publishApp(appId: string, bundle: Buffer): Promise<SdkPublishResponse> {
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}`);
});
}
}
25 changes: 24 additions & 1 deletion src/commands/sdk/sdk.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,4 +26,26 @@ export class SdkService {

logger.info(`SDK downloaded successfully: ${sdkFile}`);
}

public async publishApp(appId: string, dist: string): Promise<void> {
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();
}
}
18 changes: 18 additions & 0 deletions src/core/http/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ export class HttpClient {
})
}

public async postBinary(url: string, body: Buffer, contentType: string): Promise<any> {
return new Promise<any>((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<any> {
return new Promise<any>((resolve, reject) => {
this.axios.put(
Expand Down
22 changes: 21 additions & 1 deletion tests/commands/sdk/module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ describe("SDK Module", () => {
expect(mockConfigurator.command).toHaveBeenCalledWith("sdk");
expect(mockConfigurator.command).toHaveBeenCalledWith("download");
expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId <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 <dist>", expect.any(String));
});

it("downloads the SDK of the given app when the download command runs", async () => {
Expand All @@ -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");
});
});
});
79 changes: 79 additions & 0 deletions tests/commands/sdk/sdk-publish.spec.ts
Original file line number Diff line number Diff line change
@@ -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"), "<html></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("<html></html>");
expect(publishedBundle().readAsText("assets/main.js")).toBe("console.log()");
expect(loggingTestTransport.logMessages[0].message).toContain("App published successfully as 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"`);
});
});
24 changes: 23 additions & 1 deletion tests/core/http/http-client.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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\"");
});
});
Loading