diff --git a/packages/cli/src/commands/applications/add.ts b/packages/cli/src/commands/applications/add.ts index a896ed673..75a55048b 100644 --- a/packages/cli/src/commands/applications/add.ts +++ b/packages/cli/src/commands/applications/add.ts @@ -57,12 +57,14 @@ export default class ApplicationsAdd extends Command { clientSecret: flags.clientSecret, slug: flags.organization, domain: flags.domain, + apiVersion: flags['api-version'], scope, email: flags.email, password: flags.password } if (config.domain === configParam(ConfigParams.defaultDomain)) config.domain = undefined + if (!config.apiVersion || (config.apiVersion === configParam(ConfigParams.defaultApiVersion))) config.apiVersion = undefined try { diff --git a/packages/cli/src/commands/applications/index.ts b/packages/cli/src/commands/applications/index.ts index e1c5ae660..3b61bafd2 100644 --- a/packages/cli/src/commands/applications/index.ts +++ b/packages/cli/src/commands/applications/index.ts @@ -85,6 +85,7 @@ const extraColumns = (flags: any): any => { extra.id = { header: 'ID', get: (row: { id: any }) => clColor.dim(row.id || '') } extra.appkey = { header: 'APPKEY', get: (row: { key: any }) => clColor.dim(row.key || '') } extra.domain = { header: 'DOMAIN', get: (row: { domain: any }) => clColor.dim(row.domain || '') } + extra.apiVersion = { header: 'API VERSION', get: (row: { apiVersion: any }) => clColor.dim(row.apiVersion || '') } } return extra } diff --git a/packages/cli/src/commands/applications/login.ts b/packages/cli/src/commands/applications/login.ts index 0263e3287..3ae1b6241 100644 --- a/packages/cli/src/commands/applications/login.ts +++ b/packages/cli/src/commands/applications/login.ts @@ -2,6 +2,7 @@ import { inspect } from 'node:util' import type { ApiMode, AppAuth, AppInfo, AuthScope } from '@commercelayer/cli-core' import { clApi, clApplication, clColor, clCommand, clConfig, clToken } from '@commercelayer/cli-core' import clprovisioning from '@commercelayer/provisioning-sdk' +import type { ApiVersion } from '@commercelayer/sdk' import commercelayer, { type Application, CommerceLayerStatic, type Organization } from '@commercelayer/sdk' import type { Interfaces } from '@oclif/core' import { Command, type Config, Errors, Flags } from '@oclif/core' @@ -38,6 +39,11 @@ export default class ApplicationsLogin extends Command { required: false, hidden: true }), + 'api-version': Flags.string({ + description: 'Core API version (default: the CLI default), saved with the application', + required: false, + hidden: true + }), clientId: Flags.string({ name: 'clientId', char: 'i', @@ -111,12 +117,14 @@ export default class ApplicationsLogin extends Command { clientSecret: flags.clientSecret, slug: flags.organization, domain: flags.domain, + apiVersion: flags['api-version'], scope, email: flags.email, password: flags.password } if (config.domain === configParam(ConfigParams.defaultDomain)) config.domain = undefined + if (!config.apiVersion || (config.apiVersion === configParam(ConfigParams.defaultApiVersion))) config.apiVersion = undefined try { @@ -180,14 +188,15 @@ const getApplicationInfo = async (auth: AppAuth, accessToken: string): Promise, app: Partial, user: any if (provisioning) { - const clp = clprovisioning({ domain: auth.domain, accessToken }) + // Unversioned requests (/api/…): provisioning-sdk 3 types require 2026-05, the SDK omits the segment when undefined + const clp = clprovisioning({ apiVersion: undefined as unknown as Parameters[0]['apiVersion'], domain: auth.domain, accessToken }) // User info const usr = await clp.user.retrieve().catch(() => { error(clp.user.type()) }) if (usr) user = { name: `${usr.first_name}${(usr.first_name && usr.last_name)? ' ' : ''}${usr.last_name}`, email: usr.email } org = { slug: 'provisioning', name: user?.name || 'Provisioning API' } app = { name: 'Provisioning App' } } else { // core - const cl = commercelayer({ organization: auth.slug || '', domain: auth.domain, accessToken }) + const cl = commercelayer({ apiVersion: clApi.apiVersion({ 'api-version': auth.apiVersion }) as ApiVersion, organization: auth.slug || '', domain: auth.domain, accessToken }) // Organization info org = await cl.organization.retrieve().catch(() => { error(cl.organization.type()) }) // Application info diff --git a/packages/cli/src/commands/applications/scope.ts b/packages/cli/src/commands/applications/scope.ts index 62ee8494e..6df943aea 100644 --- a/packages/cli/src/commands/applications/scope.ts +++ b/packages/cli/src/commands/applications/scope.ts @@ -59,6 +59,7 @@ export default class ApplicationsScope extends Command { const argv: string[] = [] if (current.slug) argv.push('-o', current.slug) if (current.domain) argv.push('-d', current.domain) + if (current.apiVersion) argv.push('--api-version', current.apiVersion) let appInfo: AppInfo diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 54304c0d1..2a2d64e8d 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -139,6 +139,7 @@ const currentApplication = (app?: AppInfo): AppInfo | undefined => { name: app.name, slug: app.slug, domain: app.domain, + apiVersion: app.apiVersion, kind: app.kind, mode: app.mode, organization: app.organization, @@ -191,6 +192,7 @@ enum ConfigParams { applicationTypeCheck = 'applicationTypeCheck', scopeCheck = 'scopeCheck', defaultDomain = 'defaultDomain', + defaultApiVersion = 'defaultApiVersion', test = 'test' } @@ -203,6 +205,7 @@ const defaultConfig: any = { test: 'defaultTestValue', commandRetention: 30, // days of retention defaultDomain: clConfig.api.default_domain, + defaultApiVersion: clConfig.api.default_api_version, applicationTypeCheck: clConfig.cli.applications, scopeCheck: clConfig.application.login_scopes } diff --git a/packages/cli/src/hooks/prerun/login.ts b/packages/cli/src/hooks/prerun/login.ts index 8654f6c3f..52d43583c 100644 --- a/packages/cli/src/hooks/prerun/login.ts +++ b/packages/cli/src/hooks/prerun/login.ts @@ -81,6 +81,7 @@ const hook: Hook<'prerun'> = async function (opts) { // Add to command line args application info read from config file if (_flags.organization && configData.slug) opts.argv.splice(ffIdx, 0, '--organization=' + configData.slug) if (_flags.domain && configData.domain) opts.argv.splice(ffIdx, 0, '--domain=' + configData.domain) + if (_flags['api-version'] && configData.apiVersion) opts.argv.splice(ffIdx, 0, '--api-version=' + configData.apiVersion) // If command requires clientId and clientSecret (or scope) add them to the command line arguments if (_flags.clientId && configData.clientId) opts.argv.splice(ffIdx, 0, '--clientId=' + configData.clientId) diff --git a/packages/cli/src/patches/command.ts b/packages/cli/src/patches/command.ts index 032bbbd65..44519c4ae 100644 --- a/packages/cli/src/patches/command.ts +++ b/packages/cli/src/patches/command.ts @@ -1,6 +1,9 @@ import { clText } from "@commercelayer/cli-core" -import { CommerceLayerProvisioningStatic } from "@commercelayer/provisioning-sdk" + +// The CommonJS declarations of the provisioning-sdk preview hide its named exports +// behind export=: the value comes from require(), typed with the ESM declarations +const { CommerceLayerProvisioningStatic } = require('@commercelayer/provisioning-sdk') as typeof import('@commercelayer/provisioning-sdk', { with: { 'resolution-mode': 'import' } }) const PROVISIONING = true diff --git a/packages/cli/test/hooks/prerun/application-flags.test.ts b/packages/cli/test/hooks/prerun/application-flags.test.ts new file mode 100644 index 000000000..37dad6dfb --- /dev/null +++ b/packages/cli/test/hooks/prerun/application-flags.test.ts @@ -0,0 +1,58 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect } from 'chai' +import hook from '../../../src/hooks/prerun/login' + +/** + * The application settings saved at login reach the commands as flags: the + * domain and the Core API version are added to the command line, unless the + * command has no such flag. Runs on a temporary config folder, with the + * application passed by --appkey: the saved current application is not used. + */ +describe('prerun hook: saved application flags', () => { + let configDir: string + const key = 'testAppKey' + + const save = (app: Record) => { + const apps = join(configDir, 'applications') + mkdirSync(apps, { recursive: true }) + writeFileSync(join(apps, `${key}.config.json`), JSON.stringify({ key, mode: 'test', kind: 'integration', slug: 'cli-test-org', ...app })) + writeFileSync(join(apps, `${key}.token.json`), JSON.stringify({ accessToken: 'token', expires: new Date(Date.now() + 3_600_000).toISOString() })) + } + + const run = async (flags: string[]): Promise => { + const config = { configDir } as any + const Command = { id: 'resources:list', flags: Object.fromEntries(flags.map((f) => [f, {}])), baseFlags: {} } as any + const argv = ['skus', `--appkey=${key}`] + await hook.call({ config, error: (msg: string) => { throw new Error(msg) }, log: () => {} } as any, { Command, argv, config } as any) + return argv + } + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'cl-cli-prerun-')) + }) + afterEach(() => rmSync(configDir, { recursive: true, force: true })) + + it('adds the saved domain and API version', async () => { + save({ domain: 'commercelayer.co', apiVersion: '2026-05' }) + const argv = await run(['organization', 'domain', 'api-version', 'accessToken']) + expect(argv).to.include('--organization=cli-test-org') + expect(argv).to.include('--domain=commercelayer.co') + expect(argv).to.include('--api-version=2026-05') + expect(argv).to.include('--accessToken=token') + }) + + it('adds no API version when none is saved, so the default or CL_CLI_API_VERSION apply', async () => { + save({}) + const argv = await run(['organization', 'domain', 'api-version', 'accessToken']) + expect(argv.some((a) => a.startsWith('--api-version'))).to.equal(false) + expect(argv.some((a) => a.startsWith('--domain'))).to.equal(false) + }) + + it('adds no API version to a command without the flag', async () => { + save({ apiVersion: '2026-05' }) + const argv = await run(['organization', 'accessToken']) + expect(argv.some((a) => a.startsWith('--api-version'))).to.equal(false) + }) +}) diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 148b62f2f..d965d7e06 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -25,6 +25,17 @@ const extractDomain = (baseUrl: string): string | undefined => { } +/** + * Core API version of the requests, as the domain is for their URL: the + * --api-version flag (saved with the application at login, or set by + * CL_CLI_API_VERSION), otherwise the default version (config.api.default_api_version). + * Undefined means unversioned requests (/api/…). + */ +const apiVersion = (flags?: Record): string | undefined => { + return flags?.['api-version'] || process.env.CL_CLI_API_VERSION || config.api.default_api_version || undefined +} + + /** Decode API execution mode */ const execMode = (liveFlag: string | boolean | undefined): ApiMode => { return ((liveFlag === true) || (liveFlag === 'live')) ? 'live' : 'test' @@ -54,7 +65,7 @@ const isRecordCountEstimated = (meta?: { recordCount?: number; recordCountEstima } -export { baseURL, execMode, extractDomain, humanizeResource, isRecordCountEstimated, RECORD_COUNT_EXACT_MAX } +export { apiVersion, baseURL, execMode, extractDomain, humanizeResource, isRecordCountEstimated, RECORD_COUNT_EXACT_MAX } const CACHEABLE_RESOURCES = [ diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index db9101022..76b62ab6a 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -15,6 +15,8 @@ interface AppKey { interface AppAuth { slug?: string domain?: string + /** Core API version of the requests (see clApi.apiVersion), unversioned when missing */ + apiVersion?: string clientId: string clientSecret?: string scope?: AuthScope diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 3248d6ecf..10a3ae92d 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import type { Command, Interfaces } from "@oclif/core" +import { type Command, Flags, type Interfaces } from "@oclif/core" type FlagInput = Interfaces.FlagInput @@ -16,6 +16,18 @@ export const commandFlags = (flags: T, exclude?: Array