diff --git a/.talismanrc b/.talismanrc index 101aad31d7..bdaedb6eb0 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,6 @@ fileignoreconfig: - filename: pnpm-lock.yaml - checksum: 4bad5f9428f5bc7ed837c91567b28afc97acefa7086eb03d2ffd90b4a2820233 + checksum: 117b66122d3f3043f48724ff9755ec2d4a9dff3c156ceedef753df3014cafee6 + - filename: packages/contentstack/src/hooks/init/opt-in-plugin-guide.ts + checksum: 2b079a93988e2439a03c401f75695fcdbfeb138a75c84707e4bc892536680e9f version: '1.0' diff --git a/packages/contentstack-command/package.json b/packages/contentstack-command/package.json index 09a161b5a3..5281abbd55 100644 --- a/packages/contentstack-command/package.json +++ b/packages/contentstack-command/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-command", "description": "Contentstack CLI plugin for configuration", - "version": "2.0.0-beta.11", + "version": "2.0.0-beta.12", "author": "Contentstack", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -66,4 +66,4 @@ "url": "git+https://github.com/contentstack/cli.git", "directory": "packages/contentstack-command" } -} +} \ No newline at end of file diff --git a/packages/contentstack-command/src/index.ts b/packages/contentstack-command/src/index.ts index 35d6cc6181..afc22781ad 100644 --- a/packages/contentstack-command/src/index.ts +++ b/packages/contentstack-command/src/index.ts @@ -14,7 +14,7 @@ abstract class ContentstackCommand extends Command { get context() { // @ts-ignore - return this.config.context || {}; + return this.config.context || this.config.options?.context || {}; } get email() { diff --git a/packages/contentstack-config/package.json b/packages/contentstack-config/package.json index 88fdfac5ab..48d9db7765 100644 --- a/packages/contentstack-config/package.json +++ b/packages/contentstack-config/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-config", "description": "Contentstack CLI plugin for configuration", - "version": "2.0.0-beta.15", + "version": "2.0.0-beta.16", "author": "Contentstack", "scripts": { "build": "pnpm compile && oclif manifest && oclif readme", @@ -95,4 +95,4 @@ "url": "git+https://github.com/contentstack/cli.git", "directory": "packages/contentstack-config" } -} +} \ No newline at end of file diff --git a/packages/contentstack-config/src/commands/config/set/region.ts b/packages/contentstack-config/src/commands/config/set/region.ts index 8912c524d2..26f7afcfc6 100644 --- a/packages/contentstack-config/src/commands/config/set/region.ts +++ b/packages/contentstack-config/src/commands/config/set/region.ts @@ -48,6 +48,9 @@ export default class RegionSetCommand extends BaseCommand; } export interface Limit { diff --git a/packages/contentstack-config/src/utils/region-handler.ts b/packages/contentstack-config/src/utils/region-handler.ts index 88ed819598..817c65dc34 100644 --- a/packages/contentstack-config/src/utils/region-handler.ts +++ b/packages/contentstack-config/src/utils/region-handler.ts @@ -1,5 +1,4 @@ -import { configHandler } from '@contentstack/cli-utilities'; -import { getContentstackEndpoint } from '@contentstack/utils'; +import { configHandler, resolveCanonicalEndpoints, buildRegionFromEndpoints } from '@contentstack/cli-utilities'; import { Region, RegionsMap } from '../interfaces'; function validURL(str) { @@ -23,28 +22,10 @@ function validURL(str) { * @returns {object} Region object with all necessary URLs */ function getRegionObject(regionKey: string): Region { - try { - // getContentstackEndpoint handles all aliases defined in regions.json - const endpoints = getContentstackEndpoint(regionKey) as any; - - if (typeof endpoints === 'string') { - throw new Error('Invalid endpoint response'); - } - - return { - name: regionKey, - cma: endpoints.contentManagement, - cda: endpoints.contentDelivery, - uiHost: endpoints.application, - developerHubUrl: endpoints.developerHub, - launchHubUrl: endpoints.launch, - personalizeUrl: endpoints.personalizeManagement, - composableStudioUrl: endpoints.composableStudio, - csAssetsUrl: endpoints.assetManagement, - }; - } catch { - return null; - } + // resolveCanonicalEndpoints handles all aliases defined in regions.json + const endpoints = resolveCanonicalEndpoints(regionKey); + if (!endpoints) return null; + return buildRegionFromEndpoints(regionKey, endpoints) as Region; } /** @@ -147,19 +128,23 @@ class UserConfig { * @returns { object } JSON object with only valid keys for region */ sanitizeRegionObject(regionObject) { - const sanitizedRegion = { - cma: regionObject.cma, - cda: regionObject.cda, - uiHost: regionObject.uiHost, - name: regionObject.name, - developerHubUrl: regionObject['developerHubUrl'], - personalizeUrl: regionObject['personalizeUrl'], - launchHubUrl: regionObject['launchHubUrl'], - composableStudioUrl: regionObject['composableStudioUrl'], - csAssetsUrl: regionObject['csAssetsUrl'], + // endpoints is the single source of truth — every friendly field (cma, cda, ..., + // auth, csAssetsUrl) is derived from it below via buildRegionFromEndpoints, same + // as named regions. Falls back to synthesizing endpoints from the individual raw + // fields when the caller didn't supply one directly. + const endpoints = regionObject['endpoints'] ?? { + contentManagement: regionObject.cma, + contentDelivery: regionObject.cda, + application: regionObject.uiHost, + developerHub: regionObject['developerHubUrl'], + launch: regionObject['launchHubUrl'], + personalizeManagement: regionObject['personalizeUrl'], + composableStudio: regionObject['composableStudioUrl'], + assetManagement: regionObject['csAssetsUrl'], + auth: regionObject['authUrl'], }; - return sanitizedRegion; + return buildRegionFromEndpoints(regionObject.name, endpoints); } } diff --git a/packages/contentstack-config/test/unit/commands/region.test.ts b/packages/contentstack-config/test/unit/commands/region.test.ts index feeb481cdf..e3e6dc0836 100644 --- a/packages/contentstack-config/test/unit/commands/region.test.ts +++ b/packages/contentstack-config/test/unit/commands/region.test.ts @@ -313,7 +313,87 @@ describe('Region command', function () { csAssetsUrl: 'https://custom-asset-management.com', }; const result = UserConfig.setCustomRegion(customRegion); - expect(result).to.deep.equal(customRegion); + expect(result.cma).to.equal(customRegion.cma); + expect(result.cda).to.equal(customRegion.cda); + expect(result.uiHost).to.equal(customRegion.uiHost); + expect(result.name).to.equal(customRegion.name); + expect(result.developerHubUrl).to.equal(customRegion.developerHubUrl); + expect(result.personalizeUrl).to.equal(customRegion.personalizeUrl); + expect(result.launchHubUrl).to.equal(customRegion.launchHubUrl); + expect(result.composableStudioUrl).to.equal(customRegion.composableStudioUrl); + }); + + it('should include a matching endpoints object for a custom region without an auth host', function () { + const customRegion = { + cma: 'https://custom-cma.com', + cda: 'https://custom-cda.com', + uiHost: 'https://custom-ui.com', + name: 'Custom Region', + developerHubUrl: 'https://custom-developer-hub.com', + personalizeUrl: 'https://custom-personalize.com', + launchHubUrl: 'https://custom-launch.com', + composableStudioUrl: 'https://custom-composable-studio.com', + }; + const result = UserConfig.setCustomRegion(customRegion); + expect(result.endpoints).to.deep.equal({ + contentManagement: customRegion.cma, + contentDelivery: customRegion.cda, + application: customRegion.uiHost, + developerHub: customRegion.developerHubUrl, + launch: customRegion.launchHubUrl, + personalizeManagement: customRegion.personalizeUrl, + composableStudio: customRegion.composableStudioUrl, + assetManagement: undefined, + auth: undefined, + }); + }); + + it('should preserve an explicitly supplied endpoints object for a custom region', function () { + const endpoints = { + contentManagement: 'https://custom-cma.com', + contentDelivery: 'https://custom-cda.com', + application: 'https://custom-ui.com', + auth: 'https://custom-auth.com', + }; + const customRegion = { + cma: 'https://custom-cma.com', + cda: 'https://custom-cda.com', + uiHost: 'https://custom-ui.com', + name: 'Custom Region', + endpoints, + }; + const result = UserConfig.setCustomRegion(customRegion); + expect(result.cma).to.equal(endpoints.contentManagement); + expect(result.cda).to.equal(endpoints.contentDelivery); + expect(result.uiHost).to.equal(endpoints.application); + expect(result.endpoints).to.deep.equal(endpoints); + }); + + it('should not fall back to a stale legacy authUrl input once endpoints.auth is supplied', function () { + // endpoints is the single source of truth: a stale/mismatched top-level + // authUrl on the raw input is never used — only endpoints.auth matters. + const customRegion = { + cma: 'https://custom-cma.com', + cda: 'https://custom-cda.com', + uiHost: 'https://custom-ui.com', + name: 'Custom Region', + authUrl: 'https://stale-auth.com', + endpoints: { + contentManagement: 'https://custom-cma.com', + contentDelivery: 'https://custom-cda.com', + application: 'https://custom-ui.com', + auth: 'https://correct-auth.com', + }, + }; + const result = UserConfig.setCustomRegion(customRegion); + expect(result.endpoints.auth).to.equal('https://correct-auth.com'); + }); + + it('should include the full endpoints passthrough (incl. auth) for named regions', function () { + const result = UserConfig.setRegion('NA'); + expect(result.endpoints).to.be.an('object'); + expect(result.endpoints.auth).to.equal('https://auth-api.contentstack.com'); + expect(result.endpoints.contentManagement).to.equal(result.cma); }); it('should sanitize region object to only include valid properties', function () { diff --git a/packages/contentstack-utilities/package.json b/packages/contentstack-utilities/package.json index 685088f31f..ff40311776 100644 --- a/packages/contentstack-utilities/package.json +++ b/packages/contentstack-utilities/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/cli-utilities", - "version": "2.0.0-beta.12", + "version": "2.0.0-beta.13", "description": "Utilities for contentstack projects", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -30,6 +30,7 @@ "dependencies": { "@contentstack/management": "~1.30.3", "@contentstack/marketplace-sdk": "^1.5.2", + "@contentstack/utils": "~1.9.1", "@oclif/core": "^4.11.4", "axios": "^1.18.1", "chalk": "^5.6.2", @@ -83,4 +84,4 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/packages/contentstack-utilities/src/feature-status/build-auth-headers.ts b/packages/contentstack-utilities/src/feature-status/build-auth-headers.ts new file mode 100644 index 0000000000..ba06a444bb --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/build-auth-headers.ts @@ -0,0 +1,43 @@ +import configHandler from '../config-handler'; + +export interface AuthHeaders { + [key: string]: string; +} + +export function buildAuthHeaders(ctx?: { + managementToken?: string; + apiKey?: string; + authToken?: string; + orgUid?: string; +}): AuthHeaders { + // 1. Management token takes priority (no 'Bearer' prefix — API contract) + if (ctx?.managementToken && ctx?.apiKey) { + return { + Authorization: ctx.managementToken, + api_key: ctx.apiKey, + }; + } + + // 2. OAuth + const oauthToken = configHandler.get('oauthAccessToken') as string | undefined; + if (oauthToken) { + return { Authorization: `Bearer ${oauthToken}` }; + } + + // 3. Authtoken + organization_uid + const authtoken = ctx?.authToken ?? (configHandler.get('authtoken') as string | undefined); + const orgUid = ctx?.orgUid ?? (configHandler.get('oauthOrgUid') as string | undefined); + if (authtoken && orgUid) { + return { authtoken, organization_uid: orgUid }; + } + + // 4. Authtoken + api_key + if (authtoken && ctx?.apiKey) { + return { authtoken, api_key: ctx.apiKey }; + } + + throw new Error( + 'PLAN_CHECK: Cannot build auth headers — no valid credentials available. ' + + 'Please log in or provide a management token alias.', + ); +} diff --git a/packages/contentstack-utilities/src/feature-status/feature-status-handler.ts b/packages/contentstack-utilities/src/feature-status/feature-status-handler.ts new file mode 100644 index 0000000000..1f1143629a --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/feature-status-handler.ts @@ -0,0 +1,49 @@ +import { HttpClient } from '../http-client'; +import { resolveAuthHost } from './resolve-auth-host'; +import { buildAuthHeaders } from './build-auth-headers'; +import { FeatureStatus, FeatureCtx } from './types'; + +export async function isFeatureEnabled(featureUid: string, ctx?: FeatureCtx): Promise { + const host = resolveAuthHost(ctx); + const headers = buildAuthHeaders(ctx); + + const client = new HttpClient(); + client.baseUrl(host).headers(headers); + + const res = await client.get( + `/v1/feature-status?feature_uid=${encodeURIComponent(featureUid)}`, + ); + + if (res.status < 200 || res.status >= 300) { + throw new Error(`PLAN_CHECK: feature-status API returned ${res.status} for "${featureUid}".`); + } + + return res.data as FeatureStatus; +} + +export async function assertFeatureEnabled(featureUid: string, ctx?: FeatureCtx): Promise { + let status: FeatureStatus; + try { + status = await isFeatureEnabled(featureUid, ctx); + } catch (e) { + throw new Error( + `Could not verify your plan for "${featureUid}". ` + + `This command requires a confirmed plan status to run. ` + + `Please retry; if the problem persists contact support. (${(e as Error).message})`, + ); + } + + if (!status.is_part_of_plan) { + throw new Error( + `"${featureUid}" is not part of your current plan. Please upgrade your plan to use this feature.`, + ); + } + + if (status.status !== 'enabled') { + throw new Error( + `"${featureUid}" is not enabled for your plan (status: ${status.status}).`, + ); + } + + return status; +} diff --git a/packages/contentstack-utilities/src/feature-status/feature-uids.ts b/packages/contentstack-utilities/src/feature-status/feature-uids.ts new file mode 100644 index 0000000000..fc55046e22 --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/feature-uids.ts @@ -0,0 +1,6 @@ +export const FEATURE = { + ASSET_MANAGEMENT: 'amAssets', + ASSET_SCANNING: 'assetsScan', +} as const; + +export type FeatureUid = (typeof FEATURE)[keyof typeof FEATURE]; diff --git a/packages/contentstack-utilities/src/feature-status/index.ts b/packages/contentstack-utilities/src/feature-status/index.ts new file mode 100644 index 0000000000..97531ef9a3 --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './feature-uids'; +export { resolveAuthHost } from './resolve-auth-host'; +export { buildAuthHeaders } from './build-auth-headers'; +export { isFeatureEnabled, assertFeatureEnabled } from './feature-status-handler'; diff --git a/packages/contentstack-utilities/src/feature-status/resolve-auth-host.ts b/packages/contentstack-utilities/src/feature-status/resolve-auth-host.ts new file mode 100644 index 0000000000..04e2d29ab4 --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/resolve-auth-host.ts @@ -0,0 +1,33 @@ +import configHandler from '../config-handler'; + +// Mirrors config:set:region's own custom-region fallback (region.ts's transformUrl) — +// same heuristic, applied retroactively for a custom region that predates auth-api. +// Computed fresh on each call, never persisted back to config. +function deriveAuthFromCma(cma: string): string { + let transformed = cma.replace('api', 'auth-api'); + if (transformed.startsWith('http')) { + transformed = transformed.split('//')[1]; + } + transformed = transformed.replace(/^dev\d+/, 'dev'); + transformed = transformed.endsWith('io') ? transformed.replace('io', 'com') : transformed; + return `https://${transformed}`; +} + +export function resolveAuthHost(ctx?: { region?: { endpoints?: { auth?: string }; cma?: string } }): string { + const region = (ctx?.region ?? configHandler.get('region')) as + | { endpoints?: { auth?: string }; cma?: string } + | undefined; + + let authUrl = region?.endpoints?.auth; + if (!authUrl && region?.cma) { + authUrl = deriveAuthFromCma(region.cma); + } + + if (!authUrl) { + throw new Error( + 'PLAN_CHECK: Auth host is not configured for the current region. ' + + "Re-run `csdx config:set:region` to refresh region endpoints.", + ); + } + return String(authUrl).replace(/\/$/, ''); +} diff --git a/packages/contentstack-utilities/src/feature-status/types.ts b/packages/contentstack-utilities/src/feature-status/types.ts new file mode 100644 index 0000000000..1ef24c0f39 --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/types.ts @@ -0,0 +1,16 @@ +export interface FeatureStatus { + org_uid: string; + feature_key: string; + status: 'enabled' | 'disabled' | string; + limit: number; + max_limit: number; + is_part_of_plan: boolean; +} + +export interface FeatureCtx { + apiKey?: string; + orgUid?: string; + managementToken?: string; + authToken?: string; + region?: { name?: string; cma?: string; endpoints?: { auth?: string } }; +} diff --git a/packages/contentstack-utilities/src/index.ts b/packages/contentstack-utilities/src/index.ts index a8adeef224..78d6ade71c 100644 --- a/packages/contentstack-utilities/src/index.ts +++ b/packages/contentstack-utilities/src/index.ts @@ -89,4 +89,8 @@ export { ProgressStrategyRegistry, CustomProgressStrategy, DefaultProgressStrategy -} from './progress-summary'; \ No newline at end of file +} from './progress-summary'; + +export * from './feature-status'; +export * from './region-endpoints'; +export { refreshRegionEndpoints } from './region-refresh'; diff --git a/packages/contentstack-utilities/src/region-endpoints.ts b/packages/contentstack-utilities/src/region-endpoints.ts new file mode 100644 index 0000000000..79185b2d4e --- /dev/null +++ b/packages/contentstack-utilities/src/region-endpoints.ts @@ -0,0 +1,52 @@ +import { getContentstackEndpoint } from '@contentstack/utils'; + +export interface RegionEndpoints { + [key: string]: string; +} + +export interface CanonicalRegion { + name: string; + cma: string; + cda: string; + uiHost: string; + developerHubUrl: string; + launchHubUrl: string; + personalizeUrl: string; + composableStudioUrl: string; + csAssetsUrl?: string; + endpoints: RegionEndpoints; +} + +/** + * Resolve the canonical endpoint set for a region name/alias via @contentstack/utils. + * Returns null when the name isn't a recognized region (e.g. a custom region name). + */ +export function resolveCanonicalEndpoints(name: string): RegionEndpoints | null { + try { + const endpoints = getContentstackEndpoint(name) as unknown; + if (!endpoints || typeof endpoints === 'string') return null; + return endpoints as RegionEndpoints; + } catch { + return null; + } +} + +/** + * Build a region object from a raw endpoints map, keeping the existing named + * fields (cma/cda/uiHost/...) plus a full raw `endpoints` passthrough so any + * future endpoint Contentstack adds is available without further code changes. + */ +export function buildRegionFromEndpoints(name: string, endpoints: RegionEndpoints): CanonicalRegion { + return { + name, + cma: endpoints.contentManagement, + cda: endpoints.contentDelivery, + uiHost: endpoints.application, + developerHubUrl: endpoints.developerHub, + launchHubUrl: endpoints.launch, + personalizeUrl: endpoints.personalizeManagement, + composableStudioUrl: endpoints.composableStudio, + csAssetsUrl: endpoints.assetManagement, + endpoints, + }; +} diff --git a/packages/contentstack-utilities/src/region-refresh.ts b/packages/contentstack-utilities/src/region-refresh.ts new file mode 100644 index 0000000000..ef84165651 --- /dev/null +++ b/packages/contentstack-utilities/src/region-refresh.ts @@ -0,0 +1,23 @@ +import configHandler from './config-handler'; +import { resolveCanonicalEndpoints, buildRegionFromEndpoints } from './region-endpoints'; + +/** + * Self-heals the persisted region config on every CLI invocation so that + * customers who set their region on an older CLI version (before a field + * like authUrl existed) get it backfilled without re-running + * `csdx config:set:region`. Only named/built-in regions are touched — a + * custom region's name won't resolve via resolveCanonicalEndpoints, so it's + * left untouched. Never throws. + */ +export function refreshRegionEndpoints(): void { + const stored = configHandler.get('region') as ({ name?: string } & Record) | undefined; + if (!stored?.name) return; + + const endpoints = resolveCanonicalEndpoints(stored.name); + if (!endpoints) return; + + const merged = { ...stored, ...buildRegionFromEndpoints(stored.name, endpoints) }; + if (JSON.stringify(merged) !== JSON.stringify(stored)) { + configHandler.set('region', merged); + } +} diff --git a/packages/contentstack-utilities/test/unit/region-endpoints.test.ts b/packages/contentstack-utilities/test/unit/region-endpoints.test.ts new file mode 100644 index 0000000000..d88a00ce9d --- /dev/null +++ b/packages/contentstack-utilities/test/unit/region-endpoints.test.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; +import { resolveCanonicalEndpoints, buildRegionFromEndpoints } from '../../src/region-endpoints'; + +describe('region-endpoints', () => { + describe('resolveCanonicalEndpoints', () => { + it('should resolve endpoints for a known region name', () => { + const endpoints = resolveCanonicalEndpoints('NA'); + expect(endpoints).to.not.be.null; + expect(endpoints.contentManagement).to.equal('https://api.contentstack.io'); + expect(endpoints.auth).to.equal('https://auth-api.contentstack.com'); + }); + + it('should resolve endpoints via a known alias', () => { + const endpoints = resolveCanonicalEndpoints('us'); + expect(endpoints).to.not.be.null; + expect(endpoints.contentManagement).to.equal('https://api.contentstack.io'); + }); + + it('should return null for an unrecognized/custom region name', () => { + const endpoints = resolveCanonicalEndpoints('My Totally Custom Region'); + expect(endpoints).to.be.null; + }); + }); + + describe('buildRegionFromEndpoints', () => { + it('should map named fields and include the full raw endpoints passthrough', () => { + const endpoints = resolveCanonicalEndpoints('EU'); + const region = buildRegionFromEndpoints('EU', endpoints); + + expect(region.name).to.equal('EU'); + expect(region.cma).to.equal(endpoints.contentManagement); + expect(region.cda).to.equal(endpoints.contentDelivery); + expect(region.uiHost).to.equal(endpoints.application); + expect(region.developerHubUrl).to.equal(endpoints.developerHub); + expect(region.launchHubUrl).to.equal(endpoints.launch); + expect(region.personalizeUrl).to.equal(endpoints.personalizeManagement); + expect(region.composableStudioUrl).to.equal(endpoints.composableStudio); + expect(region.endpoints).to.equal(endpoints); + expect(region.endpoints.auth).to.equal(endpoints.auth); + // future fields not yet named on the interface remain reachable via `.endpoints` + expect(region.endpoints.assets).to.be.a('string'); + }); + }); +}); diff --git a/packages/contentstack-utilities/test/unit/region-refresh.test.ts b/packages/contentstack-utilities/test/unit/region-refresh.test.ts new file mode 100644 index 0000000000..bffec8b7df --- /dev/null +++ b/packages/contentstack-utilities/test/unit/region-refresh.test.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import configHandler from '../../src/config-handler'; +import { refreshRegionEndpoints } from '../../src/region-refresh'; + +describe('refreshRegionEndpoints', () => { + let getStub: sinon.SinonStub; + let setStub: sinon.SinonStub; + + afterEach(() => { + getStub?.restore(); + setStub?.restore(); + }); + + it('should do nothing when no region is stored', () => { + getStub = sinon.stub(configHandler, 'get').returns(undefined); + setStub = sinon.stub(configHandler, 'set'); + + refreshRegionEndpoints(); + + expect(setStub.called).to.be.false; + }); + + it('should backfill and persist a named region missing endpoints', () => { + const stale = { + name: 'NA', + cma: 'https://api.contentstack.io', + cda: 'https://cdn.contentstack.io', + uiHost: 'https://app.contentstack.com', + }; + getStub = sinon.stub(configHandler, 'get').callsFake((key) => (key === 'region' ? stale : undefined)); + setStub = sinon.stub(configHandler, 'set'); + + refreshRegionEndpoints(); + + expect(setStub.calledOnce).to.be.true; + const [key, merged] = setStub.firstCall.args; + expect(key).to.equal('region'); + expect(merged.endpoints).to.be.an('object'); + expect(merged.endpoints.auth).to.equal('https://auth-api.contentstack.com'); + }); + + it('should not write when the named region is already fully healed', () => { + // First refresh to compute the canonical/healed shape, without touching config. + const stale = { name: 'AWS-NA' }; + getStub = sinon.stub(configHandler, 'get').callsFake((key) => (key === 'region' ? stale : undefined)); + setStub = sinon.stub(configHandler, 'set'); + refreshRegionEndpoints(); + const healed = setStub.firstCall.args[1]; + + getStub.restore(); + setStub.restore(); + + getStub = sinon.stub(configHandler, 'get').callsFake((key) => (key === 'region' ? healed : undefined)); + setStub = sinon.stub(configHandler, 'set'); + + refreshRegionEndpoints(); + + expect(setStub.called).to.be.false; + }); + + it('should leave a custom/unrecognized region untouched', () => { + const custom = { + name: 'My Totally Custom Region', + cma: 'https://custom-cma.com', + cda: 'https://custom-cda.com', + uiHost: 'https://custom-ui.com', + }; + getStub = sinon.stub(configHandler, 'get').callsFake((key) => (key === 'region' ? custom : undefined)); + setStub = sinon.stub(configHandler, 'set'); + + expect(() => refreshRegionEndpoints()).to.not.throw(); + expect(setStub.called).to.be.false; + }); +}); diff --git a/packages/contentstack-utilities/test/unit/resolve-auth-host.test.ts b/packages/contentstack-utilities/test/unit/resolve-auth-host.test.ts new file mode 100644 index 0000000000..0aaffc8d90 --- /dev/null +++ b/packages/contentstack-utilities/test/unit/resolve-auth-host.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import configHandler from '../../src/config-handler'; +import { resolveAuthHost } from '../../src/feature-status/resolve-auth-host'; + +describe('resolveAuthHost', () => { + let getStub: sinon.SinonStub; + + afterEach(() => { + getStub?.restore(); + }); + + it('should use endpoints.auth when present on the passed context', () => { + const host = resolveAuthHost({ region: { endpoints: { auth: 'https://auth-api.contentstack.com/' } } }); + expect(host).to.equal('https://auth-api.contentstack.com'); + }); + + it('should fall back to deriving from cma when endpoints.auth is missing', () => { + const host = resolveAuthHost({ region: { cma: 'https://api.contentstack.io' } }); + expect(host).to.equal('https://auth-api.contentstack.com'); + }); + + it('should not persist the derived fallback', () => { + const setStub = sinon.stub(configHandler, 'set'); + resolveAuthHost({ region: { cma: 'https://api.contentstack.io' } }); + expect(setStub.called).to.be.false; + setStub.restore(); + }); + + it('should read region from configHandler when no ctx.region is given', () => { + getStub = sinon + .stub(configHandler, 'get') + .callsFake((key) => (key === 'region' ? { endpoints: { auth: 'https://auth-api.contentstack.com' } } : undefined)); + const host = resolveAuthHost(); + expect(host).to.equal('https://auth-api.contentstack.com'); + }); + + it('should throw when both endpoints.auth and cma are missing', () => { + expect(() => resolveAuthHost({ region: {} })).to.throw(/PLAN_CHECK: Auth host is not configured/); + }); + + it('should throw when region is entirely absent', () => { + getStub = sinon.stub(configHandler, 'get').returns(undefined); + expect(() => resolveAuthHost()).to.throw(/PLAN_CHECK: Auth host is not configured/); + }); +}); diff --git a/packages/contentstack/package.json b/packages/contentstack/package.json index 3ec743e825..5ed178cf15 100755 --- a/packages/contentstack/package.json +++ b/packages/contentstack/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli", "description": "Command-line tool (CLI) to interact with Contentstack", - "version": "2.0.0-beta.29", + "version": "2.0.0-beta.30", "author": "Contentstack", "bin": { "csdx": "./bin/run.js" @@ -30,10 +30,10 @@ "@contentstack/cli-cm-export-to-csv": "~2.0.0-beta.11", "@contentstack/cli-cm-import-setup": "~2.0.0-beta.18", "@contentstack/cli-cm-seed": "~2.0.0-beta.24", - "@contentstack/cli-command": "~2.0.0-beta.10", - "@contentstack/cli-config": "~2.0.0-beta.14", + "@contentstack/cli-command": "~2.0.0-beta.12", + "@contentstack/cli-config": "~2.0.0-beta.16", "@contentstack/cli-migration": "~2.0.0-beta.16", - "@contentstack/cli-utilities": "~2.0.0-beta.11", + "@contentstack/cli-utilities": "~2.0.0-beta.13", "@contentstack/cli-variants": "~2.0.0-beta.19", "@contentstack/management": "~1.30.4", "@contentstack/utils": "~1.9.1", @@ -151,10 +151,12 @@ "hooks": { "prerun": [ "./lib/hooks/prerun/init-context-for-command", + "./lib/hooks/prerun/plan-guard", "./lib/hooks/prerun/default-rate-limit-check", "./lib/hooks/prerun/latest-version-warning" ], "init": [ + "./lib/hooks/init/region-refresh", "./lib/hooks/init/context-init", "./lib/hooks/init/utils-init", "./lib/hooks/init/opt-in-plugin-guide" @@ -166,4 +168,4 @@ "url": "git+https://github.com/contentstack/cli.git", "directory": "packages/contentstack" } -} +} \ No newline at end of file diff --git a/packages/contentstack/src/hooks/init/context-init.ts b/packages/contentstack/src/hooks/init/context-init.ts index 4d87969130..8b72ee68cb 100644 --- a/packages/contentstack/src/hooks/init/context-init.ts +++ b/packages/contentstack/src/hooks/init/context-init.ts @@ -9,5 +9,10 @@ export default function (opts): void { if (opts.id) { configHandler.set('currentCommandId', opts.id); } - this.config.context = new CsdxContext(opts, this.config); + const ctx = new CsdxContext(opts, this.config); + this.config.context = ctx; + // oclif v4 always recreates Config via Config.load(existingConfig), stripping custom + // properties like `context`. Storing it in options ensures it survives the spread: + // new Config({ ...opts.options, plugins }) in Config.load + (this.config.options as any).context = ctx; } diff --git a/packages/contentstack/src/hooks/init/region-refresh.ts b/packages/contentstack/src/hooks/init/region-refresh.ts new file mode 100644 index 0000000000..ea38159996 --- /dev/null +++ b/packages/contentstack/src/hooks/init/region-refresh.ts @@ -0,0 +1,13 @@ +import { refreshRegionEndpoints } from '@contentstack/cli-utilities'; + +/** + * Silently backfills the persisted region config with any endpoint fields + * missing from an older CLI version (e.g. authUrl), before any command runs. + */ +export default function (): void { + try { + refreshRegionEndpoints(); + } catch { + // never block CLI startup on region refresh + } +} diff --git a/packages/contentstack/src/hooks/prerun/plan-guard.ts b/packages/contentstack/src/hooks/prerun/plan-guard.ts new file mode 100644 index 0000000000..6183c38742 --- /dev/null +++ b/packages/contentstack/src/hooks/prerun/plan-guard.ts @@ -0,0 +1,92 @@ +import { configHandler, isFeatureEnabled, FeatureCtx, FeatureStatus, log } from '@contentstack/cli-utilities'; + +function getArgvFlag(argv: string[], ...names: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + for (const name of names) { + if (arg === name && argv[i + 1] && !argv[i + 1].startsWith('-')) { + return argv[i + 1]; + } + if (arg.startsWith(`${name}=`)) { + return arg.split('=').slice(1).join('='); + } + } + } + return undefined; +} + +export default async function (opts: { Command?: { id?: string }; argv?: string[]; config?: any }): Promise { + const config = opts?.config ?? this.config; + const commandId = opts?.Command?.id; + const argv: string[] = opts?.argv ?? []; + + if (!commandId) return; + + const requiredFeatures: string[] = config?.context?.plugin?.config?.planProtectedFeatures ?? []; + if (requiredFeatures.length === 0) return; + + const alias = getArgvFlag(argv, '--alias', '-a'); + let managementToken: string | undefined; + let apiKeyFromAlias: string | undefined; + if (alias) { + const stored = configHandler.get(`tokens.${alias}`) as + | { token?: string; apiKey?: string; type?: string } + | undefined; + if (stored?.token && stored?.apiKey && stored?.type !== 'delivery') { + managementToken = stored.token; + apiKeyFromAlias = stored.apiKey; + } + } + + const authorisationType = configHandler.get('authorisationType') as string | undefined; + const oauthToken = configHandler.get('oauthAccessToken') as string | undefined; + const isOAuth = authorisationType === 'OAUTH' && !!oauthToken; + + const authtoken = configHandler.get('authtoken') as string | undefined; + const isBasic = authorisationType === 'BASIC' && !!authtoken; + + const apiKeyFromArgv = getArgvFlag(argv, '--stack-api-key', '-k'); + const orgUid = configHandler.get('oauthOrgUid') as string | undefined; + + const canCheckNow = (!!managementToken && !!apiKeyFromAlias) || isOAuth || (isBasic && !!(apiKeyFromArgv || orgUid)); + + if (!canCheckNow) { + config.context.planCheckRequired = requiredFeatures; + log.debug( + `[plan-guard] Deferred plan check for: ${requiredFeatures.join(', ')} — credentials not resolvable at prerun`, + { module: 'plan-guard', commandId }, + ); + return; + } + + const region = configHandler.get('region') as + | { endpoints?: { auth?: string }; name?: string; cma?: string } + | undefined; + const ctx: FeatureCtx = { + managementToken, + apiKey: apiKeyFromAlias ?? apiKeyFromArgv, + authToken: authtoken, + orgUid, + region, + }; + + const planStatus: Record = {}; + const failedFeatures: string[] = []; + for (const featureUid of requiredFeatures) { + try { + planStatus[featureUid] = await isFeatureEnabled(featureUid, ctx); + log.debug(`[plan-guard] Feature "${featureUid}" status fetched.`, { module: 'plan-guard', commandId }); + } catch (error) { + log.warn(`[plan-guard] Could not fetch status for "${featureUid}": ${(error as Error).message}`, { + module: 'plan-guard', + commandId, + featureUid, + }); + failedFeatures.push(featureUid); + } + } + config.context.planStatus = planStatus; + if (failedFeatures.length > 0) { + config.context.planCheckRequired = failedFeatures; + } +} diff --git a/packages/contentstack/src/utils/context-handler.ts b/packages/contentstack/src/utils/context-handler.ts index bc4a0da678..f95798b731 100644 --- a/packages/contentstack/src/utils/context-handler.ts +++ b/packages/contentstack/src/utils/context-handler.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { configHandler, pathValidator, sanitizePath, generateShortUid } from '@contentstack/cli-utilities'; +import { configHandler, pathValidator, sanitizePath, generateShortUid, FeatureStatus } from '@contentstack/cli-utilities'; import { machineIdSync } from 'node-machine-id'; export default class CsdxContext { @@ -16,6 +16,8 @@ export default class CsdxContext { public flagWarningPrintState: any; public flags: any; public cliVersion: string; + public planStatus: Record = {}; + public planCheckRequired: string[] = []; constructor(cliOpts: any, cliConfig: any) { const analyticsInfo = []; diff --git a/packages/contentstack/tsconfig.json b/packages/contentstack/tsconfig.json index c12161b0f1..cfbcc1054a 100644 --- a/packages/contentstack/tsconfig.json +++ b/packages/contentstack/tsconfig.json @@ -11,15 +11,8 @@ "sourceMap": false, "skipLibCheck": true, "esModuleInterop": true, - "lib": [ - "ES2019", - "es2020.promise" - ], + "lib": ["ES2019", "es2020.promise"] }, - "include": [ - "src/**/*" - ], - "exclude": [ - "src/**/*.d.ts" - ] -} \ No newline at end of file + "include": ["src/**/*"], + "exclude": ["src/**/*.d.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bcbc6abe4..bddca53b22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: tmp: 0.2.7 uuid: 14.0.0 - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 fast-uri: 3.1.4 importers: @@ -57,16 +57,16 @@ importers: specifier: ~2.0.0-beta.24 version: 2.0.0-beta.24(@types/node@18.19.130)(debug@4.4.3) '@contentstack/cli-command': - specifier: ~2.0.0-beta.10 + specifier: ~2.0.0-beta.12 version: link:../contentstack-command '@contentstack/cli-config': - specifier: ~2.0.0-beta.14 + specifier: ~2.0.0-beta.16 version: link:../contentstack-config '@contentstack/cli-migration': specifier: ~2.0.0-beta.16 version: 2.0.0-beta.16(@types/node@18.19.130)(debug@4.4.3) '@contentstack/cli-utilities': - specifier: ~2.0.0-beta.11 + specifier: ~2.0.0-beta.13 version: link:../contentstack-utilities '@contentstack/cli-variants': specifier: ~2.0.0-beta.19 @@ -366,6 +366,9 @@ importers: '@contentstack/marketplace-sdk': specifier: ^1.5.2 version: 1.5.4(debug@4.4.3) + '@contentstack/utils': + specifier: ~1.9.1 + version: 1.9.1 '@oclif/core': specifier: ^4.11.4 version: 4.13.0 @@ -1780,9 +1783,9 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -6962,7 +6965,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -7581,7 +7584,7 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@10.7.0)(typescript@5.9.3) eslint-config-xo-space: 0.35.0(eslint@10.7.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.7.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0))(eslint@10.7.0) eslint-plugin-mocha: 10.5.0(eslint@10.7.0) eslint-plugin-n: 15.7.0(eslint@10.7.0) eslint-plugin-perfectionist: 2.11.0(eslint@10.7.0)(typescript@5.9.3) @@ -7641,7 +7644,7 @@ snapshots: eslint-config-xo: 0.49.0(eslint@10.7.0) eslint-config-xo-space: 0.35.0(eslint@10.7.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.7.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0))(eslint@10.7.0) eslint-plugin-jsdoc: 50.8.0(eslint@10.7.0) eslint-plugin-mocha: 10.5.0(eslint@10.7.0) eslint-plugin-n: 17.24.0(eslint@10.7.0)(typescript@5.9.3) @@ -7693,7 +7696,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.7.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0))(eslint@10.7.0) transitivePeerDependencies: - supports-color @@ -7798,7 +7801,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.7.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0))(eslint@10.7.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7856,7 +7859,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.7.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0))(eslint@10.7.0))(eslint@10.7.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9109,23 +9112,23 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@5.1.9: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@9.0.3: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimist@1.2.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ddb712412..7aab8924fd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,5 @@ packages: overrides: tmp: 0.2.7 uuid: 14.0.0 - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 fast-uri: 3.1.4