diff --git a/.talismanrc b/.talismanrc index b66e75a899..bdaedb6eb0 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,6 @@ fileignoreconfig: - filename: pnpm-lock.yaml - checksum: 64ca5ee7000d70e76ba367e3cacc2158e5cd33e41c7844fb6af79837a9469bb6 + checksum: 117b66122d3f3043f48724ff9755ec2d4a9dff3c156ceedef753df3014cafee6 + - filename: packages/contentstack/src/hooks/init/opt-in-plugin-guide.ts + checksum: 2b079a93988e2439a03c401f75695fcdbfeb138a75c84707e4bc892536680e9f version: '1.0' diff --git a/MIGRATION.md b/MIGRATION.md index 7853bc0d8a..e9eaf9147d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -189,6 +189,45 @@ csdx cm:entries:publish --content-types blog --environments prod --locales en-us csdx cm:stacks:bulk-entries --operation publish --content-types blog --environments prod --locales en-us -k blt123 ``` +### 7. 🚀 Launch Plugin Now Opt-In + +**What Changed:** +- The `launch` plugin (`@contentstack/cli-launch`) is no longer bundled with the CLI +- All `launch:*` commands now require installing the plugin explicitly before use +- Brings `launch` in line with the opt-in, modular plugin model used elsewhere in 2.x + +> ⚠️ **This is a change relative to the 2.x beta — not only relative to 1.x.** `@contentstack/cli-launch` was bundled throughout the 1.x releases **and** the entire 2.x beta period, so `launch:*` worked out of the box for beta users too. Starting with 2.x GA it becomes opt-in, so beta users who rely on `launch:*` are also affected when they upgrade. + +**Before (1.x.x and 2.x.x-beta):** +- `launch:*` commands were bundled with the CLI and available by default after installation + +**After (2.x.x GA):** +- `launch:*` commands are provided by a separate, opt-in plugin that must be installed explicitly +- Running any `launch:*` command without the plugin installed fails with a `command not found` error where it previously worked out of the box + +**Affected commands:** `launch`, `launch:deployments`, `launch:environments`, `launch:functions`, `launch:logs`, `launch:open`, `launch:rollback` + +**Installation Methods:** + + +**Option 1: Using CLI Plugin Manager** +```bash +csdx plugins:install @contentstack/cli-launch +``` + +**Option 2: Using npm** +```bash +npm install -g @contentstack/cli-launch +``` + +**Usage:** +After installation, launch commands will be available through the CLI: +```bash +csdx launch --help +``` + +**Migration Action:** If you use any `launch:*` command, install the `@contentstack/cli-launch` plugin before (or immediately after) upgrading to 2.x GA to avoid `command not found` errors. This applies to 2.x beta users as well, since `launch` was bundled during the beta. + ## Troubleshooting ### Common Issues @@ -209,6 +248,11 @@ csdx cm:stacks:bulk-entries --operation publish --content-types blog --environme - The 2.x.x-beta version should be faster due to TypeScript modules - If you are experiencing issues, switch to console log mode for debugging +**5. `launch:*` command not found:** +- In 2.x GA, `launch` is an opt-in plugin and is no longer bundled (it was bundled in 1.x and during the 2.x beta) +- Install it with `csdx plugins:install @contentstack/cli-launch`, then re-run your `launch:*` command +- Verify it is installed with `csdx plugins` + ### Getting Help **Documentation:** diff --git a/packages/contentstack-auth/package.json b/packages/contentstack-auth/package.json index 635f23f589..58f13a3472 100644 --- a/packages/contentstack-auth/package.json +++ b/packages/contentstack-auth/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-auth", "description": "Contentstack CLI plugin for authentication activities", - "version": "2.0.0-beta.16", + "version": "2.0.0-beta.17", "author": "Contentstack", "bugs": "https://github.com/contentstack/cli/issues", "scripts": { @@ -15,8 +15,8 @@ "lint": "eslint src/**/*.ts" }, "dependencies": { - "@contentstack/cli-command": "~2.0.0-beta.10", - "@contentstack/cli-utilities": "~2.0.0-beta.11", + "@contentstack/cli-command": "~2.0.0-beta.11", + "@contentstack/cli-utilities": "~2.0.0-beta.12", "@oclif/core": "^4.11.4", "otplib": "^12.0.1" }, diff --git a/packages/contentstack-command/package.json b/packages/contentstack-command/package.json index a8ff922f9b..ed826020a5 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.10", + "version": "2.0.0-beta.11", "author": "Contentstack", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -14,7 +14,7 @@ "lint": "eslint src/**/*.ts" }, "dependencies": { - "@contentstack/cli-utilities": "~2.0.0-beta.11", + "@contentstack/cli-utilities": "~2.0.0-beta.12", "contentstack": "^3.27.0", "@oclif/core": "^4.11.4" }, @@ -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 f8f35ef7dd..af638e1559 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.14", + "version": "2.0.0-beta.15", "author": "Contentstack", "scripts": { "build": "pnpm compile && oclif manifest && oclif readme", @@ -14,8 +14,8 @@ "lint": "eslint src/**/*.ts" }, "dependencies": { - "@contentstack/cli-command": "~2.0.0-beta.10", - "@contentstack/cli-utilities": "~2.0.0-beta.11", + "@contentstack/cli-command": "~2.0.0-beta.11", + "@contentstack/cli-utilities": "~2.0.0-beta.12", "@contentstack/utils": "~1.9.1", "@oclif/core": "^4.11.4" }, @@ -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 7905d26f95..e94a4f2f25 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.11", + "version": "2.0.0-beta.12", "description": "Utilities for contentstack projects", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -30,8 +30,9 @@ "dependencies": { "@contentstack/management": "~1.30.3", "@contentstack/marketplace-sdk": "^1.5.2", + "@contentstack/utils": "~1.9.1", "@oclif/core": "^4.11.4", - "axios": "^1.16.1", + "axios": "^1.18.1", "chalk": "^5.6.2", "cli-cursor": "^3.1.0", "cli-progress": "^3.12.0", @@ -42,7 +43,7 @@ "inquirer": "12.11.1", "inquirer-search-checkbox": "^1.0.0", "inquirer-search-list": "^1.2.6", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.0", "klona": "^2.0.6", "lodash": "^4.18.1", "mkdirp": "^1.0.4", @@ -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/content-type-utils.ts b/packages/contentstack-utilities/src/content-type-utils.ts index 0edfe85b38..92cfd7377c 100644 --- a/packages/contentstack-utilities/src/content-type-utils.ts +++ b/packages/contentstack-utilities/src/content-type-utils.ts @@ -7,6 +7,15 @@ import { resolve as pResolve } from 'node:path'; * @param ignoredFiles - Files to ignore (defaults to schema.json, .DS_Store, __master.json, __priority.json) * @returns Array of content type schemas (empty if the path is missing or has no eligible files) */ +const DEFAULT_GF_IGNORED_FILES = ['schema.json', '.DS_Store', '__master.json', '__priority.json', 'field_rules_uid.json', 'globalfields.json']; + +export function readGlobalFieldSchemas( + dirPath: string, + ignoredFiles: string[] = DEFAULT_GF_IGNORED_FILES, +): Record[] { + return readContentTypeSchemas(dirPath, ignoredFiles); +} + export function readContentTypeSchemas( dirPath: string, ignoredFiles: string[] = ['schema.json', '.DS_Store', '__master.json', '__priority.json', 'field_rules_uid.json'], 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/progress-summary/cli-progress-manager.ts b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts index bf0d843e74..07a9dd25b3 100644 --- a/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts +++ b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts @@ -421,14 +421,19 @@ export default class CLIProgressManager { if (!this.showConsoleLogs && process.progressBar) { const totalProcessed = process.current; - const percentage = Math.round((totalProcessed / process.total) * 100); + // Reconcile the bar's total with the number of items actually processed so the + // rendered "value/total" matches the "Complete (success/processed)" summary. A + // process registered with an estimated total larger than the items actually ticked + // would otherwise render mismatched counts, e.g. "37/37 | Complete (27/27)". + process.progressBar.setTotal(totalProcessed); + const percentage = totalProcessed > 0 ? 100 : 0; const formattedPercentage = this.formatPercentage(percentage); const statusText = success - ? getChalk().green(`✓ Complete (${process.successCount}/${process.current})`) - : getChalk().red(`✗ Failed (${process.successCount}/${process.current})`); + ? getChalk().green(`✓ Complete (${process.successCount}/${totalProcessed})`) + : getChalk().red(`✗ Failed (${process.successCount}/${totalProcessed})`); const displayName = this.formatProcessName(processName); const indentedLabel = ` ├─ ${displayName}`.padEnd(25); - process.progressBar.update(process.total, { + process.progressBar.update(totalProcessed, { label: success ? getChalk().green(indentedLabel) : getChalk().red(indentedLabel), status: statusText, percentage: formattedPercentage, 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/cliProgressManager.test.ts b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts index cb0752b48d..ab6f20ab35 100644 --- a/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts +++ b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts @@ -34,6 +34,7 @@ const mockProgressBar = { stop: sinon.stub(), increment: sinon.stub(), update: sinon.stub(), + setTotal: sinon.stub(), }; const mockMultiBar = { @@ -335,6 +336,74 @@ describe('CLIProgressManager', () => { }); }); + describe('Complete count reconciliation', () => { + let bar: { update: sinon.SinonStub; setTotal: sinon.SinonStub; increment: sinon.SinonStub }; + + // Inject a stub bar at the rendering seam so assertions do not depend on the + // load-order-sensitive cli-progress module mock (which does not bind in CI). + function injectBar(processName: string) { + bar = { update: sinon.stub(), setTotal: sinon.stub(), increment: sinon.stub() }; + (progressManager as any).processes.get(processName).progressBar = bar; + } + + beforeEach(() => { + progressManager = new CLIProgressManager({ + enableNestedProgress: true, + moduleName: 'RECONCILE_TEST', + showConsoleLogs: false, + }); + }); + + fancy.it('renders the processed count, not the registered estimate, when fewer items were ticked', () => { + progressManager.addProcess('gf-update', 37); + injectBar('gf-update'); + for (let i = 0; i < 27; i++) { + progressManager.tick(true, `item-${i}`, null, 'gf-update'); + } + + progressManager.completeProcess('gf-update', true); + + expect(bar.setTotal.calledWith(27)).to.equal(true); + expect(bar.update.lastCall.args[0]).to.equal(27); + }); + + fancy.it('renders the full count unchanged when every registered item was ticked', () => { + progressManager.addProcess('entries-create', 59); + injectBar('entries-create'); + for (let i = 0; i < 59; i++) { + progressManager.tick(true, `item-${i}`, null, 'entries-create'); + } + + progressManager.completeProcess('entries-create', true); + + expect(bar.update.lastCall.args[0]).to.equal(59); + }); + + fancy.it('renders the registered total when a process completes with no ticks (skip case)', () => { + progressManager.addProcess('skipped', 5); + injectBar('skipped'); + + progressManager.completeProcess('skipped', true); + + expect(bar.update.lastCall.args[0]).to.equal(5); + }); + + fancy.it('reconciles the denominator for a failed process with partial ticks', () => { + progressManager.addProcess('entries', 59); + injectBar('entries'); + for (let i = 0; i < 38; i++) { + progressManager.tick(true, `ok-${i}`, null, 'entries'); + } + for (let i = 0; i < 3; i++) { + progressManager.tick(false, `err-${i}`, 'boom', 'entries'); + } + + progressManager.completeProcess('entries', false); + + expect(bar.update.lastCall.args[0]).to.equal(41); + }); + }); + describe('Progress Tracking', () => { beforeEach(() => { progressManager = new CLIProgressManager({ diff --git a/packages/contentstack-utilities/test/unit/content-type-utils.test.ts b/packages/contentstack-utilities/test/unit/content-type-utils.test.ts index 89778292be..79d03b3ba0 100644 --- a/packages/contentstack-utilities/test/unit/content-type-utils.test.ts +++ b/packages/contentstack-utilities/test/unit/content-type-utils.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { readContentTypeSchemas } from '../../src/content-type-utils'; +import { readContentTypeSchemas, readGlobalFieldSchemas } from '../../src/content-type-utils'; describe('readContentTypeSchemas', () => { afterEach(() => { @@ -144,4 +144,110 @@ describe('readContentTypeSchemas', () => { sinon.restore(); }); + + it('should NOT ignore globalfields.json (that is readGlobalFieldSchemas responsibility)', () => { + const mockBulk = [{ uid: 'gf_1', title: 'GF 1' }]; + const mockPerUid = { uid: 'gf_2', title: 'GF 2', schema: [] }; + + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['globalfields.json', 'gf_2.json']); + const readFileStub = sinon.stub(require('fs'), 'readFileSync'); + readFileStub.withArgs(sinon.match(/globalfields\.json/), 'utf8').returns(JSON.stringify(mockBulk)); + readFileStub.withArgs(sinon.match(/gf_2\.json/), 'utf8').returns(JSON.stringify(mockPerUid)); + + const result = readContentTypeSchemas('/test/path'); + + expect(result).to.have.lengthOf(2); + + sinon.restore(); + }); +}); + +describe('readGlobalFieldSchemas', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return empty array when directory does not exist', () => { + sinon.stub(require('fs'), 'existsSync').returns(false); + + const result = readGlobalFieldSchemas('/nonexistent/path'); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(0); + }); + + it('should read per-uid JSON files and ignore globalfields.json by default', () => { + const mockGF = { uid: 'gf_1', title: 'GF 1', schema: [] }; + + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['gf_1.json', 'globalfields.json', '.DS_Store', 'schema.json']); + const readFileStub = sinon.stub(require('fs'), 'readFileSync'); + readFileStub.withArgs(sinon.match(/gf_1\.json/), 'utf8').returns(JSON.stringify(mockGF)); + + const result = readGlobalFieldSchemas('/test/path'); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(1); + expect(result[0].uid).to.equal('gf_1'); + }); + + it('should not include globalfields.json — prevents bulk-array corruption on import', () => { + const mockBulkArray = [{ uid: 'gf_1' }, { uid: 'gf_2' }]; + const mockPerUid = { uid: 'gf_3', title: 'GF 3', schema: [] }; + + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['globalfields.json', 'gf_3.json']); + const readFileStub = sinon.stub(require('fs'), 'readFileSync'); + readFileStub.withArgs(sinon.match(/globalfields\.json/), 'utf8').returns(JSON.stringify(mockBulkArray)); + readFileStub.withArgs(sinon.match(/gf_3\.json/), 'utf8').returns(JSON.stringify(mockPerUid)); + + const result = readGlobalFieldSchemas('/test/path'); + + // Only the per-uid file is returned; globalfields.json array not parsed as a schema entry + expect(result).to.have.lengthOf(1); + expect(Array.isArray(result[0])).to.be.false; + expect((result[0] as any).uid).to.equal('gf_3'); + }); + + it('should read multiple per-uid files', () => { + const mockGF1 = { uid: 'gf_1', title: 'GF 1', schema: [] }; + const mockGF2 = { uid: 'gf_2', title: 'GF 2', schema: [] }; + + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['gf_1.json', 'gf_2.json', 'globalfields.json']); + const readFileStub = sinon.stub(require('fs'), 'readFileSync'); + readFileStub.withArgs(sinon.match(/gf_1\.json/), 'utf8').returns(JSON.stringify(mockGF1)); + readFileStub.withArgs(sinon.match(/gf_2\.json/), 'utf8').returns(JSON.stringify(mockGF2)); + + const result = readGlobalFieldSchemas('/test/path'); + + expect(result).to.have.lengthOf(2); + expect(result.map((r: any) => r.uid)).to.include.members(['gf_1', 'gf_2']); + }); + + it('should return empty array when directory contains only globalfields.json', () => { + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['globalfields.json', '.DS_Store']); + + const result = readGlobalFieldSchemas('/test/path'); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(0); + }); + + it('readContentTypeSchemas default ignore list is unchanged — no globalfields.json exclusion', () => { + sinon.stub(require('fs'), 'existsSync').returns(true); + sinon.stub(require('fs'), 'readdirSync').returns(['globalfields.json']); + sinon.stub(require('fs'), 'readFileSync') + .withArgs(sinon.match(/globalfields\.json/), 'utf8') + .returns(JSON.stringify([{ uid: 'gf_1' }])); + + // readContentTypeSchemas includes globalfields.json; readGlobalFieldSchemas excludes it + const ctResult = readContentTypeSchemas('/test/path'); + const gfResult = readGlobalFieldSchemas('/test/path'); + + expect(ctResult).to.have.lengthOf(1); + expect(gfResult).to.have.lengthOf(0); + }); }); 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/README.md b/packages/contentstack/README.md index d3477ee4d1..9b449ee445 100644 --- a/packages/contentstack/README.md +++ b/packages/contentstack/README.md @@ -76,13 +76,6 @@ USAGE * [`csdx config:set:rate-limit`](#csdx-configsetrate-limit) * [`csdx config:set:region [REGION]`](#csdx-configsetregion-region) * [`csdx help [COMMAND]`](#csdx-help-command) -* [`csdx launch`](#csdx-launch) -* [`csdx launch:deployments`](#csdx-launchdeployments) -* [`csdx launch:environments`](#csdx-launchenvironments) -* [`csdx launch:functions`](#csdx-launchfunctions) -* [`csdx launch:logs`](#csdx-launchlogs) -* [`csdx launch:open`](#csdx-launchopen) -* [`csdx launch:rollback`](#csdx-launchrollback) * [`csdx login`](#csdx-login) * [`csdx logout`](#csdx-logout) * [`csdx plugins`](#csdx-plugins) @@ -1681,283 +1674,13 @@ DESCRIPTION _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_ -## `csdx launch` - -Launch related operations - -``` -USAGE - $ csdx launch [-d ] [-c ] [--type GitHub|FileUpload] [--framework Gatsby|NextJs|CRA (Create - React App)|CSR (Client-Side Rendered)|Analog|Angular|Nuxt|Astro|VueJs|Remix|Other] [--org ] [-n ] [-e - ] [--branch ] [--build-command ] [--out-dir ] [--server-command ] - [--variable-type Import variables from a stack|Manually add custom variables to the list|Import variables from the - .env.local file|Skip adding environment variables...] [-a ] [--env-variables ] [--redeploy-latest] - [--redeploy-last-upload] [--response-mode buffered|streaming] - -FLAGS - -a, --alias= [optional] Alias (name) for the delivery token. - -c, --config= Path to the local '.cs-launch.json' file - -d, --data-dir= Current working directory - -e, --environment= [optional] Environment name for the Launch project. - -n, --name= [optional] Name of the project. - --branch= [optional] GitHub branch name. - --build-command= [optional] Build Command. - --env-variables= [optional] Provide the environment variables in the key:value format, separated by - comma. For example: APP_ENV:prod, TEST_ENV:testVal. - --framework=