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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion docs/command-graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -432,6 +434,11 @@
description: "List all targets for a given deployable type and package key",
options: ["-p, --profile <profile>", "--deployableType <deployableType>", "--packageKey <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 <profile>", "--appId <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 <profile>", "-h, --help"] },
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],

Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
19 changes: 19 additions & 0 deletions docs/user-guide/sdk-commands.md
Original file line number Diff line number Diff line change
@@ -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 <appId>
```

The SDK is stored as an npm package tarball at `./.celonis-sdk/<appId>.tgz` and can be installed with:

```
npm install ./.celonis-sdk/<appId>.tgz
```

Running the command again replaces the previously downloaded SDK with the latest version.
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
26 changes: 26 additions & 0 deletions src/commands/sdk/module.ts
Original file line number Diff line number Diff line change
@@ -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 <appId>", "Identifier of the app to download the SDK for")
.action(this.downloadSdk);
}

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

export = Module;
20 changes: 20 additions & 0 deletions src/commands/sdk/sdk-api.ts
Original file line number Diff line number Diff line change
@@ -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 readonly httpClient: () => HttpClient;

constructor(context: Context) {
this.httpClient = () => context.httpClient;
}

public async downloadSdk(appId: string): Promise<Buffer> {
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);
});
}
}
28 changes: 28 additions & 0 deletions src/commands/sdk/sdk.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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";

export class SdkService {
public static readonly SDK_DIRECTORY = ".celonis-sdk";

private readonly sdkApi: SdkApi;

constructor(context: Context) {
this.sdkApi = new SdkApi(context);
}

public async downloadSdk(appId: string): Promise<void> {
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}`);
}
}
14 changes: 11 additions & 3 deletions src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -212,13 +215,17 @@ 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",
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 {
Expand All @@ -242,6 +249,7 @@ export class ProfileService {
);
}
break;
}
case ProfileType.CLIENT_CREDENTIALS:
const clientCredentialsIssuer = await Issuer.discover(profile.team);
const basicResult = await this.tryClientCredentialsGrant(
Expand Down Expand Up @@ -427,7 +435,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 {
Expand Down
31 changes: 31 additions & 0 deletions tests/commands/sdk/module.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <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");
});
});
});
60 changes: 60 additions & 0 deletions tests/commands/sdk/sdk-download.spec.ts
Original file line number Diff line number Diff line change
@@ -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}$`));
});
});
Loading
Loading