diff --git a/.talismanrc b/.talismanrc index bedb52350d..eae2f228bf 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,10 @@ fileignoreconfig: - filename: pnpm-lock.yaml checksum: ba838353c1277083cb35b5f69cbbe00f0dc5d7d69e49b44c71c3a7a79e95ee73 + - filename: packages/contentstack-utilities/src/feature-status/build-auth-headers.ts + checksum: 9360dfbce41aa9c8a62889f4821c37fc222b04b8b1c552d1dfbcd8f2854d49e0 + - filename: packages/contentstack/src/hooks/prerun/plan-guard.ts + checksum: 74eeb5c98e9ae48bf6f2918e75a7cfca23915396ffdd177b8716acba35c287da + - filename: packages/contentstack-config/src/utils/region-handler.ts + checksum: fe0713019d71cad642125de835a15a20784c961d6c83a9271f7287e73c1d4141 version: '1.0' 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/src/commands/config/set/region.ts b/packages/contentstack-config/src/commands/config/set/region.ts index edc1311bac..0a640927f5 100644 --- a/packages/contentstack-config/src/commands/config/set/region.ts +++ b/packages/contentstack-config/src/commands/config/set/region.ts @@ -51,6 +51,9 @@ export default class RegionSetCommand extends BaseCommand { + 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..a86e4786ee --- /dev/null +++ b/packages/contentstack-utilities/src/feature-status/resolve-auth-host.ts @@ -0,0 +1,13 @@ +import configHandler from '../config-handler'; + +export function resolveAuthHost(ctx?: { region?: { authUrl?: string } }): string { + const region = (ctx?.region ?? configHandler.get('region')) as { authUrl?: string } | undefined; + const authUrl = region?.authUrl; + 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..136c3fee66 --- /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?: { authUrl?: string; name?: string; cma?: string }; +} diff --git a/packages/contentstack-utilities/src/index.ts b/packages/contentstack-utilities/src/index.ts index db62ccc212..66ed0e16a3 100644 --- a/packages/contentstack-utilities/src/index.ts +++ b/packages/contentstack-utilities/src/index.ts @@ -80,3 +80,5 @@ export { default as TablePrompt } from './inquirer-table-prompt'; export { Logger }; export { default as authenticationHandler } from './authentication-handler'; export {v2Logger as log, cliErrorHandler, handleAndLogError, getLogPath} from './logger/log' + +export * from './feature-status'; 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/prerun/plan-guard.ts b/packages/contentstack/src/hooks/prerun/plan-guard.ts new file mode 100644 index 0000000000..1ab99a10d8 --- /dev/null +++ b/packages/contentstack/src/hooks/prerun/plan-guard.ts @@ -0,0 +1,90 @@ +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; planProtectedFeatures?: 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[] = opts?.Command?.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 { authUrl?: string; name?: 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..be6eadc8e2 100644 --- a/packages/contentstack/tsconfig.json +++ b/packages/contentstack/tsconfig.json @@ -8,7 +8,7 @@ "strict": false, "target": "es2017", "allowJs": true, - "sourceMap": false, + "sourceMap": true, "skipLibCheck": true, "esModuleInterop": true, "lib": [