diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 35dc5c24c..1a5aa8150 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: | diff --git a/.talismanrc b/.talismanrc index cdf69c893..2ec759fd6 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,12 @@ fileignoreconfig: -- filename: pnpm-lock.yaml - checksum: 2b0f2461ea1bb240a9210b9cf99dc403a756199712b7270f9792a590480451bd + - filename: pnpm-lock.yaml + checksum: 2b0f2461ea1bb240a9210b9cf99dc403a756199712b7270f9792a590480451bd + - filename: packages/contentstack-import/test/unit/import/modules/base-class.test.ts + checksum: fe372852d5f2f3f57ef62c603406c30ccecdb444c17133ac0b21dda399b962c0 + - filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts + checksum: f8d21db7db0ca2eebe7cc40af0a59f02e74e1689efb6d50a1072dc5ca3e03e9b + - filename: packages/contentstack-export/src/export/modules/taxonomies.ts + checksum: b6d077118280bc88385405f504f921468a9fd490ac37a4a21f741be729fd1ca3 + - filename: packages/contentstack-export/test/unit/export/modules/taxonomies.test.ts + checksum: cab2ad4d897d23f04f988c1f018a9583ab7f0ee1815994d7bc9fce23dea70073 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/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..e4706e480 100644 --- a/packages/contentstack-bootstrap/package.json +++ b/packages/contentstack-bootstrap/package.json @@ -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..f4431f32f 100644 --- a/packages/contentstack-bulk-operations/package.json +++ b/packages/contentstack-bulk-operations/package.json @@ -118,4 +118,4 @@ "cm:stacks:bulk-taxonomies": "BOT" } } -} \ No newline at end of file +} 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-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-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=