From a86279b21b1aca4a72d799923da0cc6b15091636 Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:57:35 +0200 Subject: [PATCH 1/2] feat: add sdk download command --- .github/CODEOWNERS | 2 + docs/command-graph.html | 11 +++- docs/user-guide/index.md | 1 + docs/user-guide/sdk-commands.md | 19 +++++++ mkdocs.yaml | 1 + src/commands/sdk/module.ts | 26 ++++++++++ src/commands/sdk/sdk-api.ts | 20 ++++++++ src/commands/sdk/sdk.service.ts | 28 ++++++++++ src/core/profile/profile.service.ts | 11 +++- tests/commands/sdk/sdk-download.spec.ts | 60 ++++++++++++++++++++++ tests/core/profile/profile.service.spec.ts | 60 ++++++++++++++++++++-- 11 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 docs/user-guide/sdk-commands.md create mode 100644 src/commands/sdk/module.ts create mode 100644 src/commands/sdk/sdk-api.ts create mode 100644 src/commands/sdk/sdk.service.ts create mode 100644 tests/commands/sdk/sdk-download.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c58e47f3..66439bfe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,5 +21,7 @@ /src/commands/data-pipeline/ @Dusan-r @IvanGandacov @EktaCelonis @gorasoCelonis /src/commands/studio/ @celonis/astro /tests/commands/studio/ @celonis/astro +/src/commands/sdk/ @celonis/discovery +/tests/commands/sdk/ @celonis/discovery /package.json @celonis/astro @aocelo @siavash-celonis /yarn.lock @celonis/astro @aocelo @siavash-celonis diff --git a/docs/command-graph.html b/docs/command-graph.html index 2645c540..a9f736cd 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -178,6 +178,8 @@ description: "set data pool connection", options: ["-h, --help"] }, { id: "area_deployment", label: "deployment (beta)", group: "area", path: "deployment", description: "Create deployments, list their history, check active deployments, and retrieve deployables and targets", options: ["-h, --help"] }, + { id: "area_sdk", label: "sdk (beta)", group: "area", path: "sdk", + description: "Implement custom apps for Codespace assets using the Celonis SDK", options: ["-h, --help"] }, { id: "area_git", label: "git", group: "area", path: "git", description: "Commands related to Git settings", options: ["-h, --help"] }, { id: "area_profile", label: "profile", group: "area", path: "profile", @@ -432,6 +434,11 @@ description: "List all targets for a given deployable type and package key", options: ["-p, --profile ", "--deployableType ", "--packageKey ", "--json", "-h, --help"] }, + // sdk + { 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"] }, + // git profile { id: "git_profile_area", label: "profile", group: "subarea", path: "git profile", description: "Manage Git profiles required to use git-related operations.", options: ["-p, --profile ", "-h, --help"] }, @@ -475,7 +482,7 @@ ["root","area_pull"],["root","area_push"],["root","area_asset_registry"],["root","area_config"], ["root","area_t2tc"], ["root","area_update"],["root","area_get"],["root","area_set"],["root","area_deployment"], - ["root","area_git"],["root","area_profile"], + ["root","area_sdk"],["root","area_git"],["root","area_profile"], ["area_list","list_assignments"],["area_list","list_data_pools"],["area_list","list_connection"], ["area_list","list_packages"],["area_list","list_spaces"],["area_list","list_assets"], @@ -533,6 +540,8 @@ ["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_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/index.md b/docs/user-guide/index.md index 5fb464b7..3bbec302 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -12,3 +12,4 @@ Content CLI organizes its commands into groups by area. Each group covers a spec | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | | [Action Flow Commands](./action-flow-commands.md) | Analyze and export/import Action Flows and their dependencies | | [Bookmark Commands](./bookmark-commands.md) | Export and import bookmarks for packages | +| [SDK Commands](./sdk-commands.md) | Implement custom apps for Codespace assets using the Celonis SDK | diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md new file mode 100644 index 00000000..9e5c3ff5 --- /dev/null +++ b/docs/user-guide/sdk-commands.md @@ -0,0 +1,19 @@ +# SDK Commands + +The **sdk** command group provides the commands to implement custom apps for Codespace assets using the Celonis SDK. + +## Download SDK + +Download the latest Celonis SDK generated for a Codespace asset into the hidden `.celonis-sdk` directory of the working directory. + +``` +content-cli sdk download --appId +``` + +The SDK is stored as an npm package tarball at `./.celonis-sdk/.tgz` and can be installed with: + +``` +npm install ./.celonis-sdk/.tgz +``` + +Running the command again replaces the previously downloaded SDK with the latest version. diff --git a/mkdocs.yaml b/mkdocs.yaml index 0f5d234c..b5352c00 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -20,6 +20,7 @@ nav: - Data Pool Commands: './user-guide/data-pool-commands.md' - Action Flow Commands: './user-guide/action-flow-commands.md' - Bookmark Commands: './user-guide/bookmark-commands.md' + - SDK Commands: './user-guide/sdk-commands.md' - Development: - Architecture: './internal-architecture.md' - How to Add a Command: './how-to-add-command.md' diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts new file mode 100644 index 00000000..056dac5e --- /dev/null +++ b/src/commands/sdk/module.ts @@ -0,0 +1,26 @@ +import { Configurator, IModule } from "../../core/command/module-handler"; +import { Context } from "../../core/command/cli-context"; +import { Command, OptionValues } from "commander"; +import { SdkService } from "./sdk.service"; + +class Module extends IModule { + public register(context: Context, configurator: Configurator): void { + const sdkCommand = configurator + .command("sdk") + .beta() + .description("Implement custom apps for Codespace assets using the Celonis SDK"); + + sdkCommand + .command("download") + .beta() + .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); + } + + private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise { + await new SdkService(context).downloadSdk(options.appId); + } +} + +export = Module; diff --git a/src/commands/sdk/sdk-api.ts b/src/commands/sdk/sdk-api.ts new file mode 100644 index 00000000..2e541a3c --- /dev/null +++ b/src/commands/sdk/sdk-api.ts @@ -0,0 +1,20 @@ +import { HttpClient } from "../../core/http/http-client"; +import { Context } from "../../core/command/cli-context"; +import { FatalError } from "../../core/utils/logger"; + +export class SdkApi { + private httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async downloadSdk(appId: string): Promise { + return this.httpClient() + .getFile(`/pro-code-app-manager/api/npm/@celonis-sdk/${appId}/-/${appId}-latest.tgz`) + .catch((e) => { + const problem = `Problem downloading SDK for app ${appId}`; + throw new FatalError(e ? `${problem}: ${e}` : problem); + }); + } +} diff --git a/src/commands/sdk/sdk.service.ts b/src/commands/sdk/sdk.service.ts new file mode 100644 index 00000000..6ef7d8cd --- /dev/null +++ b/src/commands/sdk/sdk.service.ts @@ -0,0 +1,28 @@ +import * as fs from "fs"; +import * as path from "path"; +import { SdkApi } from "./sdk-api"; +import { Context } from "../../core/command/cli-context"; +import { logger } from "../../core/utils/logger"; + +export class SdkService { + public static readonly SDK_DIRECTORY = ".celonis-sdk"; + + private sdkApi: SdkApi; + + constructor(context: Context) { + this.sdkApi = new SdkApi(context); + } + + public async downloadSdk(appId: string): Promise { + const sdk = await this.sdkApi.downloadSdk(appId); + + const sdkDirectory = path.resolve(process.cwd(), SdkService.SDK_DIRECTORY); + fs.rmSync(sdkDirectory, { recursive: true, force: true }); + fs.mkdirSync(sdkDirectory, { recursive: true }); + + const sdkFile = path.join(SdkService.SDK_DIRECTORY, `${appId}.tgz`); + fs.writeFileSync(path.resolve(process.cwd(), sdkFile), sdk); + + logger.info(`SDK downloaded successfully: ${sdkFile}`); + } +} diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index c1b01078..21114f31 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -19,6 +19,9 @@ const expiryBuffer = 5000; const OAUTH_SCOPES = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"]; /** Device code fallback: try without action-engine.projects if all 4 scopes fail. */ const DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE = ["studio", "package-manager", "integration.data-pools"]; +/** First attempt for both auth flows: all scopes plus the pro-code-app-manager scopes, which are not yet available on every environment. */ +const OAUTH_SCOPES_WITH_PRO_CODE = [...OAUTH_SCOPES, "pro-code-app-manager.sdk:read", "pro-code-app-manager.publish"]; + export interface Config { defaultProfile: string; } @@ -218,7 +221,11 @@ export class ProfileService { client_id: "content-cli", token_endpoint_auth_method: "none", }); - const deviceCodeScopeAttempts: string[][] = [OAUTH_SCOPES, DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE]; + const deviceCodeScopeAttempts: string[][] = [ + OAUTH_SCOPES_WITH_PRO_CODE, + OAUTH_SCOPES, + DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE, + ]; let deviceCodeSuccess = false; for (const scopeList of deviceCodeScopeAttempts) { try { @@ -427,7 +434,7 @@ export class ProfileService { token_endpoint_auth_method: tokenEndpointAuthMethod, }); - const scopeAttempts = this.getScopeCombinationsOrderedBySize(OAUTH_SCOPES); + const scopeAttempts = [OAUTH_SCOPES_WITH_PRO_CODE, ...this.getScopeCombinationsOrderedBySize(OAUTH_SCOPES)]; for (const scopeList of scopeAttempts) { try { diff --git a/tests/commands/sdk/sdk-download.spec.ts b/tests/commands/sdk/sdk-download.spec.ts new file mode 100644 index 00000000..60124169 --- /dev/null +++ b/tests/commands/sdk/sdk-download.spec.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { Readable } from "stream"; +import { mockAxiosGet, mockedAxiosInstance } 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 { rmTempDir } from "../../utls/fs-utils"; + +describe("SDK download", () => { + const appId = "app-id"; + const sdkUrl = `https://myTeam.celonis.cloud/pro-code-app-manager/api/npm/@celonis-sdk/${appId}/-/${appId}-latest.tgz`; + const previousSdkFile = "previous-app-id.tgz"; + + let sdkDirectory: string; + + function mockFailedDownload(...body: string[]): void { + (mockedAxiosInstance.get as jest.Mock).mockResolvedValueOnce({ + status: 404, + data: Readable.from(body.map(chunk => Buffer.from(chunk))), + }); + } + + beforeEach(() => { + sdkDirectory = resolve(process.cwd(), ".celonis-sdk"); + mkdirSync(sdkDirectory, { recursive: true }); + writeFileSync(join(sdkDirectory, previousSdkFile), "previous-sdk"); + }); + + afterEach(() => { + rmTempDir(sdkDirectory); + }); + + it("Should replace the SDK directory with the downloaded SDK", async () => { + const sdk = Buffer.from("sdk-tarball"); + mockAxiosGet(sdkUrl, sdk); + + await new SdkService(testContext).downloadSdk(appId); + + expect(readFileSync(join(sdkDirectory, `${appId}.tgz`))).toEqual(sdk); + expect(existsSync(join(sdkDirectory, previousSdkFile))).toBe(false); + expect(loggingTestTransport.logMessages[0].message).toContain(`SDK downloaded successfully: ${join(".celonis-sdk", `${appId}.tgz`)}`); + }); + + it("Should keep the existing SDK when the download fails", async () => { + mockFailedDownload("Custom app 'app-id' not found"); + + await expect(new SdkService(testContext).downloadSdk(appId)) + .rejects.toThrow(`Problem downloading SDK for app ${appId}: Custom app 'app-id' not found`); + + expect(existsSync(join(sdkDirectory, previousSdkFile))).toBe(true); + }); + + it("Should omit the error details when the failed download has an empty response body", async () => { + mockFailedDownload(); + + await expect(new SdkService(testContext).downloadSdk(appId)) + .rejects.toThrow(new RegExp(`^Problem downloading SDK for app ${appId}$`)); + }); +}); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index 73003b45..2ab4a750 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -755,7 +755,9 @@ describe("ProfileService - tryClientCredentialsGrant", () => { expires_at: Math.floor(Date.now() / 1000) + 3600, scope: "studio package-manager", }; - const mockGrant = jest.fn().mockResolvedValue(tokenSet); + const mockGrant = jest.fn() + .mockRejectedValueOnce(new Error("invalid_scope")) + .mockResolvedValue(tokenSet); const mockIssuer = { Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), }; @@ -787,6 +789,7 @@ describe("ProfileService - tryClientCredentialsGrant", () => { scope: "studio", }; const mockGrant = jest.fn() + .mockRejectedValueOnce(new Error("invalid_scope")) .mockRejectedValueOnce(new Error("invalid_scope")) .mockResolvedValueOnce(tokenSet); const mockIssuer = { @@ -806,7 +809,7 @@ describe("ProfileService - tryClientCredentialsGrant", () => { expect(result).not.toBeNull(); expect(result!.tokenSet.access_token).toBe("token-2"); expect(result!.scopes).toEqual(["studio"]); - expect(mockGrant).toHaveBeenCalledTimes(2); + expect(mockGrant).toHaveBeenCalledTimes(3); }); it("should return null when all scope combinations fail", async () => { @@ -826,7 +829,7 @@ describe("ProfileService - tryClientCredentialsGrant", () => { ); expect(result).toBeNull(); - expect(mockGrant).toHaveBeenCalledTimes(2); + expect(mockGrant).toHaveBeenCalledTimes(3); }); it("should use fallback scopes when token response has no scope field", async () => { @@ -834,7 +837,9 @@ describe("ProfileService - tryClientCredentialsGrant", () => { access_token: "token-no-scope", expires_at: Math.floor(Date.now() / 1000) + 3600, }; - const mockGrant = jest.fn().mockResolvedValue(tokenSet); + const mockGrant = jest.fn() + .mockRejectedValueOnce(new Error("invalid_scope")) + .mockResolvedValue(tokenSet); const mockIssuer = { Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), }; @@ -849,6 +854,30 @@ describe("ProfileService - tryClientCredentialsGrant", () => { expect(result).not.toBeNull(); expect(result!.scopes).toEqual(["studio"]); }); + + it("should request pro-code-app-manager scopes before the scope combinations", async () => { + const tokenSet = { + access_token: "token-pro-code", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + const mockGrant = jest.fn().mockResolvedValue(tokenSet); + const mockIssuer = { + Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), + }; + + const result = await (profileService as any).tryClientCredentialsGrant( + mockIssuer, + mockProfile, + "client_secret_basic" + ); + + expect(result).not.toBeNull(); + expect(result!.scopes).toEqual([ + "studio", "package-manager", "integration.data-pools", "action-engine.projects", + "pro-code-app-manager.sdk:read", "pro-code-app-manager.publish", + ]); + expect(mockGrant).toHaveBeenCalledTimes(1); + }); }); describe("ProfileService - authorizeProfile (device code)", () => { @@ -866,7 +895,7 @@ describe("ProfileService - authorizeProfile (device code)", () => { }); }); - it("should throw FatalError when both scope attempts fail", async () => { + it("should throw FatalError when all scope attempts fail", async () => { mockClientInstance.deviceAuthorization.mockRejectedValue(new Error("invalid_scope")); const profile: Profile = { @@ -905,6 +934,27 @@ describe("ProfileService - authorizeProfile (device code)", () => { expect(profile.refreshToken).toBe("refresh-456"); expect(profile.expiresAt).toBe(tokenSet.expires_at); }); + + it("should request pro-code-app-manager scopes first and fall back without them", async () => { + mockClientInstance.deviceAuthorization.mockRejectedValue(new Error("invalid_scope")); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.DEVICE_CODE, + }; + + await expect(profileService.authorizeProfile(profile)).rejects.toThrow("Device code authorization failed"); + + expect(mockClientInstance.deviceAuthorization.mock.calls.map(([params]) => params.scope)).toEqual([ + "studio package-manager integration.data-pools action-engine.projects " + + "pro-code-app-manager.sdk:read pro-code-app-manager.publish", + "studio package-manager integration.data-pools action-engine.projects", + "studio package-manager integration.data-pools", + ]); + }); }); describe("ProfileService - authorizeProfile (client credentials)", () => { From 6a92e2a9d6aaeadfb5ff915ee3bca239163be80e Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:14:15 +0200 Subject: [PATCH 2/2] fix: resolve sonar issues --- src/commands/sdk/sdk-api.ts | 2 +- src/commands/sdk/sdk.service.ts | 6 +++--- src/core/profile/profile.service.ts | 3 ++- tests/commands/sdk/module.spec.ts | 31 +++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 tests/commands/sdk/module.spec.ts diff --git a/src/commands/sdk/sdk-api.ts b/src/commands/sdk/sdk-api.ts index 2e541a3c..3de2a7ec 100644 --- a/src/commands/sdk/sdk-api.ts +++ b/src/commands/sdk/sdk-api.ts @@ -3,7 +3,7 @@ import { Context } from "../../core/command/cli-context"; import { FatalError } from "../../core/utils/logger"; export class SdkApi { - private httpClient: () => HttpClient; + private readonly httpClient: () => HttpClient; constructor(context: Context) { this.httpClient = () => context.httpClient; diff --git a/src/commands/sdk/sdk.service.ts b/src/commands/sdk/sdk.service.ts index 6ef7d8cd..1b4418a2 100644 --- a/src/commands/sdk/sdk.service.ts +++ b/src/commands/sdk/sdk.service.ts @@ -1,5 +1,5 @@ -import * as fs from "fs"; -import * as path from "path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { SdkApi } from "./sdk-api"; import { Context } from "../../core/command/cli-context"; import { logger } from "../../core/utils/logger"; @@ -7,7 +7,7 @@ import { logger } from "../../core/utils/logger"; export class SdkService { public static readonly SDK_DIRECTORY = ".celonis-sdk"; - private sdkApi: SdkApi; + private readonly sdkApi: SdkApi; constructor(context: Context) { this.sdkApi = new SdkApi(context); diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 21114f31..225c78f4 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -215,7 +215,7 @@ export class ProfileService { } } break; - case ProfileType.DEVICE_CODE: + case ProfileType.DEVICE_CODE: { const deviceCodeIssuer = await Issuer.discover(profile.team); const deviceCodeOAuthClient = new deviceCodeIssuer.Client({ client_id: "content-cli", @@ -249,6 +249,7 @@ export class ProfileService { ); } break; + } case ProfileType.CLIENT_CREDENTIALS: const clientCredentialsIssuer = await Issuer.discover(profile.team); const basicResult = await this.tryClientCredentialsGrant( diff --git a/tests/commands/sdk/module.spec.ts b/tests/commands/sdk/module.spec.ts new file mode 100644 index 00000000..cfa7e9fb --- /dev/null +++ b/tests/commands/sdk/module.spec.ts @@ -0,0 +1,31 @@ +import Module = require("../../../src/commands/sdk/module"); +import { Command } from "commander"; +import { testContext } from "../../utls/test-context"; +import { createMockConfigurator } from "../../utls/configurator-mock"; +import { SdkService } from "../../../src/commands/sdk/sdk.service"; + +describe("SDK Module", () => { + describe("register", () => { + it("registers the sdk download command as beta", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + expect(mockConfigurator.command).toHaveBeenCalledWith("sdk"); + expect(mockConfigurator.command).toHaveBeenCalledWith("download"); + expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId ", expect.any(String)); + expect(mockConfigurator.beta).toHaveBeenCalledTimes(2); + }); + + it("downloads the SDK of the given app when the download command runs", async () => { + const downloadSdk = jest.spyOn(SdkService.prototype, "downloadSdk").mockResolvedValue(); + const mockConfigurator = createMockConfigurator(); + new Module().register(testContext, mockConfigurator); + + const downloadAction = mockConfigurator.action.mock.calls[0][0]; + await downloadAction(testContext, new Command(), { appId: "app-id" }); + + expect(downloadSdk).toHaveBeenCalledWith("app-id"); + }); + }); +});