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);