From 2dd15130ed55e00bd369a8143a9ef6b294867027 Mon Sep 17 00:00:00 2001
From: Philipp Koytek
Date: Thu, 15 Oct 2026 16:49:12 +0200
Subject: [PATCH] DISCO-68: Spike content-cli sdk token command
Adds `content-cli sdk token` to print the active profile's access token,
plus temporary env overrides for the device code client and scopes
(CONTENT_CLI_CLIENT_ID, CONTENT_CLI_SCOPES, CONTENT_CLI_EXTRA_SCOPES).
Co-Authored-By: Claude
Includes-AI-Code: true
---
src/commands/sdk/module.ts | 40 +++++++++++++++++++++++++++++
src/core/profile/profile.service.ts | 23 ++++++++++++++---
2 files changed, 60 insertions(+), 3 deletions(-)
create mode 100644 src/commands/sdk/module.ts
diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts
new file mode 100644
index 00000000..3fe0d022
--- /dev/null
+++ b/src/commands/sdk/module.ts
@@ -0,0 +1,40 @@
+/**
+ * Commands for building custom apps with the Celonis SDK.
+ */
+
+import { Command, OptionValues } from "commander";
+import { Configurator, IModule } from "../../core/command/module-handler";
+import { Context } from "../../core/command/cli-context";
+import { FatalError } from "../../core/utils/logger";
+
+class Module extends IModule {
+
+ public register(context: Context, configurator: Configurator): void {
+ const command = configurator.command("sdk")
+ .description("Commands for building custom apps with the Celonis SDK.");
+
+ command.command("token")
+ .description("Print an access token for the active profile, refreshing it if needed")
+ .option("--json", "Print team url, access token and expiry as JSON")
+ .action(this.token);
+ }
+
+ private async token(context: Context, command: Command, options: OptionValues): Promise {
+ const profile = context.profile;
+ if (!profile?.apiToken) {
+ throw new FatalError("No access token available. Run 'content-cli profile create' to log in.");
+ }
+ // Write to stdout directly so the output can be piped or captured by other tools.
+ if (options.json) {
+ process.stdout.write(JSON.stringify({
+ team: profile.team,
+ accessToken: profile.apiToken,
+ expiresAt: profile.expiresAt,
+ }) + "\n");
+ } else {
+ process.stdout.write(profile.apiToken + "\n");
+ }
+ }
+}
+
+export = Module;
diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts
index c1b01078..986239d2 100644
--- a/src/core/profile/profile.service.ts
+++ b/src/core/profile/profile.service.ts
@@ -214,11 +214,25 @@ export class ProfileService {
break;
case ProfileType.DEVICE_CODE:
const deviceCodeIssuer = await Issuer.discover(profile.team);
+ // Spike (DISCO-68): CONTENT_CLI_CLIENT_ID overrides the OAuth client; it is stored on the profile
+ // so that refreshes use the same client.
+ if (process.env.CONTENT_CLI_CLIENT_ID) {
+ profile.clientId = process.env.CONTENT_CLI_CLIENT_ID;
+ }
const deviceCodeOAuthClient = new deviceCodeIssuer.Client({
- client_id: "content-cli",
+ client_id: profile.clientId ?? "content-cli",
token_endpoint_auth_method: "none",
});
- const deviceCodeScopeAttempts: string[][] = [OAUTH_SCOPES, DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE];
+ // Spike (DISCO-68): CONTENT_CLI_EXTRA_SCOPES="a b" adds scopes, CONTENT_CLI_SCOPES="a b" replaces them.
+ // Either one makes a single attempt without fallback, so a rejected scope fails visibly
+ // instead of being silently dropped.
+ const parseScopes = (value?: string) => (value ?? "").split(/[\s,]+/).filter(Boolean);
+ const overrideScopes = parseScopes(process.env.CONTENT_CLI_SCOPES);
+ const extraScopes = parseScopes(process.env.CONTENT_CLI_EXTRA_SCOPES);
+ const scopesOverridden = overrideScopes.length > 0 || extraScopes.length > 0;
+ const deviceCodeScopeAttempts: string[][] = scopesOverridden
+ ? [[...(overrideScopes.length ? overrideScopes : OAUTH_SCOPES), ...extraScopes]]
+ : [OAUTH_SCOPES, DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE];
let deviceCodeSuccess = false;
for (const scopeList of deviceCodeScopeAttempts) {
try {
@@ -234,6 +248,9 @@ export class ProfileService {
break;
} catch (err) {
// This scope set failed; try next or fail below
+ if (scopesOverridden) {
+ logger.warn(`Device code authorization with scopes "${scopeList.join(" ")}" failed: ${err}`);
+ }
}
}
if (!deviceCodeSuccess) {
@@ -287,7 +304,7 @@ export class ProfileService {
const issuer = await Issuer.discover(profile.team);
if (profile.type === ProfileType.DEVICE_CODE) {
const oauthClient = new issuer.Client({
- client_id: "content-cli",
+ client_id: profile.clientId ?? "content-cli",
token_endpoint_auth_method: "none",
});
const tokenSet = await oauthClient.refresh(profile.refreshToken);