diff --git a/.github/config/release.json b/.github/config/release.json index 9863bcf7c..156187435 100755 --- a/.github/config/release.json +++ b/.github/config/release.json @@ -2,6 +2,8 @@ "releaseAll": true, "plugins": { "asset-management": false, + "variants": false, + "query-export": false, "export": false, "import": false, "clone": false, diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 35dc5c24c..c629b7b41 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -25,7 +25,7 @@ jobs: - name: Prune pnpm store run: pnpm store prune - name: Install Dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --no-frozen-lockfile - name: Build all plugins run: | @@ -86,3 +86,7 @@ jobs: - name: Run tests for Contentstack Bulk Operations working-directory: ./packages/contentstack-bulk-operations run: npm test + + - name: Run tests for Contentstack Variants + working-directory: ./packages/contentstack-variants + run: npm run test diff --git a/.talismanrc b/.talismanrc index e2a4a99c7..789dc0cda 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,10 +1,4 @@ fileignoreconfig: -- filename: packages/contentstack-bulk-operations/src/services/am-asset-service.ts - checksum: 5f6c0ecba74e27399a7079ca15e65e77ef692697093c9fb1d57213728c4fe985 -- filename: packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts - checksum: 580932f192dd3fdd8bb2c55b7a7a78f1694f646ef5c5041f86c75668778f7ecb -- filename: packages/contentstack-bulk-operations/test/unit/utils/asset-uids-from-file.test.ts - checksum: 8123f7a675a0275795b59b15d0f2d5f8f1e57ccbecf3f97249a0dc5a037b9203 - filename: pnpm-lock.yaml - checksum: f3d6c28f120dd0f2a6abcdaf033734e979996e462a29b1b5b350228c61f62c87 + checksum: cdead0797199d22bbc55b9e5b6b86983f28eb760fabe5e1f2d5139c4456a9131 version: '1.0' diff --git a/packages/contentstack-apps-cli/package.json b/packages/contentstack-apps-cli/package.json index 086aae49a..79879833c 100644 --- a/packages/contentstack-apps-cli/package.json +++ b/packages/contentstack-apps-cli/package.json @@ -110,4 +110,4 @@ "app:deploy": "APDP" } } -} \ No newline at end of file +} diff --git a/packages/contentstack-asset-management/package.json b/packages/contentstack-asset-management/package.json index b6dccd0bc..9b0439ca7 100644 --- a/packages/contentstack-asset-management/package.json +++ b/packages/contentstack-asset-management/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/cli-asset-management", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "description": "Contentstack Assets API adapter for export and import", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/packages/contentstack-asset-management/src/index.ts b/packages/contentstack-asset-management/src/index.ts index b8b2252d6..d2a9b823a 100644 --- a/packages/contentstack-asset-management/src/index.ts +++ b/packages/contentstack-asset-management/src/index.ts @@ -3,4 +3,5 @@ export * from './types'; export * from './utils'; export * from './export'; export * from './import'; +export * from './query-export'; export * from './import-setup'; diff --git a/packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts b/packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts new file mode 100644 index 000000000..9e50ae94b --- /dev/null +++ b/packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts @@ -0,0 +1,246 @@ +import { resolve as pResolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities'; + +import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api'; +import type { ExportContext } from '../types/export-types'; +import ExportAssetTypes from '../export/asset-types'; +import ExportFields from '../export/fields'; +import { CSAssetsExportAdapter } from '../export/base'; +import { getAssetItems, writeStreamToFile } from '../utils/export-helpers'; +import { runInBatches } from '../utils/concurrent-batch'; + +const DEFAULT_ASSET_BATCH_SIZE = 100; +const SEARCH_PAGE_LIMIT = 100; + +/** + * Query-based Contentstack Assets exporter. + * Exports only referenced asset UIDs from entries into the `spaces/` directory layout. + */ +export class CsAssetsQueryExporter { + private readonly options: CsAssetsQueryExportOptions; + + constructor(options: CsAssetsQueryExportOptions) { + this.options = options; + } + + async export(assetUIDs: string[]): Promise { + const { linkedWorkspaces, exportDir, context } = this.options; + + if (!assetUIDs.length) { + log.info('No asset UIDs to export for Contentstack Assets query export', context); + return; + } + + if (!linkedWorkspaces.length) { + log.warn('No linked workspaces configured for Contentstack Assets query export', context); + return; + } + + log.info( + `Starting Contentstack Assets query export (${assetUIDs.length} UID(s), ${linkedWorkspaces.length} space(s))`, + context, + ); + + const spacesRootPath = pResolve(exportDir, 'spaces'); + await mkdir(spacesRootPath, { recursive: true }); + + const apiConfig: CSAssetsAPIConfig = { + baseURL: this.options.csAssetsUrl, + headers: { organization_uid: this.options.org_uid }, + context, + }; + + const exportContext: ExportContext = { + spacesRootPath, + context, + securedAssets: this.options.securedAssets, + chunkFileSizeMb: this.options.chunkFileSizeMb, + apiConcurrency: this.options.apiConcurrency, + downloadAssetsConcurrency: this.options.downloadAssetsConcurrency, + }; + + const batchSize = this.options.assetBatchSize ?? DEFAULT_ASSET_BATCH_SIZE; + + try { + await this.bootstrapSharedModules(apiConfig, exportContext, linkedWorkspaces[0].space_uid); + + for (const workspace of linkedWorkspaces) { + try { + await this.exportWorkspaceAssets(apiConfig, exportContext, workspace, assetUIDs, batchSize); + } catch (err) { + handleAndLogError( + err, + { ...(context as Record), spaceUid: workspace.space_uid }, + `Failed Contentstack Assets query export for space ${workspace.space_uid}`, + ); + } + } + + log.success('Contentstack Assets query export completed', context); + } catch (err) { + handleAndLogError(err, context as Record, 'Contentstack Assets query export failed'); + throw err; + } + } + + private async bootstrapSharedModules( + apiConfig: CSAssetsAPIConfig, + exportContext: ExportContext, + firstSpaceUid: string, + ): Promise { + const sharedFieldsDir = pResolve(exportContext.spacesRootPath, 'fields'); + const sharedAssetTypesDir = pResolve(exportContext.spacesRootPath, 'asset_types'); + await mkdir(sharedFieldsDir, { recursive: true }); + await mkdir(sharedAssetTypesDir, { recursive: true }); + + const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext); + const exportFields = new ExportFields(apiConfig, exportContext); + await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]); + } + + private async exportWorkspaceAssets( + apiConfig: CSAssetsAPIConfig, + exportContext: ExportContext, + workspace: LinkedWorkspace, + assetUIDs: string[], + batchSize: number, + ): Promise { + const { branchName, context } = this.options; + const workspaceExporter = new QueryExportWorkspaceAdapter(apiConfig, exportContext); + await workspaceExporter.start(workspace, assetUIDs, branchName || 'main', batchSize); + log.debug(`Contentstack Assets query export finished for space ${workspace.space_uid}`, context); + } +} + +/** + * Per-space export: search by UID, write metadata/files, download binaries. + */ +class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter { + async start( + workspace: LinkedWorkspace, + assetUIDs: string[], + branchName: string, + uidBatchSize: number, + ): Promise { + await this.init(); + + const spaceDir = pResolve(this.exportContext.spacesRootPath, workspace.space_uid); + await mkdir(spaceDir, { recursive: true }); + + const spaceResponse = await this.getSpace(workspace.space_uid); + const space = spaceResponse.space; + const metadata = { + ...space, + workspace_uid: workspace.uid, + is_default: workspace.is_default, + branch: branchName, + }; + await writeFile(pResolve(spaceDir, 'metadata.json'), JSON.stringify(metadata, null, 2)); + + const assetsDir = pResolve(spaceDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + + const spaceRef = { space_uid: workspace.space_uid, workspace: workspace.uid }; + const assetItems = await this.searchAllAssets(assetUIDs, spaceRef, uidBatchSize); + + const folders = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir === true); + const files = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir !== true); + + await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2)); + + await this.writeItemsToChunkedJson( + assetsDir, + 'assets.json', + 'assets', + ['uid', 'url', 'filename', 'file_name', 'parent_uid'], + files, + ); + + await this.downloadAssets(files, assetsDir, workspace.space_uid); + } + + private async searchAllAssets( + assetUIDs: string[], + spaceRef: { space_uid: string; workspace: string }, + uidBatchSize: number, + ): Promise>> { + const seen = new Set(); + const results: Array> = []; + + for (let i = 0; i < assetUIDs.length; i += uidBatchSize) { + const uidBatch = assetUIDs.slice(i, i + uidBatchSize); + let skip = 0; + let pageItems: unknown[]; + + do { + const response = await this.searchAssets({ + assetUIDs: uidBatch, + spaces: [spaceRef], + skip, + limit: SEARCH_PAGE_LIMIT, + }); + pageItems = getAssetItems(response); + + if (pageItems.length === 0 && skip === 0) { + log.warn( + `Search returned 0 assets in space ${spaceRef.space_uid} for UID(s): [${uidBatch.join(', ')}]`, + this.exportContext.context, + ); + } + + for (const item of pageItems) { + const record = item as Record; + const key = String(record.uid ?? record.asset_id ?? record._uid ?? ''); + if (key && !seen.has(key)) { + seen.add(key); + results.push(record); + } + } + + skip += pageItems.length; + } while (pageItems.length === SEARCH_PAGE_LIMIT); + } + + return results; + } + + private async downloadAssets( + items: Array>, + assetsDir: string, + spaceUid: string, + ): Promise { + const downloadable = items.filter((asset) => Boolean(asset.url && (asset.uid ?? asset._uid))); + if (downloadable.length === 0) { + log.debug(`No downloadable assets for space ${spaceUid}`, this.exportContext.context); + return; + } + + const filesDir = pResolve(assetsDir, 'files'); + await mkdir(filesDir, { recursive: true }); + + const securedAssets = this.exportContext.securedAssets ?? false; + const authtoken = securedAssets ? configHandler.get('authtoken') : null; + + await runInBatches(downloadable, this.downloadAssetsBatchConcurrency, async (asset) => { + const uid = String(asset.uid ?? asset._uid); + const url = String(asset.url); + const filename = String(asset.filename ?? asset.file_name ?? 'asset'); + try { + const separator = url.includes('?') ? '&' : '?'; + const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url; + const response = await fetch(downloadUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = response.body; + if (!body) throw new Error('No response body'); + const nodeStream = Readable.fromWeb(body as Parameters[0]); + const assetFolderPath = pResolve(filesDir, uid); + await mkdir(assetFolderPath, { recursive: true }); + await writeStreamToFile(nodeStream, pResolve(assetFolderPath, filename)); + } catch (e) { + log.debug(`Failed to download asset ${uid} in space ${spaceUid}: ${e}`, this.exportContext.context); + } + }); + } +} diff --git a/packages/contentstack-asset-management/src/query-export/index.ts b/packages/contentstack-asset-management/src/query-export/index.ts new file mode 100644 index 000000000..a46638b88 --- /dev/null +++ b/packages/contentstack-asset-management/src/query-export/index.ts @@ -0,0 +1 @@ +export { CsAssetsQueryExporter } from './cs-assets-query-exporter'; diff --git a/packages/contentstack-asset-management/src/types/cs-assets-api.ts b/packages/contentstack-asset-management/src/types/cs-assets-api.ts index 7f7eb7f42..96dbac1bd 100644 --- a/packages/contentstack-asset-management/src/types/cs-assets-api.ts +++ b/packages/contentstack-asset-management/src/types/cs-assets-api.ts @@ -141,6 +141,30 @@ export type BulkMoveAssetsResponse = { * Adapter interface for Contentstack Assets API calls. * Used by export and (future) import. */ +/** Space + workspace pair for Contentstack Assets search API. */ +export type SearchSpaceRef = { + space_uid: string; + workspace: string; +}; + +/** Parameters for POST /api/search (asset query export). */ +export type SearchAssetsParams = { + assetUIDs: string[]; + spaces: SearchSpaceRef[]; + skip?: number; + limit?: number; +}; + +/** Response shape from POST /api/search for assets. */ +export type SearchAssetsResponse = { + count?: number; + relation?: string; + assets?: unknown[]; + items?: unknown[]; + results?: unknown[]; + folders?: unknown[]; +}; + export interface ICSAssetsAdapter { init(): Promise; listSpaces(): Promise; @@ -149,6 +173,7 @@ export interface ICSAssetsAdapter { getWorkspaceAssets(spaceUid: string, workspaceUid?: string): Promise; getWorkspaceFolders(spaceUid: string, workspaceUid?: string): Promise; getWorkspaceAssetTypes(spaceUid: string): Promise; + searchAssets(params: SearchAssetsParams): Promise; bulkDeleteAssets( spaceUid: string, workspaceUid: string | undefined, @@ -161,6 +186,23 @@ export interface ICSAssetsAdapter { ): Promise; } +/** Options for query-based Contentstack Assets export (referenced assets from entries). */ +export type CsAssetsQueryExportOptions = { + linkedWorkspaces: LinkedWorkspace[]; + exportDir: string; + branchName: string; + csAssetsUrl: string; + org_uid: string; + apiKey?: string; + context?: Record; + securedAssets?: boolean; + chunkFileSizeMb?: number; + apiConcurrency?: number; + downloadAssetsConcurrency?: number; + /** Max UIDs per search request ($in batch). */ + assetBatchSize?: number; +}; + /** * Options for exporting space structure (used by export app after fetching linked workspaces). */ diff --git a/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts b/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts index 5a8384f89..f76fa4106 100644 --- a/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts +++ b/packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts @@ -16,11 +16,41 @@ import type { CreateSpacePayload, FieldsResponse, ICSAssetsAdapter, + SearchAssetsParams, + SearchAssetsResponse, Space, SpaceResponse, SpacesListResponse, } from '../types/cs-assets-api'; +/** Default fields requested from POST /api/search for asset export. */ +export const DEFAULT_SEARCH_ASSET_FIELDS = [ + 'asset_id', + 'uid', + 'title', + 'file_name', + 'description', + 'parent_uid', + 'is_dir', + 'dimensions', + 'file_size', + 'content_type', + 'asset_type', + 'url', + 'tags', + 'created_at', + 'updated_at', + 'created_by', + 'updated_by', + 'path', + 'locale', + 'space_uid', + 'version', + 'publish_details', + 'ACL', + '_asset_scan_status', +] as const; + export class CSAssetsAdapter implements ICSAssetsAdapter { private readonly config: CSAssetsAPIConfig; private readonly apiClient: HttpClient; @@ -227,6 +257,36 @@ export class CSAssetsAdapter implements ICSAssetsAdapter { return result; } + /** + * POST /api/search — query assets by UID within linked spaces (Contentstack Assets query export). + */ + async searchAssets(params: SearchAssetsParams): Promise { + await this.init(); + const { assetUIDs, spaces, skip = 0, limit = 50 } = params; + if (!assetUIDs.length) { + return { count: 0, assets: [] }; + } + const body = { + query: { + $and: [{ uid: { $in: assetUIDs } }], + }, + skip, + limit, + desc: 'updated_at', + search_text: '', + search_field: 'all', + object_type: 'asset', + search_terms_operator: 'or', + fields: [...DEFAULT_SEARCH_ASSET_FIELDS], + spaces, + }; + log.debug( + `Searching assets (skip=${skip}, limit=${limit}, uids=${assetUIDs.length}, spaces=${spaces.length})`, + this.config.context, + ); + return this.postJson('/api/search', body); + } + // --------------------------------------------------------------------------- // POST helpers // --------------------------------------------------------------------------- diff --git a/packages/contentstack-asset-management/src/utils/export-helpers.ts b/packages/contentstack-asset-management/src/utils/export-helpers.ts index 9bc772c0f..252b33652 100644 --- a/packages/contentstack-asset-management/src/utils/export-helpers.ts +++ b/packages/contentstack-asset-management/src/utils/export-helpers.ts @@ -14,7 +14,7 @@ export function getAssetItems( ): Array<{ uid?: string; _uid?: string; url?: string; filename?: string; file_name?: string }> { if (Array.isArray(assetsData)) return assetsData; const data = assetsData as Record; - const items = data?.items ?? data?.assets; + const items = data?.items ?? data?.assets ?? data?.results; return Array.isArray(items) ? items : []; } diff --git a/packages/contentstack-asset-management/test/unit/import-setup/import-setup-asset-mappers.test.ts b/packages/contentstack-asset-management/test/unit/import-setup/import-setup-asset-mappers.test.ts index 936a6a344..b2b74383f 100644 --- a/packages/contentstack-asset-management/test/unit/import-setup/import-setup-asset-mappers.test.ts +++ b/packages/contentstack-asset-management/test/unit/import-setup/import-setup-asset-mappers.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { stub, restore } from 'sinon'; -import { AssetManagementAdapter } from '../../../src/utils/asset-management-api-adapter'; +import { CSAssetsAdapter } from '../../../src/utils/cs-assets-api-adapter'; import ImportAssets from '../../../src/import/assets'; import ImportSetupAssetMappers from '../../../src/import-setup/import-setup-asset-mappers'; @@ -77,8 +77,8 @@ describe('ImportSetupAssetMappers', () => { fs.mkdirSync(path.join(contentDir, 'spaces', 'amspace01'), { recursive: true }); fs.mkdirSync(backupDir, { recursive: true }); - stub(AssetManagementAdapter.prototype, 'init').resolves(); - stub(AssetManagementAdapter.prototype, 'listSpaces').resolves({ + stub(CSAssetsAdapter.prototype, 'init').resolves(); + stub(CSAssetsAdapter.prototype, 'listSpaces').resolves({ spaces: [{ uid: 'amspace01' }], }); stub(ImportAssets.prototype, 'buildIdentityMappersFromExport').resolves({ @@ -133,8 +133,8 @@ describe('ImportSetupAssetMappers', () => { fs.mkdirSync(path.join(contentDir, 'spaces', 'amspace01'), { recursive: true }); fs.mkdirSync(backupDir, { recursive: true }); - stub(AssetManagementAdapter.prototype, 'init').resolves(); - stub(AssetManagementAdapter.prototype, 'listSpaces').resolves({ spaces: [] }); + stub(CSAssetsAdapter.prototype, 'init').resolves(); + stub(CSAssetsAdapter.prototype, 'listSpaces').resolves({ spaces: [] }); const buildStub = stub(ImportAssets.prototype, 'buildIdentityMappersFromExport').resolves({ uidMap: {}, @@ -176,8 +176,8 @@ describe('ImportSetupAssetMappers', () => { fs.mkdirSync(path.join(contentDir, 'custom_spaces', 'amspace99'), { recursive: true }); fs.mkdirSync(backupDir, { recursive: true }); - stub(AssetManagementAdapter.prototype, 'init').resolves(); - stub(AssetManagementAdapter.prototype, 'listSpaces').resolves({ + stub(CSAssetsAdapter.prototype, 'init').resolves(); + stub(CSAssetsAdapter.prototype, 'listSpaces').resolves({ spaces: [{ uid: 'amspace99' }], }); @@ -231,8 +231,8 @@ describe('ImportSetupAssetMappers', () => { fs.mkdirSync(path.join(contentDir, 'spaces', 'amX'), { recursive: true }); fs.mkdirSync(backupDir, { recursive: true }); - stub(AssetManagementAdapter.prototype, 'init').resolves(); - stub(AssetManagementAdapter.prototype, 'listSpaces').resolves({ spaces: [{ uid: 'amX' }] }); + stub(CSAssetsAdapter.prototype, 'init').resolves(); + stub(CSAssetsAdapter.prototype, 'listSpaces').resolves({ spaces: [{ uid: 'amX' }] }); stub(ImportAssets.prototype, 'buildIdentityMappersFromExport').callsFake(async function fetchConcCheck( this: ImportAssets, diff --git a/packages/contentstack-asset-management/test/unit/query-export/cs-assets-query-exporter.test.ts b/packages/contentstack-asset-management/test/unit/query-export/cs-assets-query-exporter.test.ts new file mode 100644 index 000000000..7d5af5090 --- /dev/null +++ b/packages/contentstack-asset-management/test/unit/query-export/cs-assets-query-exporter.test.ts @@ -0,0 +1,158 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as fs from 'node:fs/promises'; +import { resolve as pResolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { HttpClient, authenticationHandler } from '@contentstack/cli-utilities'; + +import { CsAssetsQueryExporter } from '../../../src/query-export/cs-assets-query-exporter'; +import ExportAssetTypes from '../../../src/export/asset-types'; +import ExportFields from '../../../src/export/fields'; +import { CSAssetsExportAdapter } from '../../../src/export/base'; +import { CSAssetsAdapter } from '../../../src/utils/cs-assets-api-adapter'; +import * as concurrentBatch from '../../../src/utils/concurrent-batch'; + +import type { CsAssetsQueryExportOptions } from '../../../src/types/cs-assets-api'; + +describe('CsAssetsQueryExporter', () => { + let exportDir: string; + let searchAssetsStub: sinon.SinonStub; + const baseOptions: CsAssetsQueryExportOptions = { + linkedWorkspaces: [{ uid: 'main', space_uid: 'space-1', is_default: true }], + exportDir: '', + branchName: 'main', + csAssetsUrl: 'https://am.example.com', + org_uid: 'org-1', + context: { command: 'export-query' }, + assetBatchSize: 2, + }; + + beforeEach(async () => { + exportDir = await fs.mkdtemp(pResolve(tmpdir(), 'cs-assets-query-export-')); + baseOptions.exportDir = exportDir; + + sinon.stub(ExportFields.prototype, 'start').resolves(); + sinon.stub(ExportAssetTypes.prototype, 'start').resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'init').resolves(); + sinon.stub(CSAssetsExportAdapter.prototype, 'getSpace').resolves({ + space: { uid: 'space-1', title: 'Test Space' }, + }); + searchAssetsStub = sinon.stub(CSAssetsExportAdapter.prototype, 'searchAssets').resolves({ + count: 2, + relation: 'eq', + results: [ + { uid: 'asset-1', url: 'https://cdn.example.com/a1.png', file_name: 'a1.png', is_dir: false }, + { uid: 'asset-2', url: 'https://cdn.example.com/a2.png', file_name: 'a2.png', is_dir: false }, + ], + }); + sinon.stub(CSAssetsExportAdapter.prototype as any, 'writeItemsToChunkedJson').resolves(); + sinon.stub(concurrentBatch, 'runInBatches').callsFake(async (items, _concurrency, handler) => { + for (let i = 0; i < items.length; i++) { + await handler(items[i], i); + } + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return early when no asset UIDs are provided', async () => { + const exporter = new CsAssetsQueryExporter(baseOptions); + await exporter.export([]); + + expect((ExportFields.prototype.start as sinon.SinonStub).called).to.be.false; + }); + + it('should bootstrap shared fields and asset types', async () => { + const exporter = new CsAssetsQueryExporter(baseOptions); + await exporter.export(['asset-1']); + + expect((ExportFields.prototype.start as sinon.SinonStub).calledOnceWith('space-1')).to.be.true; + expect((ExportAssetTypes.prototype.start as sinon.SinonStub).calledOnceWith('space-1')).to.be.true; + }); + + it('should call searchAssets with batched UIDs and space reference', async () => { + const exporter = new CsAssetsQueryExporter(baseOptions); + await exporter.export(['asset-1', 'asset-2', 'asset-3']); + + expect(searchAssetsStub.called).to.be.true; + const firstCall = searchAssetsStub.getCall(0).args[0]; + expect(firstCall.spaces).to.deep.equal([{ space_uid: 'space-1', workspace: 'main' }]); + expect(firstCall.assetUIDs).to.deep.equal(['asset-1', 'asset-2']); + }); + + it('should write space metadata and asset files under spaces/', async () => { + const exporter = new CsAssetsQueryExporter(baseOptions); + await exporter.export(['asset-1']); + + const metadataPath = pResolve(exportDir, 'spaces', 'space-1', 'metadata.json'); + const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8')); + expect(metadata.uid).to.equal('space-1'); + expect(metadata.workspace_uid).to.equal('main'); + + const foldersPath = pResolve(exportDir, 'spaces', 'space-1', 'assets', 'folders.json'); + const folders = JSON.parse(await fs.readFile(foldersPath, 'utf-8')); + expect(folders).to.be.an('array').that.is.empty; + }); +}); + +describe('CSAssetsAdapter.searchAssets', () => { + const baseConfig = { + baseURL: 'https://am.example.com', + headers: { organization_uid: 'org-1' }, + }; + + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + sinon.stub(HttpClient.prototype, 'headers').returnsThis(); + sinon.stub(HttpClient.prototype, 'baseUrl').returnsThis(); + sinon.stub(authenticationHandler, 'getAuthDetails').resolves(); + sinon.stub(authenticationHandler, 'isOauthEnabled').get(() => false); + sinon.stub(authenticationHandler, 'accessToken').get(() => 'test-token'); + + fetchStub = sinon.stub(global, 'fetch').resolves({ + ok: true, + json: async () => ({ count: 1, assets: [{ uid: 'a1' }] }), + } as Response); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should POST to /api/search with $and-wrapped uid $in query and required fields', async () => { + const adapter = new CSAssetsAdapter(baseConfig); + await adapter.searchAssets({ + assetUIDs: ['uid-1', 'uid-2'], + spaces: [{ space_uid: 'space-1', workspace: 'main' }], + skip: 0, + limit: 50, + }); + + expect(fetchStub.calledOnce).to.be.true; + const [url, init] = fetchStub.firstCall.args; + expect(url).to.equal('https://am.example.com/api/search'); + expect(init.method).to.equal('POST'); + const body = JSON.parse(init.body); + expect(body.query).to.deep.equal({ $and: [{ uid: { $in: ['uid-1', 'uid-2'] } }] }); + expect(body.object_type).to.equal('asset'); + expect(body.desc).to.equal('updated_at'); + expect(body.search_text).to.equal(''); + expect(body.search_field).to.equal('all'); + expect(body.search_terms_operator).to.equal('or'); + expect(body.spaces).to.deep.equal([{ space_uid: 'space-1', workspace: 'main' }]); + }); + + it('should return empty result when assetUIDs is empty', async () => { + const adapter = new CSAssetsAdapter(baseConfig); + const result = await adapter.searchAssets({ + assetUIDs: [], + spaces: [{ space_uid: 'space-1', workspace: 'main' }], + }); + + expect(fetchStub.called).to.be.false; + expect(result).to.deep.equal({ count: 0, assets: [] }); + }); +}); diff --git a/packages/contentstack-audit/README.md b/packages/contentstack-audit/README.md index 81eb8c8fe..1ee7bde2f 100644 --- a/packages/contentstack-audit/README.md +++ b/packages/contentstack-audit/README.md @@ -19,7 +19,7 @@ $ npm install -g @contentstack/cli-audit $ csdx COMMAND running command... $ csdx (--version|-v) -@contentstack/cli-audit/2.0.0-beta.11 darwin-arm64 node-v22.13.1 +@contentstack/cli-audit/2.0.0-beta.13 darwin-arm64 node-v22.13.1 $ csdx --help [COMMAND] USAGE $ csdx COMMAND @@ -137,24 +137,4 @@ EXAMPLES ``` _See code: [src/commands/cm/stacks/audit/fix.ts](https://github.com/contentstack/audit/blob/main/packages/contentstack-audit/src/commands/cm/stacks/audit/fix.ts)_ - -## `csdx help [COMMAND]` - -Display help for csdx. - -``` -USAGE - $ csdx help [COMMAND...] [-n] - -ARGUMENTS - [COMMAND...] Command to show help for. - -FLAGS - -n, --nested-commands Include all nested commands in the output. - -DESCRIPTION - Display help for csdx. -``` - -_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_ diff --git a/packages/contentstack-bootstrap/README.md b/packages/contentstack-bootstrap/README.md index 38991eec0..914dbca61 100644 --- a/packages/contentstack-bootstrap/README.md +++ b/packages/contentstack-bootstrap/README.md @@ -15,7 +15,7 @@ $ npm install -g @contentstack/cli-cm-bootstrap $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-bootstrap/2.0.0-beta.16 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-bootstrap/2.0.0-beta.19 darwin-arm64 node-v22.13.1 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-bootstrap/package.json b/packages/contentstack-bootstrap/package.json index c1aa7ccf8..bf13ad528 100644 --- a/packages/contentstack-bootstrap/package.json +++ b/packages/contentstack-bootstrap/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-cm-bootstrap", "description": "Bootstrap contentstack apps", - "version": "2.0.0-beta.19", + "version": "2.0.0-beta.20", "author": "Contentstack", "bugs": "https://github.com/contentstack/cli/issues", "scripts": { @@ -16,7 +16,7 @@ "test:report": "nyc --reporter=lcov mocha \"test/**/*.test.js\"" }, "dependencies": { - "@contentstack/cli-cm-seed": "~2.0.0-beta.19", + "@contentstack/cli-cm-seed": "~2.0.0-beta.20", "@contentstack/cli-command": "~2.0.0-beta.8", "@contentstack/cli-utilities": "~2.0.0-beta.9", "@contentstack/cli-config": "~2.0.0-beta.11", @@ -70,4 +70,4 @@ } }, "repository": "contentstack/cli" -} \ No newline at end of file +} diff --git a/packages/contentstack-branches/README.md b/packages/contentstack-branches/README.md index c22682f9b..ba0c0be8c 100755 --- a/packages/contentstack-branches/README.md +++ b/packages/contentstack-branches/README.md @@ -37,7 +37,7 @@ $ npm install -g @contentstack/cli-cm-branches $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-branches/2.0.0-beta.6 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-branches/2.0.0-beta.8 darwin-arm64 node-v22.13.1 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-bulk-operations/package.json b/packages/contentstack-bulk-operations/package.json index aa9190302..e983c4784 100644 --- a/packages/contentstack-bulk-operations/package.json +++ b/packages/contentstack-bulk-operations/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/cli-bulk-operations", - "version": "2.0.0-beta.0", + "version": "2.0.0-beta.1", "description": "Contentstack CLI plugin for bulk operations", "author": "Contentstack CLI", "homepage": "https://github.com/contentstack/cli-plugins/tree/main/packages/contentstack-bulk-operations", @@ -20,7 +20,7 @@ "/oclif.manifest.json" ], "dependencies": { - "@contentstack/cli-asset-management": "1.0.0-beta.3", + "@contentstack/cli-asset-management": "1.0.0-beta.4", "@contentstack/cli-command": "~2.0.0-beta.8", "@contentstack/cli-utilities": "~2.0.0-beta.9", "@contentstack/delivery-sdk": "^5.2.0", @@ -118,4 +118,4 @@ "cm:stacks:bulk-taxonomies": "BOT" } } -} \ No newline at end of file +} diff --git a/packages/contentstack-bulk-operations/src/base-am-command.ts b/packages/contentstack-bulk-operations/src/base-am-command.ts new file mode 100644 index 000000000..0d23b9c93 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/base-am-command.ts @@ -0,0 +1,29 @@ +import { Command } from '@contentstack/cli-command'; +import { handleAndLogError } from '@contentstack/cli-utilities'; + +import { fillMissingCsAssetsFlags } from './utils'; +import type { CsAssetsFlags } from './interfaces'; + +/** + * Thin base command for CS Assets operations. + * Handles flag prompting in init() and exposes typed parsedFlags / loggerContext. + * Deliberately does NOT inherit BaseBulkCommand — CS Assets operations use a different API + * surface with no stack setup, queue managers, or rate limiters. + */ +export abstract class BaseCsAssetsCommand extends Command { + protected parsedFlags!: CsAssetsFlags; + protected loggerContext!: { module: string }; + + protected async init(): Promise { + await super.init(); + const { flags } = await this.parse(this.constructor as typeof BaseCsAssetsCommand); + this.loggerContext = { module: this.id ?? 'cm:stacks:bulk-am-assets' }; + this.parsedFlags = (await fillMissingCsAssetsFlags(flags)) as CsAssetsFlags; + } + + async catch(error: Error): Promise { + handleAndLogError(error); + } + + abstract run(): Promise; +} diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index e5f0bc62c..370b44a94 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -145,15 +145,14 @@ export abstract class BaseBulkCommand extends Command { this.parsedFlags = flags; - const commandName = `cm:stacks:bulk-${this.resourceType === ResourceType.ENTRY ? 'entries' : 'assets'}`; createLogContext( - this.context?.info?.command || commandName, + this.context?.info?.command || this.id, flags['stack-api-key'] || '', flags.alias ? 'Management Token' : 'Basic Auth' ); this.logger = log; - this.loggerContext = { module: commandName }; + this.loggerContext = { module: this.id }; // Check for revert/retry EARLY - all config comes from log file const isRevertOrRetry = flags.revert || flags['retry-failed']; diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts index f1b0a7bf2..850832eca 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts @@ -1,25 +1,26 @@ import chalk from 'chalk'; -import { Command } from '@contentstack/cli-command'; -import { flags, log, createLogContext, handleAndLogError, cliux, FlagInput } from '@contentstack/cli-utilities'; +import { flags, log, createLogContext, cliux, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; import messages, { $t } from '../../../messages'; -import { AmAssetService } from '../../../services'; +import { BaseCsAssetsCommand } from '../../../base-am-command'; +import { CsAssetsService } from '../../../services'; import { loadAssetUidsFromFile, loadBulkDeleteItemsFromFile, LoadAssetUidsError, } from '../../../utils/asset-uids-from-file'; -import { AmBulkDeleteItem } from '../../../interfaces'; +import { generateCsAssetsJobStatusUrl } from '../../../utils/bulk-publish-url-generator'; +import { CsAssetsBulkDeleteItem } from '../../../interfaces'; const COMMAND_ID = 'cm:stacks:bulk-am-assets'; -type RegionWithOptionalAmUrl = { csAssetsUrl?: string }; +type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string }; /** - * AM bulk delete (job) / bulk move — CS Assets API only; asset UIDs come from a JSON file `{ "uids": [...] }`. + * CS Assets bulk delete (job) / bulk move; asset UIDs come from a JSON file `{ "uids": [...] }`. */ -export default class BulkAmAssets extends Command { - static description = messages.BULK_AM_ASSETS_DESCRIPTION; +export default class BulkCsAssets extends BaseCsAssetsCommand { + static description = messages.BULK_CS_ASSETS_DESCRIPTION; static examples = [ '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltcOrg --locale en-us --asset-uids-file ./assets.json', @@ -29,31 +30,27 @@ export default class BulkAmAssets extends Command { static flags: FlagInput = { operation: flags.string({ - description: messages.AM_OPERATION_FLAG, + description: messages.CS_ASSETS_OPERATION_FLAG, options: ['delete', 'move'], - required: true, }), 'space-uid': flags.string({ - description: messages.AM_SPACE_UID_FLAG, - required: true, + description: messages.CS_ASSETS_SPACE_UID_FLAG, }), 'org-uid': flags.string({ - description: messages.AM_ORG_UID_FLAG, - required: true, + description: messages.CS_ASSETS_ORG_UID_FLAG, }), workspace: flags.string({ default: 'main', - description: messages.AM_WORKSPACE_FLAG, + description: messages.CS_ASSETS_WORKSPACE_FLAG, }), 'asset-uids-file': flags.string({ - description: messages.AM_ASSET_UIDS_FILE_FLAG, - required: true, + description: messages.CS_ASSETS_ASSET_UIDS_FILE_FLAG, }), locale: flags.string({ - description: messages.AM_LOCALE_FLAG, + description: messages.CS_ASSETS_LOCALE_FLAG, }), 'target-folder-uid': flags.string({ - description: messages.AM_TARGET_FOLDER_FLAG, + description: messages.CS_ASSETS_TARGET_FOLDER_FLAG, }), yes: flags.boolean({ char: 'y', @@ -62,66 +59,77 @@ export default class BulkAmAssets extends Command { }), }; - private readonly loggerContext = { module: COMMAND_ID }; + private printCsAssetsSummary( + op: 'delete' | 'move', + opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string; spaceUid?: string } + ): void { + if (opts.error) { + log.error($t(messages.CS_ASSETS_OPERATION_FAILED, { operation: op }), this.loggerContext); + log.error(opts.error, this.loggerContext); + } else if (op === 'delete') { + log.success($t(messages.CS_ASSETS_DELETE_SUCCESS), this.loggerContext); + if (opts.jobId) log.info($t(messages.CS_ASSETS_DELETE_JOB_ID, { jobId: opts.jobId }), this.loggerContext); + log.info($t(messages.CS_ASSETS_DELETE_ASYNC_NOTE), this.loggerContext); + const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); + if (statusUrl) log.info(statusUrl, this.loggerContext); + } else { + log.success($t(messages.CS_ASSETS_MOVE_SUCCESS), this.loggerContext); + if (opts.count !== undefined && opts.folderUid) { + log.info( + $t(messages.CS_ASSETS_MOVE_ASSETS_COUNT, { count: opts.count, folderUid: opts.folderUid }), + this.loggerContext + ); + } + const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); + if (statusUrl) log.info(statusUrl, this.loggerContext); + } + if (opts.notice) log.info(opts.notice, this.loggerContext); + } private handleAssetUidsFileError(e: LoadAssetUidsError): void { const pathShown = e.filePath; if (e.kind === 'READ') { log.error( - $t(messages.AM_ASSET_UIDS_FILE_READ_FAILED, { path: pathShown, detail: e.message }), + $t(messages.CS_ASSETS_ASSET_UIDS_FILE_READ_FAILED, { path: pathShown, detail: e.message }), this.loggerContext ); } else { - log.error($t(messages.AM_ASSET_UIDS_FILE_INVALID, { path: pathShown, detail: e.message }), this.loggerContext); + log.error( + $t(messages.CS_ASSETS_ASSET_UIDS_FILE_INVALID, { path: pathShown, detail: e.message }), + this.loggerContext + ); } process.exitCode = 1; } async run(): Promise { try { - const { flags: f } = await this.parse(BulkAmAssets); + const f = this.parsedFlags; - const amBaseUrl = (this.region as RegionWithOptionalAmUrl).csAssetsUrl?.trim(); - if (!amBaseUrl) { - log.error($t(messages.AM_URL_NOT_CONFIGURED), this.loggerContext); + const csAssetsBaseUrl = (this.region as RegionWithOptionalCsAssetsUrl).csAssetsUrl?.trim(); + if (!csAssetsBaseUrl) { + log.error($t(messages.CS_ASSETS_URL_NOT_CONFIGURED), this.loggerContext); process.exitCode = 1; return; } const op = f.operation; if (op !== 'delete' && op !== 'move') { - log.error($t(messages.AM_INVALID_OPERATION, { operation: String(op ?? '') }), this.loggerContext); + log.error($t(messages.CS_ASSETS_INVALID_OPERATION, { operation: String(op ?? '') }), this.loggerContext); process.exitCode = 1; return; } - const spaceUid = (f['space-uid'] ?? '').trim(); - if (!spaceUid) { - log.error($t(messages.SPACE_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } + const spaceUid = f['space-uid'].trim(); + const orgUid = f['org-uid'].trim(); + const assetUidsPath = f['asset-uids-file'].trim(); - const orgUid = (f['org-uid'] ?? '').trim(); - if (!orgUid) { - log.error($t(messages.ORG_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - const assetUidsPath = (f['asset-uids-file'] ?? '').trim(); - if (!assetUidsPath) { - log.error($t(messages.AM_ASSET_UIDS_FILE_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - let deleteRows: AmBulkDeleteItem[]; + let deleteRows: CsAssetsBulkDeleteItem[]; if (op === 'delete') { const locale = (f.locale ?? '').trim(); if (!locale) { - log.error($t(messages.AM_LOCALE_REQUIRED), this.loggerContext); + log.error($t(messages.CS_ASSETS_LOCALE_REQUIRED), this.loggerContext); process.exitCode = 1; return; } @@ -138,18 +146,18 @@ export default class BulkAmAssets extends Command { } createLogContext(this.context?.info?.command || COMMAND_ID, spaceUid, 'OAuth/Token'); - const amService = new AmAssetService(amBaseUrl, spaceUid, orgUid); + const csAssetsService = new CsAssetsService(csAssetsBaseUrl, spaceUid, orgUid); const workspace = f.workspace ?? 'main'; if (!f.yes) { console.log(chalk.yellow(`\n${$t(messages.OPERATION_CONFIG_HEADER)}\n`)); - console.log(' Operation: AM bulk delete'); + console.log(' Operation: CS Assets bulk delete'); console.log(` Space UID: ${spaceUid}`); console.log(` Organization UID: ${orgUid}`); console.log(` Workspace: ${workspace}`); console.log(` Locale: ${locale}`); console.log(` Asset UIDs file: ${assetUidsPath}`); - console.log(` Total AM delete entries: ${deleteRows.length}\n`); + console.log(` Total CS Assets delete entries: ${deleteRows.length}\n`); const confirmed: boolean = await cliux.inquire({ type: 'confirm', @@ -163,19 +171,20 @@ export default class BulkAmAssets extends Command { } } - log.info($t(messages.AM_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), this.loggerContext); - const result = await amService.bulkDelete(spaceUid, workspace, deleteRows); + log.info($t(messages.CS_ASSETS_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), this.loggerContext); + const result = await csAssetsService.bulkDelete(spaceUid, workspace, deleteRows); if (!result.success) { - log.error(result.error ?? 'AM bulk delete failed', this.loggerContext); + this.printCsAssetsSummary('delete', { error: result.error ?? 'CS Assets bulk delete failed', spaceUid }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - if (result.jobId) { - log.info($t(messages.AM_DELETE_SUBMITTED, { jobId: result.jobId }), this.loggerContext); - } + this.printCsAssetsSummary('delete', { jobId: result.jobId, notice: result.notice, spaceUid }); + return; + } + + if (f.locale) { + log.error($t(messages.CS_ASSETS_LOCALE_NOT_ALLOWED_FOR_MOVE), this.loggerContext); + process.exitCode = 1; return; } @@ -200,12 +209,12 @@ export default class BulkAmAssets extends Command { } createLogContext(this.context?.info?.command || COMMAND_ID, spaceUid, 'OAuth/Token'); - const amService = new AmAssetService(amBaseUrl, spaceUid, orgUid); + const csAssetsService = new CsAssetsService(csAssetsBaseUrl, spaceUid, orgUid); const workspace = f.workspace ?? 'main'; if (!f.yes) { console.log(chalk.yellow(`\n${$t(messages.OPERATION_CONFIG_HEADER)}\n`)); - console.log(' Operation: AM bulk move'); + console.log(' Operation: CS Assets bulk move'); console.log(` Space UID: ${spaceUid}`); console.log(` Organization UID: ${orgUid}`); console.log(` Workspace: ${workspace}`); @@ -226,19 +235,21 @@ export default class BulkAmAssets extends Command { } log.info( - $t(messages.AM_MOVING_ASSETS, { count: uids.length, targetFolderUid: moveFolderUid }), + $t(messages.CS_ASSETS_MOVING_ASSETS, { count: uids.length, targetFolderUid: moveFolderUid }), this.loggerContext ); - const result = await amService.bulkMove(spaceUid, workspace, uids, moveFolderUid); + const result = await csAssetsService.bulkMove(spaceUid, workspace, uids, moveFolderUid); if (!result.success) { - log.error(result.error ?? 'AM bulk move failed', this.loggerContext); + this.printCsAssetsSummary('move', { error: result.error ?? 'CS Assets bulk move failed', spaceUid }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - log.info($t(messages.AM_MOVE_SUBMITTED), this.loggerContext); + this.printCsAssetsSummary('move', { + count: uids.length, + folderUid: moveFolderUid, + notice: result.notice, + spaceUid, + }); } catch (error) { handleAndLogError(error); } diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index b5a18fe71..04b3ed901 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -20,6 +20,7 @@ export enum ResourceType { ENTRY = 'entry', ASSET = 'asset', TAXONOMY = 'taxonomy', + CS_ASSETS = 'cs-assets', } export enum FilterType { @@ -197,7 +198,7 @@ export interface CommandFlags { // Asset-specific flags 'folder-uid'?: string; - /** AM bulk delete/move */ + /** CS Assets bulk delete/move */ 'space-uid'?: string; 'org-uid'?: string; workspace?: string; @@ -256,20 +257,32 @@ export interface AssetPublishData { publish_details?: PublishDetails[]; } -/** One row for AM bulk-delete payload `{ uid, locale }[]`. */ -export interface AmBulkDeleteItem { +/** One row for CS Assets bulk-delete payload `{ uid, locale }[]`. */ +export interface CsAssetsBulkDeleteItem { uid: string; locale: string; } -/** Normalized outcome from AM bulk delete/move calls (CLI layer). */ -export interface AmBulkOperationResult { +/** Normalized outcome from CS Assets bulk delete/move calls (CLI layer). */ +export interface CsAssetsBulkOperationResult { success: boolean; notice?: string; jobId?: string; error?: string; } +/** Typed flags for the bulk-am-assets command. */ +export interface CsAssetsFlags { + operation: string; + 'space-uid': string; + 'org-uid': string; + workspace: string; + 'asset-uids-file': string; + locale?: string; + 'target-folder-uid'?: string; + yes: boolean; +} + export interface BulkJobResult { success: number; failed: number; diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index a285f734e..1c7ea5880 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -213,35 +213,53 @@ const bulkAssetsMsg = { }; /** - * AM bulk delete/move (CS Assets API) messages + * CS Assets bulk delete/move messages */ -const amBulkAssetsMsg = { - BULK_AM_ASSETS_DESCRIPTION: - 'Bulk delete or move assets via Asset Management API (AM-enabled regions). Loads asset UIDs from a JSON file `{ "uids": [...] }`; pass organization via `--org-uid`.', - AM_URL_NOT_CONFIGURED: - 'AM operations require assetManagementUrl in your region settings. Ensure your region is configured correctly.', - SPACE_UID_REQUIRED: '--space-uid is required for AM operations', - ORG_UID_REQUIRED: '--org-uid is required for AM operations (organization_uid header)', +const csAssetsBulkMsg = { + BULK_CS_ASSETS_DESCRIPTION: + 'Bulk delete or move assets via CS Assets API. Loads asset UIDs from a JSON file `{ "uids": [...] }`; pass organization via `--org-uid`.', + CS_ASSETS_URL_NOT_CONFIGURED: + 'CS Assets operations require csAssetsUrl in your region settings. Ensure your region is configured correctly.', + SPACE_UID_REQUIRED: '--space-uid is required for CS Assets operations', + ORG_UID_REQUIRED: '--org-uid is required for CS Assets operations (organization_uid header)', TARGET_FOLDER_REQUIRED: '--target-folder-uid is required for bulk move', - AM_LOCALE_REQUIRED: '--locale is required for bulk delete (AM deletes per asset and locale)', - AM_ASSET_UIDS_FILE_REQUIRED: '--asset-uids-file is required (path to JSON `{ "uids": string[] }`)', - AM_ASSET_UIDS_FILE_READ_FAILED: 'Failed to read asset UIDs file "{path}": {detail}', - AM_ASSET_UIDS_FILE_INVALID: 'Invalid asset UIDs file "{path}": {detail}', - AM_DELETING_ASSETS: 'Deleting {count} asset/locale pair(s) from space {spaceUid}...', - AM_MOVING_ASSETS: 'Moving {count} asset(s) to folder {targetFolderUid}...', - AM_DELETE_SUBMITTED: 'Bulk delete job submitted. Job ID: {jobId}', - AM_MOVE_SUBMITTED: 'Bulk move initiated successfully.', - AM_OPERATION_NOTICE: '{notice}', - AM_OPERATION_FLAG: 'Operation: delete (AM bulk delete) or move (AM bulk move)', - AM_SPACE_UID_FLAG: 'Asset Management space UID', - AM_ORG_UID_FLAG: 'Organization UID for AM API (organization_uid header)', - AM_WORKSPACE_FLAG: 'AM workspace query parameter (default: main)', - AM_ASSET_UIDS_FILE_FLAG: + CS_ASSETS_LOCALE_REQUIRED: '--locale is required for bulk delete (CS Assets deletes per asset and locale)', + CS_ASSETS_ASSET_UIDS_FILE_REQUIRED: '--asset-uids-file is required (path to JSON `{ "uids": string[] }`)', + CS_ASSETS_ASSET_UIDS_FILE_READ_FAILED: 'Failed to read asset UIDs file "{path}": {detail}', + CS_ASSETS_ASSET_UIDS_FILE_INVALID: 'Invalid asset UIDs file "{path}": {detail}', + CS_ASSETS_DELETING_ASSETS: 'Deleting {count} asset/locale pair(s) from space {spaceUid}...', + CS_ASSETS_MOVING_ASSETS: 'Moving {count} asset(s) to folder {targetFolderUid}...', + CS_ASSETS_DELETE_SUBMITTED: 'Bulk delete job submitted. Job ID: {jobId}', + CS_ASSETS_MOVE_SUBMITTED: 'Bulk move initiated successfully.', + CS_ASSETS_OPERATION_NOTICE: '{notice}', + CS_ASSETS_OPERATION_FLAG: 'Operation: delete (CS Assets bulk delete) or move (CS Assets bulk move)', + CS_ASSETS_SPACE_UID_FLAG: 'CS Assets space UID', + CS_ASSETS_ORG_UID_FLAG: 'Organization UID for CS Assets API (organization_uid header)', + CS_ASSETS_WORKSPACE_FLAG: 'CS Assets workspace query parameter (default: main)', + CS_ASSETS_ASSET_UIDS_FILE_FLAG: 'Path to UTF-8 JSON file: exactly `{ "uids": ["uid1", "uid2"] }` (non-empty string array, no trimming; large lists: see docs for NODE_OPTIONS)', - AM_LOCALE_FLAG: 'Locale code for bulk delete (single locale per run)', - AM_TARGET_FOLDER_FLAG: 'Destination AM folder UID (required for move)', - AM_INVALID_OPERATION: 'Invalid operation: {operation}. Must be delete or move', - AM_CONFIRM_SUMMARY: 'Proceed with AM {operation} on {count} item(s)?', + CS_ASSETS_LOCALE_FLAG: + 'Locale code for bulk delete only (single locale per run). Not applicable for move — move always relocates all locale variants of an asset.', + CS_ASSETS_LOCALE_NOT_ALLOWED_FOR_MOVE: + '--locale is not applicable for the move operation. Move always relocates all locale variants of an asset. Remove --locale and try again.', + CS_ASSETS_TARGET_FOLDER_FLAG: + 'Destination CS Assets folder UID for bulk move. Use "root" to move assets to the root folder.', + CS_ASSETS_INVALID_OPERATION: 'Invalid operation: {operation}. Must be delete or move', + CS_ASSETS_CONFIRM_SUMMARY: 'Proceed with CS Assets {operation} on {count} item(s)?', + CS_ASSETS_DELETE_SUCCESS: 'CS Assets bulk delete job submitted successfully!', + CS_ASSETS_DELETE_JOB_ID: 'Job ID: {jobId}', + CS_ASSETS_DELETE_ASYNC_NOTE: 'The job runs asynchronously — check the bulk task queue for status:', + CS_ASSETS_MOVE_SUCCESS: 'CS Assets bulk move completed successfully!', + CS_ASSETS_MOVE_ASSETS_COUNT: '{count} asset(s) moved to folder: {folderUid}', + CS_ASSETS_OPERATION_FAILED: 'CS Assets {operation} failed.', + + // Interactive prompts + CS_ASSETS_SELECT_OPERATION: 'Select CS Assets operation:', + CS_ASSETS_ENTER_SPACE_UID: 'Enter CS Assets space UID:', + CS_ASSETS_ENTER_ORG_UID: 'Enter organization UID:', + CS_ASSETS_ENTER_ASSET_UIDS_FILE: 'Enter path to asset UIDs JSON file (e.g. ./assets.json):', + CS_ASSETS_ENTER_LOCALE: 'Enter locale code for bulk delete (e.g. en-us):', + CS_ASSETS_ENTER_TARGET_FOLDER: 'Enter target folder UID for bulk move (use "root" to move to the root folder):', }; /** @@ -403,7 +421,7 @@ const commandInfo = { BULK_ASSETS_DESCRIPTION: 'Bulk operations for assets (publish/unpublish/cross-publish)', BULK_TAXONOMIES_DESCRIPTION: 'Publish taxonomies to environments and locales (CMA POST /v3/taxonomies/publish; initiates a publish job)', - BULK_AM_ASSETS_DESCRIPTION: amBulkAssetsMsg.BULK_AM_ASSETS_DESCRIPTION, + BULK_CS_ASSETS_DESCRIPTION: csAssetsBulkMsg.BULK_CS_ASSETS_DESCRIPTION, }; /** @@ -420,7 +438,7 @@ const messages: typeof errors & typeof interactiveMsg & typeof flagDescriptions & typeof commandInfo & - typeof amBulkAssetsMsg = { + typeof csAssetsBulkMsg = { ...errors, ...commonMsg, ...entryServiceMsg, @@ -432,7 +450,7 @@ const messages: typeof errors & ...interactiveMsg, ...flagDescriptions, ...commandInfo, - ...amBulkAssetsMsg, + ...csAssetsBulkMsg, }; /** diff --git a/packages/contentstack-bulk-operations/src/services/am-asset-service.ts b/packages/contentstack-bulk-operations/src/services/am-asset-service.ts index 37f3b0773..d80e288f4 100644 --- a/packages/contentstack-bulk-operations/src/services/am-asset-service.ts +++ b/packages/contentstack-bulk-operations/src/services/am-asset-service.ts @@ -1,16 +1,16 @@ import { CSAssetsAdapter } from '@contentstack/cli-asset-management'; -import type { AmBulkDeleteItem, AmBulkOperationResult } from '../interfaces'; +import type { CsAssetsBulkDeleteItem, CsAssetsBulkOperationResult } from '../interfaces'; /** - * Thin wrapper around {@link CSAssetsAdapter} for AM bulk delete/move used by bulk-operations CLI. + * Thin wrapper around {@link CSAssetsAdapter} for CS Assets bulk delete/move used by bulk-operations CLI. */ -export class AmAssetService { +export class CsAssetsService { private readonly adapter: CSAssetsAdapter; - constructor(amBaseUrl: string, spaceUid: string, orgUid: string) { + constructor(csAssetsBaseUrl: string, spaceUid: string, orgUid: string) { this.adapter = new CSAssetsAdapter({ - baseURL: amBaseUrl, + baseURL: csAssetsBaseUrl, headers: { organization_uid: orgUid, space_key: spaceUid }, }); } @@ -18,8 +18,8 @@ export class AmAssetService { async bulkDelete( spaceUid: string, workspaceUid: string | undefined, - items: AmBulkDeleteItem[] - ): Promise { + items: CsAssetsBulkDeleteItem[] + ): Promise { try { const response = await this.adapter.bulkDeleteAssets(spaceUid, workspaceUid ?? 'main', { assets: items, @@ -42,7 +42,7 @@ export class AmAssetService { workspaceUid: string | undefined, assetUids: string[], targetFolderUid: string - ): Promise { + ): Promise { try { const response = await this.adapter.bulkMoveAssets(spaceUid, workspaceUid ?? 'main', { asset_uids: assetUids, diff --git a/packages/contentstack-bulk-operations/src/services/index.ts b/packages/contentstack-bulk-operations/src/services/index.ts index 4c6c3d49a..25640eff2 100644 --- a/packages/contentstack-bulk-operations/src/services/index.ts +++ b/packages/contentstack-bulk-operations/src/services/index.ts @@ -1,5 +1,5 @@ export { EntryService } from './entry-service'; export { AssetService } from './asset-service'; -export { AmAssetService } from './am-asset-service'; +export { CsAssetsService } from './am-asset-service'; export { BulkOperationService } from './bulk-operation-service'; export { TaxonomyService } from './taxonomy-service'; diff --git a/packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts b/packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts index 7d396c89e..32fc804a7 100644 --- a/packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts +++ b/packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import path from 'node:path'; -import type { AmBulkDeleteItem } from '../interfaces'; +import type { CsAssetsBulkDeleteItem } from '../interfaces'; export type LoadAssetUidsErrorKind = 'READ' | 'PARSE' | 'SCHEMA'; @@ -83,16 +83,16 @@ export function validateAssetUidsParsedJson(parsed: unknown, filePathForErrors: } /** - * Validates `{ "uids": string[] }` and builds AM bulk-delete rows in one pass over `uids`. + * Validates `{ "uids": string[] }` and builds CS Assets bulk-delete rows in one pass over `uids`. * `locale` must be the final non-empty value from the CLI (caller trims). */ export function validateAndBuildBulkDeleteItems( parsed: unknown, locale: string, filePathForErrors: string -): AmBulkDeleteItem[] { +): CsAssetsBulkDeleteItem[] { const uids = parseValidatedUidsArray(parsed, filePathForErrors); - const items = new Array(uids.length); + const items = new Array(uids.length); for (let i = 0; i < uids.length; i++) { const uid = uids[i]; if (typeof uid !== 'string') { @@ -116,9 +116,9 @@ export function loadAssetUidsFromFile(filePath: string): string[] { } /** - * Reads asset UID file and returns `{ uid, locale }[]` for AM bulk delete (single pass over `uids` after parse). + * Reads asset UID file and returns `{ uid, locale }[]` for CS Assets bulk delete (single pass over `uids` after parse). */ -export function loadBulkDeleteItemsFromFile(filePath: string, locale: string): AmBulkDeleteItem[] { +export function loadBulkDeleteItemsFromFile(filePath: string, locale: string): CsAssetsBulkDeleteItem[] { const { resolved, parsed } = readResolvedAssetUidsJson(filePath); return validateAndBuildBulkDeleteItems(parsed, locale, resolved); } diff --git a/packages/contentstack-bulk-operations/src/utils/bulk-publish-url-generator.ts b/packages/contentstack-bulk-operations/src/utils/bulk-publish-url-generator.ts index 5ccd5b906..205b473d5 100644 --- a/packages/contentstack-bulk-operations/src/utils/bulk-publish-url-generator.ts +++ b/packages/contentstack-bulk-operations/src/utils/bulk-publish-url-generator.ts @@ -20,7 +20,6 @@ function getAppUrlFromHost(): string { * Generate the bulk publish status URL based on stack configuration * @param apiKey - Stack API key * @param branch - Branch name (optional) - * @param host - Host URL (optional) * @returns The status URL or null if apiKey is not available */ export function generateBulkPublishStatusUrl(apiKey?: string, branch?: string): string | null { @@ -34,3 +33,17 @@ export function generateBulkPublishStatusUrl(apiKey?: string, branch?: string): const branchParam = branch && branch !== 'main' ? `?branch=${branch}` : ''; return `${appUrl}/#!/stack/${apiKey}/publish-queue${branchParam}`; } + +/** + * Generate the CS Assets bulk task queue URL for checking job status + * @param spaceUid - CS Assets space UID + * @returns The CS Assets job status URL or null if spaceUid is not available + */ +export function generateCsAssetsJobStatusUrl(spaceUid?: string): string | null { + if (!spaceUid) { + return null; + } + + const appUrl = getAppUrlFromHost(); + return `${appUrl}/#!/asset-management/spaces/${spaceUid}/space-settings/bulk-task-queue`; +} diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index 02c334e3e..aa1d8801f 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -35,7 +35,7 @@ import { buildBulkModeResult, handleOperationError, } from './command-helpers'; -import { fillMissingFlags } from './interactive'; +import { fillMissingFlags, fillMissingCsAssetsFlags } from './interactive'; import { RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, @@ -98,6 +98,7 @@ export { buildBulkModeResult, handleOperationError, fillMissingFlags, + fillMissingCsAssetsFlags, fetchTaxonomyList, RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index 8794efae6..2aa68cdc1 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -223,3 +223,114 @@ export async function fillMissingFlags(flags: any): Promise { return updatedFlags; } + +/** + * Runs a sequence of prompt functions wrapped in the standard interactive mode header/footer. + * Each prompt is a no-op if its condition is already satisfied (value already in flags). + */ +async function runInteractivePrompts(prompts: Array<() => Promise>): Promise { + cliux.print(messages.INTERACTIVE_MODE_START, { color: 'cyan' }); + for (const prompt of prompts) await prompt(); + cliux.print(messages.INTERACTIVE_MODE_COMPLETE, { color: 'green' }); +} + +/** + * Fills in missing flags for the bulk-am-assets command by prompting the user. + * Handles CS Assets-specific required flags including operation-conditional ones + * (locale for delete, target-folder-uid for move). + * Throws in non-TTY environments when required flags are missing. + */ +export async function fillMissingCsAssetsFlags(flags: any): Promise { + const f = { ...flags }; + + const needsLocale = f.operation === 'delete' && !f.locale; + const needsFolderUid = f.operation === 'move' && !f['target-folder-uid']; + const needsPrompt = + !f.operation || !f['space-uid'] || !f['org-uid'] || !f['asset-uids-file'] || needsLocale || needsFolderUid; + + if (!needsPrompt) return f; + + // Fail fast in non-interactive environments (CI/CD) rather than hanging on stdin + if (!process.stdin.isTTY) { + const missing = [ + !f.operation && '--operation', + !f['space-uid'] && '--space-uid', + !f['org-uid'] && '--org-uid', + !f['asset-uids-file'] && '--asset-uids-file', + f.operation === 'delete' && !f.locale && '--locale', + f.operation === 'move' && !f['target-folder-uid'] && '--target-folder-uid', + ].filter(Boolean); + throw new Error( + `Missing required flag(s): ${missing.join(', ')}. Provide all required flags when running in a non-interactive environment.` + ); + } + + await runInteractivePrompts([ + async () => { + if (!f.operation) { + f.operation = await cliux.inquire({ + type: 'list', + name: 'operation', + message: messages.CS_ASSETS_SELECT_OPERATION, + choices: [ + { name: 'Delete (CS Assets bulk delete)', value: 'delete' }, + { name: 'Move (CS Assets bulk move)', value: 'move' }, + ], + }); + } + }, + async () => { + if (!f['space-uid']) { + f['space-uid'] = await cliux.inquire({ + type: 'input', + name: 'spaceUid', + message: messages.CS_ASSETS_ENTER_SPACE_UID, + validate: (v: string) => (!v?.trim() ? messages.SPACE_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['org-uid']) { + f['org-uid'] = await cliux.inquire({ + type: 'input', + name: 'orgUid', + message: messages.CS_ASSETS_ENTER_ORG_UID, + validate: (v: string) => (!v?.trim() ? messages.ORG_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['asset-uids-file']) { + f['asset-uids-file'] = await cliux.inquire({ + type: 'input', + name: 'assetUidsFile', + message: messages.CS_ASSETS_ENTER_ASSET_UIDS_FILE, + validate: (v: string) => (!v?.trim() ? messages.CS_ASSETS_ASSET_UIDS_FILE_REQUIRED : true), + }); + } + }, + // Conditional prompts run after operation is resolved (captured by closure) + async () => { + if (f.operation === 'delete' && !f.locale) { + f.locale = await cliux.inquire({ + type: 'input', + name: 'locale', + message: messages.CS_ASSETS_ENTER_LOCALE, + validate: (v: string) => (!v?.trim() ? messages.CS_ASSETS_LOCALE_REQUIRED : true), + }); + } + }, + async () => { + if (f.operation === 'move' && !f['target-folder-uid']) { + f['target-folder-uid'] = await cliux.inquire({ + type: 'input', + name: 'targetFolderUid', + message: messages.CS_ASSETS_ENTER_TARGET_FOLDER, + validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true), + }); + } + }, + ]); + + return f; +} diff --git a/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts new file mode 100644 index 000000000..4f31c7761 --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import BulkCsAssets from '../../../src/commands/cm/stacks/bulk-am-assets'; + +describe('BulkCsAssets command', () => { + let sandbox: sinon.SinonSandbox; + let command: BulkCsAssets; + + const baseDeleteFlags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: true, + }; + + const baseMoveFlags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: true, + }; + + function setRegion(value: object): void { + Object.defineProperty(command, 'region', { value, configurable: true, writable: true }); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + command = new BulkCsAssets([], {} as any); + (command as any).parsedFlags = { ...baseDeleteFlags }; + (command as any).loggerContext = { module: 'cm:stacks:bulk-am-assets' }; + setRegion({}); + }); + + afterEach(() => { + sandbox.restore(); + process.exitCode = undefined; + }); + + describe('AM URL validation', () => { + it('should set exitCode=1 when AM URL is not configured in region', async () => { + setRegion({}); // no csAssetsUrl + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('locale not allowed for move', () => { + it('should set exitCode=1 when --locale is passed with --operation move', async () => { + (command as any).parsedFlags = { ...baseMoveFlags, locale: 'en-us' }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + // Stub the file loader to confirm it is NOT reached + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + const loadStub = sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile'); + + await command.run(); + + expect(process.exitCode).to.equal(1); + expect(loadStub.called).to.be.false; // Should have exited before loading files + }); + + it('should NOT set exitCode when --locale is absent for move and API succeeds', async () => { + (command as any).parsedFlags = { ...baseMoveFlags }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile').returns(['uid1', 'uid2']); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.CsAssetsService.prototype, 'bulkMove').resolves({ + success: true, + notice: undefined, + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + }); + + describe('delete operation', () => { + beforeEach(() => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + }); + + it('should NOT set exitCode on successful delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.CsAssetsService.prototype, 'bulkDelete').resolves({ + success: true, + jobId: 'job-abc-123', + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + + it('should set exitCode=1 on failed delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.CsAssetsService.prototype, 'bulkDelete').resolves({ + success: false, + error: 'API rate limit exceeded', + }); + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('BaseCsAssetsCommand isolation — no publish/unpublish infrastructure', () => { + it('should not have bulkOperationConfig, queueManager, or managementStack on the instance', () => { + // BulkCsAssets extends BaseCsAssetsCommand, NOT BaseBulkCommand. + // None of these publish/unpublish properties should exist. + expect((command as any).bulkOperationConfig).to.be.undefined; + expect((command as any).queueManager).to.be.undefined; + expect((command as any).managementStack).to.be.undefined; + expect((command as any).rateLimiter).to.be.undefined; + }); + }); +}); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts index 36afee32f..371a23839 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts @@ -416,4 +416,226 @@ describe('Interactive Prompts', () => { expect(validValidation).to.be.true; }); }); + + describe('fillMissingCsAssetsFlags', () => { + // We need to import fillMissingCsAssetsFlags separately + let fillMissingCsAssetsFlags: typeof import('../../../src/utils/interactive').fillMissingCsAssetsFlags; + let originalIsTTY: boolean | undefined; + + before(async () => { + ({ fillMissingCsAssetsFlags } = await import('../../../src/utils/interactive')); + }); + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + + it('should return flags unchanged when all required flags are provided (delete)', async () => { + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + expect(printStub.called).to.be.false; + }); + + it('should return flags unchanged when all required flags are provided (move)', async () => { + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + }); + + it('should throw in non-TTY when required base flags are missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { workspace: 'main', yes: false }; + + try { + await fillMissingCsAssetsFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--operation'); + expect(error.message).to.include('--space-uid'); + expect(error.message).to.include('--org-uid'); + expect(error.message).to.include('--asset-uids-file'); + expect(error.message).to.include('non-interactive'); + } + }); + + it('should throw in non-TTY and include --locale when operation=delete and locale missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingCsAssetsFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--locale'); + } + }); + + it('should throw in non-TTY and include --target-folder-uid when operation=move and folder missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingCsAssetsFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--target-folder-uid'); + } + }); + + it('should prompt for all missing base flags in TTY and show interactive header/footer', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = {}; + + inquireStub.onCall(0).resolves('delete'); // operation + inquireStub.onCall(1).resolves('sp123'); // space-uid + inquireStub.onCall(2).resolves('org456'); // org-uid + inquireStub.onCall(3).resolves('./assets.json'); // asset-uids-file + inquireStub.onCall(4).resolves('en-us'); // locale (delete-conditional) + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result.operation).to.equal('delete'); + expect(result['space-uid']).to.equal('sp123'); + expect(result['org-uid']).to.equal('org456'); + expect(result['asset-uids-file']).to.equal('./assets.json'); + expect(result.locale).to.equal('en-us'); + expect(printStub.calledTwice).to.be.true; + }); + + it('should prompt for locale only when operation=delete and locale is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('en-us'); // locale + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result.locale).to.equal('en-us'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('locale'); + }); + + it('should prompt for target-folder-uid only when operation=move and folder is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('folderABC'); // target-folder-uid + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result['target-folder-uid']).to.equal('folderABC'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('targetFolderUid'); + }); + + it('should NOT prompt for locale when operation=move', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + const result = await fillMissingCsAssetsFlags(flags); + + expect(result.locale).to.be.undefined; + expect(inquireStub.called).to.be.false; + }); + + it('should present delete/move choices for the operation prompt', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + inquireStub.onCall(0).resolves('move'); // operation + + await fillMissingCsAssetsFlags(flags); + + const operationCall = inquireStub.firstCall.args[0]; + expect(operationCall.type).to.equal('list'); + const values = operationCall.choices.map((c: any) => c.value); + expect(values).to.include('delete'); + expect(values).to.include('move'); + }); + + it('should validate that space-uid is not blank', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + }; + + inquireStub.onCall(0).resolves('sp123'); + + await fillMissingCsAssetsFlags(flags); + + const spaceUidCall = inquireStub.firstCall.args[0]; + expect(spaceUidCall.validate('')).to.not.equal(true); + expect(spaceUidCall.validate('sp123')).to.equal(true); + }); + }); }); diff --git a/packages/contentstack-cli-cm-regex-validate/.eslintignore b/packages/contentstack-cli-cm-regex-validate/.eslintignore new file mode 100644 index 000000000..502167fa0 --- /dev/null +++ b/packages/contentstack-cli-cm-regex-validate/.eslintignore @@ -0,0 +1 @@ +/lib diff --git a/packages/contentstack-cli-cm-regex-validate/LICENSE b/packages/contentstack-cli-cm-regex-validate/LICENSE new file mode 100644 index 000000000..aff1142ee --- /dev/null +++ b/packages/contentstack-cli-cm-regex-validate/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Contentstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/contentstack-cli-cm-regex-validate/README.md b/packages/contentstack-cli-cm-regex-validate/README.md index 590363338..2e45f66e5 100644 --- a/packages/contentstack-cli-cm-regex-validate/README.md +++ b/packages/contentstack-cli-cm-regex-validate/README.md @@ -50,41 +50,5 @@ USAGE # Commands -* [`csdx cm:stacks:validate-regex`](#csdx-cmstacksvalidate-regex) -## `csdx cm:stacks:validate-regex` - -This command is used to find all the invalid regexes present in the content types and global fields of your stack. - -``` -USAGE - $ csdx cm:stacks:validate-regex [-a ] [-c] [-f ] [-g] - -FLAGS - -a, --alias= Alias (name) assigned to the management token - -c, --contentType To find invalid regexes within the content types - -f, --filePath= [optional] The path or the location in your file system where the CSV output file should be - stored. - -g, --globalField To find invalid regexes within the global fields - -DESCRIPTION - This command is used to find all the invalid regexes present in the content types and global fields of your stack. - -EXAMPLES - $ csdx cm:stacks:validate-regex - - $ csdx cm:stacks:validate-regex -a - - $ csdx cm:stacks:validate-regex -c - - $ csdx cm:stacks:validate-regex -g - - $ csdx cm:stacks:validate-regex -f - - $ csdx cm:stacks:validate-regex -a -c -g - - $ csdx cm:stacks:validate-regex -a -c -g -f -``` - -_See code: [src/commands/cm/stacks/validate-regex.ts](https://github.com/contentstack/cli-plugins/blob/main/packages/contentstack-cli-cm-regex-validate/src/commands/cm/stacks/validate-regex.ts)_ diff --git a/packages/contentstack-cli-cm-regex-validate/test/mocha.opts b/packages/contentstack-cli-cm-regex-validate/test/mocha.opts new file mode 100644 index 000000000..73fb8366a --- /dev/null +++ b/packages/contentstack-cli-cm-regex-validate/test/mocha.opts @@ -0,0 +1,5 @@ +--require ts-node/register +--watch-extensions ts +--recursive +--reporter spec +--timeout 5000 diff --git a/packages/contentstack-cli-cm-regex-validate/test/tsconfig.json b/packages/contentstack-cli-cm-regex-validate/test/tsconfig.json new file mode 100644 index 000000000..95898fced --- /dev/null +++ b/packages/contentstack-cli-cm-regex-validate/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "noEmit": true + }, + "references": [ + {"path": ".."} + ] +} diff --git a/packages/contentstack-clone/README.md b/packages/contentstack-clone/README.md index 4c818981a..604d99026 100644 --- a/packages/contentstack-clone/README.md +++ b/packages/contentstack-clone/README.md @@ -16,7 +16,7 @@ $ npm install -g @contentstack/cli-cm-clone $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-clone/2.0.0-beta.17 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-clone/2.0.0-beta.20 darwin-arm64 node-v22.13.1 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-clone/package.json b/packages/contentstack-clone/package.json index 77ac1daba..26ad06779 100644 --- a/packages/contentstack-clone/package.json +++ b/packages/contentstack-clone/package.json @@ -1,13 +1,13 @@ { "name": "@contentstack/cli-cm-clone", "description": "Contentstack stack clone plugin", - "version": "2.0.0-beta.20", + "version": "2.0.0-beta.21", "author": "Contentstack", "bugs": "https://github.com/rohitmishra209/cli-cm-clone/issues", "dependencies": { "@colors/colors": "^1.6.0", - "@contentstack/cli-cm-export": "~2.0.0-beta.19", - "@contentstack/cli-cm-import": "~2.0.0-beta.19", + "@contentstack/cli-cm-export": "~2.0.0-beta.20", + "@contentstack/cli-cm-import": "~2.0.0-beta.20", "@contentstack/cli-command": "~2.0.0-beta.8", "@contentstack/cli-utilities": "~2.0.0-beta.9", "@oclif/core": "^4.11.4", diff --git a/packages/contentstack-clone/src/commands/cm/stacks/clone.ts b/packages/contentstack-clone/src/commands/cm/stacks/clone.ts index 5967230e9..06b44a255 100644 --- a/packages/contentstack-clone/src/commands/cm/stacks/clone.ts +++ b/packages/contentstack-clone/src/commands/cm/stacks/clone.ts @@ -157,7 +157,10 @@ Use this plugin to automate the process of cloning a stack in few steps. async run(): Promise { try { const self = this; - configHandler.set('log.progressSupportedModule', 'clone'); + // Clear any stale progressSupportedModule persisted from a previous run so that + // auth/pre-flight errors always reach the console regardless of showConsoleLogs setting. + // It will be re-set inside handleClone() once authentication passes. + configHandler.set('log.progressSupportedModule', null); const { flags: cloneCommandFlags } = await self.parse(StackCloneCommand); const { yes, @@ -176,6 +179,7 @@ Use this plugin to automate the process of cloning a stack in few steps. } = cloneCommandFlags; const handleClone = async (): Promise => { + configHandler.set('log.progressSupportedModule', 'clone'); const listOfTokens = configHandler.get('tokens'); const authenticationMethod = this.determineAuthenticationMethod( sourceManagementTokenAlias, diff --git a/packages/contentstack-clone/src/core/util/clone-handler.ts b/packages/contentstack-clone/src/core/util/clone-handler.ts index 2f07aae72..1cf234ddc 100644 --- a/packages/contentstack-clone/src/core/util/clone-handler.ts +++ b/packages/contentstack-clone/src/core/util/clone-handler.ts @@ -787,7 +787,6 @@ export class CloneHandler { } async cloneTypeSelection(): Promise { - console.clear(); return new Promise(async (resolve, reject) => { try { log.debug('Starting clone type selection', this.config.cloneContext); diff --git a/packages/contentstack-clone/test/commands/cm/stacks/clone.test.ts b/packages/contentstack-clone/test/commands/cm/stacks/clone.test.ts index f711fd6b9..b62603d7a 100644 --- a/packages/contentstack-clone/test/commands/cm/stacks/clone.test.ts +++ b/packages/contentstack-clone/test/commands/cm/stacks/clone.test.ts @@ -538,29 +538,30 @@ describe('StackCloneCommand', () => { } }); - it.skip('should exit when not authenticated and no management token aliases', async () => { + it('should exit when not authenticated and no management token aliases', async () => { const parseStub = sandbox.stub(command, 'parse' as any).resolves({ flags: mockFlags, }); - const exitStub = sandbox.stub(command, 'exit' as any).callsFake((() => { - throw new Error('exit called'); - }) as () => never); + const configHandlerGetStub = sandbox.stub(cliUtilities.configHandler, 'get').returns(undefined); + const configHandlerSetStub = sandbox.stub(cliUtilities.configHandler, 'set'); + const logStub = { error: sandbox.stub(), warn: sandbox.stub(), debug: sandbox.stub(), info: sandbox.stub() }; + sandbox.stub(cliUtilities, 'log').value(logStub); + // exit(1) throws inside run()'s own try/catch which swallows it — stub cleanUp to + // prevent real filesystem ops and assert on exitStub directly after run() settles. + const cleanUpStub = sandbox.stub(command, 'cleanUp').resolves(); + const exitStub = sandbox.stub(command, 'exit' as any); - // Only test if not authenticated - if (!cliUtilities.isAuthenticated()) { - try { - await command.run(); - expect.fail('Should have exited'); - } catch (error: any) { - expect(error.message).to.equal('exit called'); - } + await command.run(); - expect(parseStub.calledOnce).to.be.true; - expect(exitStub.calledOnce).to.be.true; - } + expect(parseStub.calledOnce).to.be.true; + expect(exitStub.calledOnce).to.be.true; + expect(logStub.error.called).to.be.true; + expect(logStub.error.firstCall.args[0]).to.include('Please login'); + // progressSupportedModule must NOT be set when auth fails + expect(configHandlerSetStub.calledWith('log.progressSupportedModule', 'clone')).to.be.false; }); - it.skip('should exit when management token aliases provided but not authenticated and branches provided', async () => { + it('should exit when management token aliases provided but not authenticated and branches provided', async () => { const parseStub = sandbox.stub(command, 'parse' as any).resolves({ flags: { ...mockFlags, @@ -569,22 +570,21 @@ describe('StackCloneCommand', () => { 'source-branch': 'main', }, }); - const exitStub = sandbox.stub(command, 'exit' as any).callsFake((() => { - throw new Error('exit called'); - }) as () => never); + const configHandlerGetStub = sandbox.stub(cliUtilities.configHandler, 'get').returns(undefined); + const configHandlerSetStub = sandbox.stub(cliUtilities.configHandler, 'set'); + const logStub = { error: sandbox.stub(), warn: sandbox.stub(), debug: sandbox.stub(), info: sandbox.stub() }; + sandbox.stub(cliUtilities, 'log').value(logStub); + const cleanUpStub = sandbox.stub(command, 'cleanUp').resolves(); + const exitStub = sandbox.stub(command, 'exit' as any); - // Only test if not authenticated - if (!cliUtilities.isAuthenticated()) { - try { - await command.run(); - expect.fail('Should have exited'); - } catch (error: any) { - expect(error.message).to.equal('exit called'); - } + await command.run(); - expect(parseStub.calledOnce).to.be.true; - expect(exitStub.calledOnce).to.be.true; - } + expect(parseStub.calledOnce).to.be.true; + expect(exitStub.calledOnce).to.be.true; + expect(logStub.error.called).to.be.true; + expect(logStub.error.firstCall.args[0]).to.include('Log in'); + // progressSupportedModule must NOT be set when auth fails + expect(configHandlerSetStub.calledWith('log.progressSupportedModule', 'clone')).to.be.false; }); it('should handle run error and cleanup', async () => { @@ -794,6 +794,7 @@ describe('StackCloneCommand', () => { 'destination-management-token-alias': 'dest-alias', }, }); + const configHandlerSetStub = sandbox.stub(cliUtilities.configHandler, 'set'); const configHandlerStub = sandbox.stub(cliUtilities.configHandler, 'get'); // Stub authorisationType to 'OAUTH' to make isAuthenticated() return true configHandlerStub.callsFake((key: string) => { @@ -835,6 +836,8 @@ describe('StackCloneCommand', () => { expect(cloneHandlerExecuteStub.calledOnce).to.be.true; // Verify all config flags were set expect(logStub.debug.called).to.be.true; + // progressSupportedModule must be set inside handleClone (after auth passes) + expect(configHandlerSetStub.calledWith('log.progressSupportedModule', 'clone')).to.be.true; }); it('should handle CloneHandler.execute error (covers line 263)', async () => { diff --git a/packages/contentstack-content-type/README.md b/packages/contentstack-content-type/README.md index 5321599cc..ddde8811b 100644 --- a/packages/contentstack-content-type/README.md +++ b/packages/contentstack-content-type/README.md @@ -54,171 +54,5 @@ $ csdx content-type:details -a "management token" -c "content type" --no-path # Commands -* [`csdx content-type:audit`](#csdx-content-typeaudit) -* [`csdx content-type:compare`](#csdx-content-typecompare) -* [`csdx content-type:compare-remote`](#csdx-content-typecompare-remote) -* [`csdx content-type:details`](#csdx-content-typedetails) -* [`csdx content-type:diagram`](#csdx-content-typediagram) -* [`csdx content-type:list`](#csdx-content-typelist) -## `csdx content-type:audit` - -Display recent changes to a Content Type - -``` -USAGE - $ csdx content-type:audit --content-type [-k | | -a ] - -FLAGS - -a, --alias= Alias of the management token - -k, --stack-api-key= Stack API Key - --content-type= (required) Content Type UID - -DESCRIPTION - Display recent changes to a Content Type - -EXAMPLES - $ csdx content-type:audit --stack-api-key "xxxxxxxxxxxxxxxxxxx" --content-type "home_page" - - $ csdx content-type:audit --alias "management token" --content-type "home_page" -``` - -_See code: [src/commands/content-type/audit.ts](https://github.com/contentstack/cli-plugins/blob/main/packages/contentstack-content-type/src/commands/content-type/audit.ts)_ - -## `csdx content-type:compare` - -Compare two Content Type versions - -``` -USAGE - $ csdx content-type:compare --content-type [-k | ] [-a ] [--left --right ] - -FLAGS - -a, --alias= Alias of the management token - -k, --stack-api-key= Stack API Key - --content-type= (required) Content Type UID - --left= Content Type version, i.e. prev version - --right= Content Type version, i.e. later version - -DESCRIPTION - Compare two Content Type versions - -EXAMPLES - $ csdx content-type:compare --stack-api-key "xxxxxxxxxxxxxxxxxxx" --content-type "home_page" - - $ csdx content-type:compare --stack-api-key "xxxxxxxxxxxxxxxxxxx" --content-type "home_page" --left # --right # - - $ csdx content-type:compare --alias "management token" --content-type "home_page" --left # --right # -``` - -_See code: [src/commands/content-type/compare.ts](https://github.com/contentstack/cli-plugins/blob/main/packages/contentstack-content-type/src/commands/content-type/compare.ts)_ - -## `csdx content-type:compare-remote` - -compare two Content Types on different Stacks - -``` -USAGE - $ csdx content-type:compare-remote (--origin-stack --remote-stack ) --content-type - -FLAGS - --content-type= (required) Content Type UID - --origin-stack= (required) Origin Stack API Key - --remote-stack= (required) Remote Stack API Key - -DESCRIPTION - compare two Content Types on different Stacks - -EXAMPLES - $ csdx content-type:compare-remote --origin-stack "xxxxxxxxxxxxxxxxxxx" --remote-stack "xxxxxxxxxxxxxxxxxxx" -content-type "home_page" -``` - -_See code: [src/commands/content-type/compare-remote.ts](https://github.com/contentstack/cli-plugins/blob/main/packages/contentstack-content-type/src/commands/content-type/compare-remote.ts)_ - -## `csdx content-type:details` - -Display Content Type details - -``` -USAGE - $ csdx content-type:details --content-type [-k | ] [-a ] [--path] - -FLAGS - -a, --alias= Alias of the management token - -k, --stack-api-key= Stack API Key - --content-type= (required) Content Type UID - --[no-]path show path column - -DESCRIPTION - Display Content Type details - -EXAMPLES - $ csdx content-type:details --stack-api-key "xxxxxxxxxxxxxxxxxxx" --content-type "home_page" - - $ csdx content-type:details --alias "management token" --content-type "home_page" - - $ csdx content-type:details --alias "management token" --content-type "home_page" --no-path -``` - -_See code: [src/commands/content-type/details.ts](https://github.com/contentstack/cli-plugins/blob/main/packages/contentstack-content-type/src/commands/content-type/details.ts)_ - -## `csdx content-type:diagram` - -Create a visual diagram of a Stack's Content Types - -``` -USAGE - $ csdx content-type:diagram --output --direction portrait|landscape --type svg|dot [-k | | -a - ] - -FLAGS - -a, --alias= Alias of the management token - -k, --stack-api-key= Stack API Key - --direction=